From 9c31e3949586701009d51c3ed02d9640a8b247c0 Mon Sep 17 00:00:00 2001 From: Clemens Portele Date: Sat, 2 May 2026 13:29:59 +0200 Subject: [PATCH 01/14] GML geometry encoder: Use XMLStreamWriter Use `XMLStreamWriter` instead of `StringBuilder`. --- .../gml/domain/GeometryEncoderGml.java | 184 ++++++------- .../features/gml/domain/GeometrySpec.groovy | 245 ++++++++++++------ 2 files changed, 265 insertions(+), 164 deletions(-) diff --git a/xtraplatform-features-gml/src/main/java/de/ii/xtraplatform/features/gml/domain/GeometryEncoderGml.java b/xtraplatform-features-gml/src/main/java/de/ii/xtraplatform/features/gml/domain/GeometryEncoderGml.java index 64a715f52..4128d90c7 100644 --- a/xtraplatform-features-gml/src/main/java/de/ii/xtraplatform/features/gml/domain/GeometryEncoderGml.java +++ b/xtraplatform-features-gml/src/main/java/de/ii/xtraplatform/features/gml/domain/GeometryEncoderGml.java @@ -33,15 +33,12 @@ import java.util.Map; import java.util.Optional; import java.util.Set; +import javax.xml.stream.XMLStreamException; +import javax.xml.stream.XMLStreamWriter; public class GeometryEncoderGml implements GeometryVisitor { public static final String SPACE = " "; - public static final String OPEN = "<"; - public static final String CLOSE = ">"; - public static final String EQUALS = "="; - public static final String QUOTE = "\""; - public static final String SLASH = "/"; public static final String COMMA = ","; private static final String POINT = "Point"; @@ -124,7 +121,7 @@ public enum Options { POLYGON_AS_PATCH } - private final StringBuilder builder; + private final XMLStreamWriter xmlWriter; private final Optional gmlPrefix; private final String gmlIdPrefix; private final Set options; @@ -135,8 +132,8 @@ public enum Options { private int nextGmlId = 0; private String srsName; - public GeometryEncoderGml(StringBuilder builder) { - this.builder = builder; + public GeometryEncoderGml(XMLStreamWriter xmlWriter) { + this.xmlWriter = xmlWriter; this.gmlPrefix = Optional.of("gml"); this.gmlIdPrefix = "geom_"; this.options = Set.of(); @@ -144,7 +141,7 @@ public GeometryEncoderGml(StringBuilder builder) { this.encodeAsSegmentOrPatch = Optional.of( new GeometryEncoderGml( - builder, + xmlWriter, GmlVersion.GML32, Set.of(Options.LINE_STRING_AS_SEGMENT, Options.POLYGON_AS_PATCH), this.gmlPrefix, @@ -153,19 +150,24 @@ public GeometryEncoderGml(StringBuilder builder) { this.encodeAsEmbeddedGeometry = Optional.of( new GeometryEncoderGml( - builder, GmlVersion.GML32, Set.of(), this.gmlPrefix, Optional.empty(), List.of())); + xmlWriter, + GmlVersion.GML32, + Set.of(), + this.gmlPrefix, + Optional.empty(), + List.of())); this.srsName = null; this.version = GmlVersion.GML32; } public GeometryEncoderGml( - StringBuilder builder, + XMLStreamWriter xmlWriter, GmlVersion version, Set options, Optional gmlPrefix, Optional gmlIdPrefix, List precision) { - this.builder = builder; + this.xmlWriter = xmlWriter; this.gmlPrefix = gmlPrefix; this.gmlIdPrefix = gmlIdPrefix.orElse("geom_"); this.options = options; @@ -175,7 +177,7 @@ public GeometryEncoderGml( ? Optional.empty() : Optional.of( new GeometryEncoderGml( - builder, + xmlWriter, version, ImmutableSet.builder() .addAll(options) @@ -188,7 +190,7 @@ public GeometryEncoderGml( options.contains(Options.WITH_SRS_NAME) ? Optional.of( new GeometryEncoderGml( - builder, + xmlWriter, version, ImmutableSet.builder() .addAll( @@ -225,23 +227,35 @@ public Optional initAndCheckGeometry(Geometry geometry) { } private void write(String s) { - builder.append(s); + try { + xmlWriter.writeCharacters(s); + } catch (XMLStreamException e) { + throw new IllegalStateException(e); + } } private void write(double d) { - builder.append(d); + try { + xmlWriter.writeCharacters(Double.toString(d)); + } catch (XMLStreamException e) { + throw new IllegalStateException(e); + } } private void write(BigDecimal d) { - builder.append(d.toString()); + try { + xmlWriter.writeCharacters(d.toString()); + } catch (XMLStreamException e) { + throw new IllegalStateException(e); + } } private void writeAttribute(String name, String value) { - write(name); - write(EQUALS); - write(QUOTE); - write(value); - write(QUOTE); + try { + xmlWriter.writeAttribute(name, value); + } catch (XMLStreamException e) { + throw new IllegalStateException(e); + } } private void writeStartTagObject(String tagName, boolean suppressSrsName) { @@ -262,33 +276,33 @@ private void writeStartTagProperty(String tagName, Map attribute private void writeStartTag( String tagName, Map attributes, boolean isObject, boolean suppressSrsName) { - write(OPEN); - gmlPrefix.ifPresent(pre -> write(pre + ':')); - write(tagName); - if (isObject) { - if (options.contains(Options.WITH_GML_ID)) { - write(SPACE); - writeAttribute(gmlPrefix.map(pre -> pre + ':' + ID).orElse(ID), gmlIdPrefix + nextGmlId++); + try { + if (gmlPrefix.isPresent()) { + xmlWriter.writeStartElement(gmlPrefix.get(), tagName, null); + } else { + xmlWriter.writeStartElement(tagName); } - if (options.contains(Options.WITH_SRS_NAME) && !suppressSrsName) { - write(SPACE); - writeAttribute(SRS_NAME, srsName); + if (isObject) { + if (options.contains(Options.WITH_GML_ID)) { + writeAttribute( + gmlPrefix.map(pre -> pre + ':' + ID).orElse(ID), gmlIdPrefix + nextGmlId++); + } + if (options.contains(Options.WITH_SRS_NAME) && !suppressSrsName) { + writeAttribute(SRS_NAME, srsName); + } } + attributes.forEach(this::writeAttribute); + } catch (XMLStreamException e) { + throw new IllegalStateException(e); } - attributes.forEach( - (key, value) -> { - write(SPACE); - writeAttribute(key, value); - }); - write(CLOSE); } - private void writeEndTag(String tagName) { - write(OPEN); - write(SLASH); - gmlPrefix.ifPresent(pre -> write(pre + ':')); - write(tagName); - write(CLOSE); + private void writeEndTag() { + try { + xmlWriter.writeEndElement(); + } catch (XMLStreamException e) { + throw new IllegalStateException(e); + } } private void writeCoordinates(double[] coordinates, Axes axes) { @@ -326,7 +340,7 @@ private void writePosition(double[] coordinates, Axes axes) { writeStartTagProperty(COORDINATES); } writeCoordinates(coordinates, axes); - writeEndTag(version != GmlVersion.GML21 ? POS : COORDINATES); + writeEndTag(); } private void writePositionList(double[] coordinates, Axes axes) { @@ -340,14 +354,14 @@ private void writePositionList(double[] coordinates, Axes axes) { writeStartTagProperty(COORDINATES); } writeCoordinates(coordinates, axes); - writeEndTag(version != GmlVersion.GML21 ? POS_LIST : COORDINATES); + writeEndTag(); } @Override public Void visit(Point geometry) { writeStartTagObject(POINT, false); writePosition(geometry.getValue().getCoordinates(), geometry.getAxes()); - writeEndTag(POINT); + writeEndTag(); return null; } @@ -358,9 +372,9 @@ public Void visit(MultiPoint geometry) { Point point = geometry.getValue().get(i); writeStartTagProperty(POINT_MEMBER); point.accept(encodeAsEmbeddedGeometry.orElse(this)); - writeEndTag(POINT_MEMBER); + writeEndTag(); } - writeEndTag(MULTI_POINT); + writeEndTag(); return null; } @@ -384,7 +398,7 @@ private void writeLineString(LineString geometry, boolean asSegment) { writeStartTagObject(tagName, false); } writePositionList(geometry.getValue().getCoordinates(), geometry.getAxes()); - writeEndTag(tagName); + writeEndTag(); } private void writeCircularString(CircularString geometry, boolean asSegment) { @@ -395,10 +409,10 @@ private void writeCircularString(CircularString geometry, boolean asSegment) { String tagName = geometry.getValue().getNumPositions() == 3 ? ARC : ARC_STRING; writeStartTagDataType(tagName); writePositionList(geometry.getValue().getCoordinates(), geometry.getAxes()); - writeEndTag(tagName); + writeEndTag(); if (!asSegment) { - writeEndTag(SEGMENTS); - writeEndTag(CURVE); + writeEndTag(); + writeEndTag(); } } @@ -409,9 +423,9 @@ public Void visit(MultiLineString geometry) { LineString lineString = geometry.getValue().get(i); writeStartTagProperty(version != GmlVersion.GML21 ? CURVE_MEMBER : LINE_STRING_MEMBER); lineString.accept(encodeAsEmbeddedGeometry.orElse(this)); - writeEndTag(version != GmlVersion.GML21 ? CURVE_MEMBER : LINE_STRING_MEMBER); + writeEndTag(); } - writeEndTag(version != GmlVersion.GML21 ? MULTI_CURVE : MULTI_LINE_STRING); + writeEndTag(); return null; } @@ -432,18 +446,10 @@ public Void visit(Polygon geometry) { } writeStartTagObject(LINEAR_RING, true); writePositionList(ring.getValue().getCoordinates(), geometry.getAxes()); - writeEndTag(LINEAR_RING); - if (i == 0) { - writeEndTag(version != GmlVersion.GML21 ? EXTERIOR : OUTER_BOUNDARY_IS); - } else { - writeEndTag(version != GmlVersion.GML21 ? INTERIOR : INNER_BOUNDARY_IS); - } - } - if (asPatch) { - writeEndTag(POLYGON_PATCH); - } else { - writeEndTag(POLYGON); + writeEndTag(); + writeEndTag(); } + writeEndTag(); return null; } @@ -454,9 +460,9 @@ public Void visit(MultiPolygon geometry) { Polygon polygon = geometry.getValue().get(i); writeStartTagProperty(version != GmlVersion.GML21 ? SURFACE_MEMBER : POLYGON_MEMBER); polygon.accept(encodeAsEmbeddedGeometry.orElse(this)); - writeEndTag(version != GmlVersion.GML21 ? SURFACE_MEMBER : POLYGON_MEMBER); + writeEndTag(); } - writeEndTag(version != GmlVersion.GML21 ? MULTI_SURFACE : MULTI_POLYGON); + writeEndTag(); return null; } @@ -467,9 +473,9 @@ public Void visit(MultiCurve geometry) { Geometry geometry2 = geometry.getValue().get(i); writeStartTagProperty(CURVE_MEMBER); geometry2.accept(encodeAsEmbeddedGeometry.orElse(this)); - writeEndTag(CURVE_MEMBER); + writeEndTag(); } - writeEndTag(MULTI_CURVE); + writeEndTag(); return null; } @@ -480,9 +486,9 @@ public Void visit(MultiSurface geometry) { Geometry geometry2 = geometry.getValue().get(i); writeStartTagProperty(SURFACE_MEMBER); geometry2.accept(encodeAsEmbeddedGeometry.orElse(this)); - writeEndTag(SURFACE_MEMBER); + writeEndTag(); } - writeEndTag(MULTI_SURFACE); + writeEndTag(); return null; } @@ -493,9 +499,9 @@ public Void visit(GeometryCollection geometry) { Geometry geometry2 = geometry.getValue().get(i); writeStartTagProperty(GEOMETRY_MEMBER); geometry2.accept(encodeAsEmbeddedGeometry.orElse(this)); - writeEndTag(GEOMETRY_MEMBER); + writeEndTag(); } - writeEndTag(MULTI_GEOMETRY); + writeEndTag(); return null; } @@ -507,8 +513,8 @@ public Void visit(CompoundCurve geometry) { SingleCurve curve = geometry.getValue().get(i); curve.accept(encodeAsSegmentOrPatch.orElse(this)); } - writeEndTag(SEGMENTS); - writeEndTag(CURVE); + writeEndTag(); + writeEndTag(); return null; } @@ -522,18 +528,14 @@ public Void visit(CurvePolygon geometry) { } else { writeStartTagProperty(INTERIOR); } - writeStartTagObject(RING, false); + writeStartTagObject(RING, true); writeStartTagProperty(CURVE_MEMBER); ring.accept(encodeAsEmbeddedGeometry.orElse(this)); - writeEndTag(CURVE_MEMBER); - writeEndTag(RING); - if (i == 0) { - writeEndTag(EXTERIOR); - } else { - writeEndTag(INTERIOR); - } + writeEndTag(); + writeEndTag(); + writeEndTag(); } - writeEndTag(POLYGON); + writeEndTag(); return null; } @@ -547,11 +549,11 @@ public Void visit(PolyhedralSurface geometry) { writeStartTagProperty(SURFACE_MEMBER); Polygon polygon = geometry.getValue().get(i); polygon.accept(encodeAsEmbeddedGeometry.orElse(this)); - writeEndTag(SURFACE_MEMBER); + writeEndTag(); } - writeEndTag(SHELL); - writeEndTag(EXTERIOR); - writeEndTag(SOLID); + writeEndTag(); + writeEndTag(); + writeEndTag(); } else { writeStartTagObject(POLYHEDRAL_SURFACE, false); writeStartTagProperty(PATCHES); @@ -559,8 +561,8 @@ public Void visit(PolyhedralSurface geometry) { Polygon polygon = geometry.getValue().get(i); polygon.accept(encodeAsSegmentOrPatch.orElse(this)); } - writeEndTag(PATCHES); - writeEndTag(POLYHEDRAL_SURFACE); + writeEndTag(); + writeEndTag(); } return null; } diff --git a/xtraplatform-features-gml/src/test/groovy/de/ii/xtraplatform/features/gml/domain/GeometrySpec.groovy b/xtraplatform-features-gml/src/test/groovy/de/ii/xtraplatform/features/gml/domain/GeometrySpec.groovy index 6d8f076cc..ff5dc2088 100644 --- a/xtraplatform-features-gml/src/test/groovy/de/ii/xtraplatform/features/gml/domain/GeometrySpec.groovy +++ b/xtraplatform-features-gml/src/test/groovy/de/ii/xtraplatform/features/gml/domain/GeometrySpec.groovy @@ -26,28 +26,37 @@ import de.ii.xtraplatform.geometries.domain.Position import de.ii.xtraplatform.geometries.domain.PositionList import spock.lang.Specification +import javax.xml.stream.XMLOutputFactory + class GeometrySpec extends Specification { - StringBuilder sb = new StringBuilder() - GeometryEncoderGml gmlEncoderWith = new GeometryEncoderGml(sb, GmlVersion.GML32, Set.of(GeometryEncoderGml.Options.WITH_GML_ID, GeometryEncoderGml.Options.WITH_SRS_NAME), Optional.of("gml"), Optional.of("g_"), List.of(1,1)) - GeometryEncoderGml gmlEncoderWithout = new GeometryEncoderGml(sb, GmlVersion.GML32, Set.of(), Optional.of("gml"), Optional.empty(), List.of()) - GeometryEncoderGml gmlEncoderGml21 = new GeometryEncoderGml(sb, GmlVersion.GML21, Set.of(), Optional.of("gml"), Optional.empty(), List.of(1,1)) + // ... def 'POINT XY'() { - given: Geometry geometry = Point.of(10.81, 10.37) when: - sb.setLength(0) + def sw1 = new StringWriter() + def xmlWriter1 = XMLOutputFactory.newInstance().createXMLStreamWriter(sw1) + def gmlEncoderWithout = new GeometryEncoderGml(xmlWriter1, GmlVersion.GML32, Set.of(), Optional.of("gml"), Optional.empty(), List.of()) geometry.accept(gmlEncoderWithout) - String gmlOut1 = sb.toString() - sb.setLength(0) + xmlWriter1.flush() + String gmlOut1 = sw1.toString() + + def sw2 = new StringWriter() + def xmlWriter2 = XMLOutputFactory.newInstance().createXMLStreamWriter(sw2) + def gmlEncoderWith = new GeometryEncoderGml(xmlWriter2, GmlVersion.GML32, Set.of(GeometryEncoderGml.Options.WITH_GML_ID, GeometryEncoderGml.Options.WITH_SRS_NAME), Optional.of("gml"), Optional.of("g_"), List.of(1,1)) geometry.accept(gmlEncoderWith) - String gmlOut2 = sb.toString() - sb.setLength(0) + xmlWriter2.flush() + String gmlOut2 = sw2.toString() + + def sw3 = new StringWriter() + def xmlWriter3 = XMLOutputFactory.newInstance().createXMLStreamWriter(sw3) + def gmlEncoderGml21 = new GeometryEncoderGml(xmlWriter3, GmlVersion.GML21, Set.of(), Optional.of("gml"), Optional.empty(), List.of(1,1)) geometry.accept(gmlEncoderGml21) - String gmlOut21 = sb.toString() + xmlWriter3.flush() + String gmlOut21 = sw3.toString() then: gmlOut1 == "10.81 10.37" @@ -60,12 +69,19 @@ class GeometrySpec extends Specification { Geometry geometry = Point.of(Position.ofXYZ(10.81, 10.37, 5.00)) when: - sb.setLength(0) + def sw1 = new StringWriter() + def xmlWriter1 = XMLOutputFactory.newInstance().createXMLStreamWriter(sw1) + def gmlEncoderWithout = new GeometryEncoderGml(xmlWriter1, GmlVersion.GML32, Set.of(), Optional.of("gml"), Optional.empty(), List.of()) geometry.accept(gmlEncoderWithout) - String gmlOut = sb.toString() - sb.setLength(0) + xmlWriter1.flush() + String gmlOut = sw1.toString() + + def sw2 = new StringWriter() + def xmlWriter2 = XMLOutputFactory.newInstance().createXMLStreamWriter(sw2) + def gmlEncoderGml21 = new GeometryEncoderGml(xmlWriter2, GmlVersion.GML21, Set.of(), Optional.of("gml"), Optional.empty(), List.of(1,1)) geometry.accept(gmlEncoderGml21) - String gmlOut21 = sb.toString() + xmlWriter2.flush() + String gmlOut21 = sw2.toString() then: gmlOut == "10.81 10.37 5.0" @@ -77,9 +93,12 @@ class GeometrySpec extends Specification { Geometry geometry = Point.of(Position.ofXYM(10.81, 10.37, 5.00)) when: - sb.setLength(0) + def sw = new StringWriter() + def xmlWriter = XMLOutputFactory.newInstance().createXMLStreamWriter(sw) + def gmlEncoderWithout = new GeometryEncoderGml(xmlWriter, GmlVersion.GML32, Set.of(), Optional.of("gml"), Optional.empty(), List.of()) geometry.accept(gmlEncoderWithout) - String gmlOut = sb.toString() + xmlWriter.flush() + String gmlOut = sw.toString() then: gmlOut == "10.81 10.37" @@ -90,9 +109,12 @@ class GeometrySpec extends Specification { Geometry geometry = Point.of(Position.ofXYZM(10.81, 10.37, 5.00, 7.50)) when: - sb.setLength(0) + def sw = new StringWriter() + def xmlWriter = XMLOutputFactory.newInstance().createXMLStreamWriter(sw) + def gmlEncoderWithout = new GeometryEncoderGml(xmlWriter, GmlVersion.GML32, Set.of(), Optional.of("gml"), Optional.empty(), List.of()) geometry.accept(gmlEncoderWithout) - String gmlOut = sb.toString() + xmlWriter.flush() + String gmlOut = sw.toString() then: gmlOut == "10.81 10.37 5.0" @@ -100,12 +122,15 @@ class GeometrySpec extends Specification { def 'POINT XY EMPTY'() { given: - Geometry geometry = Point.empty(Axes.XY); + Geometry geometry = Point.empty(Axes.XY) when: - sb.setLength(0) + def sw = new StringWriter() + def xmlWriter = XMLOutputFactory.newInstance().createXMLStreamWriter(sw) + def gmlEncoderWithout = new GeometryEncoderGml(xmlWriter, GmlVersion.GML32, Set.of(), Optional.of("gml"), Optional.empty(), List.of()) geometry.accept(gmlEncoderWithout) - String gmlOut = sb.toString() + xmlWriter.flush() + String gmlOut = sw.toString() then: gmlOut == "NaN NaN" @@ -113,12 +138,15 @@ class GeometrySpec extends Specification { def 'POINT XYZ EMPTY'() { given: - Geometry geometry = Point.empty(Axes.XYZ); + Geometry geometry = Point.empty(Axes.XYZ) when: - sb.setLength(0) + def sw = new StringWriter() + def xmlWriter = XMLOutputFactory.newInstance().createXMLStreamWriter(sw) + def gmlEncoderWithout = new GeometryEncoderGml(xmlWriter, GmlVersion.GML32, Set.of(), Optional.of("gml"), Optional.empty(), List.of()) geometry.accept(gmlEncoderWithout) - String gmlOut = sb.toString() + xmlWriter.flush() + String gmlOut = sw.toString() then: gmlOut == "NaN NaN NaN" @@ -126,15 +154,22 @@ class GeometrySpec extends Specification { def 'LINESTRING XY'() { given: - Geometry geometry = LineString.of(new double[]{10.0,10.0,20.0,20.0,30.0,40.0}); + Geometry geometry = LineString.of(new double[]{10.0,10.0,20.0,20.0,30.0,40.0}) when: - sb.setLength(0) + def sw1 = new StringWriter() + def xmlWriter1 = XMLOutputFactory.newInstance().createXMLStreamWriter(sw1) + def gmlEncoderWithout = new GeometryEncoderGml(xmlWriter1, GmlVersion.GML32, Set.of(), Optional.of("gml"), Optional.empty(), List.of()) geometry.accept(gmlEncoderWithout) - String gmlOut = sb.toString() - sb.setLength(0) + xmlWriter1.flush() + String gmlOut = sw1.toString() + + def sw2 = new StringWriter() + def xmlWriter2 = XMLOutputFactory.newInstance().createXMLStreamWriter(sw2) + def gmlEncoderGml21 = new GeometryEncoderGml(xmlWriter2, GmlVersion.GML21, Set.of(), Optional.of("gml"), Optional.empty(), List.of(1,1)) geometry.accept(gmlEncoderGml21) - String gmlOut21 = sb.toString() + xmlWriter2.flush() + String gmlOut21 = sw2.toString() then: gmlOut == "10.0 10.0 20.0 20.0 30.0 40.0" @@ -143,15 +178,22 @@ class GeometrySpec extends Specification { def 'LINESTRING XYZ'() { given: - Geometry geometry = LineString.of(PositionList.of(Axes.XYZ, new double[]{10.0,10.0,1.0,20.0,20.0,2.0,30.0,40.0,3.0})); + Geometry geometry = LineString.of(PositionList.of(Axes.XYZ, new double[]{10.0,10.0,1.0,20.0,20.0,2.0,30.0,40.0,3.0})) when: - sb.setLength(0) + def sw1 = new StringWriter() + def xmlWriter1 = XMLOutputFactory.newInstance().createXMLStreamWriter(sw1) + def gmlEncoderWithout = new GeometryEncoderGml(xmlWriter1, GmlVersion.GML32, Set.of(), Optional.of("gml"), Optional.empty(), List.of()) geometry.accept(gmlEncoderWithout) - String gmlOut = sb.toString() - sb.setLength(0) + xmlWriter1.flush() + String gmlOut = sw1.toString() + + def sw2 = new StringWriter() + def xmlWriter2 = XMLOutputFactory.newInstance().createXMLStreamWriter(sw2) + def gmlEncoderGml21 = new GeometryEncoderGml(xmlWriter2, GmlVersion.GML21, Set.of(), Optional.of("gml"), Optional.empty(), List.of(1,1)) geometry.accept(gmlEncoderGml21) - String gmlOut21 = sb.toString() + xmlWriter2.flush() + String gmlOut21 = sw2.toString() then: gmlOut == "10.0 10.0 1.0 20.0 20.0 2.0 30.0 40.0 3.0" @@ -163,12 +205,18 @@ class GeometrySpec extends Specification { Geometry geometry = MultiPoint.of(List.of(Point.of(10,10),Point.of(20,20))) when: - sb.setLength(0) + def sw = new StringWriter() + def xmlWriter = XMLOutputFactory.newInstance().createXMLStreamWriter(sw) + def gmlEncoderWithout = new GeometryEncoderGml(xmlWriter, GmlVersion.GML32, Set.of(), Optional.of("gml"), Optional.empty(), List.of()) geometry.accept(gmlEncoderWithout) - String gmlOut = sb.toString() - sb.setLength(0) + xmlWriter.flush() + String gmlOut = sw.toString() + def sw2 = new StringWriter() + def xmlWriter2 = XMLOutputFactory.newInstance().createXMLStreamWriter(sw2) + def gmlEncoderGml21 = new GeometryEncoderGml(xmlWriter2, GmlVersion.GML21, Set.of(), Optional.of("gml"), Optional.empty(), List.of(1,1)) geometry.accept(gmlEncoderGml21) - String gmlOut21 = sb.toString() + xmlWriter2.flush() + String gmlOut21 = sw2.toString() then: gmlOut == "10.0 10.020.0 20.0" @@ -180,12 +228,18 @@ class GeometrySpec extends Specification { Geometry geometry = Polygon.of(List.of(PositionList.of(Axes.XY,new double[]{10.0,10.0,20.0,20.0,30.0,40.0,10.0,10.0}))) when: - sb.setLength(0) + def sw = new StringWriter() + def xmlWriter = XMLOutputFactory.newInstance().createXMLStreamWriter(sw) + def gmlEncoderWithout = new GeometryEncoderGml(xmlWriter, GmlVersion.GML32, Set.of(), Optional.of("gml"), Optional.empty(), List.of()) geometry.accept(gmlEncoderWithout) - String gmlOut = sb.toString() - sb.setLength(0) + xmlWriter.flush() + String gmlOut = sw.toString() + def sw2 = new StringWriter() + def xmlWriter2 = XMLOutputFactory.newInstance().createXMLStreamWriter(sw2) + def gmlEncoderGml21 = new GeometryEncoderGml(xmlWriter2, GmlVersion.GML21, Set.of(), Optional.of("gml"), Optional.empty(), List.of(1,1)) geometry.accept(gmlEncoderGml21) - String gmlOut21 = sb.toString() + xmlWriter2.flush() + String gmlOut21 = sw2.toString() then: gmlOut == "10.0 10.0 20.0 20.0 30.0 40.0 10.0 10.0" @@ -200,12 +254,18 @@ class GeometrySpec extends Specification { )) when: - sb.setLength(0) + def sw = new StringWriter() + def xmlWriter = XMLOutputFactory.newInstance().createXMLStreamWriter(sw) + def gmlEncoderWithout = new GeometryEncoderGml(xmlWriter, GmlVersion.GML32, Set.of(), Optional.of("gml"), Optional.empty(), List.of()) geometry.accept(gmlEncoderWithout) - String gmlOut = sb.toString() - sb.setLength(0) + xmlWriter.flush() + String gmlOut = sw.toString() + def sw2 = new StringWriter() + def xmlWriter2 = XMLOutputFactory.newInstance().createXMLStreamWriter(sw2) + def gmlEncoderGml21 = new GeometryEncoderGml(xmlWriter2, GmlVersion.GML21, Set.of(), Optional.of("gml"), Optional.empty(), List.of(1,1)) geometry.accept(gmlEncoderGml21) - String gmlOut21 = sb.toString() + xmlWriter2.flush() + String gmlOut21 = sw2.toString() then: gmlOut == "10.0 10.0 20.0 20.030.0 40.0 50.0 60.0" @@ -217,12 +277,18 @@ class GeometrySpec extends Specification { Geometry geometry = MultiPolygon.of(List.of(Polygon.of(List.of(PositionList.of(Axes.XY,new double[]{10.0,10.0,20.0,20.0,30.0,40.0,10.0,10.0}))), Polygon.of(List.of(PositionList.of(Axes.XY,new double[]{50.0,50.0,60.0,60.0,70.0,80.0,50.0,50.0}))))) when: - sb.setLength(0) + def sw = new StringWriter() + def xmlWriter = XMLOutputFactory.newInstance().createXMLStreamWriter(sw) + def gmlEncoderWithout = new GeometryEncoderGml(xmlWriter, GmlVersion.GML32, Set.of(), Optional.of("gml"), Optional.empty(), List.of()) geometry.accept(gmlEncoderWithout) - String gmlOut = sb.toString() - sb.setLength(0) + xmlWriter.flush() + String gmlOut = sw.toString() + def sw2 = new StringWriter() + def xmlWriter2 = XMLOutputFactory.newInstance().createXMLStreamWriter(sw2) + def gmlEncoderGml21 = new GeometryEncoderGml(xmlWriter2, GmlVersion.GML21, Set.of(), Optional.of("gml"), Optional.empty(), List.of(1,1)) geometry.accept(gmlEncoderGml21) - String gmlOut21 = sb.toString() + xmlWriter2.flush() + String gmlOut21 = sw2.toString() then: gmlOut == "10.0 10.0 20.0 20.0 30.0 40.0 10.0 10.050.0 50.0 60.0 60.0 70.0 80.0 50.0 50.0" @@ -234,12 +300,18 @@ class GeometrySpec extends Specification { Geometry geometry = GeometryCollection.of(List.of(Point.of(10,10),LineString.of(new double[]{20.0,20.0,30.0,30.0}))) when: - sb.setLength(0) + def sw = new StringWriter() + def xmlWriter = XMLOutputFactory.newInstance().createXMLStreamWriter(sw) + def gmlEncoderWithout = new GeometryEncoderGml(xmlWriter, GmlVersion.GML32, Set.of(), Optional.of("gml"), Optional.empty(), List.of()) geometry.accept(gmlEncoderWithout) - String gmlOut = sb.toString() - sb.setLength(0) + xmlWriter.flush() + String gmlOut = sw.toString() + def sw2 = new StringWriter() + def xmlWriter2 = XMLOutputFactory.newInstance().createXMLStreamWriter(sw2) + def gmlEncoderGml21 = new GeometryEncoderGml(xmlWriter2, GmlVersion.GML21, Set.of(), Optional.of("gml"), Optional.empty(), List.of(1,1)) geometry.accept(gmlEncoderGml21) - String gmlOut21 = sb.toString() + xmlWriter2.flush() + String gmlOut21 = sw2.toString() then: gmlOut == "10.0 10.020.0 20.0 30.0 30.0" @@ -253,12 +325,18 @@ class GeometrySpec extends Specification { Geometry geometry = GeometryCollection.of(List.of(geometryCollection, multiPoint)) when: - sb.setLength(0) + def sw = new StringWriter() + def xmlWriter = XMLOutputFactory.newInstance().createXMLStreamWriter(sw) + def gmlEncoderWithout = new GeometryEncoderGml(xmlWriter, GmlVersion.GML32, Set.of(), Optional.of("gml"), Optional.empty(), List.of()) geometry.accept(gmlEncoderWithout) - String gmlOut = sb.toString() - sb.setLength(0) + xmlWriter.flush() + String gmlOut = sw.toString() + def sw2 = new StringWriter() + def xmlWriter2 = XMLOutputFactory.newInstance().createXMLStreamWriter(sw2) + def gmlEncoderGml21 = new GeometryEncoderGml(xmlWriter2, GmlVersion.GML21, Set.of(), Optional.of("gml"), Optional.empty(), List.of(1,1)) geometry.accept(gmlEncoderGml21) - String gmlOut21 = sb.toString() + xmlWriter2.flush() + String gmlOut21 = sw2.toString() then: gmlOut == "10.0 10.020.0 20.0 30.0 30.010.0 10.020.0 20.0" @@ -272,9 +350,12 @@ class GeometrySpec extends Specification { )) when: - sb.setLength(0) + def sw = new StringWriter() + def xmlWriter = XMLOutputFactory.newInstance().createXMLStreamWriter(sw) + def gmlEncoderWithout = new GeometryEncoderGml(xmlWriter, GmlVersion.GML32, Set.of(), Optional.of("gml"), Optional.empty(), List.of()) geometry.accept(gmlEncoderWithout) - String gmlOut = sb.toString() + xmlWriter.flush() + String gmlOut = sw.toString() then: gmlOut == "0.0 0.0 0.0 1.0 1.0 1.0 0.0 0.00.0 0.0 1.0 1.0 1.0 0.0 0.0 0.0" @@ -285,9 +366,12 @@ class GeometrySpec extends Specification { Geometry geometry = CircularString.of(PositionList.of(Axes.XY, new double[]{0.0, 0.0, 1.0, 1.0, 2.0, 0.0})) when: - sb.setLength(0) + def sw = new StringWriter() + def xmlWriter = XMLOutputFactory.newInstance().createXMLStreamWriter(sw) + def gmlEncoderWithout = new GeometryEncoderGml(xmlWriter, GmlVersion.GML32, Set.of(), Optional.of("gml"), Optional.empty(), List.of()) geometry.accept(gmlEncoderWithout) - String gmlOut = sb.toString() + xmlWriter.flush() + String gmlOut = sw.toString() then: gmlOut == "0.0 0.0 1.0 1.0 2.0 0.0" @@ -301,9 +385,12 @@ class GeometrySpec extends Specification { )) when: - sb.setLength(0) + def sw = new StringWriter() + def xmlWriter = XMLOutputFactory.newInstance().createXMLStreamWriter(sw) + def gmlEncoderWithout = new GeometryEncoderGml(xmlWriter, GmlVersion.GML32, Set.of(), Optional.of("gml"), Optional.empty(), List.of()) geometry.accept(gmlEncoderWithout) - String gmlOut = sb.toString() + xmlWriter.flush() + String gmlOut = sw.toString() then: gmlOut == "0.0 0.0 1.0 1.01.0 1.0 2.0 0.0" @@ -316,9 +403,12 @@ class GeometrySpec extends Specification { )) when: - sb.setLength(0) + def sw = new StringWriter() + def xmlWriter = XMLOutputFactory.newInstance().createXMLStreamWriter(sw) + def gmlEncoderWithout = new GeometryEncoderGml(xmlWriter, GmlVersion.GML32, Set.of(), Optional.of("gml"), Optional.empty(), List.of()) geometry.accept(gmlEncoderWithout) - String gmlOut = sb.toString() + xmlWriter.flush() + String gmlOut = sw.toString() then: gmlOut == "0.0 0.0 1.0 1.0 0.0 2.0 -1.0 -1.0 0.0 0.0" @@ -332,9 +422,12 @@ class GeometrySpec extends Specification { )) when: - sb.setLength(0) + def sw = new StringWriter() + def xmlWriter = XMLOutputFactory.newInstance().createXMLStreamWriter(sw) + def gmlEncoderWithout = new GeometryEncoderGml(xmlWriter, GmlVersion.GML32, Set.of(), Optional.of("gml"), Optional.empty(), List.of()) geometry.accept(gmlEncoderWithout) - String gmlOut = sb.toString() + xmlWriter.flush() + String gmlOut = sw.toString() then: gmlOut == "0.0 0.0 1.0 1.01.0 1.0 2.0 0.0 3.0 1.0" @@ -348,9 +441,12 @@ class GeometrySpec extends Specification { )) when: - sb.setLength(0) + def sw = new StringWriter() + def xmlWriter = XMLOutputFactory.newInstance().createXMLStreamWriter(sw) + def gmlEncoderWithout = new GeometryEncoderGml(xmlWriter, GmlVersion.GML32, Set.of(), Optional.of("gml"), Optional.empty(), List.of()) geometry.accept(gmlEncoderWithout) - String gmlOut = sb.toString() + xmlWriter.flush() + String gmlOut = sw.toString() then: gmlOut == "0.0 0.0 0.0 1.0 1.0 1.0 0.0 0.01.0 1.0 2.0 2.0 3.0 2.0 2.0 1.0 1.0 1.0" @@ -371,9 +467,12 @@ class GeometrySpec extends Specification { ), true) when: - sb.setLength(0) + def sw = new StringWriter() + def xmlWriter = XMLOutputFactory.newInstance().createXMLStreamWriter(sw) + def gmlEncoderWithout = new GeometryEncoderGml(xmlWriter, GmlVersion.GML32, Set.of(), Optional.of("gml"), Optional.empty(), List.of()) geometry.accept(gmlEncoderWithout) - String gmlOut = sb.toString() + xmlWriter.flush() + String gmlOut = sw.toString() then: gmlOut == "280414.631 5660090.756 40.255 280414.631 5660088.454 40.255 280414.631 5660088.454 32.967 280414.631 5660090.756 32.967 280414.631 5660090.756 40.255280414.631 5660088.454 40.255 280405.623 5660088.454 33.256 280405.623 5660088.454 32.967 280414.631 5660088.454 32.967 280414.631 5660088.454 40.255280405.623 5660088.454 33.256 280405.623 5660090.756 33.256 280405.623 5660090.756 32.967 280405.623 5660088.454 32.967 280405.623 5660088.454 33.256280405.623 5660090.756 33.256 280414.631 5660090.756 40.255 280414.631 5660090.756 32.967 280405.623 5660090.756 32.967 280405.623 5660090.756 33.256280405.623 5660088.454 33.256 280414.631 5660088.454 40.255 280411.722 5660088.454 41.63 280405.623 5660088.454 33.256280414.631 5660090.756 40.255 280405.623 5660090.756 33.256 280411.722 5660090.756 41.63 280414.631 5660090.756 40.255280414.631 5660088.454 40.255 280414.631 5660090.756 40.255 280411.722 5660090.756 41.63 280411.722 5660088.454 41.63 280414.631 5660088.454 40.255280405.623 5660090.756 33.256 280405.623 5660088.454 33.256 280411.722 5660088.454 41.63 280411.722 5660090.756 41.63 280405.623 5660090.756 33.256280414.631 5660090.756 32.967 280414.631 5660088.454 32.967 280405.623 5660088.454 32.967 280405.623 5660090.756 32.967 280414.631 5660090.756 32.967" From c725682b56665f406bdf89d5c9b6a466d9bf35fb Mon Sep 17 00:00:00 2001 From: Clemens Portele Date: Fri, 8 May 2026 12:41:08 +0000 Subject: [PATCH 02/14] GML geometry encoder: add USE_SURFACE_RING_CURVE option --- .../gml/domain/GeometryEncoderGml.java | 50 +++++++++++++++---- 1 file changed, 39 insertions(+), 11 deletions(-) diff --git a/xtraplatform-features-gml/src/main/java/de/ii/xtraplatform/features/gml/domain/GeometryEncoderGml.java b/xtraplatform-features-gml/src/main/java/de/ii/xtraplatform/features/gml/domain/GeometryEncoderGml.java index 4128d90c7..33be1e6aa 100644 --- a/xtraplatform-features-gml/src/main/java/de/ii/xtraplatform/features/gml/domain/GeometryEncoderGml.java +++ b/xtraplatform-features-gml/src/main/java/de/ii/xtraplatform/features/gml/domain/GeometryEncoderGml.java @@ -51,6 +51,7 @@ public class GeometryEncoderGml implements GeometryVisitor { private static final String MULTI_CURVE = "MultiCurve"; private static final String MULTI_LINE_STRING = "MultiLineString"; private static final String POLYGON = "Polygon"; + private static final String SURFACE = "Surface"; private static final String POLYGON_PATCH = "PolygonPatch"; private static final String POLYHEDRAL_SURFACE = "PolyhedralSurface"; private static final String MULTI_SURFACE = "MultiSurface"; @@ -118,7 +119,8 @@ public enum Options { WITH_SRS_NAME, WITH_SRS_DIMENSION, LINE_STRING_AS_SEGMENT, - POLYGON_AS_PATCH + POLYGON_AS_PATCH, + USE_SURFACE_RING_CURVE } private final XMLStreamWriter xmlWriter; @@ -389,16 +391,24 @@ public Void visit(SingleCurve geometry) { } private void writeLineString(LineString geometry, boolean asSegment) { - String tagName; + boolean useRing = options.contains(Options.USE_SURFACE_RING_CURVE); if (asSegment) { - tagName = LINE_STRING_SEGMENT; - writeStartTagDataType(tagName); + writeStartTagDataType(LINE_STRING_SEGMENT); + writePositionList(geometry.getValue().getCoordinates(), geometry.getAxes()); + writeEndTag(); + } else if (useRing) { + writeStartTagObject(CURVE, false); + writeStartTagProperty(SEGMENTS); + writeStartTagDataType(LINE_STRING_SEGMENT); + writePositionList(geometry.getValue().getCoordinates(), geometry.getAxes()); + writeEndTag(); + writeEndTag(); + writeEndTag(); } else { - tagName = LINE_STRING; - writeStartTagObject(tagName, false); + writeStartTagObject(LINE_STRING, false); + writePositionList(geometry.getValue().getCoordinates(), geometry.getAxes()); + writeEndTag(); } - writePositionList(geometry.getValue().getCoordinates(), geometry.getAxes()); - writeEndTag(); } private void writeCircularString(CircularString geometry, boolean asSegment) { @@ -432,7 +442,12 @@ public Void visit(MultiLineString geometry) { @Override public Void visit(Polygon geometry) { boolean asPatch = options.contains(Options.POLYGON_AS_PATCH); - if (asPatch) { + boolean useRing = options.contains(Options.USE_SURFACE_RING_CURVE); + if (useRing) { + writeStartTagObject(SURFACE, false); + writeStartTagProperty(PATCHES); + writeStartTagDataType(POLYGON_PATCH); + } else if (asPatch) { writeStartTagDataType(POLYGON_PATCH); } else { writeStartTagObject(POLYGON, false); @@ -444,8 +459,21 @@ public Void visit(Polygon geometry) { } else { writeStartTagProperty(version != GmlVersion.GML21 ? INTERIOR : INNER_BOUNDARY_IS); } - writeStartTagObject(LINEAR_RING, true); - writePositionList(ring.getValue().getCoordinates(), geometry.getAxes()); + if (useRing) { + writeStartTagObject(RING, true); + writeStartTagProperty(CURVE_MEMBER); + writeStartTagDataType(LINE_STRING_SEGMENT); + writePositionList(ring.getValue().getCoordinates(), geometry.getAxes()); + writeEndTag(); + writeEndTag(); + } else { + writeStartTagObject(LINEAR_RING, true); + writePositionList(ring.getValue().getCoordinates(), geometry.getAxes()); + } + writeEndTag(); + writeEndTag(); + } + if (useRing) { writeEndTag(); writeEndTag(); } From bd36c6af4751bf3610fffad8d4fba9e972ae5e40 Mon Sep 17 00:00:00 2001 From: Clemens Portele Date: Sat, 9 May 2026 11:04:55 +0000 Subject: [PATCH 03/14] GML geometry encoder: emit Surface/PolygonPatch and CompositeCurve for USE_SURFACE_RING_CURVE CurvePolygon now serializes as gml:Surface with gml:PolygonPatch; gml:Ring holds curve members directly (it is implicitly a CompositeCurve). CompoundCurve serializes as gml:CompositeCurve. Adds Spock tests for LineString, Polygon, CurvePolygon (CircularString and CompoundCurve rings), and CompoundCurve. --- .../gml/domain/GeometryEncoderGml.java | 68 ++++++++++---- .../features/gml/domain/GeometrySpec.groovy | 93 +++++++++++++++++++ 2 files changed, 143 insertions(+), 18 deletions(-) diff --git a/xtraplatform-features-gml/src/main/java/de/ii/xtraplatform/features/gml/domain/GeometryEncoderGml.java b/xtraplatform-features-gml/src/main/java/de/ii/xtraplatform/features/gml/domain/GeometryEncoderGml.java index 33be1e6aa..72482eb57 100644 --- a/xtraplatform-features-gml/src/main/java/de/ii/xtraplatform/features/gml/domain/GeometryEncoderGml.java +++ b/xtraplatform-features-gml/src/main/java/de/ii/xtraplatform/features/gml/domain/GeometryEncoderGml.java @@ -391,12 +391,12 @@ public Void visit(SingleCurve geometry) { } private void writeLineString(LineString geometry, boolean asSegment) { - boolean useRing = options.contains(Options.USE_SURFACE_RING_CURVE); + boolean useSurfaceAndCurve = options.contains(Options.USE_SURFACE_RING_CURVE); if (asSegment) { writeStartTagDataType(LINE_STRING_SEGMENT); writePositionList(geometry.getValue().getCoordinates(), geometry.getAxes()); writeEndTag(); - } else if (useRing) { + } else if (useSurfaceAndCurve) { writeStartTagObject(CURVE, false); writeStartTagProperty(SEGMENTS); writeStartTagDataType(LINE_STRING_SEGMENT); @@ -442,8 +442,8 @@ public Void visit(MultiLineString geometry) { @Override public Void visit(Polygon geometry) { boolean asPatch = options.contains(Options.POLYGON_AS_PATCH); - boolean useRing = options.contains(Options.USE_SURFACE_RING_CURVE); - if (useRing) { + boolean useSurfaceAndCurve = options.contains(Options.USE_SURFACE_RING_CURVE); + if (useSurfaceAndCurve) { writeStartTagObject(SURFACE, false); writeStartTagProperty(PATCHES); writeStartTagDataType(POLYGON_PATCH); @@ -459,7 +459,7 @@ public Void visit(Polygon geometry) { } else { writeStartTagProperty(version != GmlVersion.GML21 ? INTERIOR : INNER_BOUNDARY_IS); } - if (useRing) { + if (useSurfaceAndCurve) { writeStartTagObject(RING, true); writeStartTagProperty(CURVE_MEMBER); writeStartTagDataType(LINE_STRING_SEGMENT); @@ -473,7 +473,7 @@ public Void visit(Polygon geometry) { writeEndTag(); writeEndTag(); } - if (useRing) { + if (useSurfaceAndCurve) { writeEndTag(); writeEndTag(); } @@ -535,31 +535,63 @@ public Void visit(GeometryCollection geometry) { @Override public Void visit(CompoundCurve geometry) { - writeStartTagObject(CURVE, false); - writeStartTagProperty(SEGMENTS); - for (int i = 0; i < geometry.getNumGeometries(); i++) { - SingleCurve curve = geometry.getValue().get(i); - curve.accept(encodeAsSegmentOrPatch.orElse(this)); + boolean useSurfaceAndCurve = options.contains(Options.USE_SURFACE_RING_CURVE); + if (useSurfaceAndCurve) { + writeStartTagObject(COMPOSITE_CURVE, false); + for (int i = 0; i < geometry.getNumGeometries(); i++) { + SingleCurve curve = geometry.getValue().get(i); + writeStartTagProperty(CURVE_MEMBER); + curve.accept(encodeAsEmbeddedGeometry.orElse(this)); + writeEndTag(); + } + writeEndTag(); + } else { + writeStartTagObject(CURVE, false); + writeStartTagProperty(SEGMENTS); + for (int i = 0; i < geometry.getNumGeometries(); i++) { + SingleCurve curve = geometry.getValue().get(i); + curve.accept(encodeAsSegmentOrPatch.orElse(this)); + } + writeEndTag(); + writeEndTag(); } - writeEndTag(); - writeEndTag(); return null; } @Override public Void visit(CurvePolygon geometry) { - writeStartTagObject(POLYGON, false); + boolean useSurfaceAndCurve = options.contains(Options.USE_SURFACE_RING_CURVE); + if (useSurfaceAndCurve) { + writeStartTagObject(SURFACE, false); + writeStartTagProperty(PATCHES); + writeStartTagDataType(POLYGON_PATCH); + } else { + writeStartTagObject(POLYGON, false); + } for (int i = 0; i < geometry.getNumRings(); i++) { Curve ring = geometry.getValue().get(i); if (i == 0) { - writeStartTagProperty(EXTERIOR); + writeStartTagProperty(version != GmlVersion.GML21 ? EXTERIOR : OUTER_BOUNDARY_IS); } else { - writeStartTagProperty(INTERIOR); + writeStartTagProperty(version != GmlVersion.GML21 ? INTERIOR : INNER_BOUNDARY_IS); } writeStartTagObject(RING, true); - writeStartTagProperty(CURVE_MEMBER); - ring.accept(encodeAsEmbeddedGeometry.orElse(this)); + if (useSurfaceAndCurve && ring instanceof CompoundCurve compoundCurve) { + for (int j = 0; j < compoundCurve.getNumGeometries(); j++) { + SingleCurve segment = compoundCurve.getValue().get(j); + writeStartTagProperty(CURVE_MEMBER); + segment.accept(encodeAsEmbeddedGeometry.orElse(this)); + writeEndTag(); + } + } else { + writeStartTagProperty(CURVE_MEMBER); + ring.accept(encodeAsEmbeddedGeometry.orElse(this)); + writeEndTag(); + } + writeEndTag(); writeEndTag(); + } + if (useSurfaceAndCurve) { writeEndTag(); writeEndTag(); } diff --git a/xtraplatform-features-gml/src/test/groovy/de/ii/xtraplatform/features/gml/domain/GeometrySpec.groovy b/xtraplatform-features-gml/src/test/groovy/de/ii/xtraplatform/features/gml/domain/GeometrySpec.groovy index ff5dc2088..bc5acd08a 100644 --- a/xtraplatform-features-gml/src/test/groovy/de/ii/xtraplatform/features/gml/domain/GeometrySpec.groovy +++ b/xtraplatform-features-gml/src/test/groovy/de/ii/xtraplatform/features/gml/domain/GeometrySpec.groovy @@ -414,6 +414,99 @@ class GeometrySpec extends Specification { gmlOut == "0.0 0.0 1.0 1.0 0.0 2.0 -1.0 -1.0 0.0 0.0" } + def 'LINESTRING XY with USE_SURFACE_RING_CURVE'() { + given: + Geometry geometry = LineString.of(new double[]{0.0, 0.0, 1.0, 1.0, 2.0, 0.0}) + + when: + def sw = new StringWriter() + def xmlWriter = XMLOutputFactory.newInstance().createXMLStreamWriter(sw) + def gmlEncoder = new GeometryEncoderGml(xmlWriter, GmlVersion.GML32, Set.of(GeometryEncoderGml.Options.USE_SURFACE_RING_CURVE), Optional.of("gml"), Optional.empty(), List.of()) + geometry.accept(gmlEncoder) + xmlWriter.flush() + String gmlOut = sw.toString() + + then: + gmlOut == "0.0 0.0 1.0 1.0 2.0 0.0" + } + + def 'POLYGON XY with USE_SURFACE_RING_CURVE'() { + given: + Geometry geometry = Polygon.of(List.of( + PositionList.of(Axes.XY, new double[]{0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 0.0, 0.0}) + )) + + when: + def sw = new StringWriter() + def xmlWriter = XMLOutputFactory.newInstance().createXMLStreamWriter(sw) + def gmlEncoder = new GeometryEncoderGml(xmlWriter, GmlVersion.GML32, Set.of(GeometryEncoderGml.Options.USE_SURFACE_RING_CURVE), Optional.of("gml"), Optional.empty(), List.of()) + geometry.accept(gmlEncoder) + xmlWriter.flush() + String gmlOut = sw.toString() + + then: + gmlOut == "0.0 0.0 0.0 1.0 1.0 1.0 0.0 0.0" + } + + def 'CURVEPOLYGON XY with USE_SURFACE_RING_CURVE'() { + given: + Geometry geometry = CurvePolygon.of(List.of( + CircularString.of(PositionList.of(Axes.XY, new double[]{0.0, 0.0, 1.0, 1.0, 0.0, 2.0, -1.0, -1.0, 0.0, 0.0})) + )) + + when: + def sw = new StringWriter() + def xmlWriter = XMLOutputFactory.newInstance().createXMLStreamWriter(sw) + def gmlEncoder = new GeometryEncoderGml(xmlWriter, GmlVersion.GML32, Set.of(GeometryEncoderGml.Options.USE_SURFACE_RING_CURVE), Optional.of("gml"), Optional.empty(), List.of()) + geometry.accept(gmlEncoder) + xmlWriter.flush() + String gmlOut = sw.toString() + + then: + gmlOut == "0.0 0.0 1.0 1.0 0.0 2.0 -1.0 -1.0 0.0 0.0" + } + + def 'CURVEPOLYGON with CompoundCurve ring XY with USE_SURFACE_RING_CURVE'() { + given: + Geometry geometry = CurvePolygon.of(List.of( + CompoundCurve.of(List.of( + LineString.of(new double[]{0.0, 0.0, 1.0, 1.0}), + CircularString.of(PositionList.of(Axes.XY, new double[]{1.0, 1.0, 2.0, 0.0, 3.0, 1.0})), + LineString.of(new double[]{3.0, 1.0, 0.0, 0.0}) + )) + )) + + when: + def sw = new StringWriter() + def xmlWriter = XMLOutputFactory.newInstance().createXMLStreamWriter(sw) + def gmlEncoder = new GeometryEncoderGml(xmlWriter, GmlVersion.GML32, Set.of(GeometryEncoderGml.Options.USE_SURFACE_RING_CURVE), Optional.of("gml"), Optional.empty(), List.of()) + geometry.accept(gmlEncoder) + xmlWriter.flush() + String gmlOut = sw.toString() + + then: + gmlOut == "0.0 0.0 1.0 1.01.0 1.0 2.0 0.0 3.0 1.03.0 1.0 0.0 0.0" + } + + def 'COMPOUNDCURVE XY with USE_SURFACE_RING_CURVE'() { + given: + Geometry geometry = CompoundCurve.of(List.of( + LineString.of(new double[]{0.0, 0.0, 1.0, 1.0}), + CircularString.of(PositionList.of(Axes.XY, new double[]{1.0, 1.0, 2.0, 0.0, 3.0, 1.0})) + )) + + when: + def sw = new StringWriter() + def xmlWriter = XMLOutputFactory.newInstance().createXMLStreamWriter(sw) + def gmlEncoder = new GeometryEncoderGml(xmlWriter, GmlVersion.GML32, Set.of(GeometryEncoderGml.Options.USE_SURFACE_RING_CURVE), Optional.of("gml"), Optional.empty(), List.of()) + geometry.accept(gmlEncoder) + xmlWriter.flush() + String gmlOut = sw.toString() + + then: + gmlOut == "0.0 0.0 1.0 1.01.0 1.0 2.0 0.0 3.0 1.0" + } + def 'MULTICURVE XY'() { given: Geometry geometry = MultiCurve.of(List.of( From 52197ada578e08e5a3c05bb743d92383e6bd8dc8 Mon Sep 17 00:00:00 2001 From: Clemens Portele Date: Mon, 11 May 2026 07:40:24 +0000 Subject: [PATCH 04/14] GML geometry encoder: improve srsName mapping behavior --- .../gml/domain/GeometryEncoderGml.java | 42 ++++++++++++++----- 1 file changed, 31 insertions(+), 11 deletions(-) diff --git a/xtraplatform-features-gml/src/main/java/de/ii/xtraplatform/features/gml/domain/GeometryEncoderGml.java b/xtraplatform-features-gml/src/main/java/de/ii/xtraplatform/features/gml/domain/GeometryEncoderGml.java index 72482eb57..5753006fd 100644 --- a/xtraplatform-features-gml/src/main/java/de/ii/xtraplatform/features/gml/domain/GeometryEncoderGml.java +++ b/xtraplatform-features-gml/src/main/java/de/ii/xtraplatform/features/gml/domain/GeometryEncoderGml.java @@ -8,6 +8,7 @@ package de.ii.xtraplatform.features.gml.domain; import com.google.common.collect.ImmutableSet; +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; @@ -33,6 +34,7 @@ import java.util.Map; import java.util.Optional; import java.util.Set; +import java.util.function.Function; import javax.xml.stream.XMLStreamException; import javax.xml.stream.XMLStreamWriter; @@ -131,6 +133,7 @@ public enum Options { private final Optional encodeAsSegmentOrPatch; private final Optional encodeAsEmbeddedGeometry; private final GmlVersion version; + private final Function srsNameMapper; private int nextGmlId = 0; private String srsName; @@ -140,6 +143,7 @@ public GeometryEncoderGml(XMLStreamWriter xmlWriter) { this.gmlIdPrefix = "geom_"; this.options = Set.of(); this.precision = null; + this.srsNameMapper = EpsgCrs::toUriString; this.encodeAsSegmentOrPatch = Optional.of( new GeometryEncoderGml( @@ -148,7 +152,8 @@ public GeometryEncoderGml(XMLStreamWriter xmlWriter) { Set.of(Options.LINE_STRING_AS_SEGMENT, Options.POLYGON_AS_PATCH), this.gmlPrefix, Optional.empty(), - List.of())); + List.of(), + this.srsNameMapper)); this.encodeAsEmbeddedGeometry = Optional.of( new GeometryEncoderGml( @@ -157,7 +162,8 @@ public GeometryEncoderGml(XMLStreamWriter xmlWriter) { Set.of(), this.gmlPrefix, Optional.empty(), - List.of())); + List.of(), + this.srsNameMapper)); this.srsName = null; this.version = GmlVersion.GML32; } @@ -169,10 +175,22 @@ public GeometryEncoderGml( Optional gmlPrefix, Optional gmlIdPrefix, List precision) { + this(xmlWriter, version, options, gmlPrefix, gmlIdPrefix, precision, EpsgCrs::toUriString); + } + + public GeometryEncoderGml( + XMLStreamWriter xmlWriter, + GmlVersion version, + Set options, + Optional gmlPrefix, + Optional gmlIdPrefix, + List precision, + Function srsNameMapper) { this.xmlWriter = xmlWriter; this.gmlPrefix = gmlPrefix; this.gmlIdPrefix = gmlIdPrefix.orElse("geom_"); this.options = options; + this.srsNameMapper = srsNameMapper; this.encodeAsSegmentOrPatch = options.contains(Options.LINE_STRING_AS_SEGMENT) && options.contains(Options.POLYGON_AS_PATCH) @@ -187,7 +205,8 @@ public GeometryEncoderGml( .build(), gmlPrefix, Optional.of(this.gmlIdPrefix + "seg_"), - precision)); + precision, + srsNameMapper)); this.encodeAsEmbeddedGeometry = options.contains(Options.WITH_SRS_NAME) ? Optional.of( @@ -202,7 +221,8 @@ public GeometryEncoderGml( .build(), gmlPrefix, Optional.of(this.gmlIdPrefix + "embed_"), - precision)) + precision, + srsNameMapper)) : Optional.empty(); this.precision = precision.stream().anyMatch(v -> v > 0) @@ -216,13 +236,13 @@ public GeometryEncoderGml( public Optional initAndCheckGeometry(Geometry geometry) { if (srsName == null) { srsName = - geometry - .getCrs() - .orElse( - geometry.getAxes() == Axes.XY || geometry.getAxes() == Axes.XYM - ? OgcCrs.CRS84 - : OgcCrs.CRS84h) - .toUriString(); + srsNameMapper.apply( + geometry + .getCrs() + .orElse( + geometry.getAxes() == Axes.XY || geometry.getAxes() == Axes.XYM + ? OgcCrs.CRS84 + : OgcCrs.CRS84h)); } return Optional.empty(); From aa5cc2ade7491dc6d797cc21fdec702f7c18df6f Mon Sep 17 00:00:00 2001 From: Clemens Portele Date: Mon, 11 May 2026 07:40:32 +0000 Subject: [PATCH 05/14] GML geometry encoder: add srsName test --- .../domain/GeometrySrsNameMapperSpec.groovy | 89 +++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 xtraplatform-features-gml/src/test/groovy/de/ii/xtraplatform/features/gml/domain/GeometrySrsNameMapperSpec.groovy diff --git a/xtraplatform-features-gml/src/test/groovy/de/ii/xtraplatform/features/gml/domain/GeometrySrsNameMapperSpec.groovy b/xtraplatform-features-gml/src/test/groovy/de/ii/xtraplatform/features/gml/domain/GeometrySrsNameMapperSpec.groovy new file mode 100644 index 000000000..ceee40a85 --- /dev/null +++ b/xtraplatform-features-gml/src/test/groovy/de/ii/xtraplatform/features/gml/domain/GeometrySrsNameMapperSpec.groovy @@ -0,0 +1,89 @@ +/* + * 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.geometries.domain.Geometry +import de.ii.xtraplatform.geometries.domain.Point +import spock.lang.Specification + +import javax.xml.stream.XMLOutputFactory + +class GeometrySrsNameMapperSpec extends Specification { + + def 'TEMPLATE mapper rewrites srsName for matching CRS'() { + given: + EpsgCrs etrs89Utm32 = EpsgCrs.of(25832) + Geometry geometry = Point.of(389000.0, 5705000.0).withCrs(etrs89Utm32) + def sw = new StringWriter() + def xmlWriter = XMLOutputFactory.newInstance().createXMLStreamWriter(sw) + def encoder = new GeometryEncoderGml( + xmlWriter, + GmlVersion.GML32, + Set.of(GeometryEncoderGml.Options.WITH_SRS_NAME), + Optional.of("gml"), + Optional.empty(), + List.of(), + { EpsgCrs crs -> etrs89Utm32 == crs ? 'urn:adv:crs:ETRS89_UTM32' : crs.toUriString() } as java.util.function.Function) + + when: + geometry.accept(encoder) + xmlWriter.flush() + String gmlOut = sw.toString() + + then: + gmlOut.contains('srsName="urn:adv:crs:ETRS89_UTM32"') + } + + def 'TEMPLATE mapper falls back to OGC URI for unmapped CRS'() { + given: + EpsgCrs etrs89Utm32 = EpsgCrs.of(25832) + EpsgCrs wgs84Utm32n = EpsgCrs.of(32632) + Geometry geometry = Point.of(389000.0, 5705000.0).withCrs(wgs84Utm32n) + def sw = new StringWriter() + def xmlWriter = XMLOutputFactory.newInstance().createXMLStreamWriter(sw) + def encoder = new GeometryEncoderGml( + xmlWriter, + GmlVersion.GML32, + Set.of(GeometryEncoderGml.Options.WITH_SRS_NAME), + Optional.of("gml"), + Optional.empty(), + List.of(), + { EpsgCrs crs -> etrs89Utm32 == crs ? 'urn:adv:crs:ETRS89_UTM32' : crs.toUriString() } as java.util.function.Function) + + when: + geometry.accept(encoder) + xmlWriter.flush() + String gmlOut = sw.toString() + + then: + gmlOut.contains('srsName="http://www.opengis.net/def/crs/EPSG/0/32632"') + } + + def 'OGC default constructor preserves toUriString behavior'() { + given: + Geometry geometry = Point.of(389000.0, 5705000.0).withCrs(EpsgCrs.of(25832)) + def sw = new StringWriter() + def xmlWriter = XMLOutputFactory.newInstance().createXMLStreamWriter(sw) + def encoder = new GeometryEncoderGml( + xmlWriter, + GmlVersion.GML32, + Set.of(GeometryEncoderGml.Options.WITH_SRS_NAME), + Optional.of("gml"), + Optional.empty(), + List.of()) + + when: + geometry.accept(encoder) + xmlWriter.flush() + String gmlOut = sw.toString() + + then: + gmlOut.contains('srsName="http://www.opengis.net/def/crs/EPSG/0/25832"') + } +} From 77a980ea1b3cfec9844426fb61a3fecd8e53c2c7 Mon Sep 17 00:00:00 2001 From: Clemens Portele Date: Tue, 12 May 2026 12:07:49 +0200 Subject: [PATCH 06/14] add alias field to feature schema for encoding-time property renaming Introduce an optional `alias` property on FeatureSchema as an identifier-style alternative property name (distinct from the display-oriented `label`). Encoders that opt in via a `useAlias` flag receive the alias as the property name; explicit `rename` transformations still take precedence. Plumbed via a new 3-arg getSchemaTransformations(mapping, inCollection, useAlias) overload and a useAlias-aware constructor on SchemaTransformerChain. The chain injects a synthetic rename to the alias only when no explicit (non-pathOnly) rename exists at the property's exact path; pathOnly renames do not suppress the alias. Avoids the duplication of large `rename` transformation maps in per-format building-block configurations when an application schema (e.g. AdV NAS) has both a short and a mnemonic name per property. --- .../features/domain/FeatureSchema.java | 18 +++ .../transform/PropertyTransformations.java | 7 +- .../transform/SchemaTransformerChain.java | 36 ++++++ .../SchemaTransformerChainAliasSpec.groovy | 110 ++++++++++++++++++ 4 files changed, 170 insertions(+), 1 deletion(-) create mode 100644 xtraplatform-features/src/test/groovy/de/ii/xtraplatform/features/domain/transform/SchemaTransformerChainAliasSpec.groovy diff --git a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureSchema.java b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureSchema.java index eb28bab64..93cba3fe9 100644 --- a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureSchema.java +++ b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureSchema.java @@ -53,6 +53,7 @@ "geometryType", "objectType", "label", + "alias", "description", "unit", "format", @@ -214,6 +215,23 @@ default Type getType() { */ Optional getLabel(); + /** + * @langEn An alternative property name used by feature encodings that opt in to alias mode (for + * example, GML with `useAlias: true`). Unlike `label` (which is free-text for display), the + * alias must satisfy the encoding's identifier constraints (e.g. an XML element name or a + * JSON property name). When alias mode is active and an alias is set, the encoded property + * name is the alias instead of the schema name; an explicit `rename` transformation still + * takes precedence over the alias. + * @langDe Ein alternativer Eigenschaftsname, der von Feature-Kodierungen verwendet wird, die den + * Alias-Modus aktiviert haben (z.B. GML mit `useAlias: true`). Anders als `label` (ein freier + * Anzeigetext) muss der Alias den Identifier-Regeln der Kodierung entsprechen (z.B. + * XML-Elementname oder JSON-Eigenschaftsname). Bei aktivem Alias-Modus und gesetztem Alias + * wird der Alias anstelle des Schemanamens als Eigenschaftsname kodiert; eine explizite + * `rename`-Transformation hat weiterhin Vorrang vor dem Alias. + * @default null + */ + Optional getAlias(); + /** * @langEn Description for the schema object, used for example in HTML representations or JSON * Schema. diff --git a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/transform/PropertyTransformations.java b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/transform/PropertyTransformations.java index 9154eacbf..2c48d33d8 100644 --- a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/transform/PropertyTransformations.java +++ b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/transform/PropertyTransformations.java @@ -141,7 +141,12 @@ public PropertyTransformations mergeInto(PropertyTransformations source) { default SchemaTransformerChain getSchemaTransformations( SchemaMapping schemaMapping, boolean inCollection) { - return new SchemaTransformerChain(getTransformations(), schemaMapping, inCollection); + return getSchemaTransformations(schemaMapping, inCollection, false); + } + + default SchemaTransformerChain getSchemaTransformations( + SchemaMapping schemaMapping, boolean inCollection, boolean useAlias) { + return new SchemaTransformerChain(getTransformations(), schemaMapping, inCollection, useAlias); } default TokenSliceTransformerChain getTokenSliceTransformations( diff --git a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/transform/SchemaTransformerChain.java b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/transform/SchemaTransformerChain.java index 5fb56f036..c54aa89c7 100644 --- a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/transform/SchemaTransformerChain.java +++ b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/transform/SchemaTransformerChain.java @@ -28,11 +28,21 @@ public class SchemaTransformerChain private final List currentParentProperties; private final Map> transformers; + private final boolean useAlias; public SchemaTransformerChain( Map> allTransformations, SchemaMapping schemaMapping, boolean inCollection) { + this(allTransformations, schemaMapping, inCollection, false); + } + + public SchemaTransformerChain( + Map> allTransformations, + SchemaMapping schemaMapping, + boolean inCollection, + boolean useAlias) { + this.useAlias = useAlias; this.currentParentProperties = new ArrayList<>(); this.transformers = allTransformations.entrySet().stream() @@ -103,9 +113,35 @@ public FeatureSchema transform(String path, FeatureSchema schema) { transformed = run(transformers, path, path, schema); + if (useAlias + && transformed != null + && schema.getAlias().isPresent() + && !hasExplicitRename(path)) { + transformed = + ImmutableFeaturePropertyTransformerRename.builder() + .propertyPath(path) + .parameter(schema.getAlias().get()) + .build() + .transform(path, transformed); + } + return transformed; } + private boolean hasExplicitRename(String path) { + List atPath = transformers.get(path); + if (atPath == null) { + return false; + } + for (FeaturePropertySchemaTransformer t : atPath) { + if (t instanceof FeaturePropertyTransformerRename + && !((FeaturePropertyTransformerRename) t).pathOnly()) { + return true; + } + } + return false; + } + @Override public boolean has(String path) { return transformers.containsKey(path); diff --git a/xtraplatform-features/src/test/groovy/de/ii/xtraplatform/features/domain/transform/SchemaTransformerChainAliasSpec.groovy b/xtraplatform-features/src/test/groovy/de/ii/xtraplatform/features/domain/transform/SchemaTransformerChainAliasSpec.groovy new file mode 100644 index 000000000..08ef31f6c --- /dev/null +++ b/xtraplatform-features/src/test/groovy/de/ii/xtraplatform/features/domain/transform/SchemaTransformerChainAliasSpec.groovy @@ -0,0 +1,110 @@ +/* + * Copyright 2026 interactive instruments GmbH + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +package de.ii.xtraplatform.features.domain.transform + +import de.ii.xtraplatform.features.domain.FeatureSchema +import de.ii.xtraplatform.features.domain.ImmutableFeatureSchema +import de.ii.xtraplatform.features.domain.SchemaBase +import de.ii.xtraplatform.features.domain.SchemaMapping +import spock.lang.Specification + +class SchemaTransformerChainAliasSpec extends Specification { + + static FeatureSchema property(String name, String alias = null) { + def b = new ImmutableFeatureSchema.Builder() + .name(name) + .type(SchemaBase.Type.STRING) + .sourcePath(name) + if (alias != null) { + b.alias(alias) + } + return b.build() + } + + static FeatureSchema feature(FeatureSchema... properties) { + def b = new ImmutableFeatureSchema.Builder() + .name("test") + .type(SchemaBase.Type.OBJECT) + .sourcePath("/test") + properties.each { b.putPropertyMap(it.getName(), it) } + return b.build() + } + + static String firstPropertyName(FeatureSchema schema) { + return schema.getProperties().get(0).getName() + } + + def "no alias: property name unchanged regardless of useAlias"() { + given: + def schema = feature(property("anl")) + def chain = new SchemaTransformerChain(Map.of(), SchemaMapping.of(schema), false, useAlias) + + when: + def transformed = schema.accept(chain) + + then: + firstPropertyName(transformed) == "anl" + + where: + useAlias << [true, false] + } + + def "alias present, useAlias=false: property name unchanged"() { + given: + def schema = feature(property("anl", "anlass")) + def chain = new SchemaTransformerChain(Map.of(), SchemaMapping.of(schema), false, false) + + when: + def transformed = schema.accept(chain) + + then: + firstPropertyName(transformed) == "anl" + } + + def "alias present, useAlias=true: property name becomes alias"() { + given: + def schema = feature(property("anl", "anlass")) + def chain = new SchemaTransformerChain(Map.of(), SchemaMapping.of(schema), false, true) + + when: + def transformed = schema.accept(chain) + + then: + firstPropertyName(transformed) == "anlass" + } + + def "alias present, useAlias=true, explicit rename at same path: rename wins"() { + given: + def schema = feature(property("anl", "anlass")) + def transformations = Map.of( + "anl", + List.of(new ImmutablePropertyTransformation.Builder().rename("custom").build())) + def chain = new SchemaTransformerChain(transformations, SchemaMapping.of(schema), false, true) + + when: + def transformed = schema.accept(chain) + + then: + firstPropertyName(transformed) == "custom" + } + + def "alias present, useAlias=true, renamePathOnly at same path: alias still wins on name"() { + given: + def schema = feature(property("anl", "anlass")) + def transformations = Map.of( + "anl", + List.of(new ImmutablePropertyTransformation.Builder().renamePathOnly("custom").build())) + def chain = new SchemaTransformerChain(transformations, SchemaMapping.of(schema), false, true) + + when: + def transformed = schema.accept(chain) + + then: + firstPropertyName(transformed) == "anlass" + } +} From c1c846b4d6bd91aa1e523ef9eff5143efbe9f18c Mon Sep 17 00:00:00 2001 From: Clemens Portele Date: Tue, 12 May 2026 12:42:17 +0200 Subject: [PATCH 07/14] plumb useAlias flag through encoding pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a default boolean useAlias() predicate on PropertyTransformations (defaults to false) and have FeatureTokenTransformerMappings forward that predicate to the 3-arg getSchemaTransformations() overload added in the previous commit. This is the encoding-side wiring for the alias mechanism: only the feature-encoding pipeline reads useAlias(). Schema-derivation paths (WithTransformationsApplied for queryables, sortables, JSON Schema documents, etc.) call the 2-arg getSchemaTransformations() and so continue to use schema names regardless of any format configuration's alias preference. When useAlias=true, walk the feature schema and inject each property's alias as an explicit rename into propertyTransformations before applyRename. This re-keys every downstream transformation (wrap for virtual objects, auto-DATETIME formatter, value transformers, …) by the aliased path, so schema lookups, SchemaTransformerChain, and TokenSliceTransformerChain all see consistent aliased paths. useAlias() is propagated through PropertyTransformations.mergeInto and withSubstitutions. --- .../features/domain/FeatureStreamImpl.java | 27 +++++++++++++++++ .../FeatureTokenTransformerMappings.java | 3 +- .../transform/PropertyTransformations.java | 29 ++++++++++++++++++- 3 files changed, 57 insertions(+), 2 deletions(-) diff --git a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureStreamImpl.java b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureStreamImpl.java index 65400a1a9..4a3b05149 100644 --- a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureStreamImpl.java +++ b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureStreamImpl.java @@ -329,9 +329,36 @@ static PropertyTransformations getPropertyTransformations( .map(p -> p.mergeInto(providerTransformations)) .orElse(providerTransformations); + if (merged.useAlias()) { + merged = injectAliasRenames(merged, featureSchemas.get(typeQuery.getType())); + } + return applyRename(merged); } + private static PropertyTransformations injectAliasRenames( + PropertyTransformations propertyTransformations, FeatureSchema schema) { + Map> aliasRenames = new LinkedHashMap<>(); + collectAliasRenames(schema, aliasRenames); + if (aliasRenames.isEmpty()) { + return propertyTransformations; + } + return propertyTransformations.mergeInto(() -> aliasRenames); + } + + private static void collectAliasRenames( + FeatureSchema schema, Map> aliasRenames) { + schema + .getAlias() + .filter(alias -> !schema.getFullPath().isEmpty()) + .ifPresent( + alias -> + aliasRenames.put( + schema.getFullPathAsString(), + List.of(new ImmutablePropertyTransformation.Builder().rename(alias).build()))); + schema.getProperties().forEach(child -> collectAliasRenames(child, aliasRenames)); + } + private static PropertyTransformations applyRename( PropertyTransformations propertyTransformations) { if (propertyTransformations.getTransformations().values().stream() diff --git a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureTokenTransformerMappings.java b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureTokenTransformerMappings.java index 27049e2b4..755f8bb17 100644 --- a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureTokenTransformerMappings.java +++ b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureTokenTransformerMappings.java @@ -74,7 +74,8 @@ public void onStart(ModifiableContext context) { .getSchemaTransformations( entry.getValue(), (!(context.query() instanceof FeatureQuery) - || !((FeatureQuery) context.query()).returnsSingleFeature())))) + || !((FeatureQuery) context.query()).returnsSingleFeature()), + propertyTransformations.get(entry.getKey()).useAlias()))) .collect(ImmutableMap.toImmutableMap(Entry::getKey, Entry::getValue)); this.sliceTransformerChains = diff --git a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/transform/PropertyTransformations.java b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/transform/PropertyTransformations.java index 2c48d33d8..328edfd9e 100644 --- a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/transform/PropertyTransformations.java +++ b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/transform/PropertyTransformations.java @@ -79,6 +79,7 @@ default Map> withTransformation( default PropertyTransformations withSubstitutions(Map substitutions) { Map> transformations = this.getTransformations(); + boolean useAlias = this.useAlias(); return new PropertyTransformations() { @Override @@ -86,6 +87,11 @@ public Map> getTransformations() { return transformations; } + @Override + public boolean useAlias() { + return useAlias; + } + @Override public TransformerChain getValueTransformations( SchemaMapping schemaMapping, Map codelists, ZoneId defaultTimeZone) { @@ -139,6 +145,16 @@ public PropertyTransformations mergeInto(PropertyTransformations source) { }; } + /** + * Whether the encoding pipeline should substitute the alias declared on each feature-schema + * property in place of its schema name. Overridden by ldproxy's {@code AliasConfiguration} when a + * format configuration opts in. Only the feature-encoding pipeline reads this flag; schema + * derivation paths for queryables, sortables, JSON Schema, etc. always use schema names. + */ + default boolean useAlias() { + return false; + } + default SchemaTransformerChain getSchemaTransformations( SchemaMapping schemaMapping, boolean inCollection) { return getSchemaTransformations(schemaMapping, inCollection, false); @@ -196,6 +212,17 @@ default PropertyTransformations mergeInto(PropertyTransformations source) { } }); - return () -> mergedTransformations; + boolean mergedUseAlias = useAlias() || source.useAlias(); + return new PropertyTransformations() { + @Override + public Map> getTransformations() { + return mergedTransformations; + } + + @Override + public boolean useAlias() { + return mergedUseAlias; + } + }; } } From 824af10786349597c054b12927a2c4ebd877a611 Mon Sep 17 00:00:00 2001 From: Clemens Portele Date: Wed, 13 May 2026 12:28:49 +0200 Subject: [PATCH 08/14] fix: cascade renames when rebuilding propertyTransformations keys applyRename rebuilt the propertyTransformations map keyed by the rename target. For a top-level rename (e.g. qag -> qualitaetsangaben) the single-element rename matched the renamed full path. For nested renames the key was only the leaf rename, not the cumulative renamed path - so the lookup at runtime (which uses the full target path) missed any transformation registered for the inner property. Two symptoms when nested renames were configured: 1. The auto-added DATETIME formatter (provider-level, keyed by the original full path) was re-keyed by just the leaf rename. A DATETIME leaf inside a renamed object came through as the raw JDBC string instead of ISO-8601. 2. Wrap transformers for virtual objects (intermediate OBJECT levels without sourcePath) were similarly mis-keyed. The token-slice wrap step did not fire for those intermediates, so OBJECT/OBJECT_END tokens were never synthesized and the renamed inner objects disappeared from the output. Both symptoms have the same root cause and the same fix: walk the parent chain and apply each segment's rename so the new key is the full renamed path. --- .../features/domain/FeatureStreamImpl.java | 82 +++++++++++-------- 1 file changed, 46 insertions(+), 36 deletions(-) diff --git a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureStreamImpl.java b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureStreamImpl.java index 4a3b05149..237bff3db 100644 --- a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureStreamImpl.java +++ b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureStreamImpl.java @@ -25,7 +25,6 @@ import de.ii.xtraplatform.streams.domain.Reactive.Stream; import java.time.ZoneId; import java.util.AbstractMap.SimpleImmutableEntry; -import java.util.Collection; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -361,43 +360,54 @@ private static void collectAliasRenames( private static PropertyTransformations applyRename( PropertyTransformations propertyTransformations) { - if (propertyTransformations.getTransformations().values().stream() - .flatMap(Collection::stream) - .anyMatch(propertyTransformation -> propertyTransformation.getRename().isPresent())) { - Map> renamed = new LinkedHashMap<>(); - - propertyTransformations - .getTransformations() - .forEach( - (key, value) -> { - Optional rename = - value.stream() - .filter( - propertyTransformation -> - propertyTransformation.getRename().isPresent()) - .map(propertyTransformation -> propertyTransformation.getRename().get()) - .findFirst(); - - if (rename.isPresent()) { - renamed.put(rename.get(), value); - - String prefix = key + "."; - - propertyTransformations - .getTransformations() - .forEach( - (key2, value2) -> { - if (key2.startsWith(prefix)) { - renamed.put(key2.replace(key, rename.get()), value2); - } - }); - } - }); - - return propertyTransformations.mergeInto(() -> renamed); + // Collect every rename keyed by its original full path. + Map renames = new LinkedHashMap<>(); + propertyTransformations + .getTransformations() + .forEach( + (key, value) -> + value.stream() + .filter(pt -> pt.getRename().isPresent()) + .map(pt -> pt.getRename().get()) + .findFirst() + .ifPresent(rename -> renames.put(key, rename))); + + if (renames.isEmpty()) { + return propertyTransformations; } - return propertyTransformations; + // Re-key every transformation by the cumulative renamed full path so that lookups + // by the renamed target path (e.g. "qualitaetsangaben.herkunft.gmd:processStep.gmd:dateTime") + // still find the right transformation (auto DATETIME formatter, value transformers, + // wrap transformers, ...). + Map> renamed = new LinkedHashMap<>(); + propertyTransformations + .getTransformations() + .forEach( + (key, value) -> { + String newKey = renameFullPath(key, renames); + renamed.put(newKey, value); + }); + + return propertyTransformations.mergeInto(() -> renamed); + } + + private static String renameFullPath(String path, Map renames) { + String[] segments = path.split("\\."); + StringBuilder result = new StringBuilder(); + StringBuilder running = new StringBuilder(); + for (int i = 0; i < segments.length; i++) { + if (i > 0) { + running.append("."); + } + running.append(segments[i]); + String renamedSegment = renames.getOrDefault(running.toString(), segments[i]); + if (i > 0) { + result.append("."); + } + result.append(renamedSegment); + } + return result.toString(); } private static Map> getProviderTransformations( From 581067a73b5c666e3a18024adf5a054d0cbdfa31 Mon Sep 17 00:00:00 2001 From: Clemens Portele Date: Wed, 13 May 2026 17:01:44 +0200 Subject: [PATCH 09/14] =?UTF-8?q?refactor:=20replace=20useAlias=20plumbing?= =?UTF-8?q?=20with=20a=20one-shot=20alias=E2=86=92rename=20conversion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move alias handling out of PropertyTransformations and SchemaTransformerChain. Aliases are now converted to explicit rename transformations by a single utility, FeatureSchemaAliases.injectAliasRenames, called by ldproxy at the format-extension boundary. The xtraplatform-spatial pipeline downstream of that point no longer knows about aliases — it just sees regular renames and reuses the existing rename cascade machinery for everything else (wrap transformers on virtual objects, auto-DATETIME formatters, value transformers, ...). Removed: - PropertyTransformations.useAlias() predicate and its propagation through mergeInto and withSubstitutions - 3-arg PropertyTransformations.getSchemaTransformations overload - SchemaTransformerChain.useAlias field, 4-arg constructor, in-chain alias-injection branch, and hasExplicitRename helper Added: - FeatureSchemaAliases public utility with injectAliasRenames(pt, schema) - FeatureSchemaAliasesSpec covering nested paths, no-aliases, existing transformations, and a feature-type alias being ignored The cross-module boundary now carries only PropertyTransformations; the useAlias signal stays inside ldproxy's AliasConfiguration where it belongs. --- .../features/domain/FeatureSchemaAliases.java | 43 ++++++ .../features/domain/FeatureStreamImpl.java | 27 ---- .../FeatureTokenTransformerMappings.java | 3 +- .../transform/PropertyTransformations.java | 36 +---- .../transform/SchemaTransformerChain.java | 36 ----- .../domain/FeatureSchemaAliasesSpec.groovy | 127 ++++++++++++++++++ .../SchemaTransformerChainAliasSpec.groovy | 110 --------------- 7 files changed, 173 insertions(+), 209 deletions(-) create mode 100644 xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureSchemaAliases.java create mode 100644 xtraplatform-features/src/test/groovy/de/ii/xtraplatform/features/domain/FeatureSchemaAliasesSpec.groovy delete mode 100644 xtraplatform-features/src/test/groovy/de/ii/xtraplatform/features/domain/transform/SchemaTransformerChainAliasSpec.groovy diff --git a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureSchemaAliases.java b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureSchemaAliases.java new file mode 100644 index 000000000..dbd35535e --- /dev/null +++ b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureSchemaAliases.java @@ -0,0 +1,43 @@ +/* + * Copyright 2026 interactive instruments GmbH + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +package de.ii.xtraplatform.features.domain; + +import de.ii.xtraplatform.features.domain.transform.ImmutablePropertyTransformation; +import de.ii.xtraplatform.features.domain.transform.PropertyTransformation; +import de.ii.xtraplatform.features.domain.transform.PropertyTransformations; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +public final class FeatureSchemaAliases { + + private FeatureSchemaAliases() {} + + public static PropertyTransformations injectAliasRenames( + PropertyTransformations propertyTransformations, FeatureSchema schema) { + Map> aliasRenames = new LinkedHashMap<>(); + collectAliasRenames(schema, aliasRenames); + if (aliasRenames.isEmpty()) { + return propertyTransformations; + } + return propertyTransformations.mergeInto(() -> aliasRenames); + } + + private static void collectAliasRenames( + FeatureSchema schema, Map> aliasRenames) { + schema + .getAlias() + .filter(alias -> !schema.getFullPath().isEmpty()) + .ifPresent( + alias -> + aliasRenames.put( + schema.getFullPathAsString(), + List.of(new ImmutablePropertyTransformation.Builder().rename(alias).build()))); + schema.getProperties().forEach(child -> collectAliasRenames(child, aliasRenames)); + } +} diff --git a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureStreamImpl.java b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureStreamImpl.java index 237bff3db..8464b52ff 100644 --- a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureStreamImpl.java +++ b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureStreamImpl.java @@ -328,36 +328,9 @@ static PropertyTransformations getPropertyTransformations( .map(p -> p.mergeInto(providerTransformations)) .orElse(providerTransformations); - if (merged.useAlias()) { - merged = injectAliasRenames(merged, featureSchemas.get(typeQuery.getType())); - } - return applyRename(merged); } - private static PropertyTransformations injectAliasRenames( - PropertyTransformations propertyTransformations, FeatureSchema schema) { - Map> aliasRenames = new LinkedHashMap<>(); - collectAliasRenames(schema, aliasRenames); - if (aliasRenames.isEmpty()) { - return propertyTransformations; - } - return propertyTransformations.mergeInto(() -> aliasRenames); - } - - private static void collectAliasRenames( - FeatureSchema schema, Map> aliasRenames) { - schema - .getAlias() - .filter(alias -> !schema.getFullPath().isEmpty()) - .ifPresent( - alias -> - aliasRenames.put( - schema.getFullPathAsString(), - List.of(new ImmutablePropertyTransformation.Builder().rename(alias).build()))); - schema.getProperties().forEach(child -> collectAliasRenames(child, aliasRenames)); - } - private static PropertyTransformations applyRename( PropertyTransformations propertyTransformations) { // Collect every rename keyed by its original full path. diff --git a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureTokenTransformerMappings.java b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureTokenTransformerMappings.java index 755f8bb17..27049e2b4 100644 --- a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureTokenTransformerMappings.java +++ b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureTokenTransformerMappings.java @@ -74,8 +74,7 @@ public void onStart(ModifiableContext context) { .getSchemaTransformations( entry.getValue(), (!(context.query() instanceof FeatureQuery) - || !((FeatureQuery) context.query()).returnsSingleFeature()), - propertyTransformations.get(entry.getKey()).useAlias()))) + || !((FeatureQuery) context.query()).returnsSingleFeature())))) .collect(ImmutableMap.toImmutableMap(Entry::getKey, Entry::getValue)); this.sliceTransformerChains = diff --git a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/transform/PropertyTransformations.java b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/transform/PropertyTransformations.java index 328edfd9e..9154eacbf 100644 --- a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/transform/PropertyTransformations.java +++ b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/transform/PropertyTransformations.java @@ -79,7 +79,6 @@ default Map> withTransformation( default PropertyTransformations withSubstitutions(Map substitutions) { Map> transformations = this.getTransformations(); - boolean useAlias = this.useAlias(); return new PropertyTransformations() { @Override @@ -87,11 +86,6 @@ public Map> getTransformations() { return transformations; } - @Override - public boolean useAlias() { - return useAlias; - } - @Override public TransformerChain getValueTransformations( SchemaMapping schemaMapping, Map codelists, ZoneId defaultTimeZone) { @@ -145,24 +139,9 @@ public PropertyTransformations mergeInto(PropertyTransformations source) { }; } - /** - * Whether the encoding pipeline should substitute the alias declared on each feature-schema - * property in place of its schema name. Overridden by ldproxy's {@code AliasConfiguration} when a - * format configuration opts in. Only the feature-encoding pipeline reads this flag; schema - * derivation paths for queryables, sortables, JSON Schema, etc. always use schema names. - */ - default boolean useAlias() { - return false; - } - default SchemaTransformerChain getSchemaTransformations( SchemaMapping schemaMapping, boolean inCollection) { - return getSchemaTransformations(schemaMapping, inCollection, false); - } - - default SchemaTransformerChain getSchemaTransformations( - SchemaMapping schemaMapping, boolean inCollection, boolean useAlias) { - return new SchemaTransformerChain(getTransformations(), schemaMapping, inCollection, useAlias); + return new SchemaTransformerChain(getTransformations(), schemaMapping, inCollection); } default TokenSliceTransformerChain getTokenSliceTransformations( @@ -212,17 +191,6 @@ default PropertyTransformations mergeInto(PropertyTransformations source) { } }); - boolean mergedUseAlias = useAlias() || source.useAlias(); - return new PropertyTransformations() { - @Override - public Map> getTransformations() { - return mergedTransformations; - } - - @Override - public boolean useAlias() { - return mergedUseAlias; - } - }; + return () -> mergedTransformations; } } diff --git a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/transform/SchemaTransformerChain.java b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/transform/SchemaTransformerChain.java index c54aa89c7..5fb56f036 100644 --- a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/transform/SchemaTransformerChain.java +++ b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/transform/SchemaTransformerChain.java @@ -28,21 +28,11 @@ public class SchemaTransformerChain private final List currentParentProperties; private final Map> transformers; - private final boolean useAlias; public SchemaTransformerChain( Map> allTransformations, SchemaMapping schemaMapping, boolean inCollection) { - this(allTransformations, schemaMapping, inCollection, false); - } - - public SchemaTransformerChain( - Map> allTransformations, - SchemaMapping schemaMapping, - boolean inCollection, - boolean useAlias) { - this.useAlias = useAlias; this.currentParentProperties = new ArrayList<>(); this.transformers = allTransformations.entrySet().stream() @@ -113,35 +103,9 @@ public FeatureSchema transform(String path, FeatureSchema schema) { transformed = run(transformers, path, path, schema); - if (useAlias - && transformed != null - && schema.getAlias().isPresent() - && !hasExplicitRename(path)) { - transformed = - ImmutableFeaturePropertyTransformerRename.builder() - .propertyPath(path) - .parameter(schema.getAlias().get()) - .build() - .transform(path, transformed); - } - return transformed; } - private boolean hasExplicitRename(String path) { - List atPath = transformers.get(path); - if (atPath == null) { - return false; - } - for (FeaturePropertySchemaTransformer t : atPath) { - if (t instanceof FeaturePropertyTransformerRename - && !((FeaturePropertyTransformerRename) t).pathOnly()) { - return true; - } - } - return false; - } - @Override public boolean has(String path) { return transformers.containsKey(path); diff --git a/xtraplatform-features/src/test/groovy/de/ii/xtraplatform/features/domain/FeatureSchemaAliasesSpec.groovy b/xtraplatform-features/src/test/groovy/de/ii/xtraplatform/features/domain/FeatureSchemaAliasesSpec.groovy new file mode 100644 index 000000000..f79ed4d0d --- /dev/null +++ b/xtraplatform-features/src/test/groovy/de/ii/xtraplatform/features/domain/FeatureSchemaAliasesSpec.groovy @@ -0,0 +1,127 @@ +/* + * Copyright 2026 interactive instruments GmbH + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +package de.ii.xtraplatform.features.domain + +import de.ii.xtraplatform.features.domain.transform.PropertyTransformations +import spock.lang.Specification + +class FeatureSchemaAliasesSpec extends Specification { + + static FeatureSchema property(String name, String alias = null) { + def b = new ImmutableFeatureSchema.Builder() + .name(name) + .type(SchemaBase.Type.STRING) + .sourcePath(name) + if (alias != null) { + b.alias(alias) + } + return b.build() + } + + static FeatureSchema object(String name, String alias, FeatureSchema... children) { + def b = new ImmutableFeatureSchema.Builder() + .name(name) + .type(SchemaBase.Type.OBJECT) + if (alias != null) { + b.alias(alias) + } + children.each { b.putPropertyMap(it.getName(), it) } + return b.build() + } + + static FeatureSchema feature(FeatureSchema... properties) { + def b = new ImmutableFeatureSchema.Builder() + .name("test") + .type(SchemaBase.Type.OBJECT) + .sourcePath("/test") + properties.each { b.putPropertyMap(it.getName(), it) } + return b.build() + } + + static PropertyTransformations base(Map transformations = [:]) { + return { -> transformations as Map } as PropertyTransformations + } + + static String renameAt(PropertyTransformations pt, String path) { + def entries = pt.transformations.get(path) + return entries == null ? null : entries.find { it.rename.present }?.rename?.orElse(null) + } + + def "schema with no aliases: transformations map unchanged"() { + given: + def schema = feature(property("anl")) + def input = base() + + when: + def result = FeatureSchemaAliases.injectAliasRenames(input, schema) + + then: + result.is(input) + } + + def "property alias: rename entry is added at the property's full path"() { + given: + def schema = feature(property("anl", "anlass")) + def input = base() + + when: + def result = FeatureSchemaAliases.injectAliasRenames(input, schema) + + then: + renameAt(result, "anl") == "anlass" + } + + def "nested aliases: rename entries are added at each level's full path"() { + given: + def schema = feature( + object("qag", "qualitaetsangaben", + object("dpl", "herkunft", + property("prs", "gmd:processStep")))) + def input = base() + + when: + def result = FeatureSchemaAliases.injectAliasRenames(input, schema) + + then: + renameAt(result, "qag") == "qualitaetsangaben" + renameAt(result, "qag.dpl") == "herkunft" + renameAt(result, "qag.dpl.prs") == "gmd:processStep" + } + + def "existing transformations are preserved alongside injected aliases"() { + given: + def schema = feature(property("anl", "anlass")) + def input = base(["other.path": []]) + + when: + def result = FeatureSchemaAliases.injectAliasRenames(input, schema) + + then: + result.transformations.containsKey("other.path") + renameAt(result, "anl") == "anlass" + } + + def "feature type's own alias is not injected (only properties)"() { + given: + def schema = new ImmutableFeatureSchema.Builder() + .name("test") + .type(SchemaBase.Type.OBJECT) + .sourcePath("/test") + .alias("ignored") + .putPropertyMap("anl", property("anl", "anlass")) + .build() + def input = base() + + when: + def result = FeatureSchemaAliases.injectAliasRenames(input, schema) + + then: + !result.transformations.containsKey("") + renameAt(result, "anl") == "anlass" + } +} diff --git a/xtraplatform-features/src/test/groovy/de/ii/xtraplatform/features/domain/transform/SchemaTransformerChainAliasSpec.groovy b/xtraplatform-features/src/test/groovy/de/ii/xtraplatform/features/domain/transform/SchemaTransformerChainAliasSpec.groovy deleted file mode 100644 index 08ef31f6c..000000000 --- a/xtraplatform-features/src/test/groovy/de/ii/xtraplatform/features/domain/transform/SchemaTransformerChainAliasSpec.groovy +++ /dev/null @@ -1,110 +0,0 @@ -/* - * Copyright 2026 interactive instruments GmbH - * - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. - */ -package de.ii.xtraplatform.features.domain.transform - -import de.ii.xtraplatform.features.domain.FeatureSchema -import de.ii.xtraplatform.features.domain.ImmutableFeatureSchema -import de.ii.xtraplatform.features.domain.SchemaBase -import de.ii.xtraplatform.features.domain.SchemaMapping -import spock.lang.Specification - -class SchemaTransformerChainAliasSpec extends Specification { - - static FeatureSchema property(String name, String alias = null) { - def b = new ImmutableFeatureSchema.Builder() - .name(name) - .type(SchemaBase.Type.STRING) - .sourcePath(name) - if (alias != null) { - b.alias(alias) - } - return b.build() - } - - static FeatureSchema feature(FeatureSchema... properties) { - def b = new ImmutableFeatureSchema.Builder() - .name("test") - .type(SchemaBase.Type.OBJECT) - .sourcePath("/test") - properties.each { b.putPropertyMap(it.getName(), it) } - return b.build() - } - - static String firstPropertyName(FeatureSchema schema) { - return schema.getProperties().get(0).getName() - } - - def "no alias: property name unchanged regardless of useAlias"() { - given: - def schema = feature(property("anl")) - def chain = new SchemaTransformerChain(Map.of(), SchemaMapping.of(schema), false, useAlias) - - when: - def transformed = schema.accept(chain) - - then: - firstPropertyName(transformed) == "anl" - - where: - useAlias << [true, false] - } - - def "alias present, useAlias=false: property name unchanged"() { - given: - def schema = feature(property("anl", "anlass")) - def chain = new SchemaTransformerChain(Map.of(), SchemaMapping.of(schema), false, false) - - when: - def transformed = schema.accept(chain) - - then: - firstPropertyName(transformed) == "anl" - } - - def "alias present, useAlias=true: property name becomes alias"() { - given: - def schema = feature(property("anl", "anlass")) - def chain = new SchemaTransformerChain(Map.of(), SchemaMapping.of(schema), false, true) - - when: - def transformed = schema.accept(chain) - - then: - firstPropertyName(transformed) == "anlass" - } - - def "alias present, useAlias=true, explicit rename at same path: rename wins"() { - given: - def schema = feature(property("anl", "anlass")) - def transformations = Map.of( - "anl", - List.of(new ImmutablePropertyTransformation.Builder().rename("custom").build())) - def chain = new SchemaTransformerChain(transformations, SchemaMapping.of(schema), false, true) - - when: - def transformed = schema.accept(chain) - - then: - firstPropertyName(transformed) == "custom" - } - - def "alias present, useAlias=true, renamePathOnly at same path: alias still wins on name"() { - given: - def schema = feature(property("anl", "anlass")) - def transformations = Map.of( - "anl", - List.of(new ImmutablePropertyTransformation.Builder().renamePathOnly("custom").build())) - def chain = new SchemaTransformerChain(transformations, SchemaMapping.of(schema), false, true) - - when: - def transformed = schema.accept(chain) - - then: - firstPropertyName(transformed) == "anlass" - } -} From da4163bd3c084ed50b3329eb98557e02ea419b1b Mon Sep 17 00:00:00 2001 From: Clemens Portele Date: Thu, 14 May 2026 14:04:53 +0200 Subject: [PATCH 10/14] Add comma separation for polygon rings Fix GeometryEncoderWkt to insert commas between polygon rings when encoding WKT so multiple rings (holes) are correctly separated. Also add a unit test (POLYGON XY with inner ring) to verify decoding/encoding round-trip, ring counts and coordinates. Modified GeometryEncoderWkt.java and GeometryWktWkbSpec.groovy. --- .../transcode/wktwkb/GeometryEncoderWkt.java | 5 +++++ .../geometries/domain/GeometryWktWkbSpec.groovy | 14 ++++++++++++++ 2 files changed, 19 insertions(+) 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/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))" From a185fdb9085b6a4769ee3618de552f1ddd8a9e63 Mon Sep 17 00:00:00 2001 From: Clemens Portele Date: Fri, 15 May 2026 08:40:46 +0200 Subject: [PATCH 11/14] Rewrite GML geometry decoder with stack-based context model Replace the flat dispatch in GeometryDecoderGml with a stack of frames that distinguishes top-level geometry objects, transparent wrappers (member/exterior/interior/shell/segments/patches), rings, surface patches, and curve segments. On END_ELEMENT each frame builds its geometry and contributes it to the nearest non-transparent ancestor, preserving the existing async/incremental contract (isWaitingForInput, continueDecoding with buffered text). Coverage: Point/MultiPoint, LineString/MultiLineString, Curve and CompositeCurve and MultiCurve with LineStringSegment/Arc/ArcString/Circle, Polygon (LinearRing) and CurvePolygon (gml:Ring), Surface, CompositeSurface, PolyhedralSurface with PolygonPatch, Solid mapped to a closed PolyhedralSurface (inner shells rejected), and GeometryCollection. CompositeSolid, MultiSolid, OrientableCurve/Surface, Triangle/Rectangle/Tin/Cone/Cylinder/Sphere, and curve segments other than LineStringSegment/Arc/ArcString/Circle (GeodesicString, CubicSpline, ArcByCenterPoint, ...) are rejected with a clear error. Collapse rule: segments inside a single are parts of one primitive Curve and are merged into a single LineString or CircularString (matching primitive types). Heterogeneous segments yield a CompoundCurve. Across multiple s in a Ring or CompositeCurve, primitive Curves are preserved as separate members - no linear collapse, even if every member is linear, because that would erase the input's primitive structure. CRS inheritance: ring, patch, and segment frames now flow through isObject, so leaf primitives inherit srsName/srsDimension from the enclosing object. Without this a resolved EpsgCrs on an outer CompositeCurve was dropped by inner LineStringSegments and CompoundCurve.check rejected the resulting CRS mismatch. ADV URN forms like urn:adv:crs:ETRS89_UTM32 are not yet recognized by parseSrsName - they currently decode to an empty CRS, to be resolved via a future configurable srsNameMappings. Tests: - GeometryDecoderGmlSpec verifies each construct by decoding then encoding the result as WKT and comparing exact strings (geometry type, ring/member order, axes, coordinates), plus failure paths for the unsupported constructs above. - GeometryDecoderGmlRoundtripSpec covers the geometry patterns observed in the ALKIS Bonn NAS sample set (Point, MultiPoint, Curve with LineStringSegment/Arc/Circle, MultiCurve, CompositeCurve, Surface with PolygonPatch/Ring, MultiSurface). Each test decodes the input, re-encodes via GeometryEncoderGml with options matching the input shape (USE_SURFACE_RING_CURVE for Surface/Ring/Curve inputs), decodes again, and compares WKT and CRS. Includes a real ALKIS CompositeCurve sample with srsName="urn:adv:crs:ETRS89_UTM32" (CRS currently unresolved) and a sibling test using urn:ogc:def:crs:EPSG::25832 with WITH_SRS_NAME that confirms the CRS plumbing preserves a resolved CRS through the round-trip. --- .../gml/domain/GeometryDecoderGml.java | 1106 ++++++++++++----- .../GeometryDecoderGmlRoundtripSpec.groovy | 459 +++++++ .../gml/domain/GeometryDecoderGmlSpec.groovy | 552 ++++++++ 3 files changed, 1814 insertions(+), 303 deletions(-) create mode 100644 xtraplatform-features-gml/src/test/groovy/de/ii/xtraplatform/features/gml/domain/GeometryDecoderGmlRoundtripSpec.groovy create mode 100644 xtraplatform-features-gml/src/test/groovy/de/ii/xtraplatform/features/gml/domain/GeometryDecoderGmlSpec.groovy 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..d9109d584 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,62 +13,123 @@ 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.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 boolean waitingForInput = false; - private boolean waitingForGeometry = true; - private PartialGeometry currentGeometry = null; + private Geometry result; public GeometryDecoderGml() {} @@ -83,222 +144,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 +194,728 @@ 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); + 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; + }; + } + + 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; + }; + } - // get CRS from parent scope - PartialGeometry g = currentGeometry; - while (g != null) { - if (g.geometry != null && g.geometry.getCrs().isPresent()) { - return g.geometry.getCrs(); + 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) { + String srsName = parser.getAttributeValue(null, "srsName"); + if (srsName == null || srsName.isEmpty()) { + return Optional.empty(); + } + if (srsName.startsWith("urn:ogc:def:crs:EPSG::")) { + try { + return Optional.of( + EpsgCrs.of(Integer.parseInt(srsName.substring(srsName.lastIndexOf(':') + 1)))); + } catch (Exception e) { + // fall through + } + } else if (srsName.startsWith("http://www.opengis.net/def/crs/EPSG/0/")) { 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 ("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/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) + } +} From b7decc9e1f62a80b9d15457d8242e8bffd1206a8 Mon Sep 17 00:00:00 2001 From: Clemens Portele Date: Mon, 18 May 2026 12:09:01 +0200 Subject: [PATCH 12/14] Add schema-resolved GML decoder for write-path use MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New FeatureTokenDecoderGml in the .domain package: walks GML against the feature schema and emits values at the dotted property-name path (e.g. qag.dpl.prs.des), independent of any source-system prefix. The SQL feature encoder's writable/value column maps are the canonical consumer. Supports nested OBJECT(_ARRAY), FEATURE_REF and FEATURE_REF_ARRAY (wrap=OBJECT and wrap=OBJECT_ARRAY), GML array properties (one property element wrapping multiple peer object elements), per-level VALUE_ARRAY / FEATURE_REF_ARRAY / OBJECT_ARRAY bracketing at every container depth (feature root and every nested OBJECT_ELEMENT, closing when a sibling with a different name appears or when the enclosing OBJECT_ELEMENT ends), geometry decoding at any nesting depth, valueWrap chains with hardcoded gmd/gco value-wrapper recognition, xsi:nil → null (xsi:type rejected, nilReason dropped), variableObjectElementNames at the feature root and on nested OBJECTs, namespace-aware property matching, and single-feature ingest through a configured FeatureCollection/FeatureMember wrapper chain. Mixed-CRS geometries inside one feature are rejected. - New FeatureTokenDecoderGmlInputProfile interface defining the reverse mappings of GmlConfiguration that the decoder needs: defaultNamespace, applicationNamespaces, objectTypeNamespaces, useAlias, gmlIdPrefix, srsNameMappings, uomMappings, codelistProperties, codelistUriTemplate, featureRefTemplate, valueWrap, xmlAttributes, variableObjectElementNames and the feature-collection / feature-member wrapper names. Plus a VariableObjectName value type that mirrors the encoder's VariableName with the wire-name → source-value mapping direction reversed. - Rename the existing WFS-shape decoder from FeatureTokenDecoderGml (in .app) to FeatureTokenDecoderGmlFromWfs so the path-shape distinction between the two decoders is explicit at the type level; the new schema-resolved decoder takes the unsuffixed name. FeatureProviderWfs and the matching spec follow the rename. - GeometryDecoderGml: accept an srsNameMappings parameter and consult it before the built-in EPSG / OGC URN parsers, so application-profile srsName URNs (e.g. ALKIS NAS) resolve. - FeatureEncoderSql: open and close a junction-table row around each VALUE_ARRAY element so the writable column on the junction table is reachable. Columns existed in the mapping but were never written. - SqlMappingDeriver: skip the object-table dedup for VALUE_ARRAY tables — the table target collides with the single value column's target after cleanup, which would otherwise drop the column from the writable loop. - NAS-grounded Spock test suites (FeatureTokenDecoderGmlSpec and FeatureTokenDecoderGmlNasSpec) with eleven bare-feature ALKIS NAS sample documents (Datenlizenz Deutschland Zero 2.0, City of Bonn). --- .../features/gml/app/FeatureProviderWfs.java | 2 +- ...ava => FeatureTokenDecoderGmlFromWfs.java} | 11 +- .../gml/domain/FeatureTokenDecoderGml.java | 1262 ++++++++ .../FeatureTokenDecoderGmlInputProfile.java | 168 + .../gml/domain/GeometryDecoderGml.java | 25 +- .../gml/domain/VariableObjectName.java | 34 + ... FeatureTokenDecoderGmlFromWfsSpec.groovy} | 6 +- .../FeatureTokenDecoderGmlNasSpec.groovy | 217 ++ .../domain/FeatureTokenDecoderGmlSpec.groovy | 2716 +++++++++++++++++ .../test/resources/nas/AX_Aufnahmepunkt.xml | 24 + .../nas/AX_BesondereFlurstuecksgrenze.xml | 43 + .../test/resources/nas/AX_BoeschungKliff.xml | 30 + .../test/resources/nas/AX_Fahrwegachse.xml | 41 + .../src/test/resources/nas/AX_Flurstueck.xml | 243 ++ .../src/test/resources/nas/AX_Gebaeude.xml | 122 + .../nas/AX_LagebezeichnungOhneHausnummer.xml | 25 + .../src/test/resources/nas/AX_PunktortAU.xml | 69 + .../test/resources/nas/AX_Strassenverkehr.xml | 83 + .../src/test/resources/nas/AX_Turm.xml | 120 + .../src/test/resources/nas/AX_Wald.xml | 125 + .../test/resources/nas/AX_Wohnbauflaeche.xml | 573 ++++ .../src/test/resources/nas/NOTICE | 10 + .../features/sql/app/FeatureEncoderSql.java | 70 +- .../features/sql/app/SqlMappingDeriver.java | 8 +- 24 files changed, 5996 insertions(+), 31 deletions(-) rename xtraplatform-features-gml/src/main/java/de/ii/xtraplatform/features/gml/app/{FeatureTokenDecoderGml.java => FeatureTokenDecoderGmlFromWfs.java} (96%) create mode 100644 xtraplatform-features-gml/src/main/java/de/ii/xtraplatform/features/gml/domain/FeatureTokenDecoderGml.java create mode 100644 xtraplatform-features-gml/src/main/java/de/ii/xtraplatform/features/gml/domain/FeatureTokenDecoderGmlInputProfile.java create mode 100644 xtraplatform-features-gml/src/main/java/de/ii/xtraplatform/features/gml/domain/VariableObjectName.java rename xtraplatform-features-gml/src/test/groovy/de/ii/xtraplatform/features/gml/app/{FeatureTokenDecoderGmlSpec.groovy => FeatureTokenDecoderGmlFromWfsSpec.groovy} (95%) create mode 100644 xtraplatform-features-gml/src/test/groovy/de/ii/xtraplatform/features/gml/domain/FeatureTokenDecoderGmlNasSpec.groovy create mode 100644 xtraplatform-features-gml/src/test/groovy/de/ii/xtraplatform/features/gml/domain/FeatureTokenDecoderGmlSpec.groovy create mode 100644 xtraplatform-features-gml/src/test/resources/nas/AX_Aufnahmepunkt.xml create mode 100644 xtraplatform-features-gml/src/test/resources/nas/AX_BesondereFlurstuecksgrenze.xml create mode 100644 xtraplatform-features-gml/src/test/resources/nas/AX_BoeschungKliff.xml create mode 100644 xtraplatform-features-gml/src/test/resources/nas/AX_Fahrwegachse.xml create mode 100644 xtraplatform-features-gml/src/test/resources/nas/AX_Flurstueck.xml create mode 100644 xtraplatform-features-gml/src/test/resources/nas/AX_Gebaeude.xml create mode 100644 xtraplatform-features-gml/src/test/resources/nas/AX_LagebezeichnungOhneHausnummer.xml create mode 100644 xtraplatform-features-gml/src/test/resources/nas/AX_PunktortAU.xml create mode 100644 xtraplatform-features-gml/src/test/resources/nas/AX_Strassenverkehr.xml create mode 100644 xtraplatform-features-gml/src/test/resources/nas/AX_Turm.xml create mode 100644 xtraplatform-features-gml/src/test/resources/nas/AX_Wald.xml create mode 100644 xtraplatform-features-gml/src/test/resources/nas/AX_Wohnbauflaeche.xml create mode 100644 xtraplatform-features-gml/src/test/resources/nas/NOTICE 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 d9109d584..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 @@ -39,6 +39,7 @@ 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; @@ -128,10 +129,23 @@ static class Frame { } private final Deque stack = new ArrayDeque<>(); + private final Map srsNameMappings; private boolean waitingForInput = false; 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, @@ -273,7 +287,7 @@ private boolean handleStart( f.elementName = localName; if (isObject(kind)) { - Optional explicitCrs = parseSrsName(parser); + Optional explicitCrs = parseSrsName(parser, srsNameMappings); f.crs = explicitCrs.or(() -> defaultCrs).or(this::inheritedCrs); OptionalInt dim = parseSrsDimension(parser); if (dim.isEmpty()) { @@ -455,11 +469,16 @@ private OptionalInt inheritedSrsDimension() { return OptionalInt.empty(); } - private static Optional parseSrsName(AsyncXMLStreamReader parser) { + 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 Optional.of( 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/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..0fc192228 --- /dev/null +++ b/xtraplatform-features-gml/src/test/resources/nas/AX_Flurstueck.xml @@ -0,0 +1,243 @@ + + + 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 + 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..fc410750c --- /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 eb4db7271..ad3e2af90 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()); } From 62432cca6a5f65c0c8f2c38cff83762b72168fa7 Mon Sep 17 00:00:00 2001 From: Clemens Portele Date: Mon, 18 May 2026 10:20:05 +0200 Subject: [PATCH 13/14] Improve error reporting for CRS transformation failures CrsTransform: when the underlying transformer returns null (e.g. coordinates outside the valid range of the source CRS), raise a descriptive IOException naming the source/target CRS and pointing at the likely cause, instead of letting the call hit a NullPointerException further down. CoordinatesTransformer: propagate the underlying IOException's message verbatim rather than masking it with a generic "Error transforming coordinates." string. --- .../domain/transform/CoordinatesTransformer.java | 2 +- .../geometries/domain/transform/CrsTransform.java | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) 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); From 5e3a920c894a1dfa11c19a09428c7ebc77e5a960 Mon Sep 17 00:00:00 2001 From: Clemens Portele Date: Mon, 18 May 2026 13:45:04 +0200 Subject: [PATCH 14/14] amend test features --- .../src/test/resources/nas/AX_Flurstueck.xml | 8 +++++++- .../src/test/resources/nas/AX_Gebaeude.xml | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/xtraplatform-features-gml/src/test/resources/nas/AX_Flurstueck.xml b/xtraplatform-features-gml/src/test/resources/nas/AX_Flurstueck.xml index 0fc192228..943e4bcec 100644 --- a/xtraplatform-features-gml/src/test/resources/nas/AX_Flurstueck.xml +++ b/xtraplatform-features-gml/src/test/resources/nas/AX_Flurstueck.xml @@ -24,7 +24,7 @@ - + @@ -229,6 +229,12 @@ 25 false false + + + + 361972.327 5614528.243 + + 1861-01-01 diff --git a/xtraplatform-features-gml/src/test/resources/nas/AX_Gebaeude.xml b/xtraplatform-features-gml/src/test/resources/nas/AX_Gebaeude.xml index fc410750c..c73d7db2a 100644 --- a/xtraplatform-features-gml/src/test/resources/nas/AX_Gebaeude.xml +++ b/xtraplatform-features-gml/src/test/resources/nas/AX_Gebaeude.xml @@ -18,7 +18,7 @@ - +