Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
9c31e39
GML geometry encoder: Use XMLStreamWriter
cportele May 2, 2026
e0c134f
Merge branch 'master' into nas
cportele May 8, 2026
c725682
GML geometry encoder: add USE_SURFACE_RING_CURVE option
cportele May 8, 2026
bd36c6a
GML geometry encoder: emit Surface/PolygonPatch and CompositeCurve fo…
cportele May 9, 2026
52197ad
GML geometry encoder: improve srsName mapping behavior
cportele May 11, 2026
aa5cc2a
GML geometry encoder: add srsName test
cportele May 11, 2026
77a980e
add alias field to feature schema for encoding-time property renaming
cportele May 12, 2026
c1c846b
plumb useAlias flag through encoding pipeline
cportele May 12, 2026
824af10
fix: cascade renames when rebuilding propertyTransformations keys
cportele May 13, 2026
761f243
Merge branch 'master' into nas
cportele May 13, 2026
581067a
refactor: replace useAlias plumbing with a one-shot alias→rename conv…
cportele May 13, 2026
f1a0d14
Merge branch 'master' into nas
cportele May 13, 2026
62f115a
Merge branch 'master' into nas
cportele May 14, 2026
da4163b
Add comma separation for polygon rings
cportele May 14, 2026
a185fdb
Rewrite GML geometry decoder with stack-based context model
cportele May 15, 2026
b7decc9
Add schema-resolved GML decoder for write-path use
cportele May 18, 2026
62432cc
Improve error reporting for CRS transformation failures
cportele May 18, 2026
5e3a920
amend test features
cportele May 18, 2026
1cf1190
Add multi-action SQL mutation session with cross-feature insert batching
cportele May 19, 2026
1be95b5
Include offending coordinates in geometry validation messages
cportele May 19, 2026
0ec3736
Forward buffered text when geometry decoder resumes at end of coord e…
cportele May 19, 2026
f47fc97
Buffer GeoJSON geometry tokens to survive async chunk boundaries
cportele May 19, 2026
3b35444
Emit xlink:href as value on empty STRING property element
cportele May 27, 2026
1e4d5e7
Resolve bare Type.VALUE to its valueType in MappingRule emission
cportele May 30, 2026
00017e5
Log WARN when xlink:href does not match the reverse template
cportele May 30, 2026
40598c1
Handle gml:Circle as a closed circle in GML decoder and encoder
cportele May 30, 2026
89885f3
Merge branch 'master' into transactions
cportele Jun 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
/*
* 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;

/**
* Helpers for converting between {@code <gml:Circle>} (3 control points on a circle) and the
* 5-position closed CIRCULARSTRING representation used internally. The internal form satisfies
* {@code isClosed()} (first position equals last) so it passes ring-closure validation and is
* accepted as a full circle by PostGIS as {@code CIRCULARSTRING(P1, P2, P3, antipode(P2), P1)}.
*
* <p>Only 2D (XY) circles are handled; 3D circles would require resolving the circle's plane in
* 3-space and are not produced by current data.
*/
final class Circles {

private Circles() {}

/** Coordinate-unit tolerance for the {@link #isFullCircleClosed(double[])} check. */
private static final double EPS = 1.0e-6;

/** Determinant tolerance below which three points are treated as colinear. */
private static final double COLINEAR_EPS = 1.0e-12;

/**
* Expand the 3 control points of a {@code <gml:Circle>} into a 5-position closed CIRCULARSTRING:
* {@code (P1, P2, P3, antipode(P2), P1)}.
*
* @param xyP1P2P3 6 doubles: {@code x1, y1, x2, y2, x3, y3}
* @return 10 doubles: the 5 expanded positions
* @throws IllegalArgumentException if the three points are colinear (no circle is defined)
*/
static double[] expandCircleToClosed(double[] xyP1P2P3) {
if (xyP1P2P3.length != 6) {
throw new IllegalArgumentException(
"expected exactly 3 XY positions (6 doubles), got " + xyP1P2P3.length);
}
double x1 = xyP1P2P3[0];
double y1 = xyP1P2P3[1];
double x2 = xyP1P2P3[2];
double y2 = xyP1P2P3[3];
double x3 = xyP1P2P3[4];
double y3 = xyP1P2P3[5];
double[] c = circumcenter(x1, y1, x2, y2, x3, y3);
double x4 = 2.0 * c[0] - x2;
double y4 = 2.0 * c[1] - y2;
return new double[] {x1, y1, x2, y2, x3, y3, x4, y4, x1, y1};
}

/**
* Test whether 5 closed positions form a full circle: positions 1, 2, 3 define a circle, the
* first and fifth positions coincide, and the fourth position is the antipode of the second
* position on that circle (all within {@link #EPS}).
*
* @param xy5positions 10 doubles: {@code x1, y1, x2, y2, x3, y3, x4, y4, x5, y5}
*/
static boolean isFullCircleClosed(double[] xy5positions) {
if (xy5positions.length != 10) {
return false;
}
double x1 = xy5positions[0];
double y1 = xy5positions[1];
double x5 = xy5positions[8];
double y5 = xy5positions[9];
if (Math.abs(x1 - x5) > EPS || Math.abs(y1 - y5) > EPS) {
return false;
}
double x2 = xy5positions[2];
double y2 = xy5positions[3];
double x3 = xy5positions[4];
double y3 = xy5positions[5];
double x4 = xy5positions[6];
double y4 = xy5positions[7];
try {
double[] c = circumcenter(x1, y1, x2, y2, x3, y3);
double expectedX4 = 2.0 * c[0] - x2;
double expectedY4 = 2.0 * c[1] - y2;
return Math.abs(x4 - expectedX4) <= EPS && Math.abs(y4 - expectedY4) <= EPS;
} catch (IllegalArgumentException colinear) {
return false;
}
}

/**
* Circumcenter of three non-colinear 2D points.
*
* @throws IllegalArgumentException if the three points are colinear
*/
static double[] circumcenter(double x1, double y1, double x2, double y2, double x3, double y3) {
double d = 2.0 * (x1 * (y2 - y3) + x2 * (y3 - y1) + x3 * (y1 - y2));
if (Math.abs(d) < COLINEAR_EPS) {
throw new IllegalArgumentException("three points are colinear; no circle is defined");
}
double s1 = x1 * x1 + y1 * y1;
double s2 = x2 * x2 + y2 * y2;
double s3 = x3 * x3 + y3 * y3;
double cx = (s1 * (y2 - y3) + s2 * (y3 - y1) + s3 * (y1 - y2)) / d;
double cy = (s1 * (x3 - x2) + s2 * (x1 - x3) + s3 * (x2 - x1)) / d;
return new double[] {cx, cy};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,16 @@ private static final class Frame {
boolean nilOnCurrent;
String pendingXlinkHrefValue;

/**
* Temporary fallback for a STRING-typed VALUE / VALUE_ARRAY property whose element carries an
* {@code xlink:href} attribute but no text content. Used only when the property does not route
* hrefs via {@link #pendingXlinkHrefValue} (i.e. it is not a feature-ref or codelist property),
* and the element body produced no buffered text. Supports the workaround where a concat'd
* {@code FEATURE_REF_ARRAY} is modelled as a plain STRING {@code VALUE_ARRAY}, with the target
* id still arriving on the wire as {@code xlink:href}.
*/
String pendingXlinkHrefFallback;

/**
* For VALUE_PROPERTY: set to {@code true} when the property's {@code fullPathAsString} is
* listed in {@link FeatureTokenDecoderGmlInputProfile#getValueWrap()}. Children that appear
Expand Down Expand Up @@ -653,6 +663,14 @@ && resolveVariableNameDiscriminator(
Frame frame = Frame.valueProperty(prop, segment, segmentPathDepth);
frame.nilOnCurrent = readXsiNil();
frame.pendingXlinkHrefValue = readXlinkHrefAsValue(prop);
if (frame.pendingXlinkHrefValue == null
&& prop.getValueType().orElse(prop.getType()) == Type.STRING) {
String raw = readRawXlinkHref();
frame.pendingXlinkHrefFallback =
raw == null
? null
: applyReverseTemplate(inputProfile.getFeatureRefTemplate(), raw).orElse(raw);
}
frame.valueWrapped = isValueWrapped(prop);
validateUom(prop);
frames.push(frame);
Expand Down Expand Up @@ -699,8 +717,20 @@ && resolveVariableNameDiscriminator(

private void onEndElement() throws XMLStreamException, java.io.IOException {
if (geometryDecoder.isWaitingForInput()) {
// The geometry decoder paused on EVENT_INCOMPLETE while reading a coordinate element's text
// (pos/posList/coordinates). Any CHARACTERS that arrived after the pause landed in this
// decoder's `buffer` via the main loop. Hand them over so continueDecoding can append them
// to the geometry decoder's coord frame before finalising — otherwise the trailing chunk of
// the coordinate text is silently dropped (observed: a posList split mid-number produced a
// truncated odd coord count, which the dimension heuristic then promoted to XYZ).
String pending = isBuffering ? buffer.toString() : "";
if (isBuffering) {
isBuffering = false;
buffer.setLength(0);
}
Optional<Geometry<?>> optGeometry =
geometryDecoder.continueDecoding(parser, crs, srsDimension, parser.getLocalName(), "");
geometryDecoder.continueDecoding(
parser, crs, srsDimension, parser.getLocalName(), pending);
if (optGeometry.isPresent()) {
emitGeometry(optGeometry.get());
}
Expand Down Expand Up @@ -745,6 +775,10 @@ private void onEndElement() throws XMLStreamException, java.io.IOException {
context.setValue(bufferedText);
context.setValueType(Type.STRING);
downstream.onValue(context);
} else if (frame.pendingXlinkHrefFallback != null) {
context.setValue(frame.pendingXlinkHrefFallback);
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
Expand Down Expand Up @@ -1074,35 +1108,54 @@ private String readXlinkHrefAsValue(FeatureSchema prop) {
if (!shouldRouteXlinkHrefAsValue(prop)) {
return null;
}
String href = null;
String href = readRawXlinkHref();
if (href == null) {
return null;
}
return reverseXlinkHrefTemplate(href, prop);
}

/** Returns the raw {@code xlink:href} attribute on the current START_ELEMENT, or {@code null}. */
private String readRawXlinkHref() {
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;
return parser.getAttributeValue(i);
}
}
if (href == null) {
return null;
}
return reverseXlinkHrefTemplate(href, prop);
return null;
}

/**
* 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.
* {@code {{codelistId}}} first. If no template is configured, the href is returned unchanged
* silently. If a template is configured but the href does not match, the href is returned
* unchanged and a warning is logged — the raw URI will almost always overflow the storage column
* or be wrong as a value, so the operator needs visibility to fix the config mismatch.
*/
private String reverseXlinkHrefTemplate(String href, FeatureSchema prop) {
String template;
if (prop.isFeatureRef()) {
return applyReverseTemplate(inputProfile.getFeatureRefTemplate(), href, null);
template = inputProfile.getFeatureRefTemplate();
} else {
Optional<String> codelistId = prop.getConstraints().flatMap(SchemaConstraints::getCodelist);
if (codelistId.isEmpty()) {
return href;
}
String raw = inputProfile.getCodelistUriTemplate();
template = raw == null ? null : raw.replace("{{codelistId}}", codelistId.get());
}
Optional<String> codelistId = prop.getConstraints().flatMap(SchemaConstraints::getCodelist);
if (codelistId.isPresent()) {
return applyReverseTemplate(inputProfile.getCodelistUriTemplate(), href, codelistId.get());
Optional<String> reduced = applyReverseTemplate(template, href);
if (reduced.isEmpty() && template != null && !template.isEmpty() && LOGGER.isWarnEnabled()) {
LOGGER.warn(
"xlink:href '{}' on property '{}' does not match the configured template '{}'; "
+ "the unchanged href is passed through as the value",
href,
prop.getFullPathAsString(),
template);
}
return href;
return reduced.orElse(href);
}

private static boolean shouldRouteXlinkHrefAsValue(FeatureSchema prop) {
Expand All @@ -1112,21 +1165,19 @@ private static boolean shouldRouteXlinkHrefAsValue(FeatureSchema prop) {
return prop.getConstraints().flatMap(SchemaConstraints::getCodelist).isPresent();
}

private static String applyReverseTemplate(String template, String href, String codelistId) {
private static Optional<String> applyReverseTemplate(String template, String href) {
if (template == null || template.isEmpty()) {
return href;
return Optional.empty();
}
String substituted =
codelistId == null ? template : template.replace("{{codelistId}}", codelistId);
int idx = substituted.indexOf("{{value}}");
int idx = template.indexOf("{{value}}");
if (idx < 0) {
return href;
return Optional.empty();
}
String prefix = substituted.substring(0, idx);
String suffix = substituted.substring(idx + "{{value}}".length());
String prefix = template.substring(0, idx);
String suffix = template.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;
return m.matches() ? Optional.of(m.group(1)) : Optional.empty();
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -597,7 +597,7 @@ private Geometry<?> buildGeometry(Frame f) throws IOException {
case POLYGON_PATCH -> buildPolygon(f);
case LINE_STRING_SEGMENT -> buildSinglePosList(f, false);
case ARC, ARC_STRING -> buildSinglePosList(f, true);
case CIRCLE -> buildSinglePosList(f, true);
case CIRCLE -> buildCircle(f);
default -> null;
};
}
Expand All @@ -611,6 +611,37 @@ private Point buildPoint(Frame f) throws IOException {
return Point.of(Position.of(axes, c), f.crs);
}

/**
* A {@code <gml:Circle>} is 3 points defining a full circle. Internally we store it as a
* 5-position closed CIRCULARSTRING — {@code (P1, P2, P3, antipode(P2), P1)} — so it satisfies
* ring-closure validation and round-trips through WKT/WKB into PostGIS as a full circle. The
* original 3-point form is recovered by {@link GeometryEncoderGml} when emitting GML.
*/
private Geometry<?> buildCircle(Frame f) throws IOException {
double[] c = f.coords != null ? f.coords : new double[0];
if (c.length == 0) {
throw new IOException("Empty <gml:Circle>.");
}
Axes axes = axesOf(f);
if (axes != Axes.XY) {
// 3D circles would need the plane of the circle resolved in 3-space; no current data
// requires this. Fall back to the linear 3-point form (which downstream ring-closure
// validation will reject loudly).
return buildSinglePosList(f, true);
}
if (c.length != 6) {
throw new IOException(
"<gml:Circle> requires exactly 3 XY positions, got " + (c.length / 2) + ".");
}
double[] expanded;
try {
expanded = Circles.expandCircleToClosed(c);
} catch (IllegalArgumentException colinear) {
throw new IOException("Invalid <gml:Circle>: " + colinear.getMessage());
}
return CircularString.of(PositionList.of(axes, expanded), 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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ public class GeometryEncoderGml implements GeometryVisitor<Void> {
private static final String LINE_STRING_SEGMENT = "LineStringSegment";
private static final String ARC = "Arc";
private static final String ARC_STRING = "ArcString";
private static final String CIRCLE = "Circle";
private static final String MULTI_CURVE = "MultiCurve";
private static final String MULTI_LINE_STRING = "MultiLineString";
private static final String POLYGON = "Polygon";
Expand Down Expand Up @@ -436,10 +437,22 @@ private void writeCircularString(CircularString geometry, boolean asSegment) {
writeStartTagObject(CURVE, false);
writeStartTagProperty(SEGMENTS);
}
String tagName = geometry.getValue().getNumPositions() == 3 ? ARC : ARC_STRING;
writeStartTagDataType(tagName);
writePositionList(geometry.getValue().getCoordinates(), geometry.getAxes());
writeEndTag();
double[] coords = geometry.getValue().getCoordinates();
int numPositions = geometry.getValue().getNumPositions();
if (geometry.getAxes() == Axes.XY && numPositions == 5 && Circles.isFullCircleClosed(coords)) {
// Round-trip our 5-position closed-circle representation back to gml:Circle (3 control
// points). See GeometryDecoderGml#buildCircle for the inverse expansion.
writeStartTagDataType(CIRCLE);
double[] threePoints = new double[6];
System.arraycopy(coords, 0, threePoints, 0, 6);
writePositionList(threePoints, geometry.getAxes());
writeEndTag();
} else {
String tagName = numPositions == 3 ? ARC : ARC_STRING;
writeStartTagDataType(tagName);
writePositionList(coords, geometry.getAxes());
writeEndTag();
}
if (!asSegment) {
writeEndTag();
writeEndTag();
Expand Down
Loading
Loading