diff --git a/xtraplatform-features-gml/src/main/java/de/ii/xtraplatform/features/gml/app/FeatureProviderWfs.java b/xtraplatform-features-gml/src/main/java/de/ii/xtraplatform/features/gml/app/FeatureProviderWfs.java index 4620e2d8f..89f189625 100644 --- a/xtraplatform-features-gml/src/main/java/de/ii/xtraplatform/features/gml/app/FeatureProviderWfs.java +++ b/xtraplatform-features-gml/src/main/java/de/ii/xtraplatform/features/gml/app/FeatureProviderWfs.java @@ -240,7 +240,7 @@ protected FeatureQueryEncoder getQueryEncoder() { new QName( namespaceNormalizer.getNamespaceURI(namespaceNormalizer.extractURI(name)), namespaceNormalizer.getLocalName(name)); - return new FeatureTokenDecoderGml( + return new FeatureTokenDecoderGmlFromWfs( namespaces, ImmutableList.of(qualifiedName), featureSchema, diff --git a/xtraplatform-features-gml/src/main/java/de/ii/xtraplatform/features/gml/app/FeatureTokenDecoderGml.java b/xtraplatform-features-gml/src/main/java/de/ii/xtraplatform/features/gml/app/FeatureTokenDecoderGmlFromWfs.java similarity index 96% rename from xtraplatform-features-gml/src/main/java/de/ii/xtraplatform/features/gml/app/FeatureTokenDecoderGml.java rename to xtraplatform-features-gml/src/main/java/de/ii/xtraplatform/features/gml/app/FeatureTokenDecoderGmlFromWfs.java index 11a2723e3..f89c877a9 100644 --- a/xtraplatform-features-gml/src/main/java/de/ii/xtraplatform/features/gml/app/FeatureTokenDecoderGml.java +++ b/xtraplatform-features-gml/src/main/java/de/ii/xtraplatform/features/gml/app/FeatureTokenDecoderGmlFromWfs.java @@ -32,9 +32,15 @@ import javax.xml.stream.XMLStreamException; /** + * Decodes a streaming WFS response (a {@code wfs:FeatureCollection} of many features) into the + * platform's feature token stream. Path emission uses the qualified XML names directly, on the + * assumption that the consumer keys off XML-shaped paths (e.g. {@code adv:Flurstueck}, {@code + * gml:@id}). For consumers that key off the schema's property-name paths (e.g. the SQL feature + * encoder), use {@link FeatureTokenDecoderGml}. + * * @author zahnen */ -public class FeatureTokenDecoderGml +public class FeatureTokenDecoderGmlFromWfs extends FeatureTokenDecoder< byte[], FeatureSchema, SchemaMapping, ModifiableContext> { @@ -59,7 +65,7 @@ public class FeatureTokenDecoderGml private OptionalInt srsDimension = OptionalInt.empty(); private ModifiableContext context; - public FeatureTokenDecoderGml( + public FeatureTokenDecoderGmlFromWfs( Map namespaces, List featureTypes, FeatureSchema featureSchema, @@ -122,7 +128,6 @@ private void feedInput(byte[] data) { } } - // TODO: single feature or collection protected boolean advanceParser() { boolean feedMeMore = false; 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 new file mode 100644 index 000000000..4220bb126 --- /dev/null +++ b/xtraplatform-features-gml/src/main/java/de/ii/xtraplatform/features/gml/domain/FeatureTokenDecoderGml.java @@ -0,0 +1,1262 @@ +/* + * 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.gml.domain; + +import com.fasterxml.aalto.AsyncByteArrayFeeder; +import com.fasterxml.aalto.AsyncXMLStreamReader; +import com.fasterxml.aalto.stax.InputFactoryImpl; +import de.ii.xtraplatform.crs.domain.EpsgCrs; +import de.ii.xtraplatform.features.domain.FeatureQuery; +import de.ii.xtraplatform.features.domain.FeatureSchema; +import de.ii.xtraplatform.features.domain.SchemaBase; +import de.ii.xtraplatform.features.domain.SchemaBase.Type; +import de.ii.xtraplatform.features.domain.SchemaConstraints; +import de.ii.xtraplatform.features.domain.SchemaMapping; +import de.ii.xtraplatform.features.domain.pipeline.FeatureEventHandlerSimple.ModifiableContext; +import de.ii.xtraplatform.features.domain.pipeline.FeatureTokenBufferSimple; +import de.ii.xtraplatform.features.domain.pipeline.FeatureTokenDecoderSimple; +import de.ii.xtraplatform.geometries.domain.Geometry; +import java.nio.charset.StandardCharsets; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.OptionalInt; +import java.util.OptionalLong; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import javax.xml.namespace.QName; +import javax.xml.stream.XMLStreamConstants; +import javax.xml.stream.XMLStreamException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Decodes GML input into the schema-resolved feature-token stream. Walks the schema in parallel + * with the XML and resolves each child element to a schema property by name (or by {@code alias} + * when {@code FeatureTokenDecoderGmlInputProfile#getUseAlias()} is on), then emits values at the + * matched property's dotted property-name path (e.g. {@code qag.dpl.prs.des}). The feature root + * itself contributes no path segment, so direct children sit at depth 0. The emitted form is + * independent of any particular downstream — any consumer keying off the schema's property-name + * paths will accept the stream (the SQL feature encoder's writable/value column maps are the + * canonical example). {@code srsName} URN forms on geometries are reverse-mapped via {@link + * FeatureTokenDecoderGmlInputProfile#getSrsNameMappings()}, and {@code gml:id} is routed (with + * {@link FeatureTokenDecoderGmlInputProfile#getGmlIdPrefix()} prefix stripping) to the schema + * property whose role is {@link SchemaBase.Role#ID}. Multi-feature wrappers ({@code + * wfs:FeatureCollection}, {@code adv:AX_Bestandsdatenauszug}, …) at the root are rejected unless + * the input profile names the wrapper explicitly via {@link + * FeatureTokenDecoderGmlInputProfile#getFeatureCollectionElementName()} and/or {@link + * FeatureTokenDecoderGmlInputProfile#getFeatureMemberElementName()}; in that case the decoder + * descends through the configured wrappers and processes a single contained feature. A second + * feature sibling inside the wrapper is rejected as multi-feature ingest. + * + *

When the feature schema, or a nested OBJECT property's schema, declares an {@code objectType} + * that has a {@link FeatureTokenDecoderGmlInputProfile#getVariableObjectElementNames() + * variableObjectElementNames} entry, the wire element name of that GML object may vary across + * subtype instances. At the feature root the decoder accepts any wire element whose qualified name + * appears in that entry's mapping; at a nested OBJECT_PROPERTY's inner object element the wire + * element name is not otherwise validated, so the mapping is only consulted to decide whether to + * emit a discriminator value. In both cases the mapped source value is emitted at the configured + * discriminator property's source path, at the level of the OBJECT it discriminates. + * + *

Namespace handling mirrors the encoder's qualification chain: when the input profile carries + * {@link FeatureTokenDecoderGmlInputProfile#getDefaultNamespace()} or {@link + * FeatureTokenDecoderGmlInputProfile#getObjectTypeNamespaces()}, child elements are required to + * live in the expected namespace (explicit {@code prefix:} in the schema name/alias → + * objectTypeNamespaces[parent.objectType] → defaultNamespace). Mismatching elements are skipped + * rather than mis-matched against a same-localName property in another namespace. With no namespace + * configuration, matching is by local name alone, preserving the simpler test fixtures. + * + *

GML's object/property alternation only applies inside the GML namespace and the feature-type's + * application namespace. ISO 19115 ({@code gmd}/{@code gco}) instead allows object elements to + * directly carry text content (e.g. {@code }, {@code }), and + * these routinely appear as children of a scalar property element. The decoder hardcodes {@link + * #GMD_NS} and {@link #GCO_NS} as value-carrying namespaces: any element in those namespaces inside + * a {@code VALUE_PROPERTY} is treated as a {@code VALUE_WRAPPER} around the property's scalar text, + * no explicit {@code valueWrap} entry required. Wrappers in other namespaces (e.g. {@code + * }) still need explicit {@code valueWrap} + * configuration on the input profile. To be documented as a Phase 5 restriction of the GML building + * block. + */ +public class FeatureTokenDecoderGml + extends FeatureTokenDecoderSimple< + byte[], FeatureSchema, SchemaMapping, ModifiableContext> { + + private static final Logger LOGGER = LoggerFactory.getLogger(FeatureTokenDecoderGml.class); + + private static final String XSI_NS = "http://www.w3.org/2001/XMLSchema-instance"; + private static final String XLINK_NS = "http://www.w3.org/1999/xlink"; + + /** + * ISO 19115 metadata namespace. GML's object/property alternation rule only applies inside the + * GML namespace and the feature-type's application namespace; ISO 19115 ({@code gmd}/{@code gco}) + * instead uses object elements that directly carry text content (e.g. {@code + * }, {@code }). When such an element appears as a child of + * a scalar property element the decoder treats it as a value wrapper around the scalar text, even + * without an explicit {@code valueWrap} entry. Any other external namespace that follows the same + * convention needs a similar entry here when the need arises — documented as a known restriction + * in Phase 5 of the GML building block. + */ + private static final String GMD_NS = "http://www.isotc211.org/2005/gmd"; + + /** ISO 19115 common types namespace; see {@link #GMD_NS}. */ + private static final String GCO_NS = "http://www.isotc211.org/2005/gco"; + + private final AsyncXMLStreamReader parser; + private final XMLNamespaceNormalizer namespaceNormalizer; + private final FeatureSchema featureSchema; + private final FeatureQuery featureQuery; + private final Map mappings; + private final List featureTypes; + private final Optional defaultCrs; + private final Optional nullValue; + private final FeatureTokenDecoderGmlInputProfile inputProfile; + private final GeometryDecoderGml geometryDecoder; + private final StringBuilder buffer; + + /** + * Qualified names of the configured wrapper elements ({@code featureCollectionElementName} then + * {@code featureMemberElementName}), ordered outermost-first. Empty when no wrappers are + * configured: the document root is directly the feature element. Indexed by {@link #depth} during + * wrapper descent. + */ + private final List wrapperElementNames; + + /** + * Depth at which the feature element is expected (i.e. {@code wrapperElementNames.size()}). The + * first START at this depth is the feature root; the corresponding END pops it back. With no + * wrappers this is {@code 0}, preserving the original behaviour. + */ + private final int featureRootDepth; + + /** + * Maps each property's {@link FeatureSchema#getFullPathAsString()} to the equivalent path with + * each segment replaced by the property's {@code alias} (falling back to the segment name when no + * alias is set). The encoder applies {@code alias} as a {@code rename} transformation before + * consulting {@link FeatureTokenDecoderGmlInputProfile#getValueWrap()}, so its lookup key is the + * alias-form path. The decoder, which sees the untransformed schema, uses this map to consult + * {@code valueWrap} under the same key the encoder writes — keeping a single YAML convention + * (alias-form keys) working symmetrically for read and write. Empty (no aliases declared) when + * {@code useAlias} is off or no property carries an alias. + */ + private final Map aliasFormPathByPropertyPath; + + private int depth = 0; + private boolean inFeature = false; + private boolean featureProcessed = false; + private boolean isBuffering = false; + private String currentArrayPath; + private Optional crs = Optional.empty(); + private Optional featureGeometryCrs = Optional.empty(); + private OptionalInt srsDimension = OptionalInt.empty(); + private ModifiableContext context; + private final ArrayDeque frames = new ArrayDeque<>(); + + private FeatureTokenBufferSimple< + FeatureSchema, SchemaMapping, ModifiableContext> + downstream; + + /** + * Per-element state carried while descending into a feature. Pushed on START, popped on END. The + * feature root itself is not stacked — when {@code frames} is empty we are directly inside the + * feature element and lookup resolves against {@link #featureSchema}. + * + *

GML's object/property alternation rule shapes the frame stack: an object element + * (feature or nested object) has only property elements as direct children; a property + * element contains either scalar text or exactly one nested object element. The kinds below + * reflect those two roles. + */ + private enum FrameKind { + /** Scalar property element: characters between START/END become the emitted value. */ + VALUE_PROPERTY, + /** Geometry property element at the feature root. The geometry decoder consumes the subtree. */ + GEOMETRY_PROPERTY, + /** + * Object-valued property element — per the alternation rule, its only legal child is a single + * {@link #OBJECT_ELEMENT} (e.g. {@code } inside an {@code + * } property). + */ + OBJECT_PROPERTY, + /** + * GML object element nested inside an {@link #OBJECT_PROPERTY}. Contributes no path segment; + * its child elements are property elements resolved against the parent OBJECT_PROPERTY's + * schema. + */ + OBJECT_ELEMENT, + /** + * Wrapper element interposed by the encoder's {@code valueWrap} option between a {@link + * #VALUE_PROPERTY} and its scalar text (e.g. {@code v + * }). Carries no schema meaning; its purpose is to keep the character buffer alive + * across the wrappers' end-elements so the enclosing VALUE_PROPERTY can read the inner text on + * its own end. Only pushed when the enclosing VALUE_PROPERTY's {@code fullPathAsString} is + * listed in {@link FeatureTokenDecoderGmlInputProfile#getValueWrap()}. + */ + VALUE_WRAPPER, + /** Element with no matching schema property; descendants are ignored. */ + UNKNOWN + } + + private static final class Frame { + final FrameKind kind; + final FeatureSchema prop; + + /** + * For OBJECT_PROPERTY / OBJECT_ELEMENT: schema whose properties are matched against child START + * elements. + */ + final FeatureSchema lookupOwner; + + /** + * 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. + */ + final String segment; + + /** + * Path tracker depth for this frame. For non-transparent frames this is the index at which + * {@link #segment} lives; for transparent OBJECT_PROPERTY / OBJECT_ELEMENT frames this equals + * the parent's pathDepth so that descendants are tracked at the right depth. {@code -1} when + * the frame contributes nothing (UNKNOWN). + */ + final int pathDepth; + + /** + * For OBJECT_PROPERTY: set to {@code true} once the alternation rule's single permitted child + * OBJECT_ELEMENT has been seen. A second child START on the same OBJECT_PROPERTY violates the + * rule and is reported as malformed. + */ + boolean objectElementSeen; + + boolean nilOnCurrent; + String pendingXlinkHrefValue; + + /** + * For VALUE_PROPERTY: set to {@code true} when the property's {@code fullPathAsString} is + * listed in {@link FeatureTokenDecoderGmlInputProfile#getValueWrap()}. Children that appear + * inside such a frame are pushed as {@link FrameKind#VALUE_WRAPPER}s so that wrapper + * end-elements do not flush the character buffer before the scalar text is emitted on the + * VALUE_PROPERTY's own end. + */ + boolean valueWrapped; + + /** + * For OBJECT_ELEMENT: the {@link FeatureSchema#getName() name} of the array property currently + * open as a direct child of this object element, or {@code null} when no array is open at this + * level. The feature-root level uses the enclosing decoder's {@code currentArrayPath} field for + * the same purpose. Bracketing is per nesting level: a child OBJECT_ELEMENT does not inherit or + * affect its parent's open array. + */ + String openArrayChildPath; + + private Frame( + FrameKind kind, + FeatureSchema prop, + FeatureSchema lookupOwner, + String segment, + int pathDepth) { + this.kind = kind; + this.prop = prop; + this.lookupOwner = lookupOwner; + this.segment = segment; + this.pathDepth = pathDepth; + } + + static Frame valueProperty(FeatureSchema prop, String segment, int pathDepth) { + return new Frame(FrameKind.VALUE_PROPERTY, prop, null, segment, pathDepth); + } + + static Frame geometryProperty(FeatureSchema prop, String segment, int pathDepth) { + return new Frame(FrameKind.GEOMETRY_PROPERTY, prop, null, segment, pathDepth); + } + + static Frame objectProperty(FeatureSchema prop, String segment, int pathDepth) { + return new Frame(FrameKind.OBJECT_PROPERTY, prop, prop, segment, pathDepth); + } + + static Frame objectElement(FeatureSchema lookupOwner, int pathDepth) { + return new Frame(FrameKind.OBJECT_ELEMENT, null, lookupOwner, null, pathDepth); + } + + static Frame valueWrapper() { + return new Frame(FrameKind.VALUE_WRAPPER, null, null, null, -1); + } + + static Frame unknown() { + return new Frame(FrameKind.UNKNOWN, null, null, null, -1); + } + } + + public FeatureTokenDecoderGml( + Map namespaces, + List featureTypes, + FeatureSchema featureSchema, + FeatureQuery query, + Map mappings, + EpsgCrs storageCrs, + Optional headerCrs, + Optional nullValue, + FeatureTokenDecoderGmlInputProfile inputProfile) { + this.namespaceNormalizer = new XMLNamespaceNormalizer(namespaces); + this.featureSchema = featureSchema; + this.featureQuery = query; + this.mappings = mappings; + this.featureTypes = featureTypes; + this.defaultCrs = headerCrs.isPresent() ? headerCrs : Optional.of(storageCrs); + this.nullValue = nullValue; + this.inputProfile = inputProfile; + this.geometryDecoder = new GeometryDecoderGml(inputProfile.getSrsNameMappings()); + this.buffer = new StringBuilder(); + + List wrappers = new ArrayList<>(2); + if (!inputProfile.getFeatureCollectionElementName().isEmpty()) { + wrappers.add(inputProfile.getFeatureCollectionElementName()); + } + if (!inputProfile.getFeatureMemberElementName().isEmpty()) { + wrappers.add(inputProfile.getFeatureMemberElementName()); + } + this.wrapperElementNames = List.copyOf(wrappers); + this.featureRootDepth = this.wrapperElementNames.size(); + + if (inputProfile.getUseAlias()) { + Map aliasPaths = new HashMap<>(); + collectAliasFormPaths(featureSchema, "", "", aliasPaths); + this.aliasFormPathByPropertyPath = Map.copyOf(aliasPaths); + } else { + this.aliasFormPathByPropertyPath = Map.of(); + } + + try { + this.parser = new InputFactoryImpl().createAsyncFor(new byte[0]); + } catch (XMLStreamException e) { + throw new IllegalStateException("Could not create GML decoder: " + e.getMessage()); + } + } + + /** + * Mirrors the encoder side, which queries {@link + * FeatureTokenDecoderGmlInputProfile#getValueWrap()} after {@code alias → rename} injection — + * i.e. by the alias-form path. We check both the untransformed property path and the alias-form + * path so a YAML config keyed by either form (alias path when {@code useAlias: true}, or the bare + * property path) is recognised. + */ + private boolean isValueWrapped(FeatureSchema prop) { + Map> valueWrap = inputProfile.getValueWrap(); + if (valueWrap.isEmpty()) { + return false; + } + String path = prop.getFullPathAsString(); + if (valueWrap.containsKey(path)) { + return true; + } + String aliasPath = aliasFormPathByPropertyPath.get(path); + return aliasPath != null && valueWrap.containsKey(aliasPath); + } + + private static void collectAliasFormPaths( + FeatureSchema schema, String pathPrefix, String aliasPrefix, Map out) { + for (FeatureSchema child : schema.getProperties()) { + String name = child.getName(); + String alias = child.getAlias().orElse(name); + String path = pathPrefix.isEmpty() ? name : pathPrefix + "." + name; + String aliasPath = aliasPrefix.isEmpty() ? alias : aliasPrefix + "." + alias; + if (!path.equals(aliasPath)) { + out.put(path, aliasPath); + } + if (!child.getProperties().isEmpty()) { + collectAliasFormPaths(child, path, aliasPath, out); + } + } + } + + @Override + protected void init() { + this.context = createContext(); + this.downstream = new FeatureTokenBufferSimple<>(getDownstream(), context); + } + + @Override + protected void cleanup() { + parser.getInputFeeder().endOfInput(); + } + + @Override + public void onPush(byte[] bytes) { + feedInput(bytes); + } + + // for unit tests + void parse(String data) throws Exception { + byte[] dataBytes = data.getBytes(StandardCharsets.UTF_8); + feedInput(dataBytes); + cleanup(); + } + + private void feedInput(byte[] data) { + try { + parser.getInputFeeder().feedInput(data, 0, data.length); + } catch (XMLStreamException e) { + throw new IllegalStateException(e); + } + + boolean feedMeMore = false; + while (!feedMeMore) { + feedMeMore = advanceParser(); + } + } + + private boolean advanceParser() { + boolean feedMeMore = false; + try { + if (!parser.hasNext()) { + return true; + } + + switch (parser.next()) { + case AsyncXMLStreamReader.EVENT_INCOMPLETE: + feedMeMore = true; + break; + + case XMLStreamConstants.START_DOCUMENT: + case XMLStreamConstants.END_DOCUMENT: + break; + + case XMLStreamConstants.START_ELEMENT: + feedMeMore = onStartElement(); + break; + + case XMLStreamConstants.END_ELEMENT: + onEndElement(); + break; + + case XMLStreamConstants.CHARACTERS: + if (inFeature && !parser.isWhiteSpace()) { + isBuffering = true; + buffer.append(parser.getText()); + } + break; + + default: + // ignore: DTD, SPACE, NAMESPACE, NOTATION_DECLARATION, ENTITY_DECLARATION, + // PROCESSING_INSTRUCTION, COMMENT, CDATA. ATTRIBUTE is implicit in START_ELEMENT. + } + } catch (Exception e) { + throw new IllegalArgumentException("Could not parse GML: " + e.getMessage(), e); + } + return feedMeMore; + } + + private boolean onStartElement() throws XMLStreamException, java.io.IOException { + if (geometryDecoder.isWaitingForInput()) { + Optional> optGeometry = + geometryDecoder.continueDecoding(parser, crs, srsDimension, parser.getLocalName(), null); + if (optGeometry.isPresent()) { + emitGeometry(optGeometry.get()); + } + return false; + } + + rejectXsiType(); + + if (!inFeature && depth < featureRootDepth) { + String expected = wrapperElementNames.get(depth); + if (!matchesQualifiedElement(parser.getNamespaceURI(), parser.getLocalName(), expected)) { + throw new IllegalArgumentException( + "Multi-feature ingest is not supported by this endpoint; " + + "expected wrapper element <" + + expected + + "> at document depth " + + depth + + " but found <" + + parser.getLocalName() + + ">."); + } + depth++; + return false; + } + + if (!inFeature && depth == featureRootDepth) { + if (featureProcessed) { + throw new IllegalArgumentException( + "Multi-feature ingest is not supported by this endpoint; " + + "found a second feature element <" + + parser.getLocalName() + + "> inside the configured collection wrapper."); + } + if (!matchesFeatureType(parser.getNamespaceURI(), parser.getLocalName()) + && !matchesFeatureType(parser.getLocalName()) + && resolveVariableNameDiscriminator( + featureSchema, parser.getNamespaceURI(), parser.getLocalName()) + .isEmpty()) { + throw new IllegalArgumentException( + "Multi-feature ingest is not supported by this endpoint; " + + "expected a single feature element at the document root but found <" + + parser.getLocalName() + + ">."); + } + onFeatureStart(); + featureProcessed = true; + depth++; + return false; + } + + Frame parent = frames.peek(); + + // GML alternation: an OBJECT_PROPERTY normally contains exactly one OBJECT_ELEMENT, which + // carries no path segment of its own; the OBJECT_ELEMENT's child properties resolve against + // the OBJECT_PROPERTY's schema. *GML array properties* — one property element wrapping several + // peer object elements (max=unbounded on the inner element in the application schema) — are + // also valid and supported here when the schema declares the property as an OBJECT_ARRAY: each + // peer OBJECT_ELEMENT then emits its own OBJECT / OBJECT_END pair, anchored at the + // OBJECT_PROPERTY's path. A second peer on a non-array OBJECT_PROPERTY remains a schema/wire + // mismatch and is reported. The FEATURE_REF-as-OBJECT path (wrap=OBJECT / OBJECT_ARRAY) does + // not reach this branch because it has no inner OBJECT_ELEMENT — the property element is + // self-closing with xlink:href. + if (parent != null && parent.kind == FrameKind.OBJECT_PROPERTY) { + boolean arrayProperty = parent.prop.isArray() && !parent.prop.isFeatureRef(); + if (parent.objectElementSeen && !arrayProperty) { + throw new IllegalArgumentException( + "Unsupported GML shape: object property <" + + parent.prop.getName() + + "> is declared as a single-valued OBJECT but contains more than one object" + + " element; found extra <" + + parser.getLocalName() + + ">."); + } + if (arrayProperty) { + // For array OBJECT_PROPERTYs the per-peer OBJECT pair is emitted here (the prop START did + // not). Re-track the prop's segment first — previous peers' descendants will have moved + // the path tracker deeper. + context.pathTracker().track(parent.segment, parent.pathDepth); + downstream.onObjectStart(context); + } + parent.objectElementSeen = true; + // The inner object element of an OBJECT_PROPERTY carries no path segment itself, but its + // wire name may match a variableObjectElementNames entry on the OBJECT_PROPERTY's + // objectType — emit the discriminator value at the nested OBJECT's level before descending. + emitVariableNameDiscriminator( + parent.prop, parent.pathDepth + 1, parser.getNamespaceURI(), parser.getLocalName()); + frames.push(Frame.objectElement(parent.prop, parent.pathDepth)); + depth++; + return false; + } + + FeatureSchema lookupOwner; + int parentPathDepth; + if (parent == null) { + lookupOwner = featureSchema; + // No path-tracker segment for the feature root: emitted paths begin at the property + // name of the first child (depth 0). Property-name paths carry no source-system prefix, + // so downstreams (e.g. the SQL feature encoder's writable/value column maps) line up + // directly. + parentPathDepth = -1; + } else if (parent.kind == FrameKind.OBJECT_ELEMENT) { + lookupOwner = parent.lookupOwner; + parentPathDepth = parent.pathDepth; + } else { + // Inside a VALUE_PROPERTY / VALUE_WRAPPER / GEOMETRY_PROPERTY / UNKNOWN frame — descendants + // carry no schema meaning. (Per GML's alternation rule a scalar property has only text + // content; if we see an element here it is either unsupported mixed content or an + // already-skipped subtree.) Exceptions: (a) the encoder's valueWrap option produces a + // wrapper-element chain around the scalar text of a VALUE_PROPERTY — push VALUE_WRAPPER so + // the character buffer survives the wrappers' end-elements; (b) gmd/gco object elements + // (ISO 19115) carry text directly and routinely appear inside a property element — treat + // them as value wrappers without requiring an explicit valueWrap entry. Once inside a + // VALUE_WRAPPER, the chain continues regardless of the inner element's namespace. + boolean inValueWrapChain = + parent.kind == FrameKind.VALUE_WRAPPER + || (parent.kind == FrameKind.VALUE_PROPERTY + && (parent.valueWrapped || isExternalContentNamespace(parser.getNamespaceURI()))); + frames.push(inValueWrapChain ? Frame.valueWrapper() : Frame.unknown()); + depth++; + return false; + } + + String localName = parser.getLocalName(); + String namespaceUri = parser.getNamespaceURI(); + Optional propOpt = lookupChild(lookupOwner, localName, namespaceUri); + + // Array bracketing fires at any container level: the feature root (parent == null) and every + // OBJECT_ELEMENT (where the inner object element acts as the container for its child + // properties). The root level uses {@code currentArrayPath}; each OBJECT_ELEMENT frame + // carries its own {@code openArrayChildPath}. The open array at this level closes when the + // next sibling property has a different name. + boolean isArrayContainer = parent == null || parent.kind == FrameKind.OBJECT_ELEMENT; + if (isArrayContainer) { + String containerArrayPath = parent == null ? currentArrayPath : parent.openArrayChildPath; + String childPathSegment = propOpt.map(FeatureSchema::getName).orElse(null); + if (containerArrayPath != null && !containerArrayPath.equals(childPathSegment)) { + downstream.onArrayEnd(context); + if (parent == null) { + currentArrayPath = null; + } else { + parent.openArrayChildPath = null; + } + } + } + + if (propOpt.isEmpty()) { + if (LOGGER.isDebugEnabled()) { + LOGGER.debug("Skipping <{}>: no schema property matches.", localName); + } + frames.push(Frame.unknown()); + depth++; + return false; + } + + FeatureSchema prop = propOpt.get(); + + // Every property contributes its name as a path segment, producing a dotted property-name + // path (e.g. {@code qag.dpl.prs.des} for a deeply nested scalar). The path is independent + // of any {@code sourcePath} the property declares, so it lines up with whatever downstream + // keys off property-name paths. + String segment = prop.getName(); + int segmentPathDepth = parentPathDepth + 1; + context.pathTracker().track(segment, segmentPathDepth); + + if (isArrayContainer && prop.isArray()) { + String containerArrayPath = parent == null ? currentArrayPath : parent.openArrayChildPath; + if (containerArrayPath == null) { + downstream.onArrayStart(context); + if (parent == null) { + currentArrayPath = segment; + } else { + parent.openArrayChildPath = segment; + } + } + } + + if (prop.isSpatial()) { + // Geometry properties decode the full GML geometry subtree via {@link GeometryDecoderGml} + // regardless of nesting depth; the path tracker is already set to the property's segment + // path so {@code emitGeometry} routes the resulting Geometry to the right downstream slot. + Optional> optGeometry = geometryDecoder.decode(parser, crs, srsDimension); + frames.push(Frame.geometryProperty(prop, segment, segmentPathDepth)); + if (optGeometry.isPresent()) { + emitGeometry(optGeometry.get()); + depth++; + return false; + } + // geometry needs more input; the next push will continue via continueDecoding + depth++; + return true; + } else if (prop.isValue()) { + Frame frame = Frame.valueProperty(prop, segment, segmentPathDepth); + frame.nilOnCurrent = readXsiNil(); + frame.pendingXlinkHrefValue = readXlinkHrefAsValue(prop); + frame.valueWrapped = isValueWrapped(prop); + validateUom(prop); + frames.push(frame); + } else if (prop.isObject()) { + // OBJECT pair anchoring: for non-array OBJECT_PROPERTYs and for FEATURE_REF-as-OBJECT + // (wrap=OBJECT / OBJECT_ARRAY where the wire is a self-closing prop element with + // xlink:href), the OBJECT pair is anchored on the property element itself — emit + // OBJECT_START here and OBJECT_END at the matching property END. For array non-FEATURE_REF + // OBJECT_PROPERTYs the pair is emitted per peer OBJECT_ELEMENT instead, which lets both + // shape (a) (many sibling property elements each with one inner object) and shape (b) + // (one property element wrapping many peer object elements) feed the downstream with the + // same per-member OBJECT bracketing. The enclosing ARRAY at the parent's level still + // brackets the whole sequence. + boolean arrayOfObjects = prop.isArray() && !prop.isFeatureRef(); + if (!arrayOfObjects) { + downstream.onObjectStart(context); + } + // A FEATURE_REF property whose schema has been expanded to an OBJECT (id/title/type + // children) by an upstream wrap=OBJECT transformation carries its identifier on the + // property element's xlink:href, not as a nested object element. Reduce the href via + // featureRefTemplate and emit it at the conventional .id child path so the writable + // column wired to that child receives the value. The wire payload contains no inner + // element, so the OBJECT_PROPERTY frame's END just emits onObjectEnd. Pre-expansion + // FEATURE_REFs (type==FEATURE_REF) take the isValue() branch above and emit the + // reduced value directly at the property's own path. + String hrefValue = prop.isFeatureRef() ? readXlinkHrefAsValue(prop) : null; + if (hrefValue != null) { + context.pathTracker().track("id", segmentPathDepth + 1); + context.setValue(hrefValue); + context.setValueType(Type.STRING); + downstream.onValue(context); + } + frames.push(Frame.objectProperty(prop, segment, segmentPathDepth)); + } else { + if (LOGGER.isDebugEnabled()) { + LOGGER.debug("Skipping child <{}>: not handled by this decoder.", localName); + } + frames.push(Frame.unknown()); + } + + depth++; + return false; + } + + private void onEndElement() throws XMLStreamException, java.io.IOException { + if (geometryDecoder.isWaitingForInput()) { + Optional> optGeometry = + geometryDecoder.continueDecoding(parser, crs, srsDimension, parser.getLocalName(), ""); + if (optGeometry.isPresent()) { + emitGeometry(optGeometry.get()); + } + return; + } + + // A VALUE_WRAPPER about to be popped must not flush the character buffer: the inner text was + // emitted as CHARACTERS inside this wrapper, and the enclosing VALUE_PROPERTY still needs to + // read it on its own end. For every other frame, capture and clear the buffer as before. + Frame about = frames.peek(); + boolean preserveBuffer = about != null && about.kind == FrameKind.VALUE_WRAPPER; + String bufferedText; + if (preserveBuffer) { + bufferedText = ""; + } else { + bufferedText = isBuffering ? buffer.toString() : ""; + if (isBuffering) { + isBuffering = false; + buffer.setLength(0); + } + } + + depth--; + + Frame frame = frames.poll(); + if (frame != null && frame.kind == FrameKind.VALUE_PROPERTY) { + // Re-track the property's path in case any UNKNOWN descendants shifted the path tracker + // (GML's alternation rule forbids element children inside a scalar property, but we still + // tolerate them defensively). + context.pathTracker().track(frame.segment, frame.pathDepth); + if (frame.nilOnCurrent) { + if (nullValue.isPresent()) { + context.setValue(nullValue.get()); + context.setValueType(Type.STRING); + downstream.onValue(context); + } + } else if (frame.pendingXlinkHrefValue != null) { + context.setValue(frame.pendingXlinkHrefValue); + context.setValueType(Type.STRING); + downstream.onValue(context); + } else if (!bufferedText.isEmpty()) { + context.setValue(bufferedText); + context.setValueType(Type.STRING); + downstream.onValue(context); + } + } else if (frame != null && frame.kind == FrameKind.OBJECT_PROPERTY) { + // Re-track the OBJECT_PROPERTY's own path before emitting onObjectEnd — nested child + // elements may have pushed deeper segments onto the path tracker. For array non-FEATURE_REF + // OBJECT_PROPERTYs the OBJECT pair was emitted per peer OBJECT_ELEMENT, so the prop END + // just pops without an additional onObjectEnd. + context.pathTracker().track(frame.segment, frame.pathDepth); + boolean arrayOfObjects = frame.prop.isArray() && !frame.prop.isFeatureRef(); + if (!arrayOfObjects) { + downstream.onObjectEnd(context); + } + } else if (frame != null && frame.kind == FrameKind.OBJECT_ELEMENT) { + // Close any array still open inside this OBJECT_ELEMENT before bookkeeping at the enclosing + // OBJECT_PROPERTY level. The path tracker still points at the array property's path from + // the last child emit, which is where ARRAY_END belongs. + if (frame.openArrayChildPath != null) { + downstream.onArrayEnd(context); + } + // 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); + downstream.onObjectEnd(context); + } + } + + if (inFeature && depth == featureRootDepth) { + // Close any array still open at the last array property (path tracker still points at + // it). Drops the last array bracket so ARRAY/ARRAY_END remain balanced for every feature. + if (currentArrayPath != null) { + downstream.onArrayEnd(context); + currentArrayPath = null; + } + inFeature = false; + downstream.onFeatureEnd(context); + } + + if (depth == 0) { + downstream.onEnd(context); + } + } + + private void onFeatureStart() { + inFeature = true; + crs = defaultCrs; + featureGeometryCrs = Optional.empty(); + + context.metadata().numberReturned(OptionalLong.of(1L)); + context.metadata().numberMatched(OptionalLong.of(1L)); + context.metadata().isSingleFeature(true); + + downstream.onStart(context); + + String gmlId = readGmlId(); + + // No path-tracker segment for the feature root itself: emitted paths start at the property + // name of the first child (depth 0). Property-name paths carry no source-system prefix, + // so they line up directly with any downstream's property-name lookups. + downstream.onFeatureStart(context); + + if (gmlId != null) { + Optional idProperty = + featureSchema.getProperties().stream() + .filter(p -> p.getRole().filter(r -> r == SchemaBase.Role.ID).isPresent()) + .findFirst(); + if (idProperty.isPresent()) { + context.pathTracker().track(idProperty.get().getName(), 0); + context.setValue(gmlId); + context.setValueType(Type.STRING); + downstream.onValue(context); + } + } + + emitVariableNameDiscriminator( + featureSchema, 0, parser.getNamespaceURI(), parser.getLocalName()); + + emitXmlAttributesOnCurrent(featureSchema, 0); + } + + /** + * Reverse of the encoder's {@code variableObjectElementNames} option. When the wire element of a + * GML object (feature root or a nested OBJECT_PROPERTY's inner element) matches a configured + * qualified name for {@code owner.objectType}, the mapped source value is emitted at the + * discriminator property's source path at depth {@code childPathDepth}. The OBJECT element itself + * is accepted independently of this method — at the feature root by {@link #onStartElement}'s + * feature-type / variable-name accept check, at nested OBJECT_PROPERTYs by GML's alternation rule + * (the single inner element is the object regardless of its name). + * + *

The lookup is by schema property name regardless of {@code useAlias}, since {@link + * VariableObjectName#getProperty()} carries the source-side property identifier. + */ + private void emitVariableNameDiscriminator( + FeatureSchema owner, int childPathDepth, String wireNamespaceUri, String wireLocalName) { + Optional match = + resolveVariableNameDiscriminator(owner, wireNamespaceUri, wireLocalName); + if (match.isEmpty()) { + return; + } + Optional propOpt = + owner.getProperties().stream() + .filter(p -> p.getName().equals(match.get().property)) + .findFirst(); + if (propOpt.isEmpty()) { + return; + } + FeatureSchema prop = propOpt.get(); + context.pathTracker().track(prop.getName(), childPathDepth); + context.setValue(match.get().value); + context.setValueType(Type.STRING); + downstream.onValue(context); + } + + /** + * Returns the discriminator property name and source value for the wire element {@code + * (namespaceUri, localName)}, if a configured {@code variableObjectElementNames} entry for {@code + * owner.objectType} maps the wire's qualified name to a value. Returns empty otherwise. + */ + private Optional resolveVariableNameDiscriminator( + FeatureSchema owner, String namespaceUri, String localName) { + Optional ownerObjectType = owner.getObjectType(); + if (ownerObjectType.isEmpty()) { + return Optional.empty(); + } + VariableObjectName variable = + inputProfile.getVariableObjectElementNames().get(ownerObjectType.get()); + if (variable == null) { + return Optional.empty(); + } + String qualifiedWire = + namespaceNormalizer.getQualifiedName(namespaceUri == null ? "" : namespaceUri, localName); + String value = variable.getMapping().get(qualifiedWire); + if (value == null) { + return Optional.empty(); + } + return Optional.of(new DiscriminatorMatch(variable.getProperty(), value)); + } + + private static final class DiscriminatorMatch { + final String property; + final String value; + + DiscriminatorMatch(String property, String value) { + this.property = property; + this.value = value; + } + } + + /** + * Reverse of the encoder's {@code xmlAttributes} option: when a child property's {@code + * fullPathAsString} is listed in {@link FeatureTokenDecoderGmlInputProfile#getXmlAttributes()}, + * the encoder writes the value as an unqualified XML attribute on the parent object element + * instead of as a child element. Here we scan the current START_ELEMENT's unqualified attributes + * and emit each match at the property's source path. Qualified attributes ({@code gml:id}, {@code + * xsi:*}, {@code xlink:*}, …) are not candidates and are handled by the dedicated readers. + * + * @param parent the OBJECT schema that owns the attributes on the current element (the feature + * schema for the feature root). + * @param emitDepth path tracker depth at which the emitted values sit ({@code 1} for direct + * children of the feature root). + */ + private void emitXmlAttributesOnCurrent(FeatureSchema parent, int emitDepth) { + List configured = inputProfile.getXmlAttributes(); + if (configured.isEmpty()) { + return; + } + for (int i = 0; i < parser.getAttributeCount(); i++) { + String ns = parser.getAttributeNamespace(i); + if (ns != null && !ns.isEmpty()) { + continue; + } + String localName = parser.getAttributeLocalName(i); + Optional propOpt = lookupChild(parent, localName); + if (propOpt.isEmpty()) { + continue; + } + FeatureSchema prop = propOpt.get(); + if (!configured.contains(prop.getFullPathAsString())) { + continue; + } + context.pathTracker().track(prop.getName(), emitDepth); + context.setValue(parser.getAttributeValue(i)); + context.setValueType(Type.STRING); + downstream.onValue(context); + } + } + + /** + * Reads the {@code gml:id} attribute on the current START_ELEMENT, applying {@link + * FeatureTokenDecoderGmlInputProfile#getGmlIdPrefix()} if set. Returns {@code null} if no {@code + * gml:id} attribute is present. + */ + private String readGmlId() { + for (int i = 0; i < parser.getAttributeCount(); i++) { + String qn = + namespaceNormalizer.getQualifiedName( + parser.getAttributeNamespace(i), parser.getAttributeLocalName(i)); + if ("gml:id".equals(qn)) { + String value = parser.getAttributeValue(i); + String prefix = inputProfile.getGmlIdPrefix(); + if (value != null && !prefix.isEmpty() && value.startsWith(prefix)) { + return value.substring(prefix.length()); + } + return value; + } + } + return null; + } + + /** + * Resolves the wire-form local name of a child element to a property of {@code parent}. With + * {@code useAlias} on, each property's {@code alias} is matched first (falling back to the + * property name when no alias is set); otherwise the property name is matched directly. The + * property's name/alias may carry an explicit {@code prefix:} which is stripped before the + * local-name comparison. + * + *

This overload is used for XML attribute matching ({@link #emitXmlAttributesOnCurrent}), + * where the wire attribute is unqualified and no namespace check applies. + */ + private Optional lookupChild(FeatureSchema parent, String wireLocalName) { + if (wireLocalName == null) { + return Optional.empty(); + } + boolean useAlias = inputProfile.getUseAlias(); + return parent.getProperties().stream() + .filter(p -> wireLocalName.equals(stripPrefix(propertyKey(p, useAlias)))) + .findFirst(); + } + + /** + * Element-path variant of {@link #lookupChild(FeatureSchema, String)} that additionally enforces + * the namespace expected for the property given the input profile's {@code applicationNamespaces} + * / {@code defaultNamespace} / {@code objectTypeNamespaces}. The wire element's local name must + * equal the property's name/alias (after stripping any {@code prefix:}), and its namespace URI + * must equal the property's expected URI (see {@link #expectedNamespaceUri}); when no expected + * URI is configured the wire URI is not checked. + */ + private Optional lookupChild( + FeatureSchema parent, String wireLocalName, String wireNamespaceUri) { + if (wireLocalName == null) { + return Optional.empty(); + } + boolean useAlias = inputProfile.getUseAlias(); + String wireUri = wireNamespaceUri == null ? "" : wireNamespaceUri; + for (FeatureSchema p : parent.getProperties()) { + String key = propertyKey(p, useAlias); + if (!wireLocalName.equals(stripPrefix(key))) { + continue; + } + String expectedUri = expectedNamespaceUri(parent, key); + if (expectedUri == null || expectedUri.equals(wireUri)) { + return Optional.of(p); + } + } + return Optional.empty(); + } + + private static String propertyKey(FeatureSchema p, boolean useAlias) { + return useAlias ? p.getAlias().orElse(p.getName()) : p.getName(); + } + + /** + * Returns {@code true} when the namespace URI belongs to a hardcoded external schema whose object + * elements directly carry text content (ISO 19115 {@code gmd}/{@code gco} for now). Children of a + * scalar property element in these namespaces are decoded as value wrappers around the scalar + * text — see the entry point in {@link #onStartElement}. Other external schemas with the same + * convention need to be added here when the need arises. + */ + private static boolean isExternalContentNamespace(String uri) { + return GMD_NS.equals(uri) || GCO_NS.equals(uri); + } + + private static String stripPrefix(String key) { + int colon = key.indexOf(':'); + return colon >= 0 ? key.substring(colon + 1) : key; + } + + /** + * Resolves the XML namespace URI expected for a property of {@code parent} whose schema + * name/alias is {@code key}. Mirrors the encoder's qualification chain: + * + *

    + *
  1. An explicit {@code prefix:name} in the schema name/alias takes precedence. The prefix is + * resolved against the constructor's namespace map (predefined + {@code + * applicationNamespaces}). + *
  2. Otherwise the parent's {@code objectType} is looked up in {@code objectTypeNamespaces}; + * its prefix resolves to the URI. + *
  3. Otherwise the input profile's {@code defaultNamespace} prefix resolves to the URI. + *
+ * + * Returns {@code null} when no namespace expectation is configured for this property — the caller + * then matches by local name only, preserving the decoder's behaviour when no namespace data is + * provided in the input profile. + */ + private String expectedNamespaceUri(FeatureSchema parent, String key) { + int colon = key.indexOf(':'); + if (colon > 0) { + return namespaceNormalizer.getNamespaceURI(key.substring(0, colon)); + } + Optional parentObjectType = parent.getObjectType(); + if (parentObjectType.isPresent()) { + String prefix = inputProfile.getObjectTypeNamespaces().get(parentObjectType.get()); + if (prefix != null) { + return namespaceNormalizer.getNamespaceURI(prefix); + } + } + String defaultPrefix = inputProfile.getDefaultNamespace(); + if (defaultPrefix != null && !defaultPrefix.isEmpty()) { + return namespaceNormalizer.getNamespaceURI(defaultPrefix); + } + return null; + } + + /** + * Reads {@code xlink:href} on the current START_ELEMENT and reduces it to a bare value via the + * appropriate reverse template, if the property routes xlink:href as its value. Returns {@code + * null} when there is no href, when the property is not a codelist or feature-ref, or when the + * property routes hrefs but no matching template is configured (the unchanged href is then + * emitted as-is; mismatching template inputs also fall through to the unchanged href). + */ + private String readXlinkHrefAsValue(FeatureSchema prop) { + if (!shouldRouteXlinkHrefAsValue(prop)) { + return null; + } + String href = null; + for (int i = 0; i < parser.getAttributeCount(); i++) { + if (XLINK_NS.equals(parser.getAttributeNamespace(i)) + && "href".equals(parser.getAttributeLocalName(i))) { + href = parser.getAttributeValue(i); + break; + } + } + if (href == null) { + return null; + } + return reverseXlinkHrefTemplate(href, prop); + } + + /** + * Reduces an {@code xlink:href} to its bare value segment via the appropriate reverse template + * from the input profile. For codelist properties the schema's codelist id is substituted into + * {@code {{codelistId}}} first. If no template is configured, or the href does not match, the + * href is returned unchanged. + */ + private String reverseXlinkHrefTemplate(String href, FeatureSchema prop) { + if (prop.isFeatureRef()) { + return applyReverseTemplate(inputProfile.getFeatureRefTemplate(), href, null); + } + Optional codelistId = prop.getConstraints().flatMap(SchemaConstraints::getCodelist); + if (codelistId.isPresent()) { + return applyReverseTemplate(inputProfile.getCodelistUriTemplate(), href, codelistId.get()); + } + return href; + } + + private static boolean shouldRouteXlinkHrefAsValue(FeatureSchema prop) { + if (prop.isFeatureRef()) { + return true; + } + return prop.getConstraints().flatMap(SchemaConstraints::getCodelist).isPresent(); + } + + private static String applyReverseTemplate(String template, String href, String codelistId) { + if (template == null || template.isEmpty()) { + return href; + } + String substituted = + codelistId == null ? template : template.replace("{{codelistId}}", codelistId); + int idx = substituted.indexOf("{{value}}"); + if (idx < 0) { + return href; + } + String prefix = substituted.substring(0, idx); + String suffix = substituted.substring(idx + "{{value}}".length()); + String regex = Pattern.quote(prefix) + "(.+?)" + Pattern.quote(suffix); + Matcher m = Pattern.compile(regex).matcher(href); + return m.matches() ? m.group(1) : href; + } + + /** + * The {@code uom} attribute on a numeric property is consistency information against the schema's + * declared {@code unit}, not part of the value carried into the feature representation. + * Reverse-map the wire form via {@code uomMappings} (if configured) and warn when the result does + * not match the schema's unit. The attribute itself is dropped — the canonical unit lives in the + * schema. No-op when the property has no declared unit or no unqualified {@code uom} attribute is + * present. + */ + private void validateUom(FeatureSchema prop) { + Optional schemaUnit = prop.getUnit(); + if (schemaUnit.isEmpty()) { + return; + } + String wireUom = null; + for (int i = 0; i < parser.getAttributeCount(); i++) { + String ns = parser.getAttributeNamespace(i); + if ((ns == null || ns.isEmpty()) && "uom".equals(parser.getAttributeLocalName(i))) { + wireUom = parser.getAttributeValue(i); + break; + } + } + if (wireUom == null) { + return; + } + String mapped = inputProfile.getUomMappings().getOrDefault(wireUom, wireUom); + if (!schemaUnit.get().equals(mapped) && LOGGER.isWarnEnabled()) { + LOGGER.warn( + "uom attribute '{}' on property '{}' does not match the schema unit '{}'", + wireUom, + prop.getFullPathAsString(), + schemaUnit.get()); + } + } + + /** + * Returns {@code true} when the current START_ELEMENT carries {@code xsi:nil="true"}. Other + * xsi-namespaced attributes are caught by {@link #rejectXsiType()} (for {@code xsi:type}) or + * ignored. {@code nilReason} (in no namespace) is unused on input and silently dropped. + */ + private boolean readXsiNil() { + for (int i = 0; i < parser.getAttributeCount(); i++) { + if (XSI_NS.equals(parser.getAttributeNamespace(i)) + && "nil".equals(parser.getAttributeLocalName(i))) { + return "true".equalsIgnoreCase(parser.getAttributeValue(i)); + } + } + return false; + } + + /** + * {@code xsi:type} substitution is not supported by this decoder — schema lookup is by element + * name only, so a substituted type carries no extra information into the token stream and is + * almost certainly user error. Reject early with a clear message naming the element on which it + * appeared. + */ + private void rejectXsiType() { + for (int i = 0; i < parser.getAttributeCount(); i++) { + if (XSI_NS.equals(parser.getAttributeNamespace(i)) + && "type".equals(parser.getAttributeLocalName(i))) { + throw new IllegalArgumentException( + "xsi:type on element <" + + parser.getLocalName() + + "> is not supported by this decoder."); + } + } + } + + private void emitGeometry(Geometry geom) { + checkMixedCrs(geom.getCrs()); + context.setGeometry(geom); + getDownstream().onGeometry(context); + } + + /** + * Geometries within a single feature must share the same resolved CRS — a feature with two + * geometries in different CRSes is malformed because the consumer has no basis to choose one. The + * check compares resolved {@link EpsgCrs} values, so two {@code srsName} forms that map to the + * same EPSG code (e.g. via {@code srsNameMappings}) are treated as equal. + */ + private void checkMixedCrs(Optional geometryCrs) { + if (featureGeometryCrs.isEmpty()) { + featureGeometryCrs = geometryCrs; + return; + } + if (geometryCrs.isPresent() && !geometryCrs.equals(featureGeometryCrs)) { + throw new IllegalArgumentException( + "Geometries within a single feature must share the same CRS but found " + + featureGeometryCrs.get() + + " and " + + geometryCrs.get() + + "."); + } + } + + /** + * Compares a wire element {@code (namespaceUri, localName)} against a configured qualified name + * of the form {@code prefix:localName}. The configured prefix is resolved against the namespace + * normaliser; both the resolved URI and the local name must match. A configured value without a + * prefix is rejected as misconfiguration — wrapper element names are expected in the same + * qualified form the encoder writes. + */ + private boolean matchesQualifiedElement( + String namespaceUri, String localName, String configuredQualifiedName) { + int colon = configuredQualifiedName.indexOf(':'); + if (colon <= 0) { + throw new IllegalArgumentException( + "Wrapper element name '" + + configuredQualifiedName + + "' must be in 'prefix:localName' form."); + } + String expectedPrefix = configuredQualifiedName.substring(0, colon); + String expectedLocal = configuredQualifiedName.substring(colon + 1); + String expectedUri = namespaceNormalizer.getNamespaceURI(expectedPrefix); + String wireUri = namespaceUri == null ? "" : namespaceUri; + return expectedLocal.equals(localName) && expectedUri != null && expectedUri.equals(wireUri); + } + + private boolean matchesFeatureType(final String namespace, final String localName) { + return featureTypes.stream() + .anyMatch( + featureType -> + featureType.getLocalPart().equals(localName) + && Objects.nonNull(namespace) + && featureType.getNamespaceURI().equals(namespace)); + } + + private boolean matchesFeatureType(final String localName) { + return featureTypes.stream() + .anyMatch(featureType -> featureType.getLocalPart().equals(localName)); + } +} diff --git a/xtraplatform-features-gml/src/main/java/de/ii/xtraplatform/features/gml/domain/FeatureTokenDecoderGmlInputProfile.java b/xtraplatform-features-gml/src/main/java/de/ii/xtraplatform/features/gml/domain/FeatureTokenDecoderGmlInputProfile.java new file mode 100644 index 000000000..71b32c666 --- /dev/null +++ b/xtraplatform-features-gml/src/main/java/de/ii/xtraplatform/features/gml/domain/FeatureTokenDecoderGmlInputProfile.java @@ -0,0 +1,168 @@ +/* + * 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.gml.domain; + +import de.ii.xtraplatform.crs.domain.EpsgCrs; +import java.util.List; +import java.util.Map; +import org.immutables.value.Value; + +/** + * Reverse profile of the GML output configuration, consumed by the GML decoders on the input path. + * Mirrors the encoding-time options from {@code GmlConfiguration} so that input documents shaped + * like the encoder's output can be decoded losslessly. Defaults are conservative empties; an empty + * profile means the decoder runs without any reverse-mapping behaviour. + */ +@Value.Immutable +public interface FeatureTokenDecoderGmlInputProfile { + + /** + * Reverse-mapping from the {@code srsName} value seen on a geometry to the resolved {@link + * EpsgCrs}. Required for input shaped after ALKIS NAS, which uses ADV URN forms such as {@code + * urn:adv:crs:DE_DHDN_3GK2_NW101} that the built-in EPSG / OGC URN parser cannot resolve. + */ + Map getSrsNameMappings(); + + /** + * Optional prefix stripped from the value of {@code gml:id} before it is emitted as the feature + * id token. Empty string means no prefix is stripped. + */ + @Value.Default + default String getGmlIdPrefix() { + return ""; + } + + /** + * Path-keyed codelist declarations as carried by {@code GmlConfiguration#codelistProperties}. + * Routing of {@code xlink:href} on codelist properties is driven by the {@code FeatureSchema}'s + * own {@code codelist} constraint (analogous to the feature-reference routing driven by the + * schema's {@code FEATURE_REF}/{@code FEATURE_REF_ARRAY} type); this map is kept for completeness + * and future routing decisions that need the path-level mapping. + */ + Map getCodelistProperties(); + + /** + * Reverse of {@code GmlConfiguration#featureRefTemplate}. When set, an incoming {@code + * xlink:href} on a feature-reference property is matched against the template and reduced to the + * bare {@code {{value}}} segment before being emitted as the property value. The template uses + * {@code {{value}}} as the placeholder; everything else is treated literally (e.g. {@code + * urn:adv:oid:{{value}}} on {@code urn:adv:oid:DENW36ALl800005x} yields {@code + * DENW36ALl800005x}). When unset, the href is emitted unchanged. + */ + @Value.Default + default String getFeatureRefTemplate() { + return ""; + } + + /** + * Reverse of {@code GmlConfiguration#codelistUriTemplate}. When set, an incoming {@code + * xlink:href} on a codelist-valued property is matched against the template (with the schema's + * codelist id substituted for {@code {{codelistId}}}) and reduced to the bare {@code {{value}}} + * segment before being emitted. When unset, the href is emitted unchanged. + */ + @Value.Default + default String getCodelistUriTemplate() { + return ""; + } + + /** + * Reverse of {@code GmlConfiguration#uomMappings} combined with {@code UomStyle.TEMPLATE}: keys + * are the wire-form {@code uom} attribute values written by the encoder; values are the canonical + * units (matching the {@code unit} declared on the property in the provider schema). The {@code + * uom} attribute itself is not mapped to the feature representation — the canonical unit lives in + * the schema. The decoder reverse-maps the wire value through this map and warns when the result + * does not match the schema's {@code unit}; when no entry matches, the wire value is compared + * directly. + */ + Map getUomMappings(); + + /** + * When {@code true}, the decoder matches an incoming XML element local name against each schema + * property's {@code alias} (falling back to the property name if no alias is set) instead of + * matching against the property name directly. Mirrors the encoder's {@code + * GmlConfiguration#useAlias} flag: the encoder writes the alias as the element local name when + * this is on, so the decoder must look it up the same way. + */ + @Value.Default + default boolean getUseAlias() { + return false; + } + + /** + * Reverse of {@code GmlConfiguration#applicationNamespaces}: prefix → URI map of the + * application-defined namespaces the encoder writes into the output. Currently informational — + * the decoder resolves prefixes through the namespace map passed to its constructor (which merges + * predefined namespaces with these); kept here so the input profile carries the full encoder-side + * configuration. + */ + Map getApplicationNamespaces(); + + /** + * Reverse of {@code GmlConfiguration#defaultNamespace}: the prefix declared as the default + * namespace by the encoder. When set, property elements without an explicit {@code prefix:} in + * the schema name/alias and without a {@code objectTypeNamespaces} mapping are expected on the + * wire in this prefix's namespace; the decoder rejects mismatching elements. + */ + @Value.Default + default String getDefaultNamespace() { + return ""; + } + + /** + * Reverse of {@code GmlConfiguration#objectTypeNamespaces}: maps an object type's name (the + * schema's {@code objectType} value, including the feature type) to the prefix the encoder uses + * for its GML object element and its property elements. The decoder enforces the mapped + * prefix's namespace URI on those property elements; properties with an explicit {@code prefix:} + * in the schema name/alias override the mapping. + */ + Map getObjectTypeNamespaces(); + + /** + * Reverse of {@code GmlConfiguration#variableObjectElementNames}: maps an object type's name (the + * schema's {@code objectType} value) to the wire-element-name → source-value mapping the encoder + * applied. When the wire element of the feature root matches one of the configured qualified + * names for the feature schema's {@code objectType}, the decoder accepts the element as the + * feature type and emits the mapped source value at {@link VariableObjectName#getProperty + * VariableObjectName#getProperty()}. + */ + Map getVariableObjectElementNames(); + + /** + * Reverse of {@code GmlConfiguration#featureCollectionElementName}. When set, the decoder accepts + * a document whose root element is the configured wrapper (e.g. {@code sf:FeatureCollection}) and + * descends through it to the feature element instead of treating the wrapper as a feature type. + * The value must be in the form {@code prefix:localName}; the prefix is resolved against the + * namespace map passed to the decoder's constructor. When unset, the document root must directly + * be the feature element. Only a single feature inside the wrapper is supported — a second + * feature sibling is rejected as multi-feature ingest. + */ + @Value.Default + default String getFeatureCollectionElementName() { + return ""; + } + + /** + * Reverse of {@code GmlConfiguration#featureMemberElementName}. When set, the decoder expects the + * configured member element (e.g. {@code sf:featureMember}) as the child of the feature + * collection wrapper (or as the document root, when only this option is configured) and descends + * through it to the feature element. Same {@code prefix:localName} resolution as {@link + * #getFeatureCollectionElementName()}. + */ + @Value.Default + default String getFeatureMemberElementName() { + return ""; + } + + List getXmlAttributes(); + + Map> getValueWrap(); + + static FeatureTokenDecoderGmlInputProfile empty() { + return ImmutableFeatureTokenDecoderGmlInputProfile.builder().build(); + } +} diff --git a/xtraplatform-features-gml/src/main/java/de/ii/xtraplatform/features/gml/domain/GeometryDecoderGml.java b/xtraplatform-features-gml/src/main/java/de/ii/xtraplatform/features/gml/domain/GeometryDecoderGml.java index 52ac96641..ccc09ee25 100644 --- a/xtraplatform-features-gml/src/main/java/de/ii/xtraplatform/features/gml/domain/GeometryDecoderGml.java +++ b/xtraplatform-features-gml/src/main/java/de/ii/xtraplatform/features/gml/domain/GeometryDecoderGml.java @@ -13,64 +13,139 @@ import de.ii.xtraplatform.crs.domain.EpsgCrs; import de.ii.xtraplatform.crs.domain.OgcCrs; import de.ii.xtraplatform.geometries.domain.Axes; +import de.ii.xtraplatform.geometries.domain.CircularString; +import de.ii.xtraplatform.geometries.domain.CompoundCurve; +import de.ii.xtraplatform.geometries.domain.Curve; +import de.ii.xtraplatform.geometries.domain.CurvePolygon; import de.ii.xtraplatform.geometries.domain.Geometry; -import de.ii.xtraplatform.geometries.domain.GeometryType; -import de.ii.xtraplatform.geometries.domain.ImmutableGeometryCollection; -import de.ii.xtraplatform.geometries.domain.ImmutableLineString; -import de.ii.xtraplatform.geometries.domain.ImmutableMultiLineString; -import de.ii.xtraplatform.geometries.domain.ImmutableMultiPoint; -import de.ii.xtraplatform.geometries.domain.ImmutableMultiPolygon; -import de.ii.xtraplatform.geometries.domain.ImmutablePoint; -import de.ii.xtraplatform.geometries.domain.ImmutablePolygon; +import de.ii.xtraplatform.geometries.domain.GeometryCollection; import de.ii.xtraplatform.geometries.domain.LineString; +import de.ii.xtraplatform.geometries.domain.MultiCurve; import de.ii.xtraplatform.geometries.domain.MultiLineString; import de.ii.xtraplatform.geometries.domain.MultiPoint; import de.ii.xtraplatform.geometries.domain.MultiPolygon; +import de.ii.xtraplatform.geometries.domain.MultiSurface; import de.ii.xtraplatform.geometries.domain.Point; import de.ii.xtraplatform.geometries.domain.Polygon; +import de.ii.xtraplatform.geometries.domain.PolyhedralSurface; import de.ii.xtraplatform.geometries.domain.Position; import de.ii.xtraplatform.geometries.domain.PositionList; +import de.ii.xtraplatform.geometries.domain.SingleCurve; +import de.ii.xtraplatform.geometries.domain.Surface; import de.ii.xtraplatform.geometries.domain.transcode.AbstractGeometryDecoder; import java.io.IOException; import java.util.ArrayDeque; +import java.util.ArrayList; import java.util.Deque; +import java.util.Iterator; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.OptionalInt; +import java.util.Set; import javax.xml.stream.XMLStreamConstants; import javax.xml.stream.XMLStreamException; public class GeometryDecoderGml extends AbstractGeometryDecoder { - // In principle, we could support more GML geometry types, currently only the most common ones are - // supported. However, GML decoding is currently only relevant for un-maintained WFS feature - // providers. - - static final List GEOMETRY_PARTS = - new ImmutableList.Builder() - .add("pointMember") - .add("curveMember") - .add("lineStringMember") - .add("surfaceMember") - .add("polygonMember") - .build(); - static final List GEOMETRY_COORDINATES = - new ImmutableList.Builder().add("posList").add("pos").add("coordinates").build(); - - static class PartialGeometry { - PartialGeometry parent = null; - Deque children = new ArrayDeque<>(); - boolean isComplete = false; - Geometry geometry = null; - double[] coordinates = null; + private enum Kind { + POINT, + LINE_STRING, + POLYGON, + CURVE, + COMPOSITE_CURVE, + SURFACE, + COMPOSITE_SURFACE, + POLYHEDRAL_SURFACE, + SOLID, + MULTI_POINT, + MULTI_LINE_STRING, + MULTI_CURVE, + MULTI_POLYGON, + MULTI_SURFACE, + MULTI_GEOMETRY, + POINT_MEMBER, + CURVE_MEMBER, + LINE_STRING_MEMBER, + POLYGON_MEMBER, + SURFACE_MEMBER, + GEOMETRY_MEMBER, + POINT_MEMBERS, + CURVE_MEMBERS, + POLYGON_MEMBERS, + SURFACE_MEMBERS, + EXTERIOR, + INTERIOR, + OUTER_BOUNDARY_IS, + INNER_BOUNDARY_IS, + SHELL, + SEGMENTS, + PATCHES, + LINEAR_RING, + RING, + POLYGON_PATCH, + LINE_STRING_SEGMENT, + ARC, + ARC_STRING, + CIRCLE, + POS, + POS_LIST, + COORDINATES, + UNKNOWN + } + + private static final Set COORDINATE_NAMES = Set.of("pos", "posList", "coordinates"); + + private static final Set UNSUPPORTED_TOP = + Set.of("CompositeSolid", "MultiSolid", "OrientableCurve", "OrientableSurface"); + + private static final Set UNSUPPORTED_SEGMENT = + Set.of( + "GeodesicString", + "Geodesic", + "CubicSpline", + "BSpline", + "Bezier", + "ArcByCenterPoint", + "CircleByCenterPoint", + "ArcByBulge", + "ArcStringByBulge", + "OffsetCurve", + "Clothoid"); + + private static final Set UNSUPPORTED_PATCH = + Set.of("Triangle", "Rectangle", "TriangulatedSurface", "Tin", "Cone", "Cylinder", "Sphere"); + + static class Frame { + Kind kind; + String elementName; + Optional crs = Optional.empty(); + OptionalInt srsDimension = OptionalInt.empty(); + + double[] coords; StringBuilder textBuffer = new StringBuilder(); + final List> children = new ArrayList<>(); + boolean sawInterior; // for Solid: detect inner shell } + private final Deque stack = new ArrayDeque<>(); + private final Map srsNameMappings; private boolean waitingForInput = false; - private boolean waitingForGeometry = true; - private PartialGeometry currentGeometry = null; + private Geometry result; - public GeometryDecoderGml() {} + public GeometryDecoderGml() { + this(Map.of()); + } + + /** + * @param srsNameMappings reverse-mapping from {@code srsName} URI/URN forms to {@link EpsgCrs}; + * consulted before the built-in EPSG / OGC URN parsers and intended to resolve + * application-profile forms (e.g. ALKIS NAS uses {@code urn:adv:crs:DE_DHDN_3GK2_NW101}) that + * the built-in parsers cannot handle. + */ + public GeometryDecoderGml(Map srsNameMappings) { + this.srsNameMappings = srsNameMappings == null ? Map.of() : srsNameMappings; + } public Optional> decode( AsyncXMLStreamReader parser, @@ -83,222 +158,49 @@ public Optional> decode( public Optional> decode( AsyncXMLStreamReader parser, Optional defaultCrs, - OptionalInt srsDimension, + OptionalInt defaultSrsDimension, boolean useCurrentEvent) throws XMLStreamException, IOException { - String localName; waitingForInput = false; boolean doNotAdvance = useCurrentEvent; + while (doNotAdvance || parser.hasNext()) { int event = doNotAdvance ? parser.getEventType() : parser.next(); doNotAdvance = false; + switch (event) { case AsyncXMLStreamReader.EVENT_INCOMPLETE: waitingForInput = true; return Optional.empty(); + case XMLStreamConstants.START_ELEMENT: - localName = parser.getLocalName(); - if (waitingForGeometry) { - Optional crs = getCrsFromSrsName(parser).or(() -> defaultCrs); - Axes axes = - getDimFromSrsDimension(parser).orElse(srsDimension.orElse(2)) == 2 - ? Axes.XY - : Axes.XYZ; - Geometry geom = - switch (localName) { - case "Point" -> - ImmutablePoint.builder().crs(crs).value(Position.empty(axes)).build(); - case "LineString" -> - ImmutableLineString.builder() - .crs(crs) - .value(PositionList.empty(axes)) - .build(); - case "Polygon" -> ImmutablePolygon.builder().crs(crs).axes(axes).build(); - case "MultiPoint" -> ImmutableMultiPoint.builder().crs(crs).axes(axes).build(); - case "MultiCurve", "MultiLineString" -> - ImmutableMultiLineString.builder().crs(crs).axes(axes).build(); - case "MultiSurface", "MultiPolygon" -> - ImmutableMultiPolygon.builder().crs(crs).axes(axes).build(); - case "MultiGeometry" -> - ImmutableGeometryCollection.builder().crs(crs).axes(axes).build(); - default -> throw new IOException("Unsupported GML geometry type: " + localName); - }; - if (currentGeometry == null) { - currentGeometry = new PartialGeometry(); - } else { - PartialGeometry parent = currentGeometry; - currentGeometry = new PartialGeometry(); - currentGeometry.parent = parent; - parent.children.add(currentGeometry); - } - currentGeometry.geometry = geom; - waitingForGeometry = false; - } else if (GEOMETRY_COORDINATES.contains(localName)) { - if (currentGeometry == null) { - throw new IllegalStateException( - "No geometry started before element."); - } - if (currentGeometry.isComplete) { - throw new IllegalStateException( - " element cannot be added to a completed geometry."); - } - double[] coords = parseCoordinates(parser, localName); - if (waitingForInput) { + { + String localName = parser.getLocalName(); + boolean isCoordElement = + handleStart(parser, localName, defaultCrs, defaultSrsDimension); + if (isCoordElement && !readCoordinateText(parser)) { return Optional.empty(); } - handleCoordinates(localName, coords); - } else if (GEOMETRY_PARTS.contains(localName)) { - if (currentGeometry == null) { - throw new IllegalStateException( - "No geometry started before element."); - } - if (currentGeometry.isComplete) { - throw new IllegalStateException( - " element cannot be added to a completed geometry."); - } - waitingForGeometry = true; + break; } - break; + case XMLStreamConstants.END_ELEMENT: - localName = parser.getLocalName(); - if (currentGeometry == null) { - throw new IllegalStateException( - "No geometry started before element."); - } - if ("Point".equals(localName) - || "LineString".equals(localName) - || "Polygon".equals(localName) - || "MultiPoint".equals(localName) - || "MultiCurve".equals(localName) - || "MultiSurface".equals(localName)) { - if (currentGeometry.isComplete) { - throw new IllegalStateException( - "Geometry already completed for element."); - } - switch (localName) { - case "MultiPoint" -> { - var builder = - ImmutableMultiPoint.builder().from((MultiPoint) currentGeometry.geometry); - for (PartialGeometry child : currentGeometry.children) { - builder.addValue((Point) child.geometry); - } - currentGeometry.geometry = builder.build(); - } - case "MultiCurve" -> { - var builder = - ImmutableMultiLineString.builder() - .from((MultiLineString) currentGeometry.geometry); - for (PartialGeometry child : currentGeometry.children) { - builder.addValue((LineString) child.geometry); - } - currentGeometry.geometry = builder.build(); - } - case "MultiSurface" -> { - var builder = - ImmutableMultiPolygon.builder().from((MultiPolygon) currentGeometry.geometry); - for (PartialGeometry child : currentGeometry.children) { - builder.addValue((Polygon) child.geometry); - } - currentGeometry.geometry = builder.build(); - } - } - currentGeometry.isComplete = true; - if (currentGeometry.geometry.isEmpty()) { - throw new IllegalStateException( - "Geometry is empty for element."); - } - if (currentGeometry.parent != null) { - currentGeometry = currentGeometry.parent; - } else { - Geometry geom = currentGeometry.geometry; - currentGeometry = null; - waitingForGeometry = true; - waitingForInput = false; - return Optional.of(geom); - } + handleEnd(parser.getLocalName()); + if (result != null && stack.isEmpty()) { + Geometry r = result; + result = null; + waitingForInput = false; + return Optional.of(r); } break; - case XMLStreamConstants.CHARACTERS: + + default: break; } } throw new IOException("Unexpected end of XML stream, no complete geometry found."); } - private void handleCoordinates(String localName, double[] coords) { - Geometry geom = currentGeometry.geometry; - GeometryType geomType = geom.getType(); - Axes axes = geom.getAxes(); - PartialGeometry child; - switch (geomType) { - case POINT: - if ("posList".equals(localName)) { - throw new IllegalStateException(" is not allowed for Point coordinates."); - } - addCoordinates(coords); - Position position = - Position.of(coords.length == 3 ? Axes.XYZ : Axes.XY, currentGeometry.coordinates); - currentGeometry.geometry = ImmutablePoint.copyOf((Point) geom).withValue(position); - break; - case LINE_STRING: - addCoordinates(coords); - if (!"pos".equals(localName)) { - LineString lineString = (LineString) geom; - PositionList positionList = PositionList.of(axes, currentGeometry.coordinates); - currentGeometry.geometry = ImmutableLineString.copyOf(lineString).withValue(positionList); - currentGeometry.coordinates = null; - } - break; - case POLYGON: - addCoordinates(coords); - if (!"pos".equals(localName)) { - Polygon polygon = (Polygon) geom; - PositionList positionList = PositionList.of(axes, currentGeometry.coordinates); - currentGeometry.geometry = - ImmutablePolygon.copyOf(polygon) - .withValue( - ImmutableList.builder() - .addAll(polygon.getValue()) - .add(LineString.of(positionList, polygon.getCrs())) - .build()); - currentGeometry.coordinates = null; - } - break; - case MULTI_POINT: - Position pos = Position.of(coords.length == 3 ? Axes.XYZ : Axes.XY, coords); - Point point = ImmutablePoint.builder().crs(geom.getCrs()).value(pos).build(); - child = new PartialGeometry(); - child.parent = currentGeometry; - child.geometry = point; - currentGeometry.children.add(child); - break; - case MULTI_LINE_STRING: - PositionList posList = PositionList.of(axes, coords); - LineString lineString = - ImmutableLineString.builder().crs(geom.getCrs()).value(posList).build(); - child = new PartialGeometry(); - child.parent = currentGeometry; - child.geometry = lineString; - currentGeometry.children.add(child); - break; - case MULTI_POLYGON: - PositionList ring = PositionList.of(axes, coords); - Polygon polygon = - ImmutablePolygon.builder() - .crs(geom.getCrs()) - .axes(axes) - .addValue(LineString.of(ring, geom.getCrs())) - .build(); - child = new PartialGeometry(); - child.parent = currentGeometry; - child.geometry = polygon; - currentGeometry.children.add(child); - break; - default: - throw new IllegalStateException("Unsupported geometry type: " + geomType); - } - } - public Optional> continueDecoding( AsyncXMLStreamReader parser, Optional defaultCrs, @@ -306,116 +208,733 @@ public Optional> continueDecoding( String localName, String bufferedText) throws XMLStreamException, IOException { - if (GEOMETRY_COORDINATES.contains(localName)) { - if (currentGeometry == null) { - throw new IllegalStateException( - "No geometry started before element."); - } - if (currentGeometry.isComplete) { - throw new IllegalStateException( - " element cannot be added to a completed geometry."); + if (COORDINATE_NAMES.contains(localName) && !stack.isEmpty()) { + Frame top = stack.peek(); + if (top.kind == coordinateKind(localName)) { + if (bufferedText != null) { + top.textBuffer.append(bufferedText); + } + finalizeCoordinates(top); + stack.pop(); + applyCoordinates(top); + return decode(parser, defaultCrs, srsDimension, false); } - double[] coords = parseDoubles(currentGeometry.textBuffer.append(bufferedText).toString()); - currentGeometry.textBuffer.setLength(0); - handleCoordinates(localName, coords); - return decode(parser, defaultCrs, srsDimension, false); } return decode(parser, defaultCrs, srsDimension, true); } - private void addCoordinates(double[] coords) { - if (currentGeometry.coordinates == null) { - currentGeometry.coordinates = coords; - } else { - double[] newCoords = new double[currentGeometry.coordinates.length + coords.length]; - System.arraycopy( - currentGeometry.coordinates, 0, newCoords, 0, currentGeometry.coordinates.length); - System.arraycopy(coords, 0, newCoords, currentGeometry.coordinates.length, coords.length); - currentGeometry.coordinates = newCoords; + public boolean isWaitingForInput() { + return waitingForInput; + } + + private static Kind coordinateKind(String localName) { + return switch (localName) { + case "pos" -> Kind.POS; + case "posList" -> Kind.POS_LIST; + case "coordinates" -> Kind.COORDINATES; + default -> null; + }; + } + + /** Returns true when the started element is a coordinate text element. */ + private boolean handleStart( + AsyncXMLStreamReader parser, + String localName, + Optional defaultCrs, + OptionalInt defaultSrsDimension) + throws IOException { + + if (UNSUPPORTED_TOP.contains(localName)) { + throw new IOException("Unsupported GML geometry type: " + localName); + } + if (UNSUPPORTED_PATCH.contains(localName)) { + throw new IOException("Unsupported GML surface patch: " + localName); + } + if (UNSUPPORTED_SEGMENT.contains(localName)) { + throw new IOException("Unsupported GML curve segment: " + localName); + } + + if (COORDINATE_NAMES.contains(localName)) { + Frame f = new Frame(); + f.kind = coordinateKind(localName); + f.elementName = localName; + stack.push(f); + return true; + } + + Kind kind = classify(localName); + if (kind == null) { + if (stack.isEmpty()) { + throw new IOException("Unsupported GML geometry type: " + localName); + } + Frame f = new Frame(); + f.kind = Kind.UNKNOWN; + f.elementName = localName; + stack.push(f); + return false; + } + + // Reject Solid with inner shell as soon as we see directly under + if (kind == Kind.INTERIOR || kind == Kind.INNER_BOUNDARY_IS) { + Frame parent = stack.peek(); + if (parent != null && parent.kind == Kind.SOLID) { + throw new IOException("Solid with inner shells is not supported."); + } } + + Frame f = new Frame(); + f.kind = kind; + f.elementName = localName; + + if (isObject(kind)) { + Optional explicitCrs = parseSrsName(parser, srsNameMappings); + f.crs = explicitCrs.or(() -> defaultCrs).or(this::inheritedCrs); + OptionalInt dim = parseSrsDimension(parser); + if (dim.isEmpty()) { + dim = defaultSrsDimension.isPresent() ? defaultSrsDimension : inheritedSrsDimension(); + } + f.srsDimension = dim; + } + + stack.push(f); + return false; } - public boolean isWaitingForInput() { - return waitingForInput; + private void handleEnd(String localName) throws IOException { + if (stack.isEmpty()) { + throw new IllegalStateException("Unbalanced end element: " + localName); + } + Frame top = stack.peek(); + + // tolerate stray closes that don't match our top frame (well-formed XML guarantees match) + if (!localName.equals(top.elementName)) { + return; + } + + stack.pop(); + + if (top.kind == Kind.UNKNOWN) { + return; + } + + Geometry built = buildGeometry(top); + if (built == null) { + // transparent wrapper — nothing to contribute + return; + } + + if (stack.isEmpty()) { + result = built; + return; + } + + contributeToConsumer(built); } - private Optional getCrsFromSrsName(AsyncXMLStreamReader parser) { - String srsName = parser.getAttributeValue(null, "srsName"); - if (srsName != null) { - if (srsName.startsWith("urn:ogc:def:crs:EPSG::")) { - String code = srsName.substring(srsName.lastIndexOf(':') + 1); - try { - return Optional.of(EpsgCrs.of(Integer.parseInt(code))); - } catch (Exception e) { - // ignore, fallback to default - } - } else if (srsName.startsWith("http://www.opengis.net/def/crs/EPSG/0/")) { - String code = srsName.substring(srsName.lastIndexOf('/') + 1); - try { - return Optional.of(EpsgCrs.of(Integer.parseInt(code))); - } catch (Exception e) { - // ignore, fallback to default - } - } else if ("http://www.opengis.net/def/crs/OGC/0/CRS84".equals(srsName) - || "http://www.opengis.net/def/crs/OGC/1.3/CRS84".equals(srsName) - || "urn:ogc:def:crs:OGC:1.3:CRS84".equals(srsName)) { - return Optional.of(OgcCrs.CRS84); - } else if ("http://www.opengis.net/def/crs/OGC/0/CRS84h".equals(srsName) - || "urn:ogc:def:crs:OGC::CRS84h".equals(srsName)) { - return Optional.of(OgcCrs.CRS84h); + /** Adds {@code geom} to the nearest non-transparent ancestor in the stack. */ + private void contributeToConsumer(Geometry geom) { + Iterator it = stack.iterator(); + while (it.hasNext()) { + Frame anc = it.next(); + if (isTransparent(anc.kind)) { + continue; } + anc.children.add(geom); + return; } + // no consumer found — geometry becomes the root result + result = geom; + } + + private static boolean isTransparent(Kind kind) { + return switch (kind) { + case POINT_MEMBER, + CURVE_MEMBER, + LINE_STRING_MEMBER, + POLYGON_MEMBER, + SURFACE_MEMBER, + GEOMETRY_MEMBER, + POINT_MEMBERS, + CURVE_MEMBERS, + POLYGON_MEMBERS, + SURFACE_MEMBERS, + EXTERIOR, + INTERIOR, + OUTER_BOUNDARY_IS, + INNER_BOUNDARY_IS, + SHELL, + SEGMENTS, + PATCHES, + UNKNOWN -> + true; + default -> false; + }; + } + + /** + * Frames that produce a geometry value; they capture srsName/srsDimension at start and inherit + * from ancestors so that leaf primitives carry the same CRS as their enclosing object. Includes + * rings, surface patches, and curve segments — none of those typically carry a srsName attribute + * themselves, but they must still inherit one for the built primitive. + */ + private static boolean isObject(Kind kind) { + return switch (kind) { + case POINT, + LINE_STRING, + POLYGON, + CURVE, + COMPOSITE_CURVE, + SURFACE, + COMPOSITE_SURFACE, + POLYHEDRAL_SURFACE, + SOLID, + MULTI_POINT, + MULTI_LINE_STRING, + MULTI_CURVE, + MULTI_POLYGON, + MULTI_SURFACE, + MULTI_GEOMETRY, + LINEAR_RING, + RING, + POLYGON_PATCH, + LINE_STRING_SEGMENT, + ARC, + ARC_STRING, + CIRCLE -> + true; + default -> false; + }; + } - // get CRS from parent scope - PartialGeometry g = currentGeometry; - while (g != null) { - if (g.geometry != null && g.geometry.getCrs().isPresent()) { - return g.geometry.getCrs(); + private static Kind classify(String localName) { + return switch (localName) { + case "Point" -> Kind.POINT; + case "LineString" -> Kind.LINE_STRING; + case "Polygon" -> Kind.POLYGON; + case "Curve" -> Kind.CURVE; + case "CompositeCurve" -> Kind.COMPOSITE_CURVE; + case "Surface" -> Kind.SURFACE; + case "CompositeSurface" -> Kind.COMPOSITE_SURFACE; + case "PolyhedralSurface" -> Kind.POLYHEDRAL_SURFACE; + case "Solid" -> Kind.SOLID; + case "MultiPoint" -> Kind.MULTI_POINT; + case "MultiLineString" -> Kind.MULTI_LINE_STRING; + case "MultiCurve" -> Kind.MULTI_CURVE; + case "MultiPolygon" -> Kind.MULTI_POLYGON; + case "MultiSurface" -> Kind.MULTI_SURFACE; + case "MultiGeometry" -> Kind.MULTI_GEOMETRY; + case "pointMember" -> Kind.POINT_MEMBER; + case "curveMember" -> Kind.CURVE_MEMBER; + case "lineStringMember" -> Kind.LINE_STRING_MEMBER; + case "polygonMember" -> Kind.POLYGON_MEMBER; + case "surfaceMember" -> Kind.SURFACE_MEMBER; + case "geometryMember" -> Kind.GEOMETRY_MEMBER; + case "pointMembers" -> Kind.POINT_MEMBERS; + case "curveMembers" -> Kind.CURVE_MEMBERS; + case "polygonMembers" -> Kind.POLYGON_MEMBERS; + case "surfaceMembers" -> Kind.SURFACE_MEMBERS; + case "exterior" -> Kind.EXTERIOR; + case "interior" -> Kind.INTERIOR; + case "outerBoundaryIs" -> Kind.OUTER_BOUNDARY_IS; + case "innerBoundaryIs" -> Kind.INNER_BOUNDARY_IS; + case "Shell" -> Kind.SHELL; + case "segments" -> Kind.SEGMENTS; + case "patches" -> Kind.PATCHES; + case "LinearRing" -> Kind.LINEAR_RING; + case "Ring" -> Kind.RING; + case "PolygonPatch" -> Kind.POLYGON_PATCH; + case "LineStringSegment" -> Kind.LINE_STRING_SEGMENT; + case "Arc" -> Kind.ARC; + case "ArcString" -> Kind.ARC_STRING; + case "Circle" -> Kind.CIRCLE; + default -> null; + }; + } + + private Optional inheritedCrs() { + for (Frame f : stack) { + if (f.crs.isPresent()) { + return f.crs; } - g = g.parent; } return Optional.empty(); } - private OptionalInt getDimFromSrsDimension(AsyncXMLStreamReader parser) { - String srsDimension = parser.getAttributeValue(null, "srsDimension"); - if (srsDimension != null) { + private OptionalInt inheritedSrsDimension() { + for (Frame f : stack) { + if (f.srsDimension.isPresent()) { + return f.srsDimension; + } + } + return OptionalInt.empty(); + } + + private static Optional parseSrsName( + AsyncXMLStreamReader parser, Map srsNameMappings) { + String srsName = parser.getAttributeValue(null, "srsName"); + if (srsName == null || srsName.isEmpty()) { + return Optional.empty(); + } + EpsgCrs mapped = srsNameMappings.get(srsName); + if (mapped != null) { + return Optional.of(mapped); + } + if (srsName.startsWith("urn:ogc:def:crs:EPSG::")) { try { - return OptionalInt.of(Integer.parseInt(srsDimension)); + return Optional.of( + EpsgCrs.of(Integer.parseInt(srsName.substring(srsName.lastIndexOf(':') + 1)))); } catch (Exception e) { - // ignore, fallback to default + // fall through } + } else if (srsName.startsWith("http://www.opengis.net/def/crs/EPSG/0/")) { + try { + return Optional.of( + EpsgCrs.of(Integer.parseInt(srsName.substring(srsName.lastIndexOf('/') + 1)))); + } catch (Exception e) { + // fall through + } + } else if ("http://www.opengis.net/def/crs/OGC/0/CRS84".equals(srsName) + || "http://www.opengis.net/def/crs/OGC/1.3/CRS84".equals(srsName) + || "urn:ogc:def:crs:OGC:1.3:CRS84".equals(srsName)) { + return Optional.of(OgcCrs.CRS84); + } else if ("http://www.opengis.net/def/crs/OGC/0/CRS84h".equals(srsName) + || "urn:ogc:def:crs:OGC::CRS84h".equals(srsName)) { + return Optional.of(OgcCrs.CRS84h); } + return Optional.empty(); + } - // get dimension from parent scope - PartialGeometry g = currentGeometry; - while (g != null) { - if (g.geometry != null) { - return OptionalInt.of(g.geometry.getAxes().size()); + private static OptionalInt parseSrsDimension(AsyncXMLStreamReader parser) { + String dim = parser.getAttributeValue(null, "srsDimension"); + if (dim != null) { + try { + return OptionalInt.of(Integer.parseInt(dim)); + } catch (NumberFormatException e) { + // fall through } - g = g.parent; } - return OptionalInt.empty(); } - private double[] parseCoordinates( - AsyncXMLStreamReader parser, String tagName) throws XMLStreamException { - StringBuilder text = new StringBuilder(); + private boolean readCoordinateText(AsyncXMLStreamReader parser) + throws XMLStreamException { + Frame coordFrame = stack.peek(); while (parser.hasNext()) { int event = parser.next(); - if (event == AsyncXMLStreamReader.EVENT_INCOMPLETE) { - waitingForInput = true; - currentGeometry.textBuffer = new StringBuilder(text); - // keep current state and resume processing when more input is available - return null; - } else if (event == XMLStreamConstants.CHARACTERS) { - text.append(parser.getText()); - } else if (event == XMLStreamConstants.END_ELEMENT && tagName.equals(parser.getLocalName())) { - break; - } - } - return parseDoubles(text.toString()); + switch (event) { + case AsyncXMLStreamReader.EVENT_INCOMPLETE: + waitingForInput = true; + return false; + case XMLStreamConstants.CHARACTERS: + coordFrame.textBuffer.append(parser.getText()); + break; + case XMLStreamConstants.END_ELEMENT: + if (coordFrame.elementName.equals(parser.getLocalName())) { + finalizeCoordinates(coordFrame); + stack.pop(); + applyCoordinates(coordFrame); + return true; + } + break; + default: + break; + } + } + waitingForInput = true; + return false; + } + + private static void finalizeCoordinates(Frame coordFrame) { + String text = coordFrame.textBuffer.toString().trim(); + coordFrame.coords = text.isEmpty() ? new double[0] : parseDoubles(text); + } + + private void applyCoordinates(Frame coordFrame) { + if (stack.isEmpty()) { + return; + } + Frame parent = stack.peek(); + if (parent.coords == null) { + parent.coords = coordFrame.coords; + } else { + double[] a = parent.coords; + double[] b = coordFrame.coords; + double[] c = new double[a.length + b.length]; + System.arraycopy(a, 0, c, 0, a.length); + System.arraycopy(b, 0, c, a.length, b.length); + parent.coords = c; + } + } + + private static Axes axesOf(Frame f) { + if (f.srsDimension.isPresent()) { + return f.srsDimension.getAsInt() >= 3 ? Axes.XYZ : Axes.XY; + } + return Axes.XY; + } + + // -- builders -------------------------------------------------------------- + + private Geometry buildGeometry(Frame f) throws IOException { + return switch (f.kind) { + case POINT -> buildPoint(f); + case LINE_STRING -> buildSinglePosList(f, false); + case CURVE -> buildCurve(f); + case COMPOSITE_CURVE -> buildCompositeCurve(f); + case POLYGON -> buildPolygon(f); + case SURFACE -> buildSurface(f); + case COMPOSITE_SURFACE -> buildCompositeSurface(f); + case POLYHEDRAL_SURFACE -> buildPolyhedralSurface(f, false); + case SOLID -> buildSolid(f); + case MULTI_POINT -> buildMultiPoint(f); + case MULTI_LINE_STRING -> buildMultiLineString(f); + case MULTI_CURVE -> buildMultiCurve(f); + case MULTI_POLYGON -> buildMultiPolygon(f); + case MULTI_SURFACE -> buildMultiSurface(f); + case MULTI_GEOMETRY -> buildMultiGeometry(f); + case LINEAR_RING -> buildSinglePosList(f, false); + case RING -> buildRing(f); + case POLYGON_PATCH -> buildPolygon(f); + case LINE_STRING_SEGMENT -> buildSinglePosList(f, false); + case ARC, ARC_STRING -> buildSinglePosList(f, true); + case CIRCLE -> buildSinglePosList(f, true); + default -> null; + }; + } + + private Point buildPoint(Frame f) throws IOException { + double[] c = f.coords != null ? f.coords : new double[0]; + if (c.length == 0) { + throw new IOException("Empty ."); + } + Axes axes = c.length >= 3 ? Axes.XYZ : Axes.XY; + return Point.of(Position.of(axes, c), f.crs); + } + + private Geometry buildSinglePosList(Frame f, boolean curved) throws IOException { + double[] c = f.coords != null ? f.coords : new double[0]; + if (c.length == 0) { + throw new IOException("Empty ."); + } + Axes axes = axesOf(f); + // If srsDimension wasn't declared explicitly anywhere in scope, heuristically pick 3D when + // total coordinate count is divisible by 3 but not by 2. Otherwise default 2D. + if (f.srsDimension.isEmpty()) { + if (c.length % 2 != 0 && c.length % 3 == 0) { + axes = Axes.XYZ; + } + } + PositionList pl = PositionList.of(axes, c); + return curved ? CircularString.of(pl, f.crs) : LineString.of(pl, f.crs); + } + + /** + * Each curveMember of a Ring carries a primitive Curve. Primitives are preserved — a Ring with + * multiple curveMembers becomes a CompoundCurve even if every member is linear, since merging + * separate primitive Curves into one LineString would erase the input's primitive structure. + */ + private Curve buildRing(Frame f) throws IOException { + if (f.children.isEmpty()) { + throw new IOException("Empty ."); + } + if (f.children.size() == 1) { + Geometry only = f.children.get(0); + if (only instanceof LineString ls) { + return ls; + } + if (only instanceof CircularString cs) { + return cs; + } + if (only instanceof CompoundCurve cc) { + return cc; + } + } + List segs = new ArrayList<>(); + for (Geometry g : f.children) { + if (g instanceof SingleCurve sc) { + segs.add(sc); + } else if (g instanceof CompoundCurve cc) { + // CompoundCurve cannot nest in our model; flatten — the curveMember had heterogeneous + // segments which already lost primitive identity at Curve build time. + segs.addAll(cc.getValue()); + } else { + throw new IOException( + "Unsupported geometry inside : " + g.getClass().getSimpleName()); + } + } + return CompoundCurve.of(segs, f.crs); + } + + private static Axes axesFromMembers(List> members) { + for (Geometry g : members) { + if (!g.isEmpty()) { + return g.getAxes(); + } + } + return Axes.XY; + } + + /** Concatenate connected SingleCurve segments, dropping each segment's duplicated start. */ + private static double[] mergeConnectedSegments(List> segments, Axes axes) { + int dim = axes.size(); + int total = 0; + for (int i = 0; i < segments.size(); i++) { + SingleCurve sc = (SingleCurve) segments.get(i); + int n = sc.getValue().getNumPositions(); + total += i == 0 ? n : Math.max(0, n - 1); + } + double[] out = new double[total * dim]; + int pos = 0; + for (int i = 0; i < segments.size(); i++) { + SingleCurve sc = (SingleCurve) segments.get(i); + double[] cc = sc.getValue().getCoordinates(); + int start = i == 0 ? 0 : dim; + System.arraycopy(cc, start, out, pos, cc.length - start); + pos += cc.length - start; + } + return out; + } + + /** + * Segments inside a single {@code } are parts of one primitive Curve, not separate + * primitives — consecutive segments of the same type are merged into one primitive (LineString or + * CircularString). Heterogeneous segments become a CompoundCurve. + */ + private Geometry buildCurve(Frame f) throws IOException { + if (f.children.isEmpty()) { + throw new IOException("Empty ."); + } + if (f.children.size() == 1) { + return f.children.get(0); + } + boolean allLinear = f.children.stream().allMatch(g -> g instanceof LineString); + if (allLinear) { + Axes axes = axesFromMembers(f.children); + return LineString.of(PositionList.of(axes, mergeConnectedSegments(f.children, axes)), f.crs); + } + boolean allCircular = f.children.stream().allMatch(g -> g instanceof CircularString); + if (allCircular) { + Axes axes = axesFromMembers(f.children); + return CircularString.of( + PositionList.of(axes, mergeConnectedSegments(f.children, axes)), f.crs); + } + List sc = new ArrayList<>(); + for (Geometry g : f.children) { + if (g instanceof SingleCurve s) { + sc.add(s); + } else { + throw new IOException( + "Unsupported geometry inside : " + g.getClass().getSimpleName()); + } + } + return CompoundCurve.of(sc, f.crs); + } + + private Geometry buildCompositeCurve(Frame f) throws IOException { + if (f.children.isEmpty()) { + throw new IOException("Empty ."); + } + List sc = new ArrayList<>(); + for (Geometry g : f.children) { + if (g instanceof SingleCurve s) { + sc.add(s); + } else if (g instanceof CompoundCurve cc) { + sc.addAll(cc.getValue()); + } else { + throw new IOException( + "Unsupported geometry inside : " + g.getClass().getSimpleName()); + } + } + if (sc.size() == 1) { + return sc.get(0); + } + return CompoundCurve.of(sc, f.crs); + } + + private Geometry buildPolygon(Frame f) throws IOException { + if (f.children.isEmpty()) { + throw new IOException("Empty ."); + } + boolean allLinear = f.children.stream().allMatch(g -> g instanceof LineString); + if (allLinear) { + List rings = new ArrayList<>(f.children.size()); + for (Geometry g : f.children) { + rings.add(((LineString) g).getValue()); + } + return Polygon.of(rings, f.crs); + } + List> rings = new ArrayList<>(); + for (Geometry g : f.children) { + if (g instanceof Curve c) { + rings.add(c); + } else { + throw new IOException( + "Unsupported ring geometry inside : " + + g.getClass().getSimpleName()); + } + } + return CurvePolygon.of(rings, f.crs); + } + + private Geometry buildSurface(Frame f) throws IOException { + if (f.children.isEmpty()) { + throw new IOException("Empty ."); + } + if (f.children.size() == 1) { + return f.children.get(0); + } + List polys = new ArrayList<>(); + for (Geometry g : f.children) { + if (g instanceof Polygon p) { + polys.add(p); + } else { + throw new IOException( + "Multi-patch requires planar PolygonPatches; found " + + g.getClass().getSimpleName()); + } + } + return PolyhedralSurface.of(polys, false, f.crs); + } + + private Geometry buildCompositeSurface(Frame f) throws IOException { + if (f.children.isEmpty()) { + throw new IOException("Empty ."); + } + boolean allPlanar = f.children.stream().allMatch(g -> g instanceof Polygon); + if (allPlanar) { + List polys = new ArrayList<>(); + for (Geometry g : f.children) { + polys.add((Polygon) g); + } + return PolyhedralSurface.of(polys, false, f.crs); + } + List> surfaces = new ArrayList<>(); + for (Geometry g : f.children) { + if (g instanceof Surface s) { + surfaces.add(s); + } else { + throw new IOException( + "Unsupported geometry inside : " + g.getClass().getSimpleName()); + } + } + return MultiSurface.of(surfaces, f.crs); + } + + private Geometry buildPolyhedralSurface(Frame f, boolean closed) throws IOException { + if (f.children.isEmpty()) { + throw new IOException("Empty ."); + } + List polys = new ArrayList<>(); + for (Geometry g : f.children) { + if (g instanceof Polygon p) { + polys.add(p); + } else { + throw new IOException( + "Non-planar patch in : " + g.getClass().getSimpleName()); + } + } + return PolyhedralSurface.of(polys, closed, f.crs); + } + + private Geometry buildSolid(Frame f) throws IOException { + if (f.children.isEmpty()) { + throw new IOException("Empty ."); + } + List polys = new ArrayList<>(); + for (Geometry g : f.children) { + if (g instanceof Polygon p) { + polys.add(p); + } else { + throw new IOException("Non-planar surface in : " + g.getClass().getSimpleName()); + } + } + return PolyhedralSurface.of(polys, true, f.crs); + } + + private Geometry buildMultiPoint(Frame f) throws IOException { + List pts = new ArrayList<>(); + for (Geometry g : f.children) { + if (g instanceof Point p) { + pts.add(p); + } else { + throw new IOException( + "Unsupported member in : " + g.getClass().getSimpleName()); + } + } + return MultiPoint.of(pts, f.crs); + } + + private Geometry buildMultiLineString(Frame f) throws IOException { + List ls = new ArrayList<>(); + for (Geometry g : f.children) { + if (g instanceof LineString l) { + ls.add(l); + } else { + throw new IOException( + "Unsupported member in : " + g.getClass().getSimpleName()); + } + } + return MultiLineString.of(ls, f.crs); + } + + private Geometry buildMultiCurve(Frame f) throws IOException { + boolean allLinear = f.children.stream().allMatch(g -> g instanceof LineString); + if (allLinear) { + List ls = new ArrayList<>(); + for (Geometry g : f.children) { + ls.add((LineString) g); + } + return MultiLineString.of(ls, f.crs); + } + List> cs = new ArrayList<>(); + for (Geometry g : f.children) { + if (g instanceof Curve c) { + cs.add(c); + } else { + throw new IOException( + "Unsupported member in : " + g.getClass().getSimpleName()); + } + } + return MultiCurve.of(cs, f.crs); + } + + private Geometry buildMultiPolygon(Frame f) throws IOException { + List ps = new ArrayList<>(); + for (Geometry g : f.children) { + if (g instanceof Polygon p) { + ps.add(p); + } else { + throw new IOException( + "Unsupported member in : " + g.getClass().getSimpleName()); + } + } + return MultiPolygon.of(ps, f.crs); + } + + private Geometry buildMultiSurface(Frame f) throws IOException { + if (f.children.stream().allMatch(g -> g instanceof Polygon)) { + List ps = new ArrayList<>(); + for (Geometry g : f.children) { + ps.add((Polygon) g); + } + return MultiPolygon.of(ps, f.crs); + } + List> ss = new ArrayList<>(); + for (Geometry g : f.children) { + if (g instanceof Surface s) { + ss.add(s); + } else { + throw new IOException( + "Unsupported member in : " + g.getClass().getSimpleName()); + } + } + return MultiSurface.of(ss, f.crs); + } + + private Geometry buildMultiGeometry(Frame f) { + return GeometryCollection.of(ImmutableList.copyOf(f.children), f.crs); } private static double[] parseDoubles(String text) { diff --git a/xtraplatform-features-gml/src/main/java/de/ii/xtraplatform/features/gml/domain/VariableObjectName.java b/xtraplatform-features-gml/src/main/java/de/ii/xtraplatform/features/gml/domain/VariableObjectName.java new file mode 100644 index 000000000..6e194e11a --- /dev/null +++ b/xtraplatform-features-gml/src/main/java/de/ii/xtraplatform/features/gml/domain/VariableObjectName.java @@ -0,0 +1,34 @@ +/* + * 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.gml.domain; + +import java.util.Map; +import org.immutables.value.Value; + +/** + * Reverse counterpart of the encoder's {@code VariableName}. Configured per object type whose GML + * object element name varies with a discriminator property value: the decoder, when it sees one of + * the configured wire element names, emits {@link #getProperty()} as the path segment and the + * mapped source value at that path. + * + *

The {@link #getMapping()} direction is reversed relative to the encoder: keys are the + * qualified wire element names ({@code prefix:LocalName} as they appear on the wire after + * namespace-prefix normalisation), values are the source-side property values to emit. + */ +@Value.Immutable +public interface VariableObjectName { + + /** Schema property name into which the discriminator value is emitted. */ + String getProperty(); + + /** + * Wire qualified element name → source value. The qualified name uses the prefix declared in the + * decoder's namespace map for the element's namespace URI. + */ + Map getMapping(); +} diff --git a/xtraplatform-features-gml/src/test/groovy/de/ii/xtraplatform/features/gml/app/FeatureTokenDecoderGmlSpec.groovy b/xtraplatform-features-gml/src/test/groovy/de/ii/xtraplatform/features/gml/app/FeatureTokenDecoderGmlFromWfsSpec.groovy similarity index 95% rename from xtraplatform-features-gml/src/test/groovy/de/ii/xtraplatform/features/gml/app/FeatureTokenDecoderGmlSpec.groovy rename to xtraplatform-features-gml/src/test/groovy/de/ii/xtraplatform/features/gml/app/FeatureTokenDecoderGmlFromWfsSpec.groovy index 4575d9d3e..a170afa93 100644 --- a/xtraplatform-features-gml/src/test/groovy/de/ii/xtraplatform/features/gml/app/FeatureTokenDecoderGmlSpec.groovy +++ b/xtraplatform-features-gml/src/test/groovy/de/ii/xtraplatform/features/gml/app/FeatureTokenDecoderGmlFromWfsSpec.groovy @@ -22,9 +22,9 @@ import javax.xml.namespace.QName /** * @author zahnen */ -class FeatureTokenDecoderGmlSpec extends Specification { +class FeatureTokenDecoderGmlFromWfsSpec extends Specification { - static final Logger LOGGER = LoggerFactory.getLogger(FeatureTokenDecoderGmlSpec.class) + static final Logger LOGGER = LoggerFactory.getLogger(FeatureTokenDecoderGmlFromWfsSpec.class) @Shared Reactive reactive @@ -57,7 +57,7 @@ class FeatureTokenDecoderGmlSpec extends Specification { .geometryType(GeometryType.POLYGON)) .build() - decoder = new FeatureTokenDecoderGml( + decoder = new FeatureTokenDecoderGmlFromWfs( ["bag": "http://bag.geonovum.nl", "gml": "http://www.opengis.net/gml/3.2"], [new QName("http://bag.geonovum.nl", "pand")], schema, diff --git a/xtraplatform-features-gml/src/test/groovy/de/ii/xtraplatform/features/gml/domain/FeatureTokenDecoderGmlNasSpec.groovy b/xtraplatform-features-gml/src/test/groovy/de/ii/xtraplatform/features/gml/domain/FeatureTokenDecoderGmlNasSpec.groovy new file mode 100644 index 000000000..62df8bc0b --- /dev/null +++ b/xtraplatform-features-gml/src/test/groovy/de/ii/xtraplatform/features/gml/domain/FeatureTokenDecoderGmlNasSpec.groovy @@ -0,0 +1,217 @@ +/* + * 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.gml.domain + +import de.ii.xtraplatform.crs.domain.EpsgCrs +import de.ii.xtraplatform.features.domain.FeatureSchema +import de.ii.xtraplatform.features.domain.FeatureTokenType +import de.ii.xtraplatform.features.domain.ImmutableFeatureQuery +import de.ii.xtraplatform.features.domain.ImmutableFeatureSchema +import de.ii.xtraplatform.features.domain.ImmutableSchemaMapping +import de.ii.xtraplatform.features.domain.SchemaBase +import de.ii.xtraplatform.features.domain.SchemaMapping +import de.ii.xtraplatform.features.domain.pipeline.FeatureEventHandlerSimple +import de.ii.xtraplatform.features.domain.pipeline.FeatureTokenDecoderSimple +import de.ii.xtraplatform.features.gml.domain.FeatureTokenDecoderGmlInputProfile +import de.ii.xtraplatform.features.gml.domain.ImmutableFeatureTokenDecoderGmlInputProfile +import de.ii.xtraplatform.geometries.domain.Geometry +import de.ii.xtraplatform.geometries.domain.GeometryType +import de.ii.xtraplatform.streams.app.ReactiveRx +import de.ii.xtraplatform.streams.domain.Reactive +import spock.lang.Shared +import spock.lang.Specification +import spock.lang.Unroll + +import javax.xml.namespace.QName + +/** + * Drives the SQL-targeted decoder over real ALKIS NAS feature slices. Fixtures under {@code + * src/test/resources/nas/} are produced from ALKIS data. The schemas are for an SQL + * feature provider. + */ +class FeatureTokenDecoderGmlNasSpec extends Specification { + + @Shared Reactive reactive + @Shared Reactive.Runner runner + + static final String ADV_NS = "http://www.adv-online.de/namespaces/adv/gid/7.1" + + static final Map NAMESPACES = [ + "adv": ADV_NS, + "gml": "http://www.opengis.net/gml/3.2", + "xlink": "http://www.w3.org/1999/xlink", + "xsi": "http://www.w3.org/2001/XMLSchema-instance" + ] + + static final EpsgCrs STORAGE_CRS = EpsgCrs.of(25832) + + /** + * NAS wraps every geometry in {@code }; the geometry property carries + * {@code position} as its alias and the profile turns on alias-based lookup. + */ + static final FeatureTokenDecoderGmlInputProfile NAS_PROFILE = + ImmutableFeatureTokenDecoderGmlInputProfile.builder() + .useAlias(true) + .build() + + def setupSpec() { + reactive = new ReactiveRx() + runner = reactive.runner("test-nas") + } + + def cleanupSpec() { + runner.close() + } + + static FeatureSchema buildSchema(String featureType, GeometryType geomType) { + def builder = new ImmutableFeatureSchema.Builder() + .name(featureType.toLowerCase()) + .sourcePath(tablePath(featureType)) + .type(SchemaBase.Type.OBJECT) + .putProperties2("oid", new ImmutableFeatureSchema.Builder() + .sourcePath("objid") + .type(SchemaBase.Type.STRING) + .role(SchemaBase.Role.ID) + .alias("id")) + if (geomType != null) { + builder.putProperties2("geo", new ImmutableFeatureSchema.Builder() + .sourcePath("geo") + .type(SchemaBase.Type.GEOMETRY) + .geometryType(geomType) + .alias("position")) + } + return builder.build() + } + + static String tablePath(String featureType) { + switch (featureType) { + case "AX_Flurstueck": return "/o11001" + case "AX_Gebaeude": return "/o31001" + case "AX_LagebezeichnungOhneHausnummer": return "/o12001" + default: return "/" + featureType.toLowerCase() + } + } + + static FeatureTokenDecoderSimple> newDecoder(String featureType, GeometryType geomType) { + def schema = buildSchema(featureType, geomType) + new FeatureTokenDecoderGml( + NAMESPACES, + [new QName(ADV_NS, featureType)], + schema, + ImmutableFeatureQuery.builder().type(schema.getName()).build(), + Map.of(schema.getName(), + new ImmutableSchemaMapping.Builder() + .targetSchema(schema) + .sourcePathTransformer((path, isValue) -> path) + .build()), + STORAGE_CRS, + Optional.empty(), + Optional.empty(), + NAS_PROFILE) + } + + List runDecoder(FeatureTokenDecoderSimple decoder, byte[] xml) { + def stream = Reactive.Source.inputStream(new ByteArrayInputStream(xml)) + .via(decoder) + .to(Reactive.Sink.reduce([], (list, element) -> { list << element; return list })) + return stream.on(runner).run().toCompletableFuture().join() as List + } + + @Unroll + def 'NAS feature #featureType: gml:id and geometry are routed to the schema source paths'() { + given: + def decoder = newDecoder(featureType, expectedGeomType) + def bytes = new File("src/test/resources/nas/${featureType}.xml").bytes + + when: + def tokens = runDecoder(decoder, bytes) + + then: + // Document structure: starts with INPUT, contains exactly one feature. + tokens.first() == FeatureTokenType.INPUT + tokens.last() == FeatureTokenType.INPUT_END + tokens.count { it == FeatureTokenType.FEATURE } == 1 + tokens.count { it == FeatureTokenType.FEATURE_END } == 1 + + // Every ALKIS feature in the Bonn data set carries an OID-style id starting with "DENW". + def idValue = valueAtPath(tokens, ["oid"]) + idValue != null + ((String) idValue).startsWith("DENW") + + def geomTokens = tokens.findAll { it instanceof Geometry } as List + geomTokens.size() == (expectedGeomType == null ? 0 : 1) + if (expectedGeomType != null) { + assert geomTokens[0].type == expectedGeomType : + "expected ${expectedGeomType} for ${featureType} but got ${geomTokens[0].type}" + assert pathBeforeGeometry(tokens) == ["geo"] + } + + where: + featureType | expectedGeomType + 'AX_Aufnahmepunkt' | null + 'AX_BoeschungKliff' | null + 'AX_LagebezeichnungOhneHausnummer' | null + 'AX_PunktortAU' | GeometryType.POINT + 'AX_BesondereFlurstuecksgrenze' | GeometryType.LINE_STRING + 'AX_Fahrwegachse' | GeometryType.LINE_STRING + 'AX_Gebaeude' | GeometryType.CURVE_POLYGON + 'AX_Wohnbauflaeche' | GeometryType.CURVE_POLYGON + 'AX_Strassenverkehr' | GeometryType.CURVE_POLYGON + 'AX_Flurstueck' | GeometryType.MULTI_SURFACE + 'AX_Wald' | GeometryType.CURVE_POLYGON + 'AX_Turm' | GeometryType.CURVE_POLYGON + } + + def 'wrapped wfs:FeatureCollection root from a NAS-shaped document is rejected'() { + given: + def decoder = newDecoder('AX_Aufnahmepunkt', null) + // Re-wrap the bare feature in adv:AX_Bestandsdatenauszug + wfs:FeatureCollection + // to mimic what the source NAS files look like; CRUD mode must reject it. + def bare = new File('src/test/resources/nas/AX_Aufnahmepunkt.xml').text + .replaceFirst('<\\?xml[^>]*\\?>\\s*', '') + def wrapped = '\n' + + '' + + '' + + bare + + '' + + '' + + when: + runDecoder(decoder, wrapped.getBytes("UTF-8")) + + then: + def e = thrown(Exception) + rootCauseMessage(e).contains("Multi-feature ingest is not supported") + } + + private static Object valueAtPath(List tokens, List targetPath) { + for (int i = 0; i < tokens.size() - 2; i++) { + if (tokens[i] != FeatureTokenType.VALUE) continue + if (tokens.get(i + 1) == targetPath) { + return tokens.get(i + 2) + } + } + return null + } + + private static List pathBeforeGeometry(List tokens) { + for (int i = 0; i < tokens.size(); i++) { + if (tokens[i] instanceof Geometry && i > 0 && tokens[i - 1] instanceof List) { + return (List) tokens[i - 1] + } + } + return null + } + + private static String rootCauseMessage(Throwable t) { + Throwable cur = t + while (cur.cause != null && cur.cause != cur) cur = cur.cause + return cur.message ?: "" + } +} 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 new file mode 100644 index 000000000..ea6a06b60 --- /dev/null +++ b/xtraplatform-features-gml/src/test/groovy/de/ii/xtraplatform/features/gml/domain/FeatureTokenDecoderGmlSpec.groovy @@ -0,0 +1,2716 @@ +/* + * 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.gml.domain + +import ch.qos.logback.classic.Level +import ch.qos.logback.classic.Logger as LogbackLogger +import ch.qos.logback.classic.spi.ILoggingEvent +import ch.qos.logback.core.read.ListAppender +import de.ii.xtraplatform.crs.domain.EpsgCrs +import de.ii.xtraplatform.features.domain.FeatureSchema +import de.ii.xtraplatform.features.domain.FeatureTokenType +import de.ii.xtraplatform.features.domain.ImmutableFeatureQuery +import de.ii.xtraplatform.features.domain.ImmutableFeatureSchema +import de.ii.xtraplatform.features.domain.ImmutableSchemaConstraints +import de.ii.xtraplatform.features.domain.ImmutableSchemaMapping +import de.ii.xtraplatform.features.domain.SchemaBase +import de.ii.xtraplatform.features.domain.SchemaMapping +import de.ii.xtraplatform.features.domain.pipeline.FeatureEventHandlerSimple +import de.ii.xtraplatform.features.domain.pipeline.FeatureTokenDecoderSimple +import de.ii.xtraplatform.features.gml.domain.FeatureTokenDecoderGmlInputProfile +import de.ii.xtraplatform.features.gml.domain.ImmutableFeatureTokenDecoderGmlInputProfile +import de.ii.xtraplatform.features.gml.domain.ImmutableVariableObjectName +import de.ii.xtraplatform.geometries.domain.Geometry +import de.ii.xtraplatform.geometries.domain.GeometryType +import de.ii.xtraplatform.streams.app.ReactiveRx +import de.ii.xtraplatform.streams.domain.Reactive +import org.slf4j.LoggerFactory +import spock.lang.Shared +import spock.lang.Specification + +import javax.xml.namespace.QName + +/** + * Exercises {@link FeatureTokenDecoderGml} against an ALKIS-grounded fixture mirroring + * {@code ax_flurstueck}: short property names with SQL-column source paths and German aliases + * that the encoder writes as XML element local names. + */ +class FeatureTokenDecoderGmlSpec extends Specification { + + @Shared Reactive reactive + @Shared Reactive.Runner runner + + static final String ADV_NS = "http://www.adv-online.de/namespaces/adv/gid/7.1" + static final String SF_NS = "http://www.opengis.net/ogcapi-features-1/1.0/sf" + + static final Map NAMESPACES = [ + "adv": ADV_NS, + "gml": "http://www.opengis.net/gml/3.2", + "xlink": "http://www.w3.org/1999/xlink", + "xsi": "http://www.w3.org/2001/XMLSchema-instance", + "sf": SF_NS + ] + + static final List FEATURE_TYPES = [new QName(ADV_NS, "AX_Flurstueck")] + static final EpsgCrs STORAGE_CRS = EpsgCrs.of(25832) + + def setupSpec() { + reactive = new ReactiveRx() + runner = reactive.runner("test-sql") + } + + def cleanupSpec() { + runner.close() + } + + /** + * AX_Flurstueck slice: {@code oid} carries role ID with SQL column {@code objid} and alias + * {@code id}; {@code obk} is a POINT geometry with SQL column {@code obk} and alias {@code + * objektkoordinaten}; {@code fsk} is the Flurstückskennzeichen, a plain STRING column with + * alias {@code flurstueckskennzeichen}, used to exercise scalar value emission and {@code + * xsi:nil} handling. + */ + static FeatureSchema axFlurstueckSchema() { + new ImmutableFeatureSchema.Builder() + .name("ax_flurstueck") + .sourcePath("/o11001") + .type(SchemaBase.Type.OBJECT) + .putProperties2("oid", new ImmutableFeatureSchema.Builder() + .sourcePath("objid") + .type(SchemaBase.Type.STRING) + .role(SchemaBase.Role.ID) + .label("id") + .alias("id")) + .putProperties2("obk", new ImmutableFeatureSchema.Builder() + .sourcePath("obk") + .type(SchemaBase.Type.GEOMETRY) + .geometryType(GeometryType.POINT) + .label("objektkoordinaten") + .alias("objektkoordinaten")) + .putProperties2("fsk", new ImmutableFeatureSchema.Builder() + .sourcePath("fsk") + .type(SchemaBase.Type.STRING) + .label("flurstueckskennzeichen") + .alias("flurstueckskennzeichen")) + .build() + } + + /** + * Mirrors the encoder side: {@code useAlias} on, so the decoder matches incoming XML element + * local names against each property's {@code alias}. + */ + static FeatureTokenDecoderGmlInputProfile useAliasProfile() { + ImmutableFeatureTokenDecoderGmlInputProfile.builder() + .useAlias(true) + .build() + } + + static FeatureTokenDecoderSimple> newDecoder( + FeatureSchema schema, FeatureTokenDecoderGmlInputProfile profile, + Optional nullValue = Optional.empty(), + Optional headerCrs = Optional.empty()) { + new FeatureTokenDecoderGml( + NAMESPACES, + FEATURE_TYPES, + schema, + ImmutableFeatureQuery.builder().type(schema.getName()).build(), + Map.of(schema.getName(), + new ImmutableSchemaMapping.Builder() + .targetSchema(schema) + .sourcePathTransformer((path, isValue) -> path) + .build()), + STORAGE_CRS, + headerCrs, + nullValue, + profile) + } + + /** + * AX_Flurstueck slice exercising xlink/codelist routing: the codelist-valued {@code anl} + * (anlass), the relation properties {@code "11001-21008"} (istGebucht, FEATURE_REF) and + * {@code "11001-12001"} (zeigtAuf, FEATURE_REF_ARRAY), and the plain STRING {@code qid} + * (quellobjektID) inherited from AA_Objekt. Property names, source paths, types and aliases + * follow the AdV NAS schema so the test exercises real shapes. + */ + static FeatureSchema axFlurstueckWithRefsSchema() { + new ImmutableFeatureSchema.Builder() + .name("ax_flurstueck") + .sourcePath("/o11001") + .type(SchemaBase.Type.OBJECT) + .putProperties2("oid", new ImmutableFeatureSchema.Builder() + .sourcePath("objid") + .type(SchemaBase.Type.STRING) + .role(SchemaBase.Role.ID) + .alias("id")) + .putProperties2("anl", new ImmutableFeatureSchema.Builder() + .sourcePath("[id=rid]o11001__anl/anl_href") + .type(SchemaBase.Type.VALUE_ARRAY) + .valueType(SchemaBase.Type.STRING) + .alias("anlass") + .constraints(new ImmutableSchemaConstraints.Builder() + .codelist("AA_Anlassart") + .build())) + .putProperties2("11001-21008", new ImmutableFeatureSchema.Builder() + .sourcePath("p1100121008") + .type(SchemaBase.Type.FEATURE_REF) + .valueType(SchemaBase.Type.STRING) + .alias("istGebucht") + .refType("ax_buchungsstelle")) + .putProperties2("11001-12001", new ImmutableFeatureSchema.Builder() + .sourcePath("[id=rid]o11001__p1100112001/[p1100112001=objid]o12001/objid") + .type(SchemaBase.Type.FEATURE_REF_ARRAY) + .valueType(SchemaBase.Type.STRING) + .alias("zeigtAuf") + .refType("ax_lagebezeichnungohnehausnummer")) + .putProperties2("qid", new ImmutableFeatureSchema.Builder() + .sourcePath("qid") + .type(SchemaBase.Type.STRING) + .alias("quellobjektID")) + .build() + } + + static final FeatureTokenDecoderGmlInputProfile NAS_TEMPLATES = + ImmutableFeatureTokenDecoderGmlInputProfile.builder() + .useAlias(true) + .featureRefTemplate("urn:adv:oid:{{value}}") + .codelistUriTemplate("https://registry.gdi-de.org/codelist/de.adv-online.gid/{{codelistId}}/{{value}}") + .build() + + /** + * AX_Flurstueck slice for the uom validation checks: a FLOAT property {@code afl} + * (amtlicheFlaeche) with a {@code unit("m2")} declaration so the decoder's uom validation + * logic engages. + */ + static FeatureSchema axFlurstueckWithFlaecheSchema() { + new ImmutableFeatureSchema.Builder() + .name("ax_flurstueck") + .sourcePath("/o11001") + .type(SchemaBase.Type.OBJECT) + .putProperties2("oid", new ImmutableFeatureSchema.Builder() + .sourcePath("objid") + .type(SchemaBase.Type.STRING) + .role(SchemaBase.Role.ID) + .alias("id")) + .putProperties2("afl", new ImmutableFeatureSchema.Builder() + .sourcePath("afl") + .type(SchemaBase.Type.FLOAT) + .alias("amtlicheFlaeche") + .unit("m2")) + .build() + } + + /** + * ax_flurstueck variant with two geometry columns (POINT geometries), used to drive the + * mixed-CRS guard and srsName resolution checks across two geometries in one feature. + */ + static FeatureSchema twoGeometrySchema() { + new ImmutableFeatureSchema.Builder() + .name("ax_flurstueck") + .sourcePath("/o11001") + .type(SchemaBase.Type.OBJECT) + .putProperties2("oid", new ImmutableFeatureSchema.Builder() + .sourcePath("objid") + .type(SchemaBase.Type.STRING) + .role(SchemaBase.Role.ID) + .alias("id")) + .putProperties2("a", new ImmutableFeatureSchema.Builder() + .sourcePath("a") + .type(SchemaBase.Type.GEOMETRY) + .geometryType(GeometryType.POINT) + .alias("a")) + .putProperties2("b", new ImmutableFeatureSchema.Builder() + .sourcePath("b") + .type(SchemaBase.Type.GEOMETRY) + .geometryType(GeometryType.POINT) + .alias("b")) + .build() + } + + List runDecoder(FeatureTokenDecoderSimple decoder, String xml) { + def stream = Reactive.Source.inputStream(new ByteArrayInputStream(xml.getBytes("UTF-8"))) + .via(decoder) + .to(Reactive.Sink.reduce([], (list, element) -> { list << element; return list })) + return stream.on(runner).run().toCompletableFuture().join() as List + } + + def 'gml:id and a POINT geometry are routed to the ID and geometry properties via alias → source path'() { + given: + def decoder = newDecoder(axFlurstueckSchema(), useAliasProfile()) + // Encoder shape: gml:id on the feature element, as the alias-named + // wrapper around the gml:Point. Expected: gml:id at [o11001, objid], geometry at + // [o11001, obk]. + def xml = """ + + 1 2 + + """ + + when: + def tokens = runDecoder(decoder, xml) + + then: + tokens.first() == FeatureTokenType.INPUT + tokens.last() == FeatureTokenType.INPUT_END + tokens.count { it == FeatureTokenType.FEATURE } == 1 + tokens.count { it == FeatureTokenType.FEATURE_END } == 1 + + valueAtPath(tokens, ["oid"]) == "DENW36AL10000XYZ" + + def geometries = tokens.findAll { it instanceof Geometry } as List + geometries.size() == 1 + geometries[0].type == GeometryType.POINT + pathBeforeGeometry(tokens) == ["obk"] + } + + def 'a scalar property text value is emitted at the property source path'() { + given: + def decoder = newDecoder(axFlurstueckSchema(), useAliasProfile()) + def xml = """ + 05334001001000010001__ + """ + + when: + def tokens = runDecoder(decoder, xml) + + then: + // The fsk value is emitted at the SQL column source path, not at the alias. + valueAtPath(tokens, ["fsk"]) == "05334001001000010001__" + } + + def 'xsi:nil="true" emits the configured null marker'() { + given: + // nilReason on the same element must not leak as a token. + def decoder = newDecoder(axFlurstueckSchema(), useAliasProfile(), Optional.of("__NULL__")) + def xml = """ + + """ + + when: + def tokens = runDecoder(decoder, xml) + + then: + valueAtPath(tokens, ["fsk"]) == "__NULL__" + !tokens.contains("missing") + } + + def 'xsi:nil with no configured nullValue drops the property value'() { + given: + def decoder = newDecoder(axFlurstueckSchema(), useAliasProfile()) + def xml = """ + + """ + + when: + def tokens = runDecoder(decoder, xml) + + then: + // gml:id is the only value emitted; the nil scalar property is dropped. + tokens.count { it == FeatureTokenType.VALUE } == 1 + valueAtPath(tokens, ["oid"]) == "DENW36AL10000XYZ" + } + + def 'geometry srsName takes precedence over header and storage CRS'() { + given: + // header is set, storage is the default 25832; srsName on the gml:Point must win. + def decoder = newDecoder(axFlurstueckSchema(), useAliasProfile(), + Optional.empty(), Optional.of(EpsgCrs.of(4326))) + def xml = """ + + 1 2 + + """ + + when: + def tokens = runDecoder(decoder, xml) + + then: + def geometry = tokens.find { it instanceof Geometry } as Geometry + geometry.crs.get() == EpsgCrs.of(28992) + } + + def 'geometry without srsName falls back to header CRS'() { + given: + def decoder = newDecoder(axFlurstueckSchema(), useAliasProfile(), + Optional.empty(), Optional.of(EpsgCrs.of(4326))) + def xml = """ + + 1 2 + + """ + + when: + def tokens = runDecoder(decoder, xml) + + then: + def geometry = tokens.find { it instanceof Geometry } as Geometry + geometry.crs.get() == EpsgCrs.of(4326) + } + + def 'geometry without srsName or header falls back to storage CRS'() { + given: + def decoder = newDecoder(axFlurstueckSchema(), useAliasProfile()) + def xml = """ + + 1 2 + + """ + + when: + def tokens = runDecoder(decoder, xml) + + then: + def geometry = tokens.find { it instanceof Geometry } as Geometry + geometry.crs.get() == STORAGE_CRS + } + + def 'mixed CRSs across geometries in one feature are rejected'() { + given: + def decoder = newDecoder(twoGeometrySchema(), useAliasProfile()) + def xml = """ + 1 2 + 3 4 + """ + + when: + runDecoder(decoder, xml) + + then: + def e = thrown(Exception) + rootCauseMessage(e).contains("same CRS") + } + + def 'srsNameMappings resolves an ADV URN to the configured EpsgCrs'() { + given: + // Built-in srsName parser does not understand ADV URN forms; without the mapping + // the geometry would fall back to storage / header CRS. DE_DHDN_3GK2_NW101 is a + // 3-degree Gauss-Krueger zone 2 realization → EPSG:31466. + def profile = ImmutableFeatureTokenDecoderGmlInputProfile.builder() + .useAlias(true) + .putSrsNameMappings("urn:adv:crs:DE_DHDN_3GK2_NW101", EpsgCrs.of(31466)) + .build() + def decoder = newDecoder(axFlurstueckSchema(), profile) + def xml = """ + + 1 2 + + """ + + when: + def tokens = runDecoder(decoder, xml) + + then: + def geometry = tokens.find { it instanceof Geometry } as Geometry + geometry.crs.get() == EpsgCrs.of(31466) + } + + def 'srsNameMappings interacts correctly with the mixed-CRS guard'() { + given: + // Two ADV URN realizations both resolved to EPSG:31466. The mixed-CRS check + // compares resolved EpsgCrs values, not raw URNs, so these must NOT trip the guard. + def profile = ImmutableFeatureTokenDecoderGmlInputProfile.builder() + .useAlias(true) + .putSrsNameMappings("urn:adv:crs:DE_DHDN_3GK2_NW101", EpsgCrs.of(31466)) + .putSrsNameMappings("urn:adv:crs:DE_DHDN_3GK2_NW177", EpsgCrs.of(31466)) + .build() + def decoder = newDecoder(twoGeometrySchema(), profile) + def xml = """ + 1 2 + 3 4 + """ + + when: + def tokens = runDecoder(decoder, xml) + + then: + noExceptionThrown() + def geometries = tokens.findAll { it instanceof Geometry } as List + geometries.size() == 2 + geometries.every { it.crs.get() == EpsgCrs.of(31466) } + } + + def 'codelist xlink:href on adv:anlass is reduced to the bare code via codelistUriTemplate'() { + given: + def decoder = newDecoder(axFlurstueckWithRefsSchema(), NAS_TEMPLATES) + // anl is the AA_Anlassart codelist on AX_Flurstueck. The encoder emits xlink:href + + // xlink:title; the decoder reverses codelistUriTemplate so that the emitted value is + // the raw codelist value ("010704") and drops xlink:title. + def xml = """ + + """ + + when: + def tokens = runDecoder(decoder, xml) + + then: + tokens.contains("010704") + !tokens.contains("https://registry.gdi-de.org/codelist/de.adv-online.gid/AA_Anlassart/010704") + !tokens.contains("Qualitätssicherung und Datenpflege") + } + + def 'feature-ref xlink:href on adv:istGebucht is reduced to the bare feature id via featureRefTemplate'() { + given: + def decoder = newDecoder(axFlurstueckWithRefsSchema(), NAS_TEMPLATES) + def xml = """ + + """ + + when: + def tokens = runDecoder(decoder, xml) + + then: + valueAtPath(tokens, ["11001-21008"]) == "DENW36ALl800005x" + !tokens.contains("urn:adv:oid:DENW36ALl800005x") + } + + def 'feature-ref-array xlink:href on adv:zeigtAuf is reduced for each member via featureRefTemplate'() { + given: + def decoder = newDecoder(axFlurstueckWithRefsSchema(), NAS_TEMPLATES) + def xml = """ + + + """ + + when: + def tokens = runDecoder(decoder, xml) + + then: + tokens.contains("DENW36AL00000AAA") + tokens.contains("DENW36AL00000BBB") + !tokens.contains("urn:adv:oid:DENW36AL00000AAA") + } + + def 'xlink:href is emitted unchanged when no template is configured'() { + given: + def profile = ImmutableFeatureTokenDecoderGmlInputProfile.builder() + .useAlias(true) + .build() + def decoder = newDecoder(axFlurstueckWithRefsSchema(), profile) + def xml = """ + + """ + + when: + def tokens = runDecoder(decoder, xml) + + then: + valueAtPath(tokens, ["11001-21008"]) == "urn:adv:oid:DENW36ALl800005x" + } + + def 'xlink:href is emitted unchanged when the configured template does not match'() { + given: + def decoder = newDecoder(axFlurstueckWithRefsSchema(), NAS_TEMPLATES) + def xml = """ + + """ + + when: + def tokens = runDecoder(decoder, xml) + + then: + valueAtPath(tokens, ["11001-21008"]) == "https://example.org/items/some-other-id" + } + + def 'wrap=OBJECT FEATURE_REF: xlink:href on the property element emits the reduced id at the .id child'() { + given: + // Mirrors the live schema shape after an upstream wrap=OBJECT transformation expands a + // FEATURE_REF into an OBJECT with id/title/type children — the form + // FeatureEncoderSql actually receives in production. The id child carries the + // sourcePath of the underlying column; the type child is a constant marker (ignored + // here). The decoder must read xlink:href from the property element itself and emit it + // as the .id child's value so the writable column wired to id receives the bare ref id. + def schema = new ImmutableFeatureSchema.Builder() + .name("ax_flurstueck") + .sourcePath("/o11001") + .type(SchemaBase.Type.OBJECT) + .putProperties2("oid", new ImmutableFeatureSchema.Builder() + .sourcePath("objid") + .type(SchemaBase.Type.STRING) + .role(SchemaBase.Role.ID) + .alias("id")) + .putProperties2("11001-21008", new ImmutableFeatureSchema.Builder() + .type(SchemaBase.Type.OBJECT) + .alias("istGebucht") + .refType("ax_buchungsstelle") + .putProperties2("id", new ImmutableFeatureSchema.Builder() + .sourcePath("p1100121008") + .type(SchemaBase.Type.STRING)) + .putProperties2("title", new ImmutableFeatureSchema.Builder() + .sourcePath("p1100121008") + .type(SchemaBase.Type.STRING)) + .putProperties2("type", new ImmutableFeatureSchema.Builder() + .sourcePath("constant_11001_21008_x") + .type(SchemaBase.Type.STRING) + .constantValue("ax_buchungsstelle"))) + .build() + def decoder = newDecoder(schema, NAS_TEMPLATES) + def xml = """ + + """ + + when: + def tokens = runDecoder(decoder, xml) + + then: + // OBJECT bookends sit at the property's own path … + indexOfTokenAtPath(tokens, FeatureTokenType.OBJECT, ["11001-21008"]) >= 0 + indexOfTokenAtPath(tokens, FeatureTokenType.OBJECT_END, ["11001-21008"]) >= 0 + // … and the reduced ref id is emitted at the conventional .id child path. + valueAtPath(tokens, ["11001-21008", "id"]) == "DENW36ALl800005x" + !tokens.contains("urn:adv:oid:DENW36ALl800005x") + } + + def 'wrap=OBJECT_ARRAY FEATURE_REF_ARRAY: each xlink:href member emits its reduced id at the .id child, wrapped in ARRAY/ARRAY_END'() { + given: + // Mirrors the live schema shape after wrap=OBJECT_ARRAY expands a FEATURE_REF_ARRAY: + // the property is OBJECT_ARRAY with id/title/type children, refType set. Each wire + // sibling xlink:href becomes one OBJECT_START/.id-onValue/OBJECT_END triple inside a + // single ARRAY/ARRAY_END pair at the feature root. The encoder's onObjectStart picks + // up getTableForObject and opens one junction row per member; the .id onValue fills + // the FK column on that row. + def schema = new ImmutableFeatureSchema.Builder() + .name("ax_flurstueck") + .sourcePath("/o11001") + .type(SchemaBase.Type.OBJECT) + .putProperties2("oid", new ImmutableFeatureSchema.Builder() + .sourcePath("objid") + .type(SchemaBase.Type.STRING) + .role(SchemaBase.Role.ID) + .alias("id")) + .putProperties2("11001-12001", new ImmutableFeatureSchema.Builder() + .type(SchemaBase.Type.OBJECT_ARRAY) + .alias("zeigtAuf") + .refType("ax_lagebezeichnungohnehausnummer") + .putProperties2("id", new ImmutableFeatureSchema.Builder() + .sourcePath("[id=rid]o11001__p1100112001/p1100112001") + .type(SchemaBase.Type.STRING)) + .putProperties2("title", new ImmutableFeatureSchema.Builder() + .sourcePath("[id=rid]o11001__p1100112001/p1100112001") + .type(SchemaBase.Type.STRING)) + .putProperties2("type", new ImmutableFeatureSchema.Builder() + .sourcePath("constant_11001_12001_x") + .type(SchemaBase.Type.STRING) + .constantValue("ax_lagebezeichnungohnehausnummer"))) + .build() + def decoder = newDecoder(schema, NAS_TEMPLATES) + def xml = """ + + + """ + + when: + def tokens = runDecoder(decoder, xml) + + then: + // Exactly one ARRAY pair brackets all members at the OBJECT_ARRAY's path. + def arrayStart = indexOfTokenAtPath(tokens, FeatureTokenType.ARRAY, ["11001-12001"]) + def arrayEnd = indexOfTokenAtPath(tokens, FeatureTokenType.ARRAY_END, ["11001-12001"]) + arrayStart >= 0 && arrayEnd > arrayStart + // Two OBJECT bookend pairs sit at the OBJECT_ARRAY's path between the brackets. + def objectStarts = indicesOfTokenAtPath(tokens, FeatureTokenType.OBJECT, ["11001-12001"]) + objectStarts.size() == 2 + objectStarts.every { it > arrayStart && it < arrayEnd } + // Each member's reduced ref id is emitted at the conventional .id child path. + tokens.contains("DENW36AL00000AAA") + tokens.contains("DENW36AL00000BBB") + !tokens.contains("urn:adv:oid:DENW36AL00000AAA") + } + + def 'xlink:href on a property that is neither codelist nor feature-ref is dropped'() { + given: + // qid (quellobjektID) is a plain STRING inherited from aa_objekt — no codelist, not a + // feature-ref. An xlink:href on it must not surface as the value, and xlink:* must not + // leak as additional attributes. + def decoder = newDecoder(axFlurstueckWithRefsSchema(), NAS_TEMPLATES) + def xml = """ + + """ + + when: + def tokens = runDecoder(decoder, xml) + + then: + !tokens.contains("urn:something:else:42") + // only the gml:id is emitted as a value + tokens.count { it == FeatureTokenType.VALUE } == 1 + } + + def 'xlink:href takes precedence over text content when both are present on a codelist property'() { + given: + def decoder = newDecoder(axFlurstueckWithRefsSchema(), NAS_TEMPLATES) + def xml = """ + human label + """ + + when: + def tokens = runDecoder(decoder, xml) + + then: + tokens.contains("010704") + !tokens.contains("human label") + } + + def 'uom attribute is dropped from the token stream and silently passes when it matches the schema unit'() { + given: + // Encoder direction: schema unit "m2" → wire "urn:adv:uom:m2" (via UomMapping + + // UomStyle.TEMPLATE). Reverse on input: wire URN as key, canonical "m2" as value. The + // uom attribute itself is not surfaced — the canonical unit is already on the schema. + def profile = ImmutableFeatureTokenDecoderGmlInputProfile.builder() + .useAlias(true) + .putUomMappings("urn:adv:uom:m2", "m2") + .build() + def decoder = newDecoder(axFlurstueckWithFlaecheSchema(), profile) + def xml = """ + 1234.56 + """ + + List tokens = null + List warnings = captureWarnings { tokens = runDecoder(decoder, xml) } + + expect: + // The numeric value is emitted; the uom attribute (in either wire URN or canonical + // form) is not. + tokens.contains("1234.56") + !tokens.contains("urn:adv:uom:m2") + !tokens.contains("m2") + warnings.empty + } + + def 'mismatched uom (after reverse-mapping) logs a warning'() { + given: + // Wire value is unmapped and does not match the schema unit "m2". + def profile = ImmutableFeatureTokenDecoderGmlInputProfile.builder() + .useAlias(true) + .putUomMappings("urn:adv:uom:m2", "m2") + .build() + def decoder = newDecoder(axFlurstueckWithFlaecheSchema(), profile) + def xml = """ + 7.0 + """ + + List tokens = null + List warnings = captureWarnings { tokens = runDecoder(decoder, xml) } + + expect: + // The attribute is still dropped from the stream (validation only). + !tokens.contains("urn:adv:uom:km2") + // A warning is emitted naming the wire uom and the schema unit. + warnings.any { + it.formattedMessage.contains("urn:adv:uom:km2") && + it.formattedMessage.contains("m2") + } + } + + def 'uom attribute on a property without a declared unit is silently dropped'() { + given: + // qid is a plain STRING with no schema-declared unit, so a stray uom attribute is just + // dropped without a warning. + def decoder = newDecoder(axFlurstueckWithRefsSchema(), NAS_TEMPLATES) + def xml = """ + stray + """ + + List tokens = null + List warnings = captureWarnings { tokens = runDecoder(decoder, xml) } + + expect: + !tokens.contains("urn:adv:uom:m2") + warnings.empty + } + + private static List captureWarnings(Closure block) { + def appender = new ListAppender() + appender.start() + def logger = (LogbackLogger) LoggerFactory.getLogger(FeatureTokenDecoderGml) + logger.addAppender(appender) + try { + block.call() + } finally { + logger.detachAppender(appender) + } + return appender.list.findAll { it.level == Level.WARN } + } + + def 'gmlIdPrefix strips the configured prefix from the emitted feature id'() { + given: + def profile = ImmutableFeatureTokenDecoderGmlInputProfile.builder() + .useAlias(true) + .gmlIdPrefix("DENW36AL") + .build() + def decoder = newDecoder(axFlurstueckSchema(), profile) + def xml = """""" + + when: + def tokens = runDecoder(decoder, xml) + + then: + valueAtPath(tokens, ["oid"]) == "10000XYZ" + !tokens.contains("DENW36AL10000XYZ") + } + + def 'gmlIdPrefix leaves a non-matching gml:id unchanged'() { + given: + def profile = ImmutableFeatureTokenDecoderGmlInputProfile.builder() + .useAlias(true) + .gmlIdPrefix("OTHER_") + .build() + def decoder = newDecoder(axFlurstueckSchema(), profile) + def xml = """""" + + when: + def tokens = runDecoder(decoder, xml) + + then: + valueAtPath(tokens, ["oid"]) == "DENW36AL10000XYZ" + } + + def 'a single-member VALUE_ARRAY emits ARRAY around the one value'() { + given: + def decoder = newDecoder(axFlurstueckWithRefsSchema(), NAS_TEMPLATES) + // anl is VALUE_ARRAY (codelist). Even a single occurrence must be bracketed so the + // downstream consumer sees an array, not a scalar. + def xml = """ + + """ + + when: + def tokens = runDecoder(decoder, xml) + + then: + tokens.count { it == FeatureTokenType.ARRAY } == 1 + tokens.count { it == FeatureTokenType.ARRAY_END } == 1 + // ARRAY token is followed by the path the value sits at. + def arrayIdx = tokens.findIndexOf { it == FeatureTokenType.ARRAY } + tokens[arrayIdx + 1] == ["anl"] + // The reduced codelist value sits inside the array brackets. + def arrayEndIdx = tokens.findIndexOf { it == FeatureTokenType.ARRAY_END } + def codeValueIdx = tokens.indexOf("010704") + arrayIdx < codeValueIdx + codeValueIdx < arrayEndIdx + } + + def 'a multi-member FEATURE_REF_ARRAY brackets all members in one ARRAY pair'() { + given: + def decoder = newDecoder(axFlurstueckWithRefsSchema(), NAS_TEMPLATES) + // zeigtAuf carries two members on the wire; both must sit inside a single ARRAY/ARRAY_END + // pair at the property's source path. + def xml = """ + + + """ + + when: + def tokens = runDecoder(decoder, xml) + + then: + tokens.count { it == FeatureTokenType.ARRAY } == 1 + tokens.count { it == FeatureTokenType.ARRAY_END } == 1 + // Both members emitted inside the array. + def arrayIdx = tokens.findIndexOf { it == FeatureTokenType.ARRAY } + def arrayEndIdx = tokens.findIndexOf { it == FeatureTokenType.ARRAY_END } + def memberIndices = tokens.findIndexValues { it == "DENW36AL00000AAA" || it == "DENW36AL00000BBB" } + memberIndices.size() == 2 + memberIndices.every { (long) arrayIdx < it && it < (long) arrayEndIdx } + } + + def 'the open array closes when the wire moves on to a non-array sibling property'() { + given: + def decoder = newDecoder(axFlurstueckWithRefsSchema(), NAS_TEMPLATES) + // anl (VALUE_ARRAY) followed by quellobjektID (plain STRING). The array must close before + // the scalar property emits. + def xml = """ + + some-source-id + """ + + when: + def tokens = runDecoder(decoder, xml) + + then: + def arrayEndIdx = tokens.findIndexOf { it == FeatureTokenType.ARRAY_END } + def qidValueIdx = tokens.findIndexOf { i -> + tokens.indexOf(i) > arrayEndIdx && i == "some-source-id" + } + arrayEndIdx > 0 + // The plain STRING value follows after the array closes. + tokens.indexOf("some-source-id") > arrayEndIdx + } + + def 'a single FEATURE_REF (non-array) emits no ARRAY tokens'() { + given: + def decoder = newDecoder(axFlurstueckWithRefsSchema(), NAS_TEMPLATES) + // istGebucht is FEATURE_REF (single, not array) — never brackets. + def xml = """ + + """ + + when: + def tokens = runDecoder(decoder, xml) + + then: + !tokens.contains(FeatureTokenType.ARRAY) + !tokens.contains(FeatureTokenType.ARRAY_END) + } + + def 'xmlAttributes routes an unqualified attribute on the feature root to the child property source path'() { + given: + // Encoder direction: a STRING property listed in xmlAttributes is written as an unqualified + // attribute on the parent object element (the feature element here). On the wire, the + // attribute name follows the same alias/name rule as element naming. Here fsk + // (flurstueckskennzeichen) is listed; the wire form omits the + // element and instead carries flurstueckskennzeichen="…" on the feature. + def profile = ImmutableFeatureTokenDecoderGmlInputProfile.builder() + .useAlias(true) + .addXmlAttributes("fsk") + .build() + def decoder = newDecoder(axFlurstueckSchema(), profile) + def xml = """""" + + when: + def tokens = runDecoder(decoder, xml) + + then: + valueAtPath(tokens, ["fsk"]) == "05334001001000010001__" + } + + def 'an unqualified attribute on the feature root is ignored when xmlAttributes is empty'() { + given: + // Without xmlAttributes configured, an unrelated attribute on the feature element must + // not surface as a token — only the gml:id value is emitted. + def decoder = newDecoder(axFlurstueckSchema(), useAliasProfile()) + def xml = """""" + + when: + def tokens = runDecoder(decoder, xml) + + then: + tokens.count { it == FeatureTokenType.VALUE } == 1 + valueAtPath(tokens, ["oid"]) == "DENW36AL10000XYZ" + !tokens.contains("05334001001000010001__") + } + + /** + * Mirrors the AX_Flurstueck → gmk(AX_Gemarkung_Schluessel) → {land, gemarkungsnummer} chain + * from the AdV NAS schema. The nested OBJECT property carries an explicit sourcePath + * segment so the emitted child paths are unambiguous in this test; flattening (OBJECT + * without sourcePath, where child source paths live in the parent table) is left for a + * follow-up. + */ + static FeatureSchema axFlurstueckWithGemarkungSchema() { + new ImmutableFeatureSchema.Builder() + .name("ax_flurstueck") + .sourcePath("/o11001") + .type(SchemaBase.Type.OBJECT) + .putProperties2("oid", new ImmutableFeatureSchema.Builder() + .sourcePath("objid") + .type(SchemaBase.Type.STRING) + .role(SchemaBase.Role.ID) + .alias("id")) + .putProperties2("gmk", new ImmutableFeatureSchema.Builder() + .sourcePath("gmk") + .type(SchemaBase.Type.OBJECT) + .objectType("AX_Gemarkung_Schluessel") + .alias("gemarkung") + .putProperties2("lan", new ImmutableFeatureSchema.Builder() + .sourcePath("lan") + .type(SchemaBase.Type.STRING) + .alias("land")) + .putProperties2("gmn", new ImmutableFeatureSchema.Builder() + .sourcePath("gmn") + .type(SchemaBase.Type.STRING) + .alias("gemarkungsnummer"))) + .putProperties2("fsk", new ImmutableFeatureSchema.Builder() + .sourcePath("fsk") + .type(SchemaBase.Type.STRING) + .alias("flurstueckskennzeichen")) + .build() + } + + def 'nested OBJECT children are resolved against the OBJECT schema and emit at the nested source path'() { + given: + // NAS wire form: (alias for OBJECT prop gmk) wraps the object-type element + // , which in turn carries the scalar children and + // . The wrapper element contributes no path segment. + def decoder = newDecoder(axFlurstueckWithGemarkungSchema(), useAliasProfile()) + def xml = """ + + + 05 + 4320 + + + 05432002500008______ + """ + + when: + def tokens = runDecoder(decoder, xml) + + then: + valueAtPath(tokens, ["gmk", "lan"]) == "05" + valueAtPath(tokens, ["gmk", "gmn"]) == "4320" + // The depth-1 sibling after the nested OBJECT must land at its own depth-1 path, + // proving that the path tracker shortens when descending back out of the nested object. + valueAtPath(tokens, ["fsk"]) == "05432002500008______" + } + + def 'an OBJECT property with two object-element children is rejected as unsupported'() { + given: + // GML's usual property pattern is one property element wrapping a single nested object + // element. The spec does also allow GML *array properties*, where one property element + // wraps multiple peer object elements, but those are rare in feature data and do not + // appear in the NAS schema this decoder targets — so we reject the shape here. If array + // properties ever need to be supported, the OBJECT_PROPERTY branch in + // FeatureTokenDecoderGml.onStartElement (which currently throws on a second + // object-element child) needs to be reworked to emit one OBJECT_START/OBJECT_END per + // child element under one ARRAY/ARRAY_END pair. + def decoder = newDecoder(axFlurstueckWithGemarkungSchema(), useAliasProfile()) + def xml = """ + + + 05 + 4320 + + + 06 + 4321 + + + """ + + when: + runDecoder(decoder, xml) + + then: + def e = thrown(Exception) + def msg = rootCauseMessage(e) + msg.contains("object property") + msg.contains("gmk") + } + + def 'unknown children inside a nested OBJECT are ignored without disturbing sibling values'() { + given: + def decoder = newDecoder(axFlurstueckWithGemarkungSchema(), useAliasProfile()) + // has no matching property under AX_Gemarkung_Schluessel; the decoder must + // descend through it (without emitting) and still resolve correctly. + def xml = """ + + + 05 + ignored + 4320 + + + """ + + when: + def tokens = runDecoder(decoder, xml) + + then: + valueAtPath(tokens, ["gmk", "lan"]) == "05" + valueAtPath(tokens, ["gmk", "gmn"]) == "4320" + !tokens.contains("ignored") + } + + /** + * AX_Wohnbauflaeche slice exercising two shapes that AdV NAS uses heavily: an OBJECT_ARRAY + * of {@code modellart} (each member wraps the {@code AA_Modellart} type) and the two-level + * nested {@code zeigtAufExternes} → {@code fachdatenobjekt} chain. Property names, source + * paths and aliases follow AA_Objekt, AA_Modellart, AA_Fachdatenverbindung and + * AA_Fachdatenobjekt. + */ + static FeatureSchema axWohnbauflaecheSchema() { + new ImmutableFeatureSchema.Builder() + .name("ax_wohnbauflaeche") + .sourcePath("/o41001") + .type(SchemaBase.Type.OBJECT) + .putProperties2("oid", new ImmutableFeatureSchema.Builder() + .sourcePath("objid") + .type(SchemaBase.Type.STRING) + .role(SchemaBase.Role.ID) + .alias("id")) + .putProperties2("mat", new ImmutableFeatureSchema.Builder() + .sourcePath("[id=rid]o02330__mat") + .type(SchemaBase.Type.OBJECT_ARRAY) + .objectType("AA_Modellart") + .alias("modellart") + .putProperties2("stm", new ImmutableFeatureSchema.Builder() + .sourcePath("stm") + .type(SchemaBase.Type.STRING) + .alias("advStandardModell")) + .putProperties2("som", new ImmutableFeatureSchema.Builder() + .sourcePath("som") + .type(SchemaBase.Type.STRING) + .alias("sonstigesModell") + .constraints(new ImmutableSchemaConstraints.Builder() + .codelist("AA_WeitereModellart") + .build()))) + .putProperties2("fdv", new ImmutableFeatureSchema.Builder() + .sourcePath("[id=rid]o02330__fdv") + .type(SchemaBase.Type.OBJECT_ARRAY) + .objectType("AA_Fachdatenverbindung") + .alias("zeigtAufExternes") + .putProperties2("art", new ImmutableFeatureSchema.Builder() + .sourcePath("art") + .type(SchemaBase.Type.STRING) + .alias("art")) + .putProperties2("fdo", new ImmutableFeatureSchema.Builder() + .type(SchemaBase.Type.OBJECT) + .objectType("AA_Fachdatenobjekt") + .alias("fachdatenobjekt") + .putProperties2("uri", new ImmutableFeatureSchema.Builder() + .sourcePath("fdo__uri") + .type(SchemaBase.Type.STRING) + .alias("uri")))) + .build() + } + + static FeatureTokenDecoderSimple> newWohnbauflaecheDecoder( + FeatureTokenDecoderGmlInputProfile profile) { + def schema = axWohnbauflaecheSchema() + new FeatureTokenDecoderGml( + NAMESPACES, + [new QName(ADV_NS, "AX_Wohnbauflaeche")], + schema, + ImmutableFeatureQuery.builder().type(schema.getName()).build(), + Map.of(schema.getName(), + new ImmutableSchemaMapping.Builder() + .targetSchema(schema) + .sourcePathTransformer((path, isValue) -> path) + .build()), + STORAGE_CRS, + Optional.empty(), + Optional.empty(), + profile) + } + + def 'AX_Wohnbauflaeche modellart OBJECT_ARRAY and zeigtAufExternes two-level nested OBJECT'() { + given: + // Codelist template needed because the second modellart member carries sonstigesModell as + // xlink:href to the AA_WeitereModellart codelist; the decoder reverses it to bare "NWABK". + def profile = ImmutableFeatureTokenDecoderGmlInputProfile.builder() + .useAlias(true) + .codelistUriTemplate("https://registry.gdi-de.org/codelist/de.adv-online.gid/{{codelistId}}/{{value}}") + .build() + def decoder = newWohnbauflaecheDecoder(profile) + // Wire form lifted from src/test/resources/nas/AX_Wohnbauflaeche.xml — two modellart array + // members followed by a zeigtAufExternes whose AA_Fachdatenverbindung wraps a nested + // AA_Fachdatenobjekt with its own uri child. + def xml = """ + + + DLKM + + + + + + + + + + urn:adv:fdv:0901 + + + urn:adv:oid:DENW36PS00001I8A + + + + + """ + + when: + def tokens = runDecoder(decoder, xml) + + then: + // Paths are dotted property-name chains, matching the SqlQueryMapping's column keys + // (e.g. {@code mat.stm}, {@code fdv.fdo.uri}). The nested OBJECT property fdo contributes + // its own name as a path segment, so its child {@code uri} sits at fdv → fdo → uri. + def matPath = ["mat"] + def fdvPath = ["fdv"] + valueAtPath(tokens, matPath + "stm") == "DLKM" + valueAtPath(tokens, matPath + "som") == "NWABK" + valueAtPath(tokens, fdvPath + "art") == "urn:adv:fdv:0901" + valueAtPath(tokens, fdvPath + ["fdo", "uri"]) == "urn:adv:oid:DENW36PS00001I8A" + + // Array brackets around the OBJECT_ARRAY members; the path tracker still points at the + // array property when each bracket is emitted. + def matArrayStart = indexOfTokenAtPath(tokens, FeatureTokenType.ARRAY, matPath) + def matArrayEnd = indexOfTokenAtPath(tokens, FeatureTokenType.ARRAY_END, matPath) + matArrayStart >= 0 + matArrayEnd > matArrayStart + + // Two OBJECT/OBJECT_END pairs for the two modellart members, both inside the array. + def matObjectStarts = indicesOfTokenAtPath(tokens, FeatureTokenType.OBJECT, matPath) + def matObjectEnds = indicesOfTokenAtPath(tokens, FeatureTokenType.OBJECT_END, matPath) + matObjectStarts.size() == 2 + matObjectEnds.size() == 2 + matObjectStarts.every { it > matArrayStart && it < matArrayEnd } + matObjectEnds.every { it > matArrayStart && it < matArrayEnd } + + // One OBJECT_START / OBJECT_END pair at the fdv member's level (its sole array entry), + // plus an inner pair at fdv → fdo for the nested fachdatenobjekt OBJECT. + def fdvObjectStarts = indicesOfTokenAtPath(tokens, FeatureTokenType.OBJECT, fdvPath) + def fdvObjectEnds = indicesOfTokenAtPath(tokens, FeatureTokenType.OBJECT_END, fdvPath) + fdvObjectStarts.size() == 1 + fdvObjectEnds.size() == 1 + indicesOfTokenAtPath(tokens, FeatureTokenType.OBJECT, fdvPath + "fdo").size() == 1 + indicesOfTokenAtPath(tokens, FeatureTokenType.OBJECT_END, fdvPath + "fdo").size() == 1 + } + + def 'GML array property shape: one OBJECT_PROPERTY wrapping multiple peer OBJECT_ELEMENTs emits per-peer OBJECT bookends inside one ARRAY pair'() { + given: + // Same OBJECT_ARRAY schema as the previous spec (mat = AA_Modellart), but the wire shape + // collapses the two members into ONE property element wrapping two peer + // children. The downstream token stream must be indistinguishable from + // the multi-property-element shape. + def profile = ImmutableFeatureTokenDecoderGmlInputProfile.builder() + .useAlias(true) + .codelistUriTemplate("https://registry.gdi-de.org/codelist/de.adv-online.gid/{{codelistId}}/{{value}}") + .build() + def decoder = newWohnbauflaecheDecoder(profile) + def xml = """ + + + DLKM + + + + + + """ + + when: + def tokens = runDecoder(decoder, xml) + + then: + def matPath = ["mat"] + valueAtPath(tokens, matPath + "stm") == "DLKM" + valueAtPath(tokens, matPath + "som") == "NWABK" + + and: 'exactly one ARRAY pair brackets both members' + def matArrayStart = indexOfTokenAtPath(tokens, FeatureTokenType.ARRAY, matPath) + def matArrayEnd = indexOfTokenAtPath(tokens, FeatureTokenType.ARRAY_END, matPath) + matArrayStart >= 0 + matArrayEnd > matArrayStart + indicesOfTokenAtPath(tokens, FeatureTokenType.ARRAY, matPath).size() == 1 + indicesOfTokenAtPath(tokens, FeatureTokenType.ARRAY_END, matPath).size() == 1 + + and: 'two OBJECT bookend pairs sit inside the single ARRAY pair' + def matObjectStarts = indicesOfTokenAtPath(tokens, FeatureTokenType.OBJECT, matPath) + def matObjectEnds = indicesOfTokenAtPath(tokens, FeatureTokenType.OBJECT_END, matPath) + matObjectStarts.size() == 2 + matObjectEnds.size() == 2 + matObjectStarts.every { it > matArrayStart && it < matArrayEnd } + matObjectEnds.every { it > matArrayStart && it < matArrayEnd } + } + + def 'GML array property shape: a single peer OBJECT_ELEMENT inside an array OBJECT_PROPERTY still produces one OBJECT pair'() { + given: + // Mixed shapes for the same OBJECT_ARRAY schema: the first uses the + // single-peer wire shape, the second uses the multi-peer shape. Three members in total — + // three OBJECT pairs, all inside one ARRAY pair. + def profile = ImmutableFeatureTokenDecoderGmlInputProfile.builder() + .useAlias(true) + .codelistUriTemplate("https://registry.gdi-de.org/codelist/de.adv-online.gid/{{codelistId}}/{{value}}") + .build() + def decoder = newWohnbauflaecheDecoder(profile) + def xml = """ + + + DLKM + + + + + DLKM + + + + + + """ + + when: + def tokens = runDecoder(decoder, xml) + + then: + def matPath = ["mat"] + indicesOfTokenAtPath(tokens, FeatureTokenType.ARRAY, matPath).size() == 1 + indicesOfTokenAtPath(tokens, FeatureTokenType.ARRAY_END, matPath).size() == 1 + indicesOfTokenAtPath(tokens, FeatureTokenType.OBJECT, matPath).size() == 3 + indicesOfTokenAtPath(tokens, FeatureTokenType.OBJECT_END, matPath).size() == 3 + } + + def 'GML array property shape: a second peer inside a non-array OBJECT_PROPERTY is reported as a schema/wire mismatch'() { + given: + // gmk in axFlurstueckWithGemarkungSchema is a single-valued OBJECT (not OBJECT_ARRAY); a + // second peer AX_Gemarkung_Schluessel inside one is a genuine wire/schema + // mismatch and must still be reported. + def decoder = newDecoder(axFlurstueckWithGemarkungSchema(), useAliasProfile()) + def xml = """ + + + 05 + + + 06 + + + """ + + when: + runDecoder(decoder, xml) + + then: + def e = thrown(Exception) + def msg = rootCauseMessage(e) + msg.contains("gmk") + msg.contains("single-valued") + } + + /** + * Schema with a nested OBJECT property whose body holds a VALUE_ARRAY {@code tag} and a + * scalar sibling {@code lab}. Used to exercise per-level ARRAY bracketing inside an + * OBJECT_ELEMENT: the nested ARRAY pair must sit at the {@code [ngo, tag]} path, and the + * sibling scalar {@code lab} at {@code [ngo, lab]}. + */ + static FeatureSchema axFlurstueckWithNestedArraySchema() { + new ImmutableFeatureSchema.Builder() + .name("ax_flurstueck") + .sourcePath("/o11001") + .type(SchemaBase.Type.OBJECT) + .putProperties2("oid", new ImmutableFeatureSchema.Builder() + .sourcePath("objid") + .type(SchemaBase.Type.STRING) + .role(SchemaBase.Role.ID) + .alias("id")) + .putProperties2("ngo", new ImmutableFeatureSchema.Builder() + .sourcePath("ngo") + .type(SchemaBase.Type.OBJECT) + .objectType("NestedObj") + .alias("nested") + .putProperties2("tag", new ImmutableFeatureSchema.Builder() + .sourcePath("[id=rid]ngo__tag/tag") + .type(SchemaBase.Type.VALUE_ARRAY) + .valueType(SchemaBase.Type.STRING) + .alias("tags")) + .putProperties2("lab", new ImmutableFeatureSchema.Builder() + .sourcePath("lab") + .type(SchemaBase.Type.STRING) + .alias("label"))) + .putProperties2("fsk", new ImmutableFeatureSchema.Builder() + .sourcePath("fsk") + .type(SchemaBase.Type.STRING) + .alias("flurstueckskennzeichen")) + .build() + } + + def 'nested VALUE_ARRAY inside an OBJECT property emits ARRAY/ARRAY_END at the nested path'() { + given: + // The two peers sit inside an OBJECT (nested → NestedObj). The decoder must + // open ARRAY at the nested path [ngo, tag] and close it before the sibling + // emits — the bracketing is per nesting level, not just at the feature root. + def decoder = newDecoder(axFlurstueckWithNestedArraySchema(), useAliasProfile()) + def xml = """ + + + t1 + t2 + L + + + code + """ + + when: + def tokens = runDecoder(decoder, xml) + + then: + // Exactly one ARRAY pair, anchored at the nested path. + indexOfTokenAtPath(tokens, FeatureTokenType.ARRAY, ["ngo", "tag"]) >= 0 + indexOfTokenAtPath(tokens, FeatureTokenType.ARRAY_END, ["ngo", "tag"]) >= 0 + tokens.count { it == FeatureTokenType.ARRAY } == 1 + tokens.count { it == FeatureTokenType.ARRAY_END } == 1 + // Both members emit inside the brackets. + def arrayIdx = tokens.findIndexOf { it == FeatureTokenType.ARRAY } + def arrayEndIdx = tokens.findIndexOf { it == FeatureTokenType.ARRAY_END } + def t1Idx = tokens.findIndexOf { it == "t1" } + def t2Idx = tokens.findIndexOf { it == "t2" } + arrayIdx < t1Idx && t1Idx < arrayEndIdx + arrayIdx < t2Idx && t2Idx < arrayEndIdx + // Sibling scalar inside the same nested object lands at [ngo, lab], after the array closes. + valueAtPath(tokens, ["ngo", "lab"]) == "L" + tokens.indexOf("L") > arrayEndIdx + // Root-level sibling lands at its own depth-1 path. + valueAtPath(tokens, ["fsk"]) == "code" + } + + def 'nested array still open at OBJECT_ELEMENT end closes before the OBJECT_END at the outer path'() { + given: + // The VALUE_ARRAY is the *last* child of the nested object — no following + // sibling triggers the close. The OBJECT_ELEMENT pop must still emit ARRAY_END (at the + // inner path) before OBJECT_END (at the outer path) so the bracketing remains balanced. + def decoder = newDecoder(axFlurstueckWithNestedArraySchema(), useAliasProfile()) + def xml = """ + + + L + t1 + t2 + + + """ + + when: + def tokens = runDecoder(decoder, xml) + + then: + tokens.count { it == FeatureTokenType.ARRAY } == 1 + tokens.count { it == FeatureTokenType.ARRAY_END } == 1 + def arrayEndIdx = indexOfTokenAtPath(tokens, FeatureTokenType.ARRAY_END, ["ngo", "tag"]) + def objectEndIdx = indexOfTokenAtPath(tokens, FeatureTokenType.OBJECT_END, ["ngo"]) + arrayEndIdx >= 0 + objectEndIdx >= 0 + arrayEndIdx < objectEndIdx + } + + /** + * Schema with a nested OBJECT_ARRAY: outer OBJECT {@code ngo} contains the array {@code chi} + * of {@code Child} objects with scalar {@code nam}/{@code val} children. Exercises ARRAY + + * per-peer OBJECT bracketing at a non-root level. + */ + static FeatureSchema axFlurstueckWithNestedObjectArraySchema() { + new ImmutableFeatureSchema.Builder() + .name("ax_flurstueck") + .sourcePath("/o11001") + .type(SchemaBase.Type.OBJECT) + .putProperties2("oid", new ImmutableFeatureSchema.Builder() + .sourcePath("objid") + .type(SchemaBase.Type.STRING) + .role(SchemaBase.Role.ID) + .alias("id")) + .putProperties2("ngo", new ImmutableFeatureSchema.Builder() + .sourcePath("ngo") + .type(SchemaBase.Type.OBJECT) + .objectType("NestedObj") + .alias("nested") + .putProperties2("chi", new ImmutableFeatureSchema.Builder() + .sourcePath("[id=rid]ngo__chi") + .type(SchemaBase.Type.OBJECT_ARRAY) + .objectType("Child") + .alias("children") + .putProperties2("nam", new ImmutableFeatureSchema.Builder() + .sourcePath("nam") + .type(SchemaBase.Type.STRING) + .alias("name")) + .putProperties2("val", new ImmutableFeatureSchema.Builder() + .sourcePath("val") + .type(SchemaBase.Type.STRING) + .alias("value")))) + .build() + } + + def 'nested OBJECT_ARRAY inside an OBJECT property emits one ARRAY pair with per-peer OBJECT pairs'() { + given: + def decoder = newDecoder(axFlurstueckWithNestedObjectArraySchema(), useAliasProfile()) + def xml = """ + + + a1 + b2 + + + """ + + when: + def tokens = runDecoder(decoder, xml) + + then: + // One ARRAY pair at the nested array path. + tokens.count { it == FeatureTokenType.ARRAY } == 1 + tokens.count { it == FeatureTokenType.ARRAY_END } == 1 + indexOfTokenAtPath(tokens, FeatureTokenType.ARRAY, ["ngo", "chi"]) >= 0 + indexOfTokenAtPath(tokens, FeatureTokenType.ARRAY_END, ["ngo", "chi"]) >= 0 + // Two per-peer OBJECT pairs sit between the brackets, at the nested array path. + indicesOfTokenAtPath(tokens, FeatureTokenType.OBJECT, ["ngo", "chi"]).size() == 2 + indicesOfTokenAtPath(tokens, FeatureTokenType.OBJECT_END, ["ngo", "chi"]).size() == 2 + // Scalar children land at the per-peer path. + valueAtPath(tokens, ["ngo", "chi", "nam"]) == "a" + valueAtPath(tokens, ["ngo", "chi", "val"]) == "1" + } + + /** + * Schema with a geometry property inside a nested OBJECT, used to exercise nested-geometry + * decoding: the GML geometry subtree must decode regardless of nesting depth and the + * resulting Geometry token must arrive at the nested property's path. + */ + static FeatureSchema axFlurstueckWithNestedGeometrySchema() { + new ImmutableFeatureSchema.Builder() + .name("ax_flurstueck") + .sourcePath("/o11001") + .type(SchemaBase.Type.OBJECT) + .putProperties2("oid", new ImmutableFeatureSchema.Builder() + .sourcePath("objid") + .type(SchemaBase.Type.STRING) + .role(SchemaBase.Role.ID) + .alias("id")) + .putProperties2("ngo", new ImmutableFeatureSchema.Builder() + .sourcePath("ngo") + .type(SchemaBase.Type.OBJECT) + .objectType("NestedObj") + .alias("nested") + .putProperties2("pos", new ImmutableFeatureSchema.Builder() + .sourcePath("pos") + .type(SchemaBase.Type.GEOMETRY) + .geometryType(GeometryType.POINT) + .alias("position"))) + .build() + } + + def 'a geometry property inside a nested OBJECT decodes and emits at the nested path'() { + given: + def decoder = newDecoder(axFlurstueckWithNestedGeometrySchema(), useAliasProfile()) + def xml = """ + + + + + 363609.477 5614790.107 + + + + + """ + + when: + def tokens = runDecoder(decoder, xml) + + then: + def geomTokens = tokens.findAll { it instanceof Geometry } as List + geomTokens.size() == 1 + geomTokens[0].type == GeometryType.POINT + pathBeforeGeometry(tokens) == ["ngo", "pos"] + } + + /** + * AX_Flurstueck slice for the valueWrap reverse-mapping checks. {@code lzi_beg} mirrors + * the AA_Objekt {@code lebenszeitintervall} property: a DATETIME with SQL column {@code + * lzi__beg}, label {@code lebenszeitintervall_beginnt} and alias {@code + * lebenszeitintervall}. The encoder wraps the scalar value in {@code + * }, matching the + * {@code valueWrap} example in {@code GmlConfiguration}. + */ + static FeatureSchema axFlurstueckWithLifeCycleSchema() { + new ImmutableFeatureSchema.Builder() + .name("ax_flurstueck") + .sourcePath("/o11001") + .type(SchemaBase.Type.OBJECT) + .putProperties2("oid", new ImmutableFeatureSchema.Builder() + .sourcePath("objid") + .type(SchemaBase.Type.STRING) + .role(SchemaBase.Role.ID) + .alias("id")) + .putProperties2("lzi_beg", new ImmutableFeatureSchema.Builder() + .sourcePath("lzi__beg") + .type(SchemaBase.Type.DATETIME) + .alias("lebenszeitintervall")) + .putProperties2("fsk", new ImmutableFeatureSchema.Builder() + .sourcePath("fsk") + .type(SchemaBase.Type.STRING) + .alias("flurstueckskennzeichen")) + .build() + } + + def 'valueWrap reverse-maps a wrapped scalar back to the property source path'() { + given: + // Encoder shape: (alias-named property element) wraps an + // + // chain around the scalar. Reverse mapping must surface the inner text at lzi__beg. + def profile = ImmutableFeatureTokenDecoderGmlInputProfile.builder() + .useAlias(true) + .putValueWrap("lzi_beg", ["AA_Lebenszeitintervall", "beginnt"]) + .build() + def decoder = newDecoder(axFlurstueckWithLifeCycleSchema(), profile) + def xml = """ + + + 2010-09-14T11:54:36Z + + + """ + + when: + def tokens = runDecoder(decoder, xml) + + then: + valueAtPath(tokens, ["lzi_beg"]) == "2010-09-14T11:54:36Z" + } + + def 'a sibling after a valueWrap chain still resolves at its own source path'() { + given: + // Path tracker must shorten back to the feature root once the wrapper chain closes, so + // the following sibling lands at fsk, not at a stale path + // left behind by the inner wrapper. + def profile = ImmutableFeatureTokenDecoderGmlInputProfile.builder() + .useAlias(true) + .putValueWrap("lzi_beg", ["AA_Lebenszeitintervall", "beginnt"]) + .build() + def decoder = newDecoder(axFlurstueckWithLifeCycleSchema(), profile) + def xml = """ + + + 2010-09-14T11:54:36Z + + + 05334001001000010001__ + """ + + when: + def tokens = runDecoder(decoder, xml) + + then: + valueAtPath(tokens, ["lzi_beg"]) == "2010-09-14T11:54:36Z" + valueAtPath(tokens, ["fsk"]) == "05334001001000010001__" + } + + def 'an unconfigured wrapped scalar is not surfaced (no permissive fallback)'() { + given: + // valueWrap is gated on configuration: without an entry, wrapper elements inside a + // VALUE_PROPERTY are treated as unknown descendants and the inner text is not emitted. + // This matches the encoder side, which only wraps when the option is set. + def decoder = newDecoder(axFlurstueckWithLifeCycleSchema(), useAliasProfile()) + def xml = """ + + + 2010-09-14T11:54:36Z + + + """ + + when: + def tokens = runDecoder(decoder, xml) + + then: + !tokens.contains("2010-09-14T11:54:36Z") + } + + static final String GMD_NS = "http://www.isotc211.org/2005/gmd" + + static final Map NAMESPACES_WITH_GMD = [ + "adv": ADV_NS, + "gml": "http://www.opengis.net/gml/3.2", + "xlink": "http://www.w3.org/1999/xlink", + "xsi": "http://www.w3.org/2001/XMLSchema-instance", + "gmd": GMD_NS, + ] + + static final List PUNKTORTAU_FEATURE_TYPES = [new QName(ADV_NS, "AX_PunktortAU")] + + /** + * AX_PunktortAU slice with the full {@code qualitaetsangaben} (qag) tree from the AdV NAS + * schema: qag(AX_DQPunktort) → {dpl(LI_Lineage) → prs(LI_ProcessStep, OBJECT_ARRAY) → {des, + * dat, pro(CI_ResponsibleParty)→{org, ind, rol}, src(LI_Source)→des}, gst}. Every nested + * OBJECT is transparent (no {@code sourcePath}), so each leaf emits at the feature-root + * path with its {@code qag__*} SQL column name. + */ + static FeatureSchema axPunktortAuWithQagSchema() { + new ImmutableFeatureSchema.Builder() + .name("ax_punktortau") + .sourcePath("/o14003") + .type(SchemaBase.Type.OBJECT) + .objectType("AX_PunktortAU") + .putProperties2("oid", new ImmutableFeatureSchema.Builder() + .sourcePath("objid") + .type(SchemaBase.Type.STRING) + .role(SchemaBase.Role.ID) + .alias("id")) + .putProperties2("qag", new ImmutableFeatureSchema.Builder() + .type(SchemaBase.Type.OBJECT) + .objectType("AX_DQPunktort") + .alias("qualitaetsangaben") + .putProperties2("dpl", new ImmutableFeatureSchema.Builder() + .type(SchemaBase.Type.OBJECT) + .objectType("LI_Lineage") + .alias("herkunft") + .putProperties2("prs", new ImmutableFeatureSchema.Builder() + .type(SchemaBase.Type.OBJECT_ARRAY) + .objectType("LI_ProcessStep") + .alias("processStep") + .putProperties2("des", new ImmutableFeatureSchema.Builder() + .sourcePath("qag__dpl_des") + .type(SchemaBase.Type.STRING) + .alias("description")) + .putProperties2("dat", new ImmutableFeatureSchema.Builder() + .sourcePath("qag__dpl_prs_dat") + .type(SchemaBase.Type.DATETIME) + .alias("dateTime")) + .putProperties2("pro", new ImmutableFeatureSchema.Builder() + .type(SchemaBase.Type.OBJECT) + .objectType("CI_ResponsibleParty") + .alias("processor") + .putProperties2("org", new ImmutableFeatureSchema.Builder() + .sourcePath("qag__dpl_prs_pro_resp_org") + .type(SchemaBase.Type.STRING) + .alias("organisationName")) + .putProperties2("ind", new ImmutableFeatureSchema.Builder() + .sourcePath("qag__dpl_prs_pro_resp_ind") + .type(SchemaBase.Type.STRING) + .alias("individualName")) + .putProperties2("rol", new ImmutableFeatureSchema.Builder() + .sourcePath("qag__dpl_prs_pro_resp_rol_cdv") + .type(SchemaBase.Type.STRING) + .alias("role"))) + .putProperties2("src", new ImmutableFeatureSchema.Builder() + .type(SchemaBase.Type.OBJECT) + .objectType("LI_Source") + .alias("source") + .putProperties2("des", new ImmutableFeatureSchema.Builder() + .sourcePath("qag__dpl_prs_src") + .type(SchemaBase.Type.STRING) + .alias("description"))))) + .putProperties2("gst", new ImmutableFeatureSchema.Builder() + .sourcePath("qag__gst") + .type(SchemaBase.Type.STRING) + .alias("genauigkeitsstufe") + .constraints(new ImmutableSchemaConstraints.Builder() + .codelist("AX_Genauigkeitsstufe_Punktort") + .build()))) + .build() + } + + static FeatureTokenDecoderSimple> newPunktortAuDecoder( + FeatureTokenDecoderGmlInputProfile profile) { + def schema = axPunktortAuWithQagSchema() + new FeatureTokenDecoderGml( + NAMESPACES_WITH_GMD, + PUNKTORTAU_FEATURE_TYPES, + schema, + ImmutableFeatureQuery.builder().type(schema.getName()).build(), + Map.of(schema.getName(), + new ImmutableSchemaMapping.Builder() + .targetSchema(schema) + .sourcePathTransformer((path, isValue) -> path) + .build()), + STORAGE_CRS, + Optional.empty(), + Optional.empty(), + profile) + } + + /** + * NAS-shaped namespace profile: AX_PunktortAU / AX_DQPunktort fall back to the default adv + * namespace, the four ISO 19115 object types (LI_Lineage, LI_ProcessStep, LI_Source, + * CI_ResponsibleParty) are in gmd. The {@code valueWrap} entries declare the two adv- + * namespaced content-carrying wrappers that the encoder writes around two of the + * LI_ProcessStep scalar children; gmd/gco wrappers (gco:DateTime, gco:CharacterString, + * gmd:CI_RoleCode) are auto-detected by the decoder and need no entry here. + */ + static FeatureTokenDecoderGmlInputProfile nasNamespaceProfile() { + ImmutableFeatureTokenDecoderGmlInputProfile.builder() + .useAlias(true) + .defaultNamespace("adv") + .putObjectTypeNamespaces("LI_Lineage", "gmd") + .putObjectTypeNamespaces("LI_ProcessStep", "gmd") + .putObjectTypeNamespaces("LI_Source", "gmd") + .putObjectTypeNamespaces("CI_ResponsibleParty", "gmd") + .putValueWrap("qag.dpl.prs.des", ["AX_LI_ProcessStep_Punktort_Description"]) + .putValueWrap("qag.dpl.prs.src.des", ["AX_Datenerhebung_Punktort"]) + .build() + } + + def 'defaultNamespace constrains property elements to the configured namespace URI'() { + given: + // With defaultNamespace set, property elements on the wire are required to live in the + // configured namespace; same-localName elements in a different namespace must not be + // matched against the schema property. + def profile = ImmutableFeatureTokenDecoderGmlInputProfile.builder() + .useAlias(true) + .defaultNamespace("adv") + .build() + def decoder = newDecoder(axFlurstueckSchema(), profile) + def xml = """ + 05334001001000010001__ + NOT_IN_ADV_NS + """ + + when: + def tokens = runDecoder(decoder, xml) + + then: + // Only the ADV-namespaced element resolves; the same-localName element in another + // namespace must be skipped. + valueAtPath(tokens, ["fsk"]) == "05334001001000010001__" + !tokens.contains("NOT_IN_ADV_NS") + } + + def 'the full qag tree resolves every leaf across adv/gmd/gco'() { + given: + // AX_PunktortAU qualitaetsangaben (qag) wire form: the outer wrappers + // (qualitaetsangaben, AX_DQPunktort, herkunft) live in the default adv namespace; the + // ISO 19115 chain (LI_Lineage / processStep / LI_ProcessStep / CI_ResponsibleParty / + // LI_Source) is in gmd, with leaf values wrapped in adv-namespaced or gmd/gco + // content-carrying objects: + // - description (LI_ProcessStep) wraps + // — adv namespace, requires explicit valueWrap config + // - dateTime wraps — gmd/gco auto-detected + // - organisationName wraps — auto-detected + // - role wraps — auto-detected; text content used + // - source/description wraps — explicit valueWrap + // The sibling at AX_DQPunktort level exercises a scalar that + // sits next to the deep dpl subtree. + def decoder = newPunktortAuDecoder(nasNamespaceProfile()) + def xml = """ + + + + + + + + Erhebung + + + 2017-07-20T11:20:04Z + + + + + Amt für Bodenmanagement und Geoinformation Bonn + + + processor + + + + + + + 1000 + + + + + + + + 2100 + + + """ + + when: + def tokens = runDecoder(decoder, xml) + + then: + // qag and its descendants are all transparent OBJECTs, so every leaf emits at the + // feature root with its qag__* column name. + valueAtPath(tokens, ["qag", "dpl", "prs", "des"]) == "Erhebung" + valueAtPath(tokens, ["qag", "dpl", "prs", "dat"]) == "2017-07-20T11:20:04Z" + valueAtPath(tokens, ["qag", "dpl", "prs", "pro", "org"]) == "Amt für Bodenmanagement und Geoinformation Bonn" + valueAtPath(tokens, ["qag", "dpl", "prs", "pro", "rol"]) == "processor" + valueAtPath(tokens, ["qag", "dpl", "prs", "src", "des"]) == "1000" + valueAtPath(tokens, ["qag", "gst"]) == "2100" + } + + def 'LI_ProcessStep children written in the wrong namespace are skipped'() { + given: + // Sanity check: with objectTypeNamespaces[LI_ProcessStep] = gmd, an LI_ProcessStep + // child written in adv (instead of gmd) must be dropped rather than mis-matched. The + // gmd-namespaced sibling next to it still resolves, proving the check is per-element + // and does not poison the rest of the subtree. + def decoder = newPunktortAuDecoder(nasNamespaceProfile()) + def xml = """ + + + + + + + + WRONG_NS + + + + + 1000 + + + + + + + + + + """ + + when: + def tokens = runDecoder(decoder, xml) + + then: + !tokens.contains("WRONG_NS") + valueAtPath(tokens, ["qag", "dpl", "prs", "dat"]) == null + valueAtPath(tokens, ["qag", "dpl", "prs", "src", "des"]) == "1000" + } + + def 'with no namespace configuration matching is by local name alone'() { + given: + // No defaultNamespace, no objectTypeNamespaces — the existing fixture-style behaviour + // applies: the wire URI is ignored and the same-localName element in any namespace + // matches. This is what the existing tests have always relied on. + def decoder = newDecoder(axFlurstueckSchema(), useAliasProfile()) + def xml = """ + matched-by-local-name + """ + + when: + def tokens = runDecoder(decoder, xml) + + then: + valueAtPath(tokens, ["fsk"]) == "matched-by-local-name" + } + + def 'xsi:type on a property element is rejected with a clear error'() { + given: + def decoder = newDecoder(axFlurstueckSchema(), useAliasProfile()) + def xml = """ + 05334001001000010001__ + """ + + when: + runDecoder(decoder, xml) + + then: + def e = thrown(Exception) + rootCauseMessage(e).contains("xsi:type") + rootCauseMessage(e).contains("flurstueckskennzeichen") + } + + def 'xsi:type on the feature root is rejected with a clear error'() { + given: + def decoder = newDecoder(axFlurstueckSchema(), useAliasProfile()) + def xml = """""" + + when: + runDecoder(decoder, xml) + + then: + def e = thrown(Exception) + rootCauseMessage(e).contains("xsi:type") + rootCauseMessage(e).contains("AX_Flurstueck") + } + + def 'nilReason on a property element is silently dropped'() { + given: + // Verifies that an unqualified nilReason attribute, with or without xsi:nil, does not + // leak into the token stream — the encoder never emits it and the decoder mirrors that. + def decoder = newDecoder(axFlurstueckSchema(), useAliasProfile()) + def xml = """ + 05334001001000010001__ + """ + + when: + def tokens = runDecoder(decoder, xml) + + then: + valueAtPath(tokens, ["fsk"]) == "05334001001000010001__" + !tokens.contains("other") + } + + /** + * Made-up subtype-discrimination shape: AX_Flurstueck instances split into two variants with + * different qualified element names on the wire, distinguished by a single STRING property + * {@code art} on the feature root. No production NAS feature type uses this pattern (NAS + * encodes subtype variation via codelist properties rather than element-name variation), so + * the fixture is illustrative rather than data-driven. + */ + static FeatureSchema axFlurstueckVariantSchema() { + new ImmutableFeatureSchema.Builder() + .name("ax_flurstueck") + .sourcePath("/o11001") + .type(SchemaBase.Type.OBJECT) + .objectType("AX_Flurstueck") + .putProperties2("oid", new ImmutableFeatureSchema.Builder() + .sourcePath("objid") + .type(SchemaBase.Type.STRING) + .role(SchemaBase.Role.ID) + .alias("id")) + .putProperties2("art", new ImmutableFeatureSchema.Builder() + .sourcePath("art") + .type(SchemaBase.Type.STRING) + .alias("art")) + .build() + } + + static FeatureTokenDecoderGmlInputProfile variableNameProfile() { + ImmutableFeatureTokenDecoderGmlInputProfile.builder() + .useAlias(true) + .putVariableObjectElementNames("AX_Flurstueck", + new ImmutableVariableObjectName.Builder() + .property("art") + .putMapping("adv:AX_FlurstueckMitArt1", "art1") + .putMapping("adv:AX_FlurstueckMitArt2", "art2") + .build()) + .build() + } + + def 'variableObjectElementNames accepts a varying feature root element and emits the discriminator value'() { + given: + def decoder = newDecoder(axFlurstueckVariantSchema(), variableNameProfile()) + def xml = """""" + + when: + def tokens = runDecoder(decoder, xml) + + then: + tokens.first() == FeatureTokenType.INPUT + tokens.last() == FeatureTokenType.INPUT_END + tokens.count { it == FeatureTokenType.FEATURE } == 1 + valueAtPath(tokens, ["oid"]) == "DENW36AL10000XYZ" + valueAtPath(tokens, ["art"]) == "art1" + } + + def 'variableObjectElementNames resolves the wire element against the canonical namespace prefix'() { + given: + // Wire uses a different prefix declaration ("a") for the same ADV namespace URI; the + // decoder normalises via its constructor namespace map so the mapping key "adv:..." + // still matches. + def decoder = newDecoder(axFlurstueckVariantSchema(), variableNameProfile()) + def xml = """""" + + when: + def tokens = runDecoder(decoder, xml) + + then: + valueAtPath(tokens, ["art"]) == "art2" + } + + def 'an unconfigured variable wire element name is rejected as multi-feature'() { + given: + def decoder = newDecoder(axFlurstueckVariantSchema(), variableNameProfile()) + def xml = """""" + + when: + runDecoder(decoder, xml) + + then: + def e = thrown(Exception) + rootCauseMessage(e).contains("Multi-feature ingest is not supported") + } + + /** + * Same shape as {@link #axFlurstueckWithGemarkungSchema} but the nested {@code gmk} + * (AX_Gemarkung_Schluessel) OBJECT carries an additional STRING discriminator child {@code + * art}. Used together with a profile that maps two variant qualified wire element names for + * {@code AX_Gemarkung_Schluessel}: the decoder substitutes the discriminator value + * accordingly. Illustrative rather than data-driven — production NAS encodes subtype variation + * via codelist properties, not nested element-name variation. + */ + static FeatureSchema axFlurstueckWithGemarkungVariantSchema() { + new ImmutableFeatureSchema.Builder() + .name("ax_flurstueck") + .sourcePath("/o11001") + .type(SchemaBase.Type.OBJECT) + .putProperties2("oid", new ImmutableFeatureSchema.Builder() + .sourcePath("objid") + .type(SchemaBase.Type.STRING) + .role(SchemaBase.Role.ID) + .alias("id")) + .putProperties2("gmk", new ImmutableFeatureSchema.Builder() + .sourcePath("gmk") + .type(SchemaBase.Type.OBJECT) + .objectType("AX_Gemarkung_Schluessel") + .alias("gemarkung") + .putProperties2("art", new ImmutableFeatureSchema.Builder() + .sourcePath("art") + .type(SchemaBase.Type.STRING) + .alias("art")) + .putProperties2("lan", new ImmutableFeatureSchema.Builder() + .sourcePath("lan") + .type(SchemaBase.Type.STRING) + .alias("land")) + .putProperties2("gmn", new ImmutableFeatureSchema.Builder() + .sourcePath("gmn") + .type(SchemaBase.Type.STRING) + .alias("gemarkungsnummer"))) + .build() + } + + static FeatureTokenDecoderGmlInputProfile nestedVariableNameProfile() { + ImmutableFeatureTokenDecoderGmlInputProfile.builder() + .useAlias(true) + .putVariableObjectElementNames("AX_Gemarkung_Schluessel", + new ImmutableVariableObjectName.Builder() + .property("art") + .putMapping("adv:AX_GemarkungMitArt1", "art1") + .putMapping("adv:AX_GemarkungMitArt2", "art2") + .build()) + .build() + } + + def 'nested variableObjectElementNames emits the discriminator value at the nested OBJECT path'() { + given: + def decoder = newDecoder(axFlurstueckWithGemarkungVariantSchema(), nestedVariableNameProfile()) + def xml = """ + + + 05 + 4320 + + + """ + + when: + def tokens = runDecoder(decoder, xml) + + then: + valueAtPath(tokens, ["gmk", "art"]) == "art1" + valueAtPath(tokens, ["gmk", "lan"]) == "05" + valueAtPath(tokens, ["gmk", "gmn"]) == "4320" + } + + def 'nested variableObjectElementNames resolves the wire element through the canonical namespace prefix'() { + given: + // Wire uses 'a' as a second prefix for the ADV namespace URI; the decoder normalises the + // wire qualified name through its constructor namespace map so the configured mapping + // key "adv:AX_GemarkungMitArt2" still matches. + def decoder = newDecoder(axFlurstueckWithGemarkungVariantSchema(), nestedVariableNameProfile()) + def xml = """ + + + 05 + + + """ + + when: + def tokens = runDecoder(decoder, xml) + + then: + valueAtPath(tokens, ["gmk", "art"]) == "art2" + } + + def 'a nested OBJECT_ELEMENT not matching a configured variant emits no discriminator but still decodes the children'() { + given: + // The canonical AX_Gemarkung_Schluessel inner element is *not* in the variant mapping; the + // decoder still treats it as the OBJECT_ELEMENT of (per GML's alternation + // rule) and decodes its children. No synthetic discriminator value is produced. + def decoder = newDecoder(axFlurstueckWithGemarkungVariantSchema(), nestedVariableNameProfile()) + def xml = """ + + + 05 + + + """ + + when: + def tokens = runDecoder(decoder, xml) + + then: + valueAtPath(tokens, ["gmk", "art"]) == null + valueAtPath(tokens, ["gmk", "lan"]) == "05" + } + + def 'a wfs:FeatureCollection root is rejected as multi-feature'() { + given: + def decoder = newDecoder(axFlurstueckSchema(), useAliasProfile()) + def xml = """ + + + + """ + + when: + runDecoder(decoder, xml) + + then: + def e = thrown(Exception) + rootCauseMessage(e).contains("Multi-feature ingest is not supported") + } + + /** + * Profile mirroring the encoder's default collection wrapper around each feature: + * {@code }. + */ + static FeatureTokenDecoderGmlInputProfile sfWrapperProfile() { + ImmutableFeatureTokenDecoderGmlInputProfile.builder() + .useAlias(true) + .featureCollectionElementName("sf:FeatureCollection") + .featureMemberElementName("sf:featureMember") + .build() + } + + def 'configured featureCollection/featureMember wrappers are descended and the inner feature is decoded'() { + given: + def decoder = newDecoder(axFlurstueckSchema(), sfWrapperProfile()) + def xml = """ + + + 05334001001000010001__ + + + """ + + when: + def tokens = runDecoder(decoder, xml) + + then: + tokens.first() == FeatureTokenType.INPUT + tokens.last() == FeatureTokenType.INPUT_END + tokens.count { it == FeatureTokenType.FEATURE } == 1 + tokens.count { it == FeatureTokenType.FEATURE_END } == 1 + valueAtPath(tokens, ["oid"]) == "DENW36AL10000XYZ" + valueAtPath(tokens, ["fsk"]) == "05334001001000010001__" + } + + def 'configured featureMember wrapper alone is descended (no enclosing collection)'() { + given: + def profile = ImmutableFeatureTokenDecoderGmlInputProfile.builder() + .useAlias(true) + .featureMemberElementName("sf:featureMember") + .build() + def decoder = newDecoder(axFlurstueckSchema(), profile) + def xml = """ + + 05334001001000010001__ + + """ + + when: + def tokens = runDecoder(decoder, xml) + + then: + tokens.count { it == FeatureTokenType.FEATURE } == 1 + valueAtPath(tokens, ["fsk"]) == "05334001001000010001__" + } + + def 'configured collection wrapper resolves the prefix via the constructor namespace map even with a different wire prefix'() { + given: + // Wire uses 'opf' for the same sf namespace URI; the decoder normalises through its + // constructor namespace map so the profile's "sf:FeatureCollection" still matches. + def decoder = newDecoder(axFlurstueckSchema(), sfWrapperProfile()) + def xml = """ + + + + """ + + when: + def tokens = runDecoder(decoder, xml) + + then: + tokens.count { it == FeatureTokenType.FEATURE } == 1 + valueAtPath(tokens, ["oid"]) == "DENW36AL10000XYZ" + } + + def 'a mismatching wrapper root with the collection option configured is rejected as multi-feature'() { + given: + def decoder = newDecoder(axFlurstueckSchema(), sfWrapperProfile()) + // Wire root is wfs:FeatureCollection but the profile expects sf:FeatureCollection. + def xml = """ + + + + """ + + when: + runDecoder(decoder, xml) + + then: + def e = thrown(Exception) + rootCauseMessage(e).contains("Multi-feature ingest is not supported") + } + + def 'a second feature inside the configured collection wrapper is rejected as multi-feature'() { + given: + def decoder = newDecoder(axFlurstueckSchema(), sfWrapperProfile()) + def xml = """ + + + + + + + """ + + when: + runDecoder(decoder, xml) + + then: + def e = thrown(Exception) + rootCauseMessage(e).contains("Multi-feature ingest is not supported") + } + + /** + * Index of the first {@code token} whose path operand (the next list in the stream) equals + * {@code targetPath}, or {@code -1} if no such token exists. Useful for asserting on + * structural tokens (ARRAY, OBJECT, OBJECT_END, ARRAY_END) where the value-after-VALUE + * helper does not apply. + */ + private static int indexOfTokenAtPath(List tokens, FeatureTokenType token, List targetPath) { + for (int i = 0; i < tokens.size() - 1; i++) { + if (tokens[i] == token && tokens.get(i + 1) == targetPath) { + return i + } + } + return -1 + } + + private static List indicesOfTokenAtPath(List tokens, FeatureTokenType token, List targetPath) { + def indices = new ArrayList() + for (int i = 0; i < tokens.size() - 1; i++) { + if (tokens[i] == token && tokens.get(i + 1) == targetPath) { + indices.add(i) + } + } + return indices + } + + private static String valueAtPath(List tokens, List targetPath) { + for (int i = 0; i < tokens.size() - 2; i++) { + if (tokens[i] != FeatureTokenType.VALUE) continue + if (tokens.get(i + 1) == targetPath) { + return tokens.get(i + 2) as String + } + } + return null + } + + private static List pathBeforeGeometry(List tokens) { + for (int i = 0; i < tokens.size(); i++) { + if (tokens[i] instanceof Geometry && i > 0 && tokens[i - 1] instanceof List) { + return (List) tokens[i - 1] + } + } + return null + } + + private static String rootCauseMessage(Throwable t) { + Throwable cur = t + while (cur.cause != null && cur.cause != cur) cur = cur.cause + return cur.message ?: "" + } + + // ------------------------------------------------------------------------------------------- + // AX_Gebaeude end-to-end coverage. The profile exercises: + // - applicationNamespaces declaring the ADV URI under a non-default prefix ("aaa"), while the + // wire XML uses "adv" for the same URI → namespace lookup must match on URI, not prefix + // - defaultNamespace pointing at that "aaa" prefix + // - useAlias: true with mixed-namespace inner elements (adv + gmd/gco) reached via + // objectTypeNamespaces for the ISO 19115 object types + // - valueWrap entries keyed both by alias path (lebenszeitintervall) and by property-name + // path (qag.dpl.prs.des / qag.dpl.prs.src.des), which are the two key shapes the decoder + // recognises + // - codelistUriTemplate and featureRefTemplate so that xlink:href on adv:anlass and + // adv:hat is reduced to the bare value + // ------------------------------------------------------------------------------------------- + + static final String ADV_PREFIX_URI = ADV_NS + static final String GCO_NS = "http://www.isotc211.org/2005/gco" + + static final Map AX_GEBAEUDE_NAMESPACES = [ + "aaa": ADV_PREFIX_URI, + "gmd": GMD_NS, + "gco": GCO_NS, + "gml": "http://www.opengis.net/gml/3.2", + "xlink": "http://www.w3.org/1999/xlink", + "xsi": "http://www.w3.org/2001/XMLSchema-instance", + "wfs": "http://www.opengis.net/wfs/2.0" + ] + + /** + * AX_Gebaeude slice covering every property in {@code src/test/resources/nas/AX_Gebaeude.xml}: + * {@code oid} (gml:id) and {@code idn} (gml:identifier); the surface geometry {@code gpo} + * (position); the simple STRING {@code gfk} (gebaeudefunktion); the DATETIME {@code lzi_beg} + * (lebenszeitintervall) reached through an adv-namespaced valueWrap chain; the OBJECT_ARRAY + * {@code mat} (modellart) with {@code stm} / {@code som} children; the codelist VALUE_ARRAY + * {@code anl} (anlass); the FEATURE_REF {@code hat}; and the deep transparent OBJECT chain + * {@code qag} (qualitaetsangaben) → {@code dpl} (LI_Lineage) → {@code prs} + * (LI_ProcessStep, OBJECT_ARRAY) → {@code des} / {@code pro} (CI_ResponsibleParty → + * {@code org} + {@code rol}) / {@code src} (LI_Source → {@code des}). + */ + static FeatureSchema axGebaeudeSchema() { + new ImmutableFeatureSchema.Builder() + .name("ax_gebaeude") + .sourcePath("/o31001") + .type(SchemaBase.Type.OBJECT) + .objectType("AX_Gebaeude") + .putProperties2("oid", new ImmutableFeatureSchema.Builder() + .sourcePath("objid") + .type(SchemaBase.Type.STRING) + .role(SchemaBase.Role.ID) + .alias("id")) + .putProperties2("idn", new ImmutableFeatureSchema.Builder() + .sourcePath("idn") + .type(SchemaBase.Type.STRING) + // Explicit gml: prefix on the alias pins the element to the GML namespace + // regardless of the profile's defaultNamespace. + .alias("gml:identifier")) + .putProperties2("gpo", new ImmutableFeatureSchema.Builder() + .sourcePath("gpo") + .type(SchemaBase.Type.GEOMETRY) + .geometryType(GeometryType.CURVE_POLYGON) + .alias("position")) + .putProperties2("gfk", new ImmutableFeatureSchema.Builder() + .sourcePath("gfk") + .type(SchemaBase.Type.STRING) + .alias("gebaeudefunktion")) + .putProperties2("lzi_beg", new ImmutableFeatureSchema.Builder() + .sourcePath("lzi__beg") + .type(SchemaBase.Type.DATETIME) + .alias("lebenszeitintervall")) + .putProperties2("mat", new ImmutableFeatureSchema.Builder() + .sourcePath("[id=rid]o31001__mat") + .type(SchemaBase.Type.OBJECT_ARRAY) + .objectType("AA_Modellart") + .alias("modellart") + .putProperties2("stm", new ImmutableFeatureSchema.Builder() + .sourcePath("stm") + .type(SchemaBase.Type.STRING) + .alias("advStandardModell")) + .putProperties2("som", new ImmutableFeatureSchema.Builder() + .sourcePath("som") + .type(SchemaBase.Type.STRING) + .alias("sonstigesModell") + .constraints(new ImmutableSchemaConstraints.Builder() + .codelist("AA_WeitereModellart") + .build()))) + .putProperties2("anl", new ImmutableFeatureSchema.Builder() + .sourcePath("[id=rid]o31001__anl/anl_href") + .type(SchemaBase.Type.VALUE_ARRAY) + .valueType(SchemaBase.Type.STRING) + .alias("anlass") + .constraints(new ImmutableSchemaConstraints.Builder() + .codelist("AA_Anlassart") + .build())) + .putProperties2("qag", new ImmutableFeatureSchema.Builder() + .type(SchemaBase.Type.OBJECT) + .objectType("AX_DQMitDatenerhebung") + .alias("qualitaetsangaben") + .putProperties2("dpl", new ImmutableFeatureSchema.Builder() + .type(SchemaBase.Type.OBJECT) + .objectType("LI_Lineage") + .alias("herkunft") + .putProperties2("prs", new ImmutableFeatureSchema.Builder() + .type(SchemaBase.Type.OBJECT_ARRAY) + .objectType("LI_ProcessStep") + .alias("processStep") + .putProperties2("des", new ImmutableFeatureSchema.Builder() + .sourcePath("qag__dpl_des") + .type(SchemaBase.Type.STRING) + .alias("description")) + .putProperties2("pro", new ImmutableFeatureSchema.Builder() + .type(SchemaBase.Type.OBJECT) + .objectType("CI_ResponsibleParty") + .alias("processor") + .putProperties2("org", new ImmutableFeatureSchema.Builder() + .sourcePath("qag__dpl_prs_pro_resp_org") + .type(SchemaBase.Type.STRING) + .alias("organisationName")) + .putProperties2("rol", new ImmutableFeatureSchema.Builder() + .sourcePath("qag__dpl_prs_pro_resp_rol_cdv") + .type(SchemaBase.Type.STRING) + .alias("role"))) + .putProperties2("src", new ImmutableFeatureSchema.Builder() + .type(SchemaBase.Type.OBJECT) + .objectType("LI_Source") + .alias("source") + .putProperties2("des", new ImmutableFeatureSchema.Builder() + .sourcePath("qag__dpl_prs_src") + .type(SchemaBase.Type.STRING) + .alias("description")))))) + .putProperties2("hat", new ImmutableFeatureSchema.Builder() + .sourcePath("p3100131001") + .type(SchemaBase.Type.FEATURE_REF) + .valueType(SchemaBase.Type.STRING) + .alias("hat") + .refType("ax_gebaeude")) + .build() + } + + static FeatureTokenDecoderGmlInputProfile axGebaeudeProfile() { + ImmutableFeatureTokenDecoderGmlInputProfile.builder() + .useAlias(true) + .defaultNamespace("aaa") + .putApplicationNamespaces("aaa", ADV_PREFIX_URI) + .putApplicationNamespaces("gmd", GMD_NS) + .putApplicationNamespaces("gco", GCO_NS) + .putObjectTypeNamespaces("LI_Lineage", "gmd") + .putObjectTypeNamespaces("LI_ProcessStep", "gmd") + .putObjectTypeNamespaces("LI_Source", "gmd") + .putObjectTypeNamespaces("CI_ResponsibleParty", "gmd") + // valueWrap is recognised under either the alias path or the property-name path; + // exercise both shapes against the same fixture. + .putValueWrap("lebenszeitintervall", ["AA_Lebenszeitintervall", "beginnt"]) + .putValueWrap("qag.dpl.prs.des", ["AX_LI_ProcessStep_MitDatenerhebung_Description"]) + .putValueWrap("qag.dpl.prs.src.des", ["AX_Datenerhebung"]) + .codelistUriTemplate("https://registry.gdi-de.org/codelist/de.adv-online.gid/{{codelistId}}/{{value}}") + .featureRefTemplate("urn:adv:oid:{{value}}") + .build() + } + + static FeatureTokenDecoderSimple> newAxGebaeudeDecoder( + FeatureSchema schema, FeatureTokenDecoderGmlInputProfile profile) { + new FeatureTokenDecoderGml( + AX_GEBAEUDE_NAMESPACES, + [new QName(ADV_PREFIX_URI, "AX_Gebaeude")], + schema, + ImmutableFeatureQuery.builder().type(schema.getName()).build(), + Map.of(schema.getName(), + new ImmutableSchemaMapping.Builder() + .targetSchema(schema) + .sourcePathTransformer((path, isValue) -> path) + .build()), + STORAGE_CRS, + Optional.empty(), + Optional.empty(), + profile) + } + + def 'AX_Gebaeude: simple scalar property decodes via alias when wire prefix differs from configured prefix'() { + given: + def decoder = newAxGebaeudeDecoder(axGebaeudeSchema(), axGebaeudeProfile()) + // Wire uses prefix "adv" for the ADV URI; the decoder's namespace map only knows that URI + // under prefix "aaa". The lookup must match on URI, not prefix. + def xml = """ + 2500 + """ + + when: + def tokens = runDecoder(decoder, xml) + + then: + valueAtPath(tokens, ["gfk"]) == "2500" + } + + def 'AX_Gebaeude: valueWrap keyed by alias path decodes the wrapped scalar to the property source path'() { + given: + def decoder = newAxGebaeudeDecoder(axGebaeudeSchema(), axGebaeudeProfile()) + def xml = """ + + + 2024-10-15T07:55:13Z + + + """ + + when: + def tokens = runDecoder(decoder, xml) + + then: + valueAtPath(tokens, ["lzi_beg"]) == "2024-10-15T07:55:13Z" + } + + def 'AX_Gebaeude: full fixture decodes every property in AX_Gebaeude.xml to its SQL source path'() { + given: + def decoder = newAxGebaeudeDecoder(axGebaeudeSchema(), axGebaeudeProfile()) + def bytes = new File("src/test/resources/nas/AX_Gebaeude.xml").bytes + + when: + def tokens = new ArrayList<>() + def stream = Reactive.Source.inputStream(new ByteArrayInputStream(bytes)) + .via(decoder) + .to(Reactive.Sink.reduce(tokens, (list, element) -> { list << element; return list })) + stream.on(runner).run().toCompletableFuture().join() + + then: + // gml:id → oid + valueAtPath(tokens, ["oid"]) == "DENW36AL10000Egu" + // gml:identifier (matched via the gml: prefix on the alias) + valueAtPath(tokens, ["idn"]) == "urn:adv:oid:DENW36AL10000Egu" + // lebenszeitintervall/AA_Lebenszeitintervall/beginnt → lzi_beg (alias-keyed valueWrap) + valueAtPath(tokens, ["lzi_beg"]) == "2024-10-15T07:55:13Z" + // gebaeudefunktion → gfk + valueAtPath(tokens, ["gfk"]) == "2500" + + // modellart array: first member carries advStandardModell, second carries sonstigesModell + // as xlink:href reduced through codelistUriTemplate. + def matPath = ["mat"] + def matObjectStarts = indicesOfTokenAtPath(tokens, FeatureTokenType.OBJECT, matPath) + matObjectStarts.size() == 2 + def matValues = tokens.findAll { it instanceof String && (it == "DLKM" || it == "NWABK") } + matValues == ["DLKM", "NWABK"] + + // anlass: VALUE_ARRAY of one codelist value reduced through codelistUriTemplate. The + // xlink:title must not surface and the raw URI must not surface either. + def anlArrayStart = indexOfTokenAtPath(tokens, FeatureTokenType.ARRAY, ["anl"]) + def anlArrayEnd = indexOfTokenAtPath(tokens, FeatureTokenType.ARRAY_END, ["anl"]) + anlArrayStart >= 0 && anlArrayEnd > anlArrayStart + def anlCodeIdx = tokens.indexOf("010704") + anlCodeIdx > anlArrayStart && anlCodeIdx < anlArrayEnd + !tokens.contains("https://registry.gdi-de.org/codelist/de.adv-online.gid/AA_Anlassart/010704") + !tokens.contains("Qualitätssicherung und Datenpflege") + + // qualitaetsangaben deep tree: every leaf reaches its qag__* column source path. + valueAtPath(tokens, ["qag", "dpl", "prs", "des"]) == "Erhebung" + valueAtPath(tokens, ["qag", "dpl", "prs", "pro", "org"]) == "Amt für Bodenmanagement und Geoinformation Bonn" + valueAtPath(tokens, ["qag", "dpl", "prs", "pro", "rol"]) == "processor" + valueAtPath(tokens, ["qag", "dpl", "prs", "src", "des"]) == "1000" + + // hat: FEATURE_REF xlink:href reduced to the bare id via featureRefTemplate. + valueAtPath(tokens, ["hat"]) == "DENW36AL10000K6p" + !tokens.contains("urn:adv:oid:DENW36AL10000K6p") + + // position: a single CURVE_POLYGON-typed Geometry emitted at the gpo source path. + def geometries = tokens.findAll { it instanceof Geometry } as List + geometries.size() == 1 + pathBeforeGeometry(tokens) == ["gpo"] + } +} diff --git a/xtraplatform-features-gml/src/test/groovy/de/ii/xtraplatform/features/gml/domain/GeometryDecoderGmlRoundtripSpec.groovy b/xtraplatform-features-gml/src/test/groovy/de/ii/xtraplatform/features/gml/domain/GeometryDecoderGmlRoundtripSpec.groovy new file mode 100644 index 000000000..be4970342 --- /dev/null +++ b/xtraplatform-features-gml/src/test/groovy/de/ii/xtraplatform/features/gml/domain/GeometryDecoderGmlRoundtripSpec.groovy @@ -0,0 +1,459 @@ +/* + * Copyright 2025 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.gml.domain + +import com.fasterxml.aalto.AsyncByteArrayFeeder +import com.fasterxml.aalto.AsyncXMLStreamReader +import com.fasterxml.aalto.stax.InputFactoryImpl +import de.ii.xtraplatform.geometries.domain.Geometry +import de.ii.xtraplatform.crs.domain.EpsgCrs +import de.ii.xtraplatform.geometries.domain.transcode.wktwkb.GeometryEncoderWkt +import spock.lang.Specification + +import javax.xml.stream.XMLOutputFactory + +/** + * Round-trip tests for the NAS-observed GML geometry patterns (see Appendix A of + * CRUD_GML_PLAN.md). For each pattern the spec: + * 1. decodes the input GML and asserts the WKT of the decoded geometry, + * 2. re-encodes the geometry using GeometryEncoderGml with options chosen to match the + * input shape (e.g. USE_SURFACE_RING_CURVE for Surface/Ring/Curve inputs), + * 3. decodes the re-encoded GML and asserts the WKT is identical. + * + * The encoder is permitted to produce a structurally different but semantically equivalent + * GML form, so the round-trip is verified at the geometry level (via WKT) rather than as + * exact-string equality. Initial fixtures are synthetic NAS-shaped snippets; once the NAS + * extractor lands, swap in real ALKIS slices from src/test/resources/nas/. + */ +class GeometryDecoderGmlRoundtripSpec extends Specification { + + static final String GML_NS_DECL = ' xmlns:gml="http://www.opengis.net/gml/3.2"' + + static Geometry decodeGml(String xml) { + // Ensure the gml prefix is bound — the encoder emits unqualified `gml:` elements. + String input = xml.contains('xmlns:gml=') ? xml : injectGmlNamespace(xml) + AsyncXMLStreamReader parser = new InputFactoryImpl().createAsyncFor(new byte[0]) + byte[] bytes = input.getBytes("UTF-8") + parser.getInputFeeder().feedInput(bytes, 0, bytes.length) + parser.getInputFeeder().endOfInput() + def decoder = new GeometryDecoderGml() + Optional> g = decoder.decode(parser, Optional.empty(), OptionalInt.empty()) + assert g.isPresent() + return g.get() + } + + static String injectGmlNamespace(String xml) { + int firstClose = xml.indexOf('>') + int firstSpace = xml.indexOf(' ') + int insertAt = (firstSpace > 0 && firstSpace < firstClose) ? firstSpace : firstClose + return xml.substring(0, insertAt) + GML_NS_DECL + xml.substring(insertAt) + } + + static String wkt(Geometry g) { + return new GeometryEncoderWkt().encode(g) + } + + static String encodeGml(Geometry g, Set options) { + def sw = new StringWriter() + def xmlWriter = XMLOutputFactory.newInstance().createXMLStreamWriter(sw) + def encoder = new GeometryEncoderGml( + xmlWriter, GmlVersion.GML32, options, Optional.of("gml"), Optional.empty(), List.of()) + g.accept(encoder) + xmlWriter.flush() + return sw.toString() + } + + static void roundtrip(String gmlIn, String expectedWkt, Set options) { + roundtrip(gmlIn, expectedWkt, null, options) + } + + static void roundtrip( + String gmlIn, + String expectedWkt, + Optional expectedCrs, + Set options) { + def geom1 = decodeGml(gmlIn) + assert wkt(geom1) == expectedWkt + if (expectedCrs != null) { + assert geom1.crs == expectedCrs + } + String gmlOut = encodeGml(geom1, options) + def geom2 = decodeGml(gmlOut) + assert wkt(geom2) == expectedWkt + if (expectedCrs != null) { + assert geom2.crs == expectedCrs + } + } + + def 'Point (NAS shape)'() { + expect: + roundtrip( + '365001.5 5621002.25', + 'POINT(365001.5 5621002.25)', + Set.of() + ) + } + + def 'MultiPoint (NAS shape)'() { + expect: + roundtrip( + ''' + 1 2 + 3 4 + ''', + 'MULTIPOINT((1.0 2.0),(3.0 4.0))', + Set.of() + ) + } + + def 'Curve with one LineStringSegment (NAS shape)'() { + expect: + roundtrip( + ''' + + 0 0 1 1 2 0 + + ''', + 'LINESTRING(0.0 0.0,1.0 1.0,2.0 0.0)', + Set.of() + ) + } + + def 'Curve with one Arc (NAS shape)'() { + expect: + roundtrip( + ''' + + 0 0 1 1 2 0 + + ''', + 'CIRCULARSTRING(0.0 0.0,1.0 1.0,2.0 0.0)', + Set.of() + ) + } + + def 'Curve with one Circle (NAS shape)'() { + expect: + roundtrip( + ''' + + 0 0 1 1 2 0 + + ''', + 'CIRCULARSTRING(0.0 0.0,1.0 1.0,2.0 0.0)', + Set.of() + ) + } + + def 'MultiCurve of linear Curves (NAS shape)'() { + expect: + roundtrip( + ''' + + + 0 0 1 1 + + + + + 2 2 3 3 + + + ''', + 'MULTILINESTRING((0.0 0.0,1.0 1.0),(2.0 2.0,3.0 3.0))', + Set.of() + ) + } + + def 'MultiCurve mixing linear and Arc Curves (NAS shape)'() { + expect: + roundtrip( + ''' + + + 0 0 1 1 + + + + + 1 1 2 0 3 1 + + + ''', + 'MULTICURVE((0.0 0.0,1.0 1.0),CIRCULARSTRING(1.0 1.0,2.0 0.0,3.0 1.0))', + Set.of() + ) + } + + def 'CompositeCurve of linear Curves (NAS shape)'() { + expect: + roundtrip( + ''' + + + 0 0 1 1 + + + + + 1 1 2 0 + + + ''', + 'COMPOUNDCURVE((0.0 0.0,1.0 1.0),(1.0 1.0,2.0 0.0))', + Set.of(GeometryEncoderGml.Options.USE_SURFACE_RING_CURVE) + ) + } + + def 'CompositeCurve mixing linear and Arc Curves (NAS shape)'() { + expect: + roundtrip( + ''' + + + 0 0 1 1 + + + + + 1 1 2 0 3 1 + + + ''', + 'COMPOUNDCURVE((0.0 0.0,1.0 1.0),CIRCULARSTRING(1.0 1.0,2.0 0.0,3.0 1.0))', + Set.of(GeometryEncoderGml.Options.USE_SURFACE_RING_CURVE) + ) + } + + def 'CompositeCurve from NAS (4 primitive curveMembers, one Arc, one merged Curve)'() { + expect: + // The last contains two s — segments inside a single + // Curve are merged into one LineString primitive. So this CompositeCurve has 4 primitive + // curveMembers after decode, not 5. + // + // CRS handling: srsName="urn:adv:crs:ETRS89_UTM32" is an ADV URN form. The decoder does + // not yet resolve these — that is Phase 1c (srsNameMappings). The current contract is + // therefore "unresolved srsName → no CRS captured"; this assertion locks that behaviour + // and will need to flip to Optional.of(EpsgCrs.of(25832)) when Phase 1c lands. + roundtrip( + ''' + + + + + 364511.241 5614723.635 364509.431 5614726.013 + + + + + + + + + 364509.431 5614726.013 364508.987 5614730.917 364512.731 5614734.111 + + + + + + + + + 364512.731 5614734.111 364520.919 5614736.949 + + + + + + + + + 364520.919 5614736.949 364522.483 5614738.547 + + + 364522.483 5614738.547 364527.479 5614737.896 + + + + + ''', + 'COMPOUNDCURVE((364511.241 5614723.635,364509.431 5614726.013),CIRCULARSTRING(364509.431 5614726.013,364508.987 5614730.917,364512.731 5614734.111),(364512.731 5614734.111,364520.919 5614736.949),(364520.919 5614736.949,364522.483 5614738.547,364527.479 5614737.896))', + Optional.empty(), + Set.of(GeometryEncoderGml.Options.USE_SURFACE_RING_CURVE) + ) + } + + def 'CompositeCurve with resolvable EPSG srsName preserves CRS through round-trip'() { + expect: + // Sanity check that the CRS plumbing itself works when the srsName is a form the decoder + // already recognizes. Uses WITH_SRS_NAME so the encoder emits srsName on its output and + // the re-decoded geometry can recover the CRS. + roundtrip( + ''' + + + 0 0 1 1 + + + + + 1 1 2 0 3 1 + + + ''', + 'COMPOUNDCURVE((0.0 0.0,1.0 1.0),CIRCULARSTRING(1.0 1.0,2.0 0.0,3.0 1.0))', + Optional.of(EpsgCrs.of(25832)), + Set.of( + GeometryEncoderGml.Options.USE_SURFACE_RING_CURVE, + GeometryEncoderGml.Options.WITH_SRS_NAME) + ) + } + + def 'Surface with linear PolygonPatch Ring (canonical NAS shape, single curveMember)'() { + expect: + roundtrip( + ''' + + + + + + + 0 0 1 0 1 1 0 0 + + + + + + + ''', + 'POLYGON((0.0 0.0,1.0 0.0,1.0 1.0,0.0 0.0))', + Set.of(GeometryEncoderGml.Options.USE_SURFACE_RING_CURVE) + ) + } + + def 'Surface with PolygonPatch Ring of multiple linear Curves (CurvePolygon)'() { + expect: + roundtrip( + ''' + + + + + + + 0 0 1 0 + + + + + 1 0 1 1 + + + + + 1 1 0 0 + + + + + + + ''', + 'CURVEPOLYGON(COMPOUNDCURVE((0.0 0.0,1.0 0.0),(1.0 0.0,1.0 1.0),(1.0 1.0,0.0 0.0)))', + Set.of(GeometryEncoderGml.Options.USE_SURFACE_RING_CURVE) + ) + } + + def 'Surface with PolygonPatch Ring containing an Arc (CurvePolygon)'() { + expect: + roundtrip( + ''' + + + + + + + 0 0 1 1 2 0 1 -1 0 0 + + + + + + + ''', + 'CURVEPOLYGON(CIRCULARSTRING(0.0 0.0,1.0 1.0,2.0 0.0,1.0 -1.0,0.0 0.0))', + Set.of(GeometryEncoderGml.Options.USE_SURFACE_RING_CURVE) + ) + } + + def 'MultiSurface of Surfaces (NAS shape) -> MultiPolygon when all linear'() { + expect: + roundtrip( + ''' + + + + + + 0 0 1 0 1 1 0 0 + + + + + + + + + + + 2 2 3 2 3 3 2 2 + + + + + + ''', + 'MULTIPOLYGON(((0.0 0.0,1.0 0.0,1.0 1.0,0.0 0.0)),((2.0 2.0,3.0 2.0,3.0 3.0,2.0 2.0)))', + Set.of(GeometryEncoderGml.Options.USE_SURFACE_RING_CURVE) + ) + } + + def 'MultiSurface with one curved Surface (NAS shape)'() { + expect: + roundtrip( + ''' + + + + + + 0 0 1 0 1 1 0 0 + + + + + + + + + + + 0 0 1 1 2 0 1 -1 0 0 + + + + + + ''', + 'MULTISURFACE(((0.0 0.0,1.0 0.0,1.0 1.0,0.0 0.0)),CURVEPOLYGON(CIRCULARSTRING(0.0 0.0,1.0 1.0,2.0 0.0,1.0 -1.0,0.0 0.0)))', + Set.of(GeometryEncoderGml.Options.USE_SURFACE_RING_CURVE) + ) + } +} diff --git a/xtraplatform-features-gml/src/test/groovy/de/ii/xtraplatform/features/gml/domain/GeometryDecoderGmlSpec.groovy b/xtraplatform-features-gml/src/test/groovy/de/ii/xtraplatform/features/gml/domain/GeometryDecoderGmlSpec.groovy new file mode 100644 index 000000000..ebace9f19 --- /dev/null +++ b/xtraplatform-features-gml/src/test/groovy/de/ii/xtraplatform/features/gml/domain/GeometryDecoderGmlSpec.groovy @@ -0,0 +1,552 @@ +/* + * Copyright 2025 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.gml.domain + +import com.fasterxml.aalto.AsyncByteArrayFeeder +import com.fasterxml.aalto.AsyncXMLStreamReader +import com.fasterxml.aalto.stax.InputFactoryImpl +import de.ii.xtraplatform.crs.domain.EpsgCrs +import de.ii.xtraplatform.geometries.domain.Geometry +import de.ii.xtraplatform.geometries.domain.transcode.wktwkb.GeometryEncoderWkt +import spock.lang.Specification + +class GeometryDecoderGmlSpec extends Specification { + + static Geometry decode(String xml) { + AsyncXMLStreamReader parser = new InputFactoryImpl().createAsyncFor(new byte[0]) + byte[] bytes = xml.getBytes("UTF-8") + parser.getInputFeeder().feedInput(bytes, 0, bytes.length) + parser.getInputFeeder().endOfInput() + def decoder = new GeometryDecoderGml() + Optional> g = decoder.decode(parser, Optional.empty(), OptionalInt.empty()) + assert g.isPresent() + return g.get() + } + + static String wkt(Geometry g) { + return new GeometryEncoderWkt().encode(g) + } + + def 'Point XY'() { + when: + def g = decode('10 20') + + then: + wkt(g) == 'POINT(10.0 20.0)' + } + + def 'Point preserves srsName'() { + when: + def g = decode('1 2') + + then: + wkt(g) == 'POINT(1.0 2.0)' + g.crs.get() == EpsgCrs.of(25832) + } + + def 'Point XYZ via srsDimension'() { + when: + def g = decode('1 2 3') + + then: + wkt(g) == 'POINT Z(1.0 2.0 3.0)' + } + + def 'MultiPoint'() { + when: + def g = decode(''' + 1 2 + 3 4 + ''') + + then: + wkt(g) == 'MULTIPOINT((1.0 2.0),(3.0 4.0))' + } + + def 'LineString'() { + when: + def g = decode('0 0 1 1 2 0') + + then: + wkt(g) == 'LINESTRING(0.0 0.0,1.0 1.0,2.0 0.0)' + } + + def 'MultiLineString via lineStringMember'() { + when: + def g = decode(''' + 0 0 1 1 + 2 2 3 3 + ''') + + then: + wkt(g) == 'MULTILINESTRING((0.0 0.0,1.0 1.0),(2.0 2.0,3.0 3.0))' + } + + def 'MultiCurve of LineStrings collapses to MultiLineString'() { + when: + def g = decode(''' + 0 0 1 1 + 2 2 3 3 + ''') + + then: + wkt(g) == 'MULTILINESTRING((0.0 0.0,1.0 1.0),(2.0 2.0,3.0 3.0))' + } + + def 'MultiCurve with a curved member -> MultiCurve'() { + when: + def g = decode(''' + 0 0 1 1 + + 1 1 2 0 3 1 + + ''') + + then: + wkt(g) == 'MULTICURVE((0.0 0.0,1.0 1.0),CIRCULARSTRING(1.0 1.0,2.0 0.0,3.0 1.0))' + } + + def 'Curve with single LineStringSegment -> LineString'() { + when: + def g = decode(''' + + 0 0 1 1 2 0 + + ''') + + then: + wkt(g) == 'LINESTRING(0.0 0.0,1.0 1.0,2.0 0.0)' + } + + def 'Curve with Arc segment -> CircularString'() { + when: + def g = decode(''' + + 0 0 1 1 2 0 + + ''') + + then: + wkt(g) == 'CIRCULARSTRING(0.0 0.0,1.0 1.0,2.0 0.0)' + } + + def 'Curve with Circle segment -> CircularString'() { + when: + def g = decode(''' + + 0 0 1 1 2 0 + + ''') + + then: + wkt(g) == 'CIRCULARSTRING(0.0 0.0,1.0 1.0,2.0 0.0)' + } + + def 'Curve with multiple LineStringSegments -> merged LineString'() { + when: + def g = decode(''' + + 0 0 1 1 + 1 1 2 0 + + ''') + + then: + wkt(g) == 'LINESTRING(0.0 0.0,1.0 1.0,2.0 0.0)' + } + + def 'Curve with multiple Arc segments -> merged CircularString'() { + when: + def g = decode(''' + + 0 0 1 1 2 0 + 2 0 3 -1 4 0 + + ''') + + then: + wkt(g) == 'CIRCULARSTRING(0.0 0.0,1.0 1.0,2.0 0.0,3.0 -1.0,4.0 0.0)' + } + + def 'Curve with LineStringSegment + Arc -> CompoundCurve'() { + when: + def g = decode(''' + + 0 0 1 1 + 1 1 2 0 3 1 + + ''') + + then: + wkt(g) == 'COMPOUNDCURVE((0.0 0.0,1.0 1.0),CIRCULARSTRING(1.0 1.0,2.0 0.0,3.0 1.0))' + } + + def 'CompositeCurve of LineString + Curve(Arc)'() { + when: + def g = decode(''' + 0 0 1 1 + + 1 1 2 0 3 1 + + ''') + + then: + wkt(g) == 'COMPOUNDCURVE((0.0 0.0,1.0 1.0),CIRCULARSTRING(1.0 1.0,2.0 0.0,3.0 1.0))' + } + + def 'Polygon with LinearRing exterior'() { + when: + def g = decode(''' + + 0 0 1 0 1 1 0 1 0 0 + + ''') + + then: + wkt(g) == 'POLYGON((0.0 0.0,1.0 0.0,1.0 1.0,0.0 1.0,0.0 0.0))' + } + + def 'Polygon with exterior + interior LinearRings'() { + when: + def g = decode(''' + + 0 0 10 0 10 10 0 10 0 0 + + + 2 2 4 2 4 4 2 4 2 2 + + ''') + + then: + wkt(g) == 'POLYGON((0.0 0.0,10.0 0.0,10.0 10.0,0.0 10.0,0.0 0.0),(2.0 2.0,4.0 2.0,4.0 4.0,2.0 4.0,2.0 2.0))' + } + + def 'GML 2.1 outerBoundaryIs/innerBoundaryIs and coordinates'() { + when: + def g = decode(''' + + 0,0 1,0 1,1 0,1 0,0 + + ''') + + then: + wkt(g) == 'POLYGON((0.0 0.0,1.0 0.0,1.0 1.0,0.0 1.0,0.0 0.0))' + } + + def 'MultiPolygon via polygonMember'() { + when: + def g = decode(''' + + 0 0 1 0 1 1 0 0 + + + 5 5 6 5 6 6 5 5 + + ''') + + then: + wkt(g) == 'MULTIPOLYGON(((0.0 0.0,1.0 0.0,1.0 1.0,0.0 0.0)),((5.0 5.0,6.0 5.0,6.0 6.0,5.0 5.0)))' + } + + def 'MultiSurface of Polygons -> MultiPolygon'() { + when: + def g = decode(''' + + 0 0 1 0 1 1 0 0 + + ''') + + then: + wkt(g) == 'MULTIPOLYGON(((0.0 0.0,1.0 0.0,1.0 1.0,0.0 0.0)))' + } + + def 'MultiSurface with curved member -> MultiSurface'() { + when: + def g = decode(''' + + 0 0 1 0 1 1 0 0 + + + + + + 0 0 1 1 2 0 1 -1 0 0 + + + + + ''') + + then: + wkt(g) == 'MULTISURFACE(((0.0 0.0,1.0 0.0,1.0 1.0,0.0 0.0)),CURVEPOLYGON(CIRCULARSTRING(0.0 0.0,1.0 1.0,2.0 0.0,1.0 -1.0,0.0 0.0)))' + } + + def 'Surface with single PolygonPatch (LinearRing) -> Polygon'() { + when: + def g = decode(''' + + + 0 0 1 0 1 1 0 0 + + + ''') + + then: + wkt(g) == 'POLYGON((0.0 0.0,1.0 0.0,1.0 1.0,0.0 0.0))' + } + + def 'Surface with gml:Ring of multiple linear curveMembers -> CurvePolygon (Curves preserved)'() { + when: + def g = decode(''' + + + + + + + 0 0 1 0 + + + + + 1 0 1 1 + + + + + 1 1 0 0 + + + + + + + ''') + + then: + wkt(g) == 'CURVEPOLYGON(COMPOUNDCURVE((0.0 0.0,1.0 0.0),(1.0 0.0,1.0 1.0),(1.0 1.0,0.0 0.0)))' + } + + def 'Surface with gml:Ring of single linear curveMember -> Polygon'() { + when: + def g = decode(''' + + + + + + + 0 0 1 0 1 1 0 0 + + + + + + + ''') + + then: + wkt(g) == 'POLYGON((0.0 0.0,1.0 0.0,1.0 1.0,0.0 0.0))' + } + + def 'Surface with gml:Ring containing an Arc -> CurvePolygon'() { + when: + def g = decode(''' + + + + + + + 0 0 1 1 2 0 1 -1 0 0 + + + + + + + ''') + + then: + wkt(g) == 'CURVEPOLYGON(CIRCULARSTRING(0.0 0.0,1.0 1.0,2.0 0.0,1.0 -1.0,0.0 0.0))' + } + + def 'Surface with gml:Ring mixing LineStringSegment + Arc -> CurvePolygon with CompoundCurve ring'() { + when: + def g = decode(''' + + + + + + + 0 0 1 1 + + + + + 1 1 2 0 3 1 + + + + + 3 1 0 0 + + + + + + + ''') + + then: + wkt(g) == 'CURVEPOLYGON(COMPOUNDCURVE((0.0 0.0,1.0 1.0),CIRCULARSTRING(1.0 1.0,2.0 0.0,3.0 1.0),(3.0 1.0,0.0 0.0)))' + } + + def 'CompositeSurface of Polygons -> PolyhedralSurface (open)'() { + when: + def g = decode(''' + + 0 0 1 0 1 1 0 0 + + + 0 0 1 0 0 1 0 0 + + ''') + + then: + wkt(g) == 'POLYHEDRALSURFACE(((0.0 0.0,1.0 0.0,1.0 1.0,0.0 0.0)),((0.0 0.0,1.0 0.0,0.0 1.0,0.0 0.0)))' + } + + def 'PolyhedralSurface element -> open PolyhedralSurface'() { + when: + def g = decode(''' + + 0 0 1 0 1 1 0 0 + 0 0 1 0 0 1 0 0 + + ''') + + then: + wkt(g) == 'POLYHEDRALSURFACE(((0.0 0.0,1.0 0.0,1.0 1.0,0.0 0.0)),((0.0 0.0,1.0 0.0,0.0 1.0,0.0 0.0)))' + } + + def 'Solid with outer shell -> closed PolyhedralSurface'() { + when: + def g = decode(''' + + + + 0 0 1 0 1 1 0 0 + + + 0 0 1 0 0 1 0 0 + + + + ''') + + then: + g.closed + wkt(g) == 'POLYHEDRALSURFACE(((0.0 0.0,1.0 0.0,1.0 1.0,0.0 0.0)),((0.0 0.0,1.0 0.0,0.0 1.0,0.0 0.0)))' + } + + def 'MultiGeometry -> GeometryCollection'() { + when: + def g = decode(''' + 1 2 + 0 0 1 1 + ''') + + then: + wkt(g) == 'GEOMETRYCOLLECTION(POINT(1.0 2.0),LINESTRING(0.0 0.0,1.0 1.0))' + } + + def 'LineString XYZ via srsDimension on LineString'() { + when: + def g = decode('0 0 0 1 1 1 2 0 0') + + then: + wkt(g) == 'LINESTRING Z(0.0 0.0 0.0,1.0 1.0 1.0,2.0 0.0 0.0)' + } + + def 'Solid with inner shell rejected'() { + when: + decode(''' + + + 0 0 1 0 1 1 0 0 + + + + + 0 0 1 0 1 1 0 0 + + + ''') + + then: + thrown(IOException) + } + + def 'Unsupported curve segment GeodesicString rejected'() { + when: + decode(''' + + 0 0 1 1 + + ''') + + then: + thrown(IOException) + } + + def 'Unsupported surface patch Triangle rejected'() { + when: + decode(''' + + 0 0 1 0 0 1 0 0 + + ''') + + then: + thrown(IOException) + } + + def 'CompositeSolid rejected'() { + when: + decode('') + + then: + thrown(IOException) + } + + def 'MultiSolid rejected'() { + when: + decode('') + + then: + thrown(IOException) + } + + def 'OrientableCurve rejected'() { + when: + decode('') + + then: + thrown(IOException) + } + + def 'OrientableSurface rejected'() { + when: + decode('') + + then: + thrown(IOException) + } +} diff --git a/xtraplatform-features-gml/src/test/resources/nas/AX_Aufnahmepunkt.xml b/xtraplatform-features-gml/src/test/resources/nas/AX_Aufnahmepunkt.xml new file mode 100644 index 000000000..daefc8614 --- /dev/null +++ b/xtraplatform-features-gml/src/test/resources/nas/AX_Aufnahmepunkt.xml @@ -0,0 +1,24 @@ + + + urn:adv:oid:DENW36AL10000JBJ + + + 2013-02-27T15:45:05Z + + + + + DLKM + + + + 323635614440465 + + + 05 + 4360 + + + GK25755615140465 + 1000 + diff --git a/xtraplatform-features-gml/src/test/resources/nas/AX_BesondereFlurstuecksgrenze.xml b/xtraplatform-features-gml/src/test/resources/nas/AX_BesondereFlurstuecksgrenze.xml new file mode 100644 index 000000000..e5dbb3e81 --- /dev/null +++ b/xtraplatform-features-gml/src/test/resources/nas/AX_BesondereFlurstuecksgrenze.xml @@ -0,0 +1,43 @@ + + + urn:adv:oid:DENW36AL10000MvI + + + 2024-10-15T07:55:13Z + + + + + DLKM + + + + + + + + + + + urn:adv:fdv:0901 + + + urn:adv:oid:DENW36PS00000R5M + + + + + + + + + 360473.384 5614473.145 360525.943 5614447.161 + + + + + 3000 + 7003 + 7104 + 7106 + diff --git a/xtraplatform-features-gml/src/test/resources/nas/AX_BoeschungKliff.xml b/xtraplatform-features-gml/src/test/resources/nas/AX_BoeschungKliff.xml new file mode 100644 index 000000000..7e69f7365 --- /dev/null +++ b/xtraplatform-features-gml/src/test/resources/nas/AX_BoeschungKliff.xml @@ -0,0 +1,30 @@ + + + urn:adv:oid:DENW36AL1UL0001j + + + 2024-10-15T07:55:13Z + + + + + DLKM + + + + + + + + + + + urn:adv:fdv:0901 + + + urn:adv:oid:DENW36PS00000OVB + + + + + diff --git a/xtraplatform-features-gml/src/test/resources/nas/AX_Fahrwegachse.xml b/xtraplatform-features-gml/src/test/resources/nas/AX_Fahrwegachse.xml new file mode 100644 index 000000000..8ed6e3002 --- /dev/null +++ b/xtraplatform-features-gml/src/test/resources/nas/AX_Fahrwegachse.xml @@ -0,0 +1,41 @@ + + + urn:adv:oid:DENW36ALEC00007H + + + 2025-03-18T14:09:03Z + + + + + DLKM + + + + + + + + + + + urn:adv:fdv:0901 + + + urn:adv:oid:DENW36PS00000R2D + + + + + + + + + 363950.518 5614818.889 363939.731 5614870.015 + + + + + 6 + 5212 + diff --git a/xtraplatform-features-gml/src/test/resources/nas/AX_Flurstueck.xml b/xtraplatform-features-gml/src/test/resources/nas/AX_Flurstueck.xml new file mode 100644 index 000000000..943e4bcec --- /dev/null +++ b/xtraplatform-features-gml/src/test/resources/nas/AX_Flurstueck.xml @@ -0,0 +1,249 @@ + + + urn:adv:oid:DENW36AL10000Ehc + + + 2024-10-15T07:55:13Z + + + + + DLKM + + + + + + urn:adv:fdv:0901 + + + urn:adv:oid:DENW36PS00000I3Z + + + + + + + + + + + + + + + + + + 361972.327 5614528.243 362230.706 5614414.420 + + + + + + + + + 362230.706 5614414.420 362311.916 5614444.241 + + + + + + + + + 362311.916 5614444.241 362382.416 5614470.130 + + + + + + + + + 362382.416 5614470.130 362418.564 5614548.652 + + + + + + + + + 362418.564 5614548.652 362444.128 5614603.177 + + + + + + + + + 362444.128 5614603.177 362421.456 5614613.210 + + + + + + + + + 362421.456 5614613.210 362276.905 5614677.164 + + + + + + + + + 362276.905 5614677.164 362224.508 5614712.827 + + + + + + + + + 362224.508 5614712.827 362181.737 5614731.630 + + + + + + + + + 362181.737 5614731.630 362171.620 5614736.820 + + + + + + + + + 362171.620 5614736.820 362128.916 5614764.532 + + + + + + + + + 362128.916 5614764.532 362062.108 5614822.856 + + + + + + + + + 362062.108 5614822.856 362036.221 5614737.064 + + + + + + + + + 362036.221 5614737.064 361972.327 5614528.243 + + + + + + + + + + + + + + + + + + + + + 362243.289 5614408.867 362334.485 5614368.553 + + + + + + + + + 362334.485 5614368.553 362376.958 5614458.563 + + + + + + + + + 362376.958 5614458.563 362316.089 5614435.933 + + + + + + + + + 362316.089 5614435.933 362243.289 5614408.867 + + + + + + + + + + + + + + + 05 + 4320 + + + + + 8 + + + 05432002500008______ + 110397.0 + 25 + false + false + + + + 361972.327 5614528.243 + + + 1861-01-01 + + + 05 + 3 + 14 + 000 + + + + + diff --git a/xtraplatform-features-gml/src/test/resources/nas/AX_Gebaeude.xml b/xtraplatform-features-gml/src/test/resources/nas/AX_Gebaeude.xml new file mode 100644 index 000000000..c73d7db2a --- /dev/null +++ b/xtraplatform-features-gml/src/test/resources/nas/AX_Gebaeude.xml @@ -0,0 +1,122 @@ + + + urn:adv:oid:DENW36AL10000Egu + + + 2024-10-15T07:55:13Z + + + + + DLKM + + + + + + + + + + + + + + + + + + + 363609.477 5614790.107 363612.936 5614788.978 + + + + + + + + + 363612.936 5614788.978 363613.362 5614788.839 + + + + + + + + + 363613.362 5614788.839 363615.062 5614793.925 + + + + + + + + + 363615.062 5614793.925 363615.291 5614794.608 + + + + + + + + + 363615.291 5614794.608 363612.495 5614795.542 + + + + + + + + + 363612.495 5614795.542 363609.477 5614790.107 + + + + + + + + + + + 2500 + + + + + + + + Erhebung + + + + + Amt für Bodenmanagement und Geoinformation Bonn + + + processor + + + + + + + 1000 + + + + + + + + + + + + + diff --git a/xtraplatform-features-gml/src/test/resources/nas/AX_LagebezeichnungOhneHausnummer.xml b/xtraplatform-features-gml/src/test/resources/nas/AX_LagebezeichnungOhneHausnummer.xml new file mode 100644 index 000000000..6608c2e17 --- /dev/null +++ b/xtraplatform-features-gml/src/test/resources/nas/AX_LagebezeichnungOhneHausnummer.xml @@ -0,0 +1,25 @@ + + + urn:adv:oid:DENW36AL10000FFW + + + 2013-02-27T15:45:05Z + + + + + DLKM + + + + + + + + + + + Katzenlochbach + + + diff --git a/xtraplatform-features-gml/src/test/resources/nas/AX_PunktortAU.xml b/xtraplatform-features-gml/src/test/resources/nas/AX_PunktortAU.xml new file mode 100644 index 000000000..f24b2d740 --- /dev/null +++ b/xtraplatform-features-gml/src/test/resources/nas/AX_PunktortAU.xml @@ -0,0 +1,69 @@ + + + urn:adv:oid:DENW36AL1IC000L6 + + + 2024-10-15T07:55:13Z + + + + + DLKM + + + + + + urn:adv:fdv:0901 + + + urn:adv:oid:DENW36PS00000QKS + + + + + + + + 369818.203 5614520.625 + + + true + 1000 + + + + + + + + Erhebung + + + 2017-07-20T11:20:04Z + + + + + Amt für Bodenmanagement und Geoinformation Bonn + + + processor + + + + + + + 1000 + + + + + + + + 2100 + + + diff --git a/xtraplatform-features-gml/src/test/resources/nas/AX_Strassenverkehr.xml b/xtraplatform-features-gml/src/test/resources/nas/AX_Strassenverkehr.xml new file mode 100644 index 000000000..545262121 --- /dev/null +++ b/xtraplatform-features-gml/src/test/resources/nas/AX_Strassenverkehr.xml @@ -0,0 +1,83 @@ + + + urn:adv:oid:DENW36AL10000v8R + + + 2024-10-15T07:55:13Z + + + + + DLKM + + + + + + + + + + + urn:adv:fdv:0901 + + + urn:adv:oid:DENW36PS00000GVL + + + + + + + + + + + + + + + 362838.745 5610811.273 362826.644 5610812.957 + + + + + + + + + 362826.644 5610812.957 362829.771 5610802.300 + + + + + + + + + 362829.771 5610802.300 362839.836 5610801.333 + + + + + + + + + 362839.836 5610801.333 362838.745 5610811.273 + + + + + + + + + + + + + Autobahn Bonn-Meckenheim + + + diff --git a/xtraplatform-features-gml/src/test/resources/nas/AX_Turm.xml b/xtraplatform-features-gml/src/test/resources/nas/AX_Turm.xml new file mode 100644 index 000000000..5dba29b41 --- /dev/null +++ b/xtraplatform-features-gml/src/test/resources/nas/AX_Turm.xml @@ -0,0 +1,120 @@ + + + urn:adv:oid:DENW36ALVk00002s + + + 2024-10-15T07:55:13Z + + + + + DLKM + + + + + + + + + + + urn:adv:fdv:0901 + + + urn:adv:oid:DENW36PS000008X4 + + + + + + + + + + + + + + + 361278.929 5619769.888 361274.785 5619772.246 + + + + + + + + + 361274.785 5619772.246 361273.460 5619769.917 + + + + + + + + + 361273.460 5619769.917 361272.459 5619768.157 + + + + + + + + + 361272.459 5619768.157 361276.603 5619765.798 + + + + + + + + + 361276.603 5619765.798 361278.929 5619769.888 + + + + + + + + + + + + + + + + + + Erhebung + + + + + Amt für Bodenmanagement und Geoinformation Bonn + + + processor + + + + + + + 4300 + + + + + + + + + + 1012 + diff --git a/xtraplatform-features-gml/src/test/resources/nas/AX_Wald.xml b/xtraplatform-features-gml/src/test/resources/nas/AX_Wald.xml new file mode 100644 index 000000000..a3dc85282 --- /dev/null +++ b/xtraplatform-features-gml/src/test/resources/nas/AX_Wald.xml @@ -0,0 +1,125 @@ + + + urn:adv:oid:DENW36AL10000KFq + + + 2024-10-15T07:55:13Z + + + + + DLKM + + + + + + + + + + + urn:adv:fdv:0901 + + + urn:adv:oid:DENW36PS00001G70 + + + + + + + + + + + + + + + 362741.193 5611135.639 362716.879 5611162.667 + + + + + + + + + 362716.879 5611162.667 362715.346 5611167.737 + + + + + + + + + 362715.346 5611167.737 362711.983 5611178.864 + + + + + + + + + 362711.983 5611178.864 362711.735 5611178.924 + + + + + + + + + 362711.735 5611178.924 362706.739 5611161.867 + + + + + + + + + 362706.739 5611161.867 362708.036 5611155.781 362710.251 5611149.962 + + + + + + + + + 362710.251 5611149.962 362717.077 5611139.960 + + + + + + + + + 362717.077 5611139.960 362731.126 5611120.752 + + + + + + + + + 362731.126 5611120.752 362741.193 5611135.639 + + + + + + + + + + + 1100 + 2000 + diff --git a/xtraplatform-features-gml/src/test/resources/nas/AX_Wohnbauflaeche.xml b/xtraplatform-features-gml/src/test/resources/nas/AX_Wohnbauflaeche.xml new file mode 100644 index 000000000..a5688cb29 --- /dev/null +++ b/xtraplatform-features-gml/src/test/resources/nas/AX_Wohnbauflaeche.xml @@ -0,0 +1,573 @@ + + + urn:adv:oid:DENW36AL2mD000Cg + + + 2024-10-15T07:55:13Z + + + + + DLKM + + + + + + + + + + + urn:adv:fdv:0901 + + + urn:adv:oid:DENW36PS00001I8A + + + + + + + + + + + + + + + 364448.865 5612317.147 364412.985 5612294.505 + + + + + + + + + 364412.985 5612294.505 364413.436 5612294.577 + + + + + + + + + 364413.436 5612294.577 364417.246 5612293.731 + + + + + + + + + 364417.246 5612293.731 364420.210 5612291.614 + + + + + + + + + 364420.210 5612291.614 364423.173 5612289.286 + + + + + + + + + 364423.173 5612289.286 364425.925 5612285.899 + + + + + + + + + 364425.925 5612285.899 364427.195 5612282.936 + + + + + + + + + 364427.195 5612282.936 364428.147 5612279.020 + + + + + + + + + 364428.147 5612279.020 364428.465 5612275.527 + + + + + + + + + 364428.465 5612275.527 364428.570 5612272.246 + + + + + + + + + 364428.570 5612272.246 364428.147 5612269.601 + + + + + + + + + 364428.147 5612269.601 364426.983 5612267.695 + + + + + + + + + 364426.983 5612267.695 364424.972 5612266.637 + + + + + + + + + 364424.972 5612266.637 364422.961 5612266.637 + + + + + + + + + 364422.961 5612266.637 364420.950 5612266.743 + + + + + + + + + 364420.950 5612266.743 364419.257 5612267.590 + + + + + + + + + 364419.257 5612267.590 364416.611 5612268.966 + + + + + + + + + 364416.611 5612268.966 364414.283 5612271.717 + + + + + + + + + 364414.283 5612271.717 364412.060 5612275.633 + + + + + + + + + 364412.060 5612275.633 364409.626 5612279.126 + + + + + + + + + 364409.626 5612279.126 364408.356 5612282.089 + + + + + + + + + 364408.356 5612282.089 364407.086 5612284.523 + + + + + + + + + 364407.086 5612284.523 364406.134 5612287.698 + + + + + + + + + 364406.134 5612287.698 364406.378 5612290.336 + + + + + + + + + 364406.378 5612290.336 364401.240 5612287.093 + + + + + + + + + 364401.240 5612287.093 364399.489 5612286.053 + + + + + + + + + 364399.489 5612286.053 364400.941 5612281.188 + + + + + + + + + 364400.941 5612281.188 364404.712 5612270.736 + + + + + + + + + 364404.712 5612270.736 364406.630 5612266.702 + + + + + + + + + 364406.630 5612266.702 364408.220 5612263.279 + + + + + + + + + 364408.220 5612263.279 364409.077 5612261.608 + + + + + + + + + 364409.077 5612261.608 364411.393 5612257.838 + + + + + + + + + 364411.393 5612257.838 364413.046 5612255.391 + + + + + + + + + 364413.046 5612255.391 364414.164 5612253.915 + + + + + + + + + 364414.164 5612253.915 364415.939 5612253.789 364417.588 5612254.440 + + + + + + + + + 364417.588 5612254.440 364417.952 5612254.720 364418.281 5612255.040 + + + + + + + + + 364418.281 5612255.040 364420.140 5612252.732 + + + + + + + + + 364420.140 5612252.732 364418.667 5612251.568 364418.530 5612249.701 + + + + + + + + + 364418.530 5612249.701 364419.065 5612249.305 + + + + + + + + + 364419.065 5612249.305 364422.307 5612246.593 + + + + + + + + + 364422.307 5612246.593 364425.085 5612243.484 + + + + + + + + + 364425.085 5612243.484 364426.250 5612243.312 364427.339 5612243.748 + + + + + + + + + 364427.339 5612243.748 364431.215 5612246.025 + + + + + + + + + 364431.215 5612246.025 364432.983 5612243.165 + + + + + + + + + 364432.983 5612243.165 364430.180 5612240.663 + + + + + + + + + 364430.180 5612240.663 364429.457 5612239.553 364429.481 5612238.237 + + + + + + + + + 364429.481 5612238.237 364434.228 5612233.587 + + + + + + + + + 364434.228 5612233.587 364435.856 5612232.731 + + + + + + + + + 364435.856 5612232.731 364439.626 5612235.043 + + + + + + + + + 364439.626 5612235.043 364462.598 5612249.217 + + + + + + + + + 364462.598 5612249.217 364467.958 5612252.569 + + + + + + + + + 364467.958 5612252.569 364464.433 5612258.755 + + + + + + + + + 364464.433 5612258.755 364466.746 5612260.272 + + + + + + + + + 364466.746 5612260.272 364459.593 5612271.172 + + + + + + + + + 364459.593 5612271.172 364458.071 5612270.173 + + + + + + + + + 364458.071 5612270.173 364454.740 5612276.492 + + + + + + + + + 364454.740 5612276.492 364452.417 5612280.979 + + + + + + + + + 364452.417 5612280.979 364466.492 5612290.573 + + + + + + + + + 364466.492 5612290.573 364448.865 5612317.147 + + + + + + + + + + + diff --git a/xtraplatform-features-gml/src/test/resources/nas/NOTICE b/xtraplatform-features-gml/src/test/resources/nas/NOTICE new file mode 100644 index 000000000..525feeb58 --- /dev/null +++ b/xtraplatform-features-gml/src/test/resources/nas/NOTICE @@ -0,0 +1,10 @@ +The XML files in this directory are bare-feature slices extracted from the +ALKIS NAS data set of the City of Bonn: + + Datenlizenz Deutschland - Zero - Version 2.0 + https://www.govdata.de/dl-de/zero-2-0 + +Each file holds the first feature of the named type from open data +publication of the ALKIS data from Bonn, reformatted as a self- +contained bare-feature document with the namespace declarations the GML +decoder needs. diff --git a/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/app/FeatureEncoderSql.java b/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/app/FeatureEncoderSql.java index 3de485b2a..dd3980107 100644 --- a/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/app/FeatureEncoderSql.java +++ b/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/app/FeatureEncoderSql.java @@ -65,6 +65,11 @@ public class FeatureEncoderSql private JsonBuilder currentJson; private Consumer currentJsonSetter; + // Junction table targeted by the currently open VALUE_ARRAY, or null when no junction-backed + // VALUE_ARRAY is open. Set on onArrayStart when the array's value column lives on a non-current + // table; each onValue inside the array becomes its own junction row. + private SqlQuerySchema currentArrayJunctionTable; + public FeatureEncoderSql( SqlQueryMapping mapping, EpsgCrs inputCrs, @@ -79,7 +84,7 @@ public FeatureEncoderSql( this.nullValue = nullValue; this.jsonColumns = new LinkedHashMap<>(); this.isPatch = nullValue.isPresent(); - this.trace = false; + this.trace = LOGGER.isTraceEnabled(); } @Modifiable @@ -100,12 +105,13 @@ public void onFeatureStart(ModifiableContext co currentJsonColumn = null; jsonColumns.clear(); } - if (trace) LOGGER.debug("onFeatureStart: {}", context.pathAsString()); + currentArrayJunctionTable = null; + if (trace) LOGGER.trace("onFeatureStart: {}", context.pathAsString()); } @Override public void onFeatureEnd(ModifiableContext context) { - if (trace) LOGGER.debug("onFeatureEnd: {}", context.pathAsString()); + if (trace) LOGGER.trace("onFeatureEnd: {}", context.pathAsString()); if (currentJsonColumn != null) { try { @@ -123,7 +129,7 @@ public void onFeatureEnd(ModifiableContext cont .forEach( row -> { if (trace) - LOGGER.debug("push: {} {}", row.first().getFullPathAsString(), row.second()); + LOGGER.trace("push: {} {}", row.first().getFullPathAsString(), row.second()); }); push(currentFeature); @@ -131,20 +137,20 @@ public void onFeatureEnd(ModifiableContext cont @Override public void onObjectStart(ModifiableContext context) { - if (trace) LOGGER.debug("onObjectStart: {}", context.pathAsString()); + if (trace) LOGGER.trace("onObjectStart: {}", context.pathAsString()); mapping.getMainSchema().getAllObjects().stream() .filter(schema -> Objects.equals(schema.getFullPath(), context.path())) .findFirst() .ifPresent( schema -> { - if (trace) LOGGER.debug("onObjectStart: {} {}", context.pathAsString(), schema); + if (trace) LOGGER.trace("onObjectStart: {} {}", context.pathAsString(), schema); }); Optional tableSchema = mapping.getTableForObject(context.pathAsString()); if (tableSchema.isPresent()) { - if (trace) LOGGER.debug("onObjectStart: table found for {}", context.pathAsString()); + if (trace) LOGGER.trace("onObjectStart: table found for {}", context.pathAsString()); currentFeature.addRow(tableSchema.get()); return; @@ -156,7 +162,7 @@ public void onObjectStart(ModifiableContext con if (column.isPresent() && checkJson(column.get())) { currentJson.openObject(context.path()); - if (trace) LOGGER.debug("onObjectStart: JSON {}", context.pathAsString()); + if (trace) LOGGER.trace("onObjectStart: JSON {}", context.pathAsString()); return; } @@ -165,12 +171,12 @@ public void onObjectStart(ModifiableContext con @Override public void onObjectEnd(ModifiableContext context) { - if (trace) LOGGER.debug("onObjectEnd: {}", context.pathAsString()); + if (trace) LOGGER.trace("onObjectEnd: {}", context.pathAsString()); Optional tableSchema = mapping.getTableForObject(context.pathAsString()); if (tableSchema.isPresent()) { - if (trace) LOGGER.debug("onObjectEnd: table found for {}", context.pathAsString()); + if (trace) LOGGER.trace("onObjectEnd: table found for {}", context.pathAsString()); currentFeature.closeRow(tableSchema.get()); return; @@ -182,7 +188,7 @@ public void onObjectEnd(ModifiableContext conte if (column.isPresent() && checkJson(column.get())) { currentJson.closeObject(context.path()); - if (trace) LOGGER.debug("onObjectEnd: JSON {}", context.pathAsString()); + if (trace) LOGGER.trace("onObjectEnd: JSON {}", context.pathAsString()); return; } @@ -191,7 +197,7 @@ public void onObjectEnd(ModifiableContext conte @Override public void onArrayStart(ModifiableContext context) { - if (trace) LOGGER.debug("onArrayStart: {} {}", context.pathAsString()); + if (trace) LOGGER.trace("onArrayStart: {} {}", context.pathAsString()); mapping .getColumnForValue(context.pathAsString(), MappingRule.Scope.W) @@ -200,7 +206,20 @@ public void onArrayStart(ModifiableContext cont if (checkJson(column)) { currentJson.openArray(context.path()); - if (trace) LOGGER.debug("onArrayStart: JSON {}", context.pathAsString()); + if (trace) LOGGER.trace("onArrayStart: JSON {}", context.pathAsString()); + return; + } + // VALUE_ARRAY whose value column lives on a junction table other than the row at the + // top of the stack. Record the table here and defer the per-element addRow to + // onValue, so each array element becomes its own row — mirroring how OBJECT_ARRAY + // junctions get a row per member via onObjectStart. + if (!currentFeature.isCurrent(column.first())) { + currentArrayJunctionTable = column.first(); + if (trace) + LOGGER.trace( + "onArrayStart: junction {} {}", + context.pathAsString(), + column.first().getFullPathAsString()); } }, () -> { @@ -211,7 +230,7 @@ public void onArrayStart(ModifiableContext cont @Override public void onArrayEnd(ModifiableContext context) { - if (trace) LOGGER.debug("onArrayEnd: {} {}", context.pathAsString()); + if (trace) LOGGER.trace("onArrayEnd: {} {}", context.pathAsString()); mapping .getColumnForValue(context.pathAsString(), MappingRule.Scope.W) @@ -220,13 +239,15 @@ public void onArrayEnd(ModifiableContext contex if (checkJson(column)) { currentJson.closeArray(context.path()); - if (trace) LOGGER.debug("onArrayEnd: JSON {}", context.pathAsString()); + if (trace) LOGGER.trace("onArrayEnd: JSON {}", context.pathAsString()); } }, () -> { if (trace) LOGGER.warn("onArrayEnd: JSON {} not found in mapping", context.pathAsString()); }); + + currentArrayJunctionTable = null; } @Override @@ -234,7 +255,7 @@ public void onGeometry(ModifiableContext contex Geometry geometry = context.geometry(); if (trace) { - LOGGER.debug("geometry: {} {}", context.pathAsString(), geometry); + LOGGER.trace("geometry: {} {}", context.pathAsString(), geometry); } mapping @@ -250,7 +271,7 @@ public void onGeometry(ModifiableContext contex currentFeature.addColumn(column.first(), column.second(), value); if (trace) { - LOGGER.debug("onGeometry: {} {}", context.pathAsString(), value); + LOGGER.trace("onGeometry: {} {}", context.pathAsString(), value); } }, () -> { @@ -288,7 +309,7 @@ public void onValue(ModifiableContext context) // TODO: does this use the sql name or json name? currentJson.addValue(context.path(), value); - if (trace) LOGGER.debug("onValue: JSON {} {}", context.pathAsString(), value); + if (trace) LOGGER.trace("onValue: JSON {} {}", context.pathAsString(), value); return; } @@ -305,9 +326,18 @@ public void onValue(ModifiableContext context) ? String.format("'%s'", value.replaceAll("'", "''")) : value; + boolean junctionElement = + currentArrayJunctionTable != null + && Objects.equals(column.first(), currentArrayJunctionTable); + if (junctionElement) { + currentFeature.addRow(column.first()); + } currentFeature.addColumn(column.first(), column.second(), value); + if (junctionElement) { + currentFeature.closeRow(column.first()); + } - if (trace) LOGGER.debug("onValue: {} {}", context.pathAsString(), value); + if (trace) LOGGER.trace("onValue: {} {}", context.pathAsString(), value); }, () -> { if (trace) LOGGER.warn("onValue: {} not found in mapping", context.pathAsString()); @@ -356,7 +386,7 @@ private static String toTimeZone(String path, String value, ZoneId timeZone, boo String newValue = formatter.format(instant) + "Z"; if (trace) { - LOGGER.debug( + LOGGER.trace( "onValue: {} transformed datetime value from '{}' to '{}'", path, value, newValue); } diff --git a/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/app/SqlMappingDeriver.java b/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/app/SqlMappingDeriver.java index 43eff66f6..f80c710cf 100644 --- a/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/app/SqlMappingDeriver.java +++ b/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/app/SqlMappingDeriver.java @@ -169,7 +169,13 @@ private void addToMapping( if (tableRule.isWritable() && !seenWritableProperties.contains(tableRule.getTarget()) - && !Objects.equals(tableRule.getTarget(), ROOT_TARGET)) { + && !Objects.equals(tableRule.getTarget(), ROOT_TARGET) + && tableRule.getType() != Type.VALUE_ARRAY) { + // VALUE_ARRAY tables share the cleaned target with their single value column (e.g. both end + // up as "anl"). Adding the table target here would dedup the column out of the writable loop + // below, so this register-as-object-table step is reserved for OBJECT / OBJECT_ARRAY tables. + // The value column for a VALUE_ARRAY junction still reaches putWritableTables / + // putWritableColumns via the writable column loop, which is what writes need. mapping.putObjectTables(tableRule.getTarget(), querySchema); seenWritableProperties.add(tableRule.getTarget()); } diff --git a/xtraplatform-geometries/src/main/java/de/ii/xtraplatform/geometries/domain/transcode/wktwkb/GeometryEncoderWkt.java b/xtraplatform-geometries/src/main/java/de/ii/xtraplatform/geometries/domain/transcode/wktwkb/GeometryEncoderWkt.java index 4219b5755..e89a08ed0 100644 --- a/xtraplatform-geometries/src/main/java/de/ii/xtraplatform/geometries/domain/transcode/wktwkb/GeometryEncoderWkt.java +++ b/xtraplatform-geometries/src/main/java/de/ii/xtraplatform/geometries/domain/transcode/wktwkb/GeometryEncoderWkt.java @@ -193,7 +193,12 @@ private void writeMultiLineString(StringBuilder builder, MultiLineString geometr } private void writePolygon(StringBuilder builder, Polygon geometry) { + boolean first = true; for (LineString ring : geometry.getValue()) { + if (!first) { + builder.append(','); + } + first = false; if (ring.isEmpty()) { builder.append("EMPTY"); } else { diff --git a/xtraplatform-geometries/src/main/java/de/ii/xtraplatform/geometries/domain/transform/CoordinatesTransformer.java b/xtraplatform-geometries/src/main/java/de/ii/xtraplatform/geometries/domain/transform/CoordinatesTransformer.java index 0c8f5ea6a..3ca100651 100644 --- a/xtraplatform-geometries/src/main/java/de/ii/xtraplatform/geometries/domain/transform/CoordinatesTransformer.java +++ b/xtraplatform-geometries/src/main/java/de/ii/xtraplatform/geometries/domain/transform/CoordinatesTransformer.java @@ -98,7 +98,7 @@ private double[] processPositions( return transformationChain.onCoordinates( coordinates, coordinates.length, dimension, interpolation, minNumberOfPositions); } catch (IOException e) { - throw new IllegalStateException("Error transforming coordinates.", e); + throw new IllegalStateException(e.getMessage(), e); } } } diff --git a/xtraplatform-geometries/src/main/java/de/ii/xtraplatform/geometries/domain/transform/CrsTransform.java b/xtraplatform-geometries/src/main/java/de/ii/xtraplatform/geometries/domain/transform/CrsTransform.java index b0e0f1373..f08306e91 100644 --- a/xtraplatform-geometries/src/main/java/de/ii/xtraplatform/geometries/domain/transform/CrsTransform.java +++ b/xtraplatform-geometries/src/main/java/de/ii/xtraplatform/geometries/domain/transform/CrsTransform.java @@ -32,6 +32,16 @@ public double[] onCoordinates( final int positions = length / dimension; double[] transformed = getCrsTransformer().transform(coordinates, positions, dimension); + if (transformed == null) { + throw new IOException( + String.format( + "Could not transform coordinates from %s to %s. The values may be outside the" + + " valid range of the source CRS — check that the request declares the correct" + + " CRS for the supplied coordinates (e.g. via the Content-Crs header or an" + + " srsName attribute).", + getCrsTransformer().getSourceCrs(), getCrsTransformer().getTargetCrs())); + } + final int targetDimension = getCrsTransformer().getTargetDimension(); if (dimension == 3 && targetDimension == 2) { transformed = Arrays.copyOfRange(transformed, 0, positions * 2); diff --git a/xtraplatform-geometries/src/test/groovy/de/ii/xtraplatform/geometries/domain/GeometryWktWkbSpec.groovy b/xtraplatform-geometries/src/test/groovy/de/ii/xtraplatform/geometries/domain/GeometryWktWkbSpec.groovy index aedf04951..398591126 100644 --- a/xtraplatform-geometries/src/test/groovy/de/ii/xtraplatform/geometries/domain/GeometryWktWkbSpec.groovy +++ b/xtraplatform-geometries/src/test/groovy/de/ii/xtraplatform/geometries/domain/GeometryWktWkbSpec.groovy @@ -278,6 +278,20 @@ class GeometryWktWkbSpec extends Specification { new GeometryEncoderWkt().encode(new GeometryDecoderWkb().decode(new GeometryEncoderWkb().encode(geometry))) == wkt } + def 'POLYGON XY with inner ring'() { + given: + String wkt = "POLYGON((0.0 0.0,10.0 0.0,10.0 10.0,0.0 10.0,0.0 0.0),(2.0 2.0,4.0 2.0,4.0 4.0,2.0 4.0,2.0 2.0))" + when: + Geometry geometry = new GeometryDecoderWkt().decode(wkt) + then: + geometry.getType() == GeometryType.POLYGON + ((Polygon) geometry).getNumRings() == 2 + ((Polygon) geometry).getValue().get(0).getValue().getCoordinates() == [0.0, 0.0, 10.0, 0.0, 10.0, 10.0, 0.0, 10.0, 0.0, 0.0] as double[] + ((Polygon) geometry).getValue().get(1).getValue().getCoordinates() == [2.0, 2.0, 4.0, 2.0, 4.0, 4.0, 2.0, 4.0, 2.0, 2.0] as double[] + new GeometryEncoderWkt().encode(geometry) == wkt + new GeometryEncoderWkt().encode(new GeometryDecoderWkb().decode(new GeometryEncoderWkb().encode(geometry))) == wkt + } + def 'MULTILINESTRING XY'() { given: String wkt = "MULTILINESTRING((10.0 10.0,20.0 20.0),(30.0 40.0,50.0 60.0))"