From f1204ceb83daf0414f312ae599359c0c625f8a0e Mon Sep 17 00:00:00 2001 From: Clemens Portele Date: Tue, 14 Jul 2026 14:43:50 +0200 Subject: [PATCH 1/7] gml: decode ISO 19139 quantitative results The wire form of an ISO 19139 quantitative result types the anyType value element via xsi:type () and carries an empty valueUnit sibling. The decoder rejected any xsi:type; inside a value-wrap chain the attribute types the content of a value element rather than substituting a schema type, so it is now tolerated and dropped there. xsi:type on schema-mapped elements (feature, object and property elements) remains rejected. - FeatureTokenDecoderGml: exempt value-wrap chain elements from rejectXsiType via the new isValueWrapChainElement, which also replaces the equivalent inline condition for pushing VALUE_WRAPPER frames - FeatureTokenDecoderGmlSpec: cover the full genauigkeitswert structure including xsi:type and the empty valueUnit element --- .../gml/domain/FeatureTokenDecoderGml.java | 33 ++++++++++--- .../domain/FeatureTokenDecoderGmlSpec.groovy | 48 ++++++++++++++++++- 2 files changed, 73 insertions(+), 8 deletions(-) diff --git a/xtraplatform-features-gml/src/main/java/de/ii/xtraplatform/features/gml/domain/FeatureTokenDecoderGml.java b/xtraplatform-features-gml/src/main/java/de/ii/xtraplatform/features/gml/domain/FeatureTokenDecoderGml.java index 608dcb016..6f3b4ff28 100644 --- a/xtraplatform-features-gml/src/main/java/de/ii/xtraplatform/features/gml/domain/FeatureTokenDecoderGml.java +++ b/xtraplatform-features-gml/src/main/java/de/ii/xtraplatform/features/gml/domain/FeatureTokenDecoderGml.java @@ -474,7 +474,9 @@ private boolean onStartElement() throws XMLStreamException, java.io.IOException return false; } - rejectXsiType(); + if (!isValueWrapChainElement()) { + rejectXsiType(); + } if (!inFeature && depth < featureRootDepth) { String expected = wrapperElementNames.get(depth); @@ -581,11 +583,7 @@ && resolveVariableNameDiscriminator( // (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()); + frames.push(isValueWrapChainElement() ? Frame.valueWrapper() : Frame.unknown()); depth++; return false; } @@ -1251,11 +1249,32 @@ private boolean readXsiNil() { return false; } + /** + * Whether the current START_ELEMENT is part of a value-wrap chain: its parent frame is a {@link + * FrameKind#VALUE_WRAPPER}, or a {@link FrameKind#VALUE_PROPERTY} whose path is listed in {@code + * valueWrap} or whose child lives in an ISO 19115 content namespace ({@code gmd}/{@code gco}). + * Such elements carry no schema meaning — they only wrap the property's scalar text — so they are + * pushed as {@link Frame#valueWrapper()} and exempt from {@link #rejectXsiType()}: ISO 19139 + * requires typed values like {@code } inside {@code + * DQ_QuantitativeResult}, where {@code xsi:type} declares the content type of an anyType element + * rather than substituting a schema type. + */ + private boolean isValueWrapChainElement() { + Frame parent = frames.peek(); + return parent != null + && (parent.kind == FrameKind.VALUE_WRAPPER + || (parent.kind == FrameKind.VALUE_PROPERTY + && (parent.valueWrapped || isExternalContentNamespace(parser.getNamespaceURI())))); + } + /** * {@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. + * appeared. Elements inside a value-wrap chain (see {@link #isValueWrapChainElement()}) are + * exempt: there {@code xsi:type} types the content of an anyType value element (ISO 19139 {@code + * gco:Record}) and is dropped on input; the encoder regenerates it from the attributes declared + * on the {@code valueWrap} chain entry. */ private void rejectXsiType() { for (int i = 0; i < parser.getAttributeCount(); i++) { diff --git a/xtraplatform-features-gml/src/test/groovy/de/ii/xtraplatform/features/gml/domain/FeatureTokenDecoderGmlSpec.groovy b/xtraplatform-features-gml/src/test/groovy/de/ii/xtraplatform/features/gml/domain/FeatureTokenDecoderGmlSpec.groovy index 1d6e08af3..957c2bfaf 100644 --- a/xtraplatform-features-gml/src/test/groovy/de/ii/xtraplatform/features/gml/domain/FeatureTokenDecoderGmlSpec.groovy +++ b/xtraplatform-features-gml/src/test/groovy/de/ii/xtraplatform/features/gml/domain/FeatureTokenDecoderGmlSpec.groovy @@ -1867,7 +1867,7 @@ class FeatureTokenDecoderGmlSpec extends Specification { /** * 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 + * dat, pro(CI_ResponsibleParty)→{org, ind, rol}, src(LI_Source)→des}, gwt, 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. */ @@ -1929,6 +1929,10 @@ class FeatureTokenDecoderGmlSpec extends Specification { .sourcePath("qag__dpl_prs_src") .type(SchemaBase.Type.STRING) .alias("description"))))) + .putProperties2("gwt", new ImmutableFeatureSchema.Builder() + .sourcePath("qag__gwt") + .type(SchemaBase.Type.STRING) + .alias("genauigkeitswert")) .putProperties2("gst", new ImmutableFeatureSchema.Builder() .sourcePath("qag__gst") .type(SchemaBase.Type.STRING) @@ -2224,6 +2228,48 @@ class FeatureTokenDecoderGmlSpec extends Specification { rootCauseMessage(e).contains("AX_Flurstueck") } + def 'xsi:type inside an ISO 19139 value-wrap chain is tolerated and the scalar value is extracted'() { + given: + // The ISO 19139 quantitative-result pattern types the anyType gco:Record via + // xsi:type="gml:doubleList" and requires an empty gmd:valueUnit sibling before gmd:value + // (NAS AX_PunktortAU genauigkeitswert). Neither may disturb extraction of the scalar + // accuracy value; xsi:type on schema-mapped elements stays rejected (see the two + // rejection tests above). + def decoder = newPunktortAuDecoder(nasNamespaceProfile()) + def xml = """ + + + + + + + + + 0.0074721 + + + + + + 1200 + + + """ + + when: + def tokens = runDecoder(decoder, xml) + + then: + valueAtPath(tokens, ["qag", "gwt"]) == "0.0074721" + valueAtPath(tokens, ["qag", "gst"]) == "1200" + } + def 'nilReason on a property element is silently dropped'() { given: // Verifies that an unqualified nilReason attribute, with or without xsi:nil, does not From 597ff376ec1b5381c957a69bba3fd5471faaa078 Mon Sep 17 00:00:00 2001 From: Clemens Portele Date: Tue, 14 Jul 2026 17:20:15 +0200 Subject: [PATCH 2/7] features: store positions in alternative CRSs in CRS-specific properties Some application schemas (e.g. AX_PunktortAU in the German AFIS-ALKIS-ATKIS NAS schema) carry exactly one position per feature, but in one of several CRSs - including realizations that map to the same EPSG code, 1D vertical reference systems, and coordinate forms whose false easting differs from the CRS definition (Gauss-Krueger without the zone prefix). Each variant is stored in its own geometry property with its own storage CRS; the verbatim srsName is stored alongside so encoders can reproduce it. - SchemaBase/FeatureSchema: new `crs` option - the storage CRS of a geometry property, overriding the provider's nativeCrs - features-sql: the storage CRS travels as the WKT/WKB operation parameters (code, axis-order force); geometry writes route by property path, transform from the geometry's CRS to the column's storage CRS and emit the column's SRID (inserts and native updates) - FeatureTokenTransformerCoordinates: geometries with their own storage CRS pass through the request-CRS transformation untouched - geometries: EastingShift coordinates transformation - adds a constant to the easting, rounded to micrometres; converts between coordinate forms that differ only in the false easting - gml decoder: GmlGeometryVariants routing on the input profile - a position is routed by its verbatim srsName to a variant property (with a configured false-easting difference applied) or, for 1D vertical reference systems, captured as a scalar value at a FLOAT property; the verbatim srsName is emitted at a companion property; GeometryDecoderGml exposes the verbatim srsName and consumes 1D positions without building a Geometry --- .../gml/domain/FeatureTokenDecoderGml.java | 139 +++++++++++-- .../FeatureTokenDecoderGmlInputProfile.java | 18 ++ .../gml/domain/GeometryDecoderGml.java | 78 ++++++++ .../gml/domain/GmlGeometryVariants.java | 48 +++++ ...TokenDecoderGmlPositionVariantsSpec.groovy | 183 ++++++++++++++++++ .../features/sql/app/FeatureEncoderSql.java | 81 +++++--- .../features/sql/app/SqlMappingDeriver.java | 13 +- .../features/sql/app/SqlMutationSession.java | 22 ++- .../features/domain/FeatureSchema.java | 18 ++ .../FeatureTokenTransformerCoordinates.java | 9 + .../features/domain/SchemaBase.java | 3 + .../domain/transform/EastingShift.java | 52 +++++ .../geometries/domain/EastingShiftSpec.groovy | 54 ++++++ 13 files changed, 679 insertions(+), 39 deletions(-) create mode 100644 xtraplatform-features-gml/src/main/java/de/ii/xtraplatform/features/gml/domain/GmlGeometryVariants.java create mode 100644 xtraplatform-features-gml/src/test/groovy/de/ii/xtraplatform/features/gml/domain/FeatureTokenDecoderGmlPositionVariantsSpec.groovy create mode 100644 xtraplatform-geometries/src/main/java/de/ii/xtraplatform/geometries/domain/transform/EastingShift.java create mode 100644 xtraplatform-geometries/src/test/groovy/de/ii/xtraplatform/geometries/domain/EastingShiftSpec.groovy diff --git a/xtraplatform-features-gml/src/main/java/de/ii/xtraplatform/features/gml/domain/FeatureTokenDecoderGml.java b/xtraplatform-features-gml/src/main/java/de/ii/xtraplatform/features/gml/domain/FeatureTokenDecoderGml.java index 6f3b4ff28..02d6bbb14 100644 --- a/xtraplatform-features-gml/src/main/java/de/ii/xtraplatform/features/gml/domain/FeatureTokenDecoderGml.java +++ b/xtraplatform-features-gml/src/main/java/de/ii/xtraplatform/features/gml/domain/FeatureTokenDecoderGml.java @@ -258,6 +258,14 @@ private static final class Frame { */ boolean valueWrapped; + /** + * For GEOMETRY_PROPERTY: the position-variant routing configured for this property in {@link + * FeatureTokenDecoderGmlInputProfile#getGeometryVariants()}, or {@code null} when none is + * configured. Consulted by {@code emitGeometryOrVertical} together with the raw srsName the + * geometry decoder captured. + */ + GmlGeometryVariants geometryVariants; + /** * 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 @@ -323,7 +331,12 @@ public FeatureTokenDecoderGml( this.defaultCrs = headerCrs.isPresent() ? headerCrs : Optional.of(storageCrs); this.nullValue = nullValue; this.inputProfile = inputProfile; - this.geometryDecoder = new GeometryDecoderGml(inputProfile.getSrsNameMappings()); + this.geometryDecoder = + new GeometryDecoderGml( + inputProfile.getSrsNameMappings(), + inputProfile.getGeometryVariants().values().stream() + .flatMap(variants -> variants.getVerticalBySrsName().keySet().stream()) + .collect(java.util.stream.Collectors.toUnmodifiableSet())); this.buffer = new StringBuilder(); List wrappers = new ArrayList<>(2); @@ -468,8 +481,8 @@ 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()); + if (!geometryDecoder.isWaitingForInput()) { + emitGeometryOrVertical(optGeometry); } return false; } @@ -645,17 +658,19 @@ && resolveVariableNameDiscriminator( 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. + // path so {@code emitGeometryOrVertical} routes the resulting Geometry (or 1D position) to + // the right downstream slot. + Frame frame = Frame.geometryProperty(prop, segment, segmentPathDepth); + frame.geometryVariants = geometryVariantsFor(prop); + frames.push(frame); Optional> optGeometry = geometryDecoder.decode(parser, crs, srsDimension); - frames.push(Frame.geometryProperty(prop, segment, segmentPathDepth)); - if (optGeometry.isPresent()) { - emitGeometry(optGeometry.get()); - depth++; - return false; + boolean waiting = geometryDecoder.isWaitingForInput(); + if (!waiting) { + emitGeometryOrVertical(optGeometry); } - // geometry needs more input; the next push will continue via continueDecoding + // when waiting, the geometry needs more input; the next push continues via continueDecoding depth++; - return true; + return waiting; } else if (prop.isValue()) { Frame frame = Frame.valueProperty(prop, segment, segmentPathDepth); frame.nilOnCurrent = readXsiNil(); @@ -728,8 +743,8 @@ private void onEndElement() throws XMLStreamException, java.io.IOException { Optional> optGeometry = geometryDecoder.continueDecoding( parser, crs, srsDimension, parser.getLocalName(), pending); - if (optGeometry.isPresent()) { - emitGeometry(optGeometry.get()); + if (!geometryDecoder.isWaitingForInput()) { + emitGeometryOrVertical(optGeometry); } return; } @@ -1288,6 +1303,104 @@ private void rejectXsiType() { } } + /** + * Mirrors {@link #isValueWrapped}: the configuration is keyed by the property's technical full + * path, but a YAML config keyed by the alias-form path is recognised as well. + */ + private GmlGeometryVariants geometryVariantsFor(FeatureSchema prop) { + Map geometryVariants = inputProfile.getGeometryVariants(); + if (geometryVariants.isEmpty()) { + return null; + } + String path = prop.getFullPathAsString(); + GmlGeometryVariants variants = geometryVariants.get(path); + if (variants != null) { + return variants; + } + String aliasPath = aliasFormPathByPropertyPath.get(path); + return aliasPath != null ? geometryVariants.get(aliasPath) : null; + } + + /** + * Emits the completed result of a geometry decode. Without position-variant routing this is the + * decoded {@link Geometry} at the geometry property's own path. With routing (see {@link + * GmlGeometryVariants}), a geometry whose verbatim wire srsName maps to a variant property is + * emitted at that property's path instead, a 1D position (empty {@code optGeometry} although no + * further input is needed) is emitted as a scalar value at the configured vertical property, and + * in both cases the verbatim srsName is emitted at the configured srsName property. The path + * tracker is restored to the geometry property's own segment afterwards. + */ + private void emitGeometryOrVertical(Optional> optGeometry) { + Frame frame = frames.peek(); + GmlGeometryVariants variants = + frame != null && frame.kind == FrameKind.GEOMETRY_PROPERTY ? frame.geometryVariants : null; + String rawSrsName = geometryDecoder.getRawSrsName().orElse(null); + + // Coordinates whose srsName declares a false-easting difference (e.g. Gauss-Krüger without + // the zone prefix) are shifted so the emitted geometry conforms to the mapped CRS. + if (optGeometry.isPresent() && rawSrsName != null) { + Double falseEastingDifference = + inputProfile.getSrsNameFalseEastingDifferences().get(rawSrsName); + if (falseEastingDifference != null && falseEastingDifference != 0) { + optGeometry = + Optional.of( + optGeometry + .get() + .accept( + new de.ii.xtraplatform.geometries.domain.transform.CoordinatesTransformer( + de.ii.xtraplatform.geometries.domain.transform.ImmutableEastingShift.of( + Optional.empty(), falseEastingDifference)))); + } + } + + if (optGeometry.isPresent()) { + String variantProperty = + variants != null && rawSrsName != null ? variants.getBySrsName().get(rawSrsName) : null; + if (variantProperty != null) { + context.pathTracker().track(variantProperty, frame.pathDepth); + emitGeometry(optGeometry.get()); + emitSrsName(variants, rawSrsName, frame.pathDepth); + context.pathTracker().track(frame.segment, frame.pathDepth); + } else { + emitGeometry(optGeometry.get()); + } + return; + } + + Optional verticalValue = geometryDecoder.getVerticalValue(); + if (verticalValue.isEmpty()) { + return; + } + String verticalProperty = + variants != null && rawSrsName != null + ? variants.getVerticalBySrsName().get(rawSrsName) + : null; + if (verticalProperty == null) { + throw new IllegalArgumentException( + "1D position with srsName '" + + rawSrsName + + "' is not supported for this geometry property."); + } + context.pathTracker().track(verticalProperty, frame.pathDepth); + context.setValue(verticalValue.get()); + context.setValueType(Type.STRING); + downstream.onValue(context); + emitSrsName(variants, rawSrsName, frame.pathDepth); + context.pathTracker().track(frame.segment, frame.pathDepth); + } + + private void emitSrsName(GmlGeometryVariants variants, String rawSrsName, int pathDepth) { + variants + .getSrsNameProperty() + .ifPresent( + srsNameProperty -> { + context.pathTracker().track(srsNameProperty, pathDepth); + context.setValue(rawSrsName); + context.setValueType(Type.STRING); + downstream.onValue(context); + }); + } + private void emitGeometry(Geometry geom) { checkMixedCrs(geom.getCrs()); context.setGeometry(geom); 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 index 2d7254a67..6d183c25e 100644 --- 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 @@ -29,6 +29,16 @@ public interface FeatureTokenDecoderGmlInputProfile { */ Map getSrsNameMappings(); + /** + * Per wire {@code srsName}, the difference between the false easting of the mapped CRS and the + * false easting used by coordinates carrying that srsName (e.g. 3000000 for German Gauss-Krüger + * coordinates written without the zone prefix, mapped to a zone-prefixed EPSG CRS). Added to the + * easting (first ordinate) of every decoded position so the emitted coordinates conform to the + * mapped CRS; the encoder subtracts it on output. Only srsNames with a non-zero difference are + * present. + */ + Map getSrsNameFalseEastingDifferences(); + /** * 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. @@ -176,6 +186,14 @@ default String getFeatureMemberElementName() { */ Set getObjectTypeSuffixedProperties(); + /** + * Reverse of {@code GmlConfiguration#positionVariants}: per geometry property — keyed by the + * property's technical full path; the alias-form path is honored as well, mirroring {@link + * #getValueWrap()} — the routing of positions in non-native CRSs to CRS-specific sibling + * properties. See {@link GmlGeometryVariants}. + */ + Map getGeometryVariants(); + 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 b94b57b85..9de6449f5 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 @@ -130,9 +130,29 @@ static class Frame { private final Deque stack = new ArrayDeque<>(); private final Map srsNameMappings; + private final Set verticalSrsNames; private boolean waitingForInput = false; private Geometry result; + /** + * The verbatim {@code srsName} attribute of the outermost geometry element of the current decode, + * or {@code null} when none was present. Unlike the resolved {@link EpsgCrs} this distinguishes + * application-profile forms that map to the same EPSG code (e.g. the AdV realizations {@code + * urn:adv:crs:DE_DHDN_3GK3_HE100} and {@code …HE120}). + */ + private String rawSrsName; + + /** + * Set when {@link #rawSrsName} is one of {@link #verticalSrsNames}: the "geometry" is a 1D + * position (a single number in {@code gml:pos}, e.g. a height in a German height reference + * system), which has no representation in the geometry model. The coordinate text is captured + * verbatim in {@link #verticalValue} and no {@link Geometry} is built; {@code decode} returns + * empty with {@code waitingForInput == false} once the subtree is consumed. + */ + private boolean verticalMode; + + private String verticalValue; + public GeometryDecoderGml() { this(Map.of()); } @@ -144,7 +164,31 @@ public GeometryDecoderGml() { * the built-in parsers cannot handle. */ public GeometryDecoderGml(Map srsNameMappings) { + this(srsNameMappings, Set.of()); + } + + /** + * @param verticalSrsNames {@code srsName} URI/URN forms of 1D (vertical) reference systems; a + * geometry whose outermost element carries one of these is captured as a scalar value (see + * {@link #getVerticalValue()}) instead of being decoded into a {@link Geometry}. + */ + public GeometryDecoderGml(Map srsNameMappings, Set verticalSrsNames) { this.srsNameMappings = srsNameMappings == null ? Map.of() : srsNameMappings; + this.verticalSrsNames = verticalSrsNames == null ? Set.of() : verticalSrsNames; + } + + /** The verbatim srsName of the outermost geometry element of the last completed decode. */ + public Optional getRawSrsName() { + return Optional.ofNullable(rawSrsName); + } + + /** + * The verbatim coordinate text of a 1D position, present when the last decode consumed a geometry + * in a vertical reference system (see {@link #GeometryDecoderGml(Map, Set)}); in that case {@code + * decode} returned empty although no further input is needed. + */ + public Optional getVerticalValue() { + return Optional.ofNullable(verticalValue); } public Optional> decode( @@ -152,6 +196,11 @@ public Optional> decode( Optional crs, OptionalInt srsDimension) throws XMLStreamException, IOException { + // fresh decode of a new geometry property — the 4-arg overload is also used to continue a + // paused decode and must not clear the per-geometry state + this.rawSrsName = null; + this.verticalMode = false; + this.verticalValue = null; return decode(parser, crs, srsDimension, false); } @@ -192,6 +241,12 @@ public Optional> decode( waitingForInput = false; return Optional.of(r); } + if (verticalMode && stack.isEmpty()) { + // 1D position fully consumed — no Geometry to return; the caller reads the captured + // scalar via getVerticalValue() + waitingForInput = false; + return Optional.empty(); + } break; default: @@ -214,6 +269,11 @@ public Optional> continueDecoding( if (bufferedText != null) { top.textBuffer.append(bufferedText); } + if (verticalMode) { + this.verticalValue = top.textBuffer.toString().trim(); + stack.pop(); + return decode(parser, defaultCrs, srsDimension, false); + } finalizeCoordinates(top); stack.pop(); applyCoordinates(top); @@ -287,6 +347,12 @@ private boolean handleStart( f.elementName = localName; if (isObject(kind)) { + if (stack.isEmpty()) { + // outermost geometry element of this decode — keep the verbatim srsName for callers that + // need the original form, and detect 1D positions in vertical reference systems + this.rawSrsName = parser.getAttributeValue(null, "srsName"); + this.verticalMode = rawSrsName != null && verticalSrsNames.contains(rawSrsName); + } Optional explicitCrs = parseSrsName(parser, srsNameMappings); f.crs = explicitCrs.or(() -> defaultCrs).or(this::inheritedCrs); OptionalInt dim = parseSrsDimension(parser); @@ -317,6 +383,11 @@ private void handleEnd(String localName) throws IOException { return; } + if (verticalMode) { + // a 1D position builds no Geometry; the scalar was captured in verticalValue + return; + } + Geometry built = buildGeometry(top); if (built == null) { // transparent wrapper — nothing to contribute @@ -530,6 +601,13 @@ private boolean readCoordinateText(AsyncXMLStreamReader pa break; case XMLStreamConstants.END_ELEMENT: if (coordFrame.elementName.equals(parser.getLocalName())) { + if (verticalMode) { + // a 1D position is captured verbatim — a single number is not a valid coordinate + // tuple and must not reach the coordinate parser + this.verticalValue = coordFrame.textBuffer.toString().trim(); + stack.pop(); + return true; + } finalizeCoordinates(coordFrame); stack.pop(); applyCoordinates(coordFrame); diff --git a/xtraplatform-features-gml/src/main/java/de/ii/xtraplatform/features/gml/domain/GmlGeometryVariants.java b/xtraplatform-features-gml/src/main/java/de/ii/xtraplatform/features/gml/domain/GmlGeometryVariants.java new file mode 100644 index 000000000..d0de2eea5 --- /dev/null +++ b/xtraplatform-features-gml/src/main/java/de/ii/xtraplatform/features/gml/domain/GmlGeometryVariants.java @@ -0,0 +1,48 @@ +/* + * 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 java.util.Optional; +import org.immutables.value.Value; + +/** + * Routing of a geometry property's positions to CRS-specific variant properties, keyed by the + * verbatim {@code srsName} on the wire. Some application schemas (AAA/NAS {@code AX_PunktortAU}) + * carry exactly one position per feature, but in one of several CRSs — including realizations that + * map to the same EPSG code (so the resolved CRS cannot reproduce the original srsName) and 1D + * vertical systems (which have no representation in the geometry model). Each variant is stored in + * its own schema property; the verbatim srsName is stored alongside so the encoder can reproduce + * it. + * + *

All property values name sibling properties of the geometry property this instance is + * configured for (same parent object, usually the feature type itself). + */ +@Value.Immutable +public interface GmlGeometryVariants { + + /** + * Wire {@code srsName} → geometry property for 2D/3D positions in a non-native CRS. The decoder + * routes the decoded geometry to the mapped property instead of the base geometry property; the + * resolved CRS (via {@code srsNameMappings}) tags the geometry for the storage transformation. + */ + Map getBySrsName(); + + /** + * Wire {@code srsName} → scalar (FLOAT) property for 1D positions. The single coordinate value is + * emitted verbatim at the mapped property; no geometry is built. + */ + Map getVerticalBySrsName(); + + /** + * STRING property that receives the verbatim wire {@code srsName} whenever a position was routed + * via {@link #getBySrsName()} or {@link #getVerticalBySrsName()}. Unset for positions in the + * native CRS. + */ + Optional getSrsNameProperty(); +} diff --git a/xtraplatform-features-gml/src/test/groovy/de/ii/xtraplatform/features/gml/domain/FeatureTokenDecoderGmlPositionVariantsSpec.groovy b/xtraplatform-features-gml/src/test/groovy/de/ii/xtraplatform/features/gml/domain/FeatureTokenDecoderGmlPositionVariantsSpec.groovy new file mode 100644 index 000000000..c31a45c2a --- /dev/null +++ b/xtraplatform-features-gml/src/test/groovy/de/ii/xtraplatform/features/gml/domain/FeatureTokenDecoderGmlPositionVariantsSpec.groovy @@ -0,0 +1,183 @@ +/* + * 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.geometries.domain.Geometry +import de.ii.xtraplatform.geometries.domain.GeometryType +import de.ii.xtraplatform.geometries.domain.Point +import spock.lang.Specification + +import javax.xml.namespace.QName + +/** + * Position-variant routing (see {@link GmlGeometryVariants}): positions in a foreign CRS are + * routed by their verbatim srsName to a variant property (with the configured false-easting + * difference applied), 1D positions to a scalar property, and the srsName itself to a companion + * property. Positions without a routed srsName take the normal path. + */ +class FeatureTokenDecoderGmlPositionVariantsSpec extends Specification { + + static final String ADV_NS = "http://www.adv-online.de/namespaces/adv/gid/7.1" + static final String GK3_HE100 = "urn:adv:crs:DE_DHDN_3GK3_HE100" + static final String DHHN92 = "urn:adv:crs:DE_DHHN92_NH" + + static final Map NAMESPACES = [ + "adv": ADV_NS, + "gml": "http://www.opengis.net/gml/3.2" + ] + + static FeatureSchema schema() { + new ImmutableFeatureSchema.Builder() + .name("ax_punktortau") + .sourcePath("/o14003") + .type(SchemaBase.Type.OBJECT) + .putProperties2("oid", new ImmutableFeatureSchema.Builder() + .sourcePath("objid") + .type(SchemaBase.Type.STRING) + .role(SchemaBase.Role.ID) + .alias("id")) + .putProperties2("pos_srs", new ImmutableFeatureSchema.Builder() + .sourcePath("position_srs") + .type(SchemaBase.Type.STRING)) + .putProperties2("pos_gk3", new ImmutableFeatureSchema.Builder() + .sourcePath("position_gk3") + .type(SchemaBase.Type.GEOMETRY) + .geometryType(GeometryType.POINT)) + .putProperties2("pos_h", new ImmutableFeatureSchema.Builder() + .sourcePath("position_h") + .type(SchemaBase.Type.FLOAT)) + .putProperties2("geo", new ImmutableFeatureSchema.Builder() + .sourcePath("position") + .type(SchemaBase.Type.GEOMETRY) + .geometryType(GeometryType.POINT) + .alias("position")) + .build() + } + + static FeatureTokenDecoderGmlInputProfile profile() { + ImmutableFeatureTokenDecoderGmlInputProfile.builder() + .useAlias(true) + .putSrsNameMappings(GK3_HE100, EpsgCrs.of(5677)) + .putSrsNameFalseEastingDifferences(GK3_HE100, 3000000d) + .putGeometryVariants("geo", ImmutableGmlGeometryVariants.builder() + .putBySrsName(GK3_HE100, "pos_gk3") + .putVerticalBySrsName(DHHN92, "pos_h") + .srsNameProperty("pos_srs") + .build()) + .build() + } + + static FeatureTokenDecoderSimple> newDecoder() { + def schema = schema() + new FeatureTokenDecoderGml( + NAMESPACES, + [new QName(ADV_NS, "AX_PunktortAU")], + schema, + ImmutableFeatureQuery.builder().type(schema.getName()).build(), + Map.of(schema.getName(), + new ImmutableSchemaMapping.Builder() + .targetSchema(schema) + .sourcePathTransformer((path, isValue) -> path) + .build()), + EpsgCrs.of(25832), + Optional.empty(), + Optional.empty(), + profile()) + } + + List runDecoder(byte[] xml) { + def decoder = newDecoder() + def tokens = [] + decoder.init({ tokens << it } as java.util.function.Consumer) + decoder.onPush(xml) + decoder.onComplete() + return tokens + } + + static String feature(String position) { + """ + ${position} + """ + } + + 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 = 1; i < tokens.size(); i++) { + if (tokens[i] instanceof Geometry && tokens[i - 1] instanceof List) { + return (List) tokens[i - 1] + } + } + return null + } + + def 'a foreign-CRS position is routed to the variant property with the false-easting shift'() { + given: + def xml = feature(""" + 446104.620 5551059.770""") + + when: + def tokens = runDecoder(xml.getBytes("UTF-8")) + def geometry = tokens.find { it instanceof Geometry } as Geometry + + then: + pathBeforeGeometry(tokens) == ["pos_gk3"] + geometry.getCrs() == Optional.of(EpsgCrs.of(5677)) + (geometry as Point).getValue().getCoordinates() == [3446104.62d, 5551059.77d] as double[] + valueAtPath(tokens, ["pos_srs"]) == GK3_HE100 + } + + def 'a 1D position is captured as a scalar at the vertical property, no geometry is built'() { + given: + def xml = feature(""" + 229.940""") + + when: + def tokens = runDecoder(xml.getBytes("UTF-8")) + + then: + tokens.count { it instanceof Geometry } == 0 + valueAtPath(tokens, ["pos_h"]) == "229.940" + valueAtPath(tokens, ["pos_srs"]) == DHHN92 + } + + def 'a position without srsName takes the normal path'() { + given: + def xml = feature(""" + 448733.315 5539621.758""") + + when: + def tokens = runDecoder(xml.getBytes("UTF-8")) + def geometry = tokens.find { it instanceof Geometry } as Geometry + + then: + pathBeforeGeometry(tokens) == ["geo"] + (geometry as Point).getValue().getCoordinates() == [448733.315d, 5539621.758d] as double[] + valueAtPath(tokens, ["pos_srs"]) == null + } +} 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 dd3980107..675aa157c 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 @@ -33,6 +33,7 @@ import java.time.ZonedDateTime; import java.time.format.DateTimeFormatter; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Optional; @@ -52,7 +53,9 @@ public class FeatureEncoderSql private static final Logger LOGGER = LoggerFactory.getLogger(FeatureEncoderSql.class); private final SqlQueryMapping mapping; + private final EpsgCrs inputCrs; private final EpsgCrs nativeCrs; + private final CrsTransformerFactory crsTransformerFactory; private final Optional crsTransformer; private final Optional timeZone; private final Optional nullValue; @@ -78,6 +81,8 @@ public FeatureEncoderSql( Optional timeZone, Optional nullValue) { this.mapping = mapping; + this.inputCrs = inputCrs; + this.crsTransformerFactory = crsTransformerFactory; this.crsTransformer = crsTransformerFactory.getTransformer(inputCrs, nativeCrs); this.nativeCrs = nativeCrs; this.timeZone = timeZone; @@ -258,28 +263,35 @@ public void onGeometry(ModifiableContext contex LOGGER.trace("geometry: {} {}", context.pathAsString(), geometry); } - mapping - .getColumnForPrimaryGeometry() + // A geometry property that is mapped to its own writable column under its property path + // (e.g. a per-CRS position variant) takes precedence over the primary geometry. The column's + // storage CRS — the WKT/WKB operation parameter, set from the schema's `crs` option — + // determines the transformation target and the SRID of the literal; without the option this + // is the provider's nativeCrs, preserving the previous behavior. + Optional> columnForPath = + mapping + .getColumnForValue(context.pathAsString(), MappingRule.Scope.W) + .filter( + c -> + c.second().hasOperation(SqlQueryColumn.Operation.WKT) + || c.second().hasOperation(SqlQueryColumn.Operation.WKB)); + + columnForPath + .or( + () -> + mapping + .getColumnForPrimaryGeometry() + .filter(c -> mapping.getSchemaForPrimaryGeometry().isPresent())) .ifPresentOrElse( column -> { - mapping - .getSchemaForPrimaryGeometry() - .ifPresentOrElse( - schema -> { - String value = toWkt(geometry, crsTransformer, nativeCrs); - - currentFeature.addColumn(column.first(), column.second(), value); - - if (trace) { - LOGGER.trace("onGeometry: {} {}", context.pathAsString(), value); - } - }, - () -> { - if (trace) { - LOGGER.warn( - "onGeometry: {} not found in mapping", context.pathAsString()); - } - }); + EpsgCrs storageCrs = storageCrs(column.second()); + String value = toWkt(geometry, transformerFor(geometry, storageCrs), storageCrs); + + currentFeature.addColumn(column.first(), column.second(), value); + + if (trace) { + LOGGER.trace("onGeometry: {} {}", context.pathAsString(), value); + } }, () -> { if (trace) { @@ -288,6 +300,31 @@ public void onGeometry(ModifiableContext contex }); } + private EpsgCrs storageCrs(SqlQueryColumn column) { + SqlQueryColumn.Operation op = + column.hasOperation(SqlQueryColumn.Operation.WKB) + ? SqlQueryColumn.Operation.WKB + : SqlQueryColumn.Operation.WKT; + List parameters = column.getOperationParameters(op); + if (parameters.isEmpty()) { + return nativeCrs; + } + return EpsgCrs.of( + Integer.parseInt(parameters.get(0)), + parameters.size() > 1 ? EpsgCrs.Force.valueOf(parameters.get(1)) : EpsgCrs.Force.NONE); + } + + // The transformation source is the CRS the geometry actually arrived in (set by the format + // decoder from srsName or the request CRS), falling back to the request CRS for decoders that + // do not tag geometries. + private Optional transformerFor(Geometry geometry, EpsgCrs storageCrs) { + EpsgCrs sourceCrs = geometry.getCrs().orElse(inputCrs); + if (Objects.equals(sourceCrs, storageCrs)) { + return Optional.empty(); + } + return crsTransformerFactory.getTransformer(sourceCrs, storageCrs); + } + @Override public void onValue(ModifiableContext context) { mapping @@ -399,7 +436,7 @@ private static String toTimeZone(String path, String value, ZoneId timeZone, boo } private static String toWkt( - Geometry geometry, Optional crsTransformer, EpsgCrs nativeCrs) { + Geometry geometry, Optional crsTransformer, EpsgCrs storageCrs) { if (crsTransformer.isPresent()) { geometry = @@ -416,7 +453,7 @@ private static String toWkt( } // TODO: functions from Dialect - String result = String.format("ST_GeomFromText('%s',%s)", wkt, nativeCrs.getCode()); + String result = String.format("ST_GeomFromText('%s',%s)", wkt, storageCrs.getCode()); if (geometry.getType() == GeometryType.POLYGON || geometry.getType() == GeometryType.MULTI_POLYGON) { 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 f80c710cf..df1406804 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 @@ -523,7 +523,18 @@ private Map getColumnOperations( ? SqlQueryColumn.Operation.WKB : SqlQueryColumn.Operation.WKT; - operations.put(op, new String[] {}); + // The storage CRS of the column (schema option `crs`) travels as the operation parameters + // (code and axis-order force); absent for columns in the provider's nativeCrs. Consumed by + // the write path (SRID of the geometry literal and transformation target). + String[] storageCrs = + propertySchema + .flatMap(FeatureSchema::getCrs) + .map( + crs -> + new String[] {String.valueOf(crs.getCode()), crs.getForceAxisOrder().name()}) + .orElse(new String[] {}); + + operations.put(op, storageCrs); if (propertySchema.isPresent() && propertySchema.get().isForcePolygonCCW()) { operations.put(SqlQueryColumn.Operation.FORCE_POLYGON_CCW, new String[] {}); diff --git a/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/app/SqlMutationSession.java b/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/app/SqlMutationSession.java index 0729a9697..dfcec7e51 100644 --- a/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/app/SqlMutationSession.java +++ b/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/app/SqlMutationSession.java @@ -1460,8 +1460,24 @@ private String encodeGeometryLiteral( + e.getMessage(), e); } - if (crs != null && !Objects.equals(crs, nativeCrs)) { - Optional transformer = crsTransformerFactory.getTransformer(crs, nativeCrs); + // The column's storage CRS (WKT/WKB operation parameters, set from the schema's `crs` + // option) determines the transformation target and the SRID; without the option this is the + // provider's nativeCrs, preserving the previous behavior. + List storageCrsParameters = + column.getOperationParameters( + column.hasOperation(SqlQueryColumn.Operation.WKB) + ? SqlQueryColumn.Operation.WKB + : SqlQueryColumn.Operation.WKT); + EpsgCrs storageCrs = + storageCrsParameters.isEmpty() + ? nativeCrs + : EpsgCrs.of( + Integer.parseInt(storageCrsParameters.get(0)), + storageCrsParameters.size() > 1 + ? EpsgCrs.Force.valueOf(storageCrsParameters.get(1)) + : EpsgCrs.Force.NONE); + if (crs != null && !Objects.equals(crs, storageCrs)) { + Optional transformer = crsTransformerFactory.getTransformer(crs, storageCrs); if (transformer.isPresent()) { geometry = geometry.accept( @@ -1480,7 +1496,7 @@ private String encodeGeometryLiteral( + e.getMessage(), e); } - String result = String.format("ST_GeomFromText('%s',%s)", wkt, nativeCrs.getCode()); + String result = String.format("ST_GeomFromText('%s',%s)", wkt, storageCrs.getCode()); if (geometry.getType() == GeometryType.POLYGON || geometry.getType() == GeometryType.MULTI_POLYGON) { result = String.format("ST_ForcePolygonCW(%s)", result); 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 74ca5039f..20b36d2cb 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 @@ -15,6 +15,7 @@ import com.google.common.base.Preconditions; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; +import de.ii.xtraplatform.crs.domain.EpsgCrs; import de.ii.xtraplatform.docs.DocIgnore; import de.ii.xtraplatform.entities.domain.maptobuilder.Buildable; import de.ii.xtraplatform.entities.domain.maptobuilder.BuildableMap; @@ -52,6 +53,7 @@ "valueType", "geometryType", "geometryTypes", + "crs", "objectType", "label", "alias", @@ -235,6 +237,22 @@ default Type getType() { @Override List getGeometryTypes(); + /** + * @langEn The storage CRS of this geometry property, overriding the provider's `nativeCrs`. Only + * relevant for properties with `type: GEOMETRY` in SQL feature providers, when a feature type + * stores positions in more than one CRS in separate geometry columns. Geometries are read + * from and written to the column in this CRS. + * @langDe Das Koordinatenreferenzsystem, in dem diese Geometrieeigenschaft gespeichert ist, + * abweichend vom `nativeCrs` des Providers. Nur relevant für Eigenschaften mit `type: + * GEOMETRY` in SQL-Feature-Providern, wenn eine Objektart Positionen in mehreren + * Koordinatenreferenzsystemen in separaten Geometriespalten speichert. Geometrien werden in + * diesem CRS aus der Spalte gelesen und in die Spalte geschrieben. + * @default null + * @since v4.8 + */ + @Override + Optional getCrs(); + /** * @langEn Optional name for an object type, used for example in JSON Schema. *

For properties that should be mapped as links, the value `Link` can still be used. This diff --git a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureTokenTransformerCoordinates.java b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureTokenTransformerCoordinates.java index 00e848b0c..1d3ef1f9f 100644 --- a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureTokenTransformerCoordinates.java +++ b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureTokenTransformerCoordinates.java @@ -31,6 +31,15 @@ public FeatureTokenTransformerCoordinates( public void onGeometry(ModifiableContext context) { Geometry geometry = context.geometry(); if (geometry != null) { + // A geometry property with its own storage CRS (schema option `crs`) carries a position + // as-is in a non-native CRS — possibly 1D/3D or a CRS the query pipeline cannot transform. + // It is passed through untouched; formats either reproduce it verbatim (GML position + // variants) or suppress it. + if (context.schema().flatMap(SchemaBase::getCrs).isPresent()) { + getDownstream().onGeometry(context); + return; + } + CoordinatesTransformation next = null; // A SECONDARY_GEOMETRY is always forced to WGS84 longitude/latitude, not the target CRS diff --git a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/SchemaBase.java b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/SchemaBase.java index d05407ca8..bc6a7a879 100644 --- a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/SchemaBase.java +++ b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/SchemaBase.java @@ -10,6 +10,7 @@ import com.fasterxml.jackson.annotation.JsonIgnore; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableSet; +import de.ii.xtraplatform.crs.domain.EpsgCrs; import de.ii.xtraplatform.docs.DocFile; import de.ii.xtraplatform.docs.DocIgnore; import de.ii.xtraplatform.geometries.domain.GeometryType; @@ -182,6 +183,8 @@ public static List allBut(Scope... scopes) { List getGeometryTypes(); + Optional getCrs(); + Optional getFormat(); Optional getRefType(); diff --git a/xtraplatform-geometries/src/main/java/de/ii/xtraplatform/geometries/domain/transform/EastingShift.java b/xtraplatform-geometries/src/main/java/de/ii/xtraplatform/geometries/domain/transform/EastingShift.java new file mode 100644 index 000000000..cc308ad17 --- /dev/null +++ b/xtraplatform-geometries/src/main/java/de/ii/xtraplatform/geometries/domain/transform/EastingShift.java @@ -0,0 +1,52 @@ +/* + * 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.geometries.domain.transform; + +import de.ii.xtraplatform.geometries.domain.PositionList.Interpolation; +import java.io.IOException; +import java.util.Arrays; +import java.util.Optional; +import java.util.OptionalInt; +import org.immutables.value.Value; + +/** + * Adds a constant offset to the easting (first ordinate) of every position. Used to convert between + * coordinate forms of the same CRS that differ only in the false easting — e.g. German Gauss-Krüger + * coordinates written without the zone prefix (false easting 500000) versus the EPSG definition + * with the zone-prefixed false easting (e.g. 3500000 for EPSG:5677): the difference of 3000000 is + * added on input and subtracted (negative difference) on output. + */ +@Value.Immutable +public abstract class EastingShift implements CoordinatesTransformation { + + @Value.Parameter + protected abstract double getDifference(); + + @Override + public double[] onCoordinates( + double[] coordinates, + int length, + int dimension, + Optional interpolation, + OptionalInt minNumberOfPositions) + throws IOException { + double[] shifted = Arrays.copyOf(coordinates, length); + for (int i = 0; i < length; i += dimension) { + // round to micrometres to cancel the floating-point noise the addition introduces + // (e.g. 3446104.62 - 3000000 = 446104.6200000001) + shifted[i] = Math.rint((shifted[i] + getDifference()) * 1e6) / 1e6; + } + + if (getNext().isEmpty()) { + return shifted; + } + return getNext() + .get() + .onCoordinates(shifted, length, dimension, interpolation, minNumberOfPositions); + } +} diff --git a/xtraplatform-geometries/src/test/groovy/de/ii/xtraplatform/geometries/domain/EastingShiftSpec.groovy b/xtraplatform-geometries/src/test/groovy/de/ii/xtraplatform/geometries/domain/EastingShiftSpec.groovy new file mode 100644 index 000000000..76dc0bf8f --- /dev/null +++ b/xtraplatform-geometries/src/test/groovy/de/ii/xtraplatform/geometries/domain/EastingShiftSpec.groovy @@ -0,0 +1,54 @@ +/* + * 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.geometries.domain + +import de.ii.xtraplatform.geometries.domain.transform.CoordinatesTransformer +import de.ii.xtraplatform.geometries.domain.transform.ImmutableEastingShift +import spock.lang.Specification + +class EastingShiftSpec extends Specification { + + def 'adds the difference to the easting only'() { + given: + // zone-prefix-less Gauss-Krueger easting (false easting 500000) shifted to the + // EPSG:5677 form (false easting 3500000) + def point = Point.of(446104.620d, 5551059.770d) + + when: + def shifted = point.accept(new CoordinatesTransformer( + ImmutableEastingShift.of(Optional.empty(), 3000000d))) as Point + + then: + shifted.getValue().getCoordinates() == [3446104.62d, 5551059.77d] as double[] + } + + def 'a negative difference reproduces the wire form without floating-point noise'() { + given: + // 3446104.62 - 3000000 in plain double arithmetic yields 446104.6200000001 + def point = Point.of(3446104.62d, 5551059.77d) + + when: + def shifted = point.accept(new CoordinatesTransformer( + ImmutableEastingShift.of(Optional.empty(), -3000000d))) as Point + + then: + shifted.getValue().getCoordinates() == [446104.62d, 5551059.77d] as double[] + } + + def 'z ordinates are untouched for 3D positions'() { + given: + def point = Point.of(446104.62d, 5551059.77d, 123.456d) + + when: + def shifted = point.accept(new CoordinatesTransformer( + ImmutableEastingShift.of(Optional.empty(), 3000000d))) as Point + + then: + shifted.getValue().getCoordinates() == [3446104.62d, 5551059.77d, 123.456d] as double[] + } +} From 1d521af10de69eb786e4c58bb4740dde6104c715 Mon Sep 17 00:00:00 2001 From: Clemens Portele Date: Tue, 14 Jul 2026 19:02:57 +0200 Subject: [PATCH 3/7] fix collection extents polluted by positions in alternative CRSs The mutation stats collector fed every geometry token into the spatial extent of the change events, interpreting position variants stored in a different CRS as native coordinates. Only the primary geometry now contributes, mirroring the read side, which computes the extent over the filter/primary geometry column. Also accumulate min/max across geometries instead of keeping only the last one. --- .../sql/domain/FeatureTokenStatsCollector.java | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/domain/FeatureTokenStatsCollector.java b/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/domain/FeatureTokenStatsCollector.java index 389ebdcf4..6cc878ce5 100644 --- a/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/domain/FeatureTokenStatsCollector.java +++ b/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/domain/FeatureTokenStatsCollector.java @@ -104,15 +104,19 @@ public void onValue(ModifiableContext context) @Override public void onGeometry(ModifiableContext context) { - if (Objects.nonNull(context.geometry())) { + // Only the primary geometry feeds the spatial extent: secondary geometry properties may + // store positions in a different CRS (position variants), which must not be interpreted in + // the native CRS. Mirrors the read side, where the extent is computed over the + // filter/primary geometry column. + if (Objects.nonNull(context.geometry()) && hasRole(context, Role.PRIMARY_GEOMETRY)) { Geometry value = context.geometry(); double[][] minMax = value.accept(new MinMaxDeriver()); if (minMax.length > 1 && minMax[0].length >= dim && minMax[1].length >= dim) { - this.xmin = minMax[0][0]; - this.xmax = minMax[1][0]; - this.ymin = minMax[0][1]; - this.ymax = minMax[1][1]; + this.xmin = xmin == null ? minMax[0][0] : Math.min(xmin, minMax[0][0]); + this.xmax = xmax == null ? minMax[1][0] : Math.max(xmax, minMax[1][0]); + this.ymin = ymin == null ? minMax[0][1] : Math.min(ymin, minMax[0][1]); + this.ymax = ymax == null ? minMax[1][1] : Math.max(ymax, minMax[1][1]); } } From e85eb6973a2f303eb745a5d2b76dcb64f4fdfaa0 Mon Sep 17 00:00:00 2001 From: Clemens Portele Date: Wed, 15 Jul 2026 17:03:36 +0200 Subject: [PATCH 4/7] features: declare position variants in the provider schema - FeatureSchema: a 'variants' declaration on the primary geometry property groups the sibling properties that store the position as recorded (crsProperty, verticalProperty, geometryProperties); the new roles ORIGINAL_GEOMETRY, ORIGINAL_HEIGHT and ORIGINAL_CRS_IDENTIFIER identify the group members (implied when not declared) and make them internal: read from the data source, but excluded from queryables, sortables, public schemas and regular property encoding - variant properties declare the identifiers of their reference systems (originalCrsIdentifiers), the CRS they are stored in (nativeCrs), the CRS of the recorded positions (originalCrs, e.g. the authority axis order of a geographic CRS whose stored coordinates follow the GIS axis order) and a falseEastingDifference for identifiers whose coordinates use a different false easting than the CRS - VariantsResolver validates the declarations and sets the implied roles at provider start - GML decoder: a position is routed by its verbatim srsName to the variant property that lists the identifier, interpreted in originalCrs and shifted to conform to nativeCrs; a 1D vertical position is captured as a scalar at the vertical property; the srsName is stored verbatim at the crsProperty; the routing metadata moved from the decoder input profile into the schema - the read pipeline restores originalCrs so downstream encodings receive the positions as recorded - EpsgCrs: new auxiliary attribute alternativeUri declares an identifier under which a CRS is known in a community, used when rendering and decoding CRS identifiers on the wire - new WithoutInternal visitor for deriving public schemas --- .../ii/xtraplatform/crs/domain/EpsgCrs.java | 13 ++ .../gml/domain/FeatureTokenDecoderGml.java | 147 ++++++++++--- .../FeatureTokenDecoderGmlInputProfile.java | 27 +-- .../gml/domain/GmlGeometryVariants.java | 14 +- ...TokenDecoderGmlPositionVariantsSpec.groovy | 32 +-- .../sql/app/FeatureProviderSqlFactory.java | 2 + .../features/sql/app/SqlMappingDeriver.java | 2 +- .../features/domain/FeatureSchema.java | 125 ++++++++++- .../features/domain/FeatureStreamImpl.java | 3 +- .../FeatureTokenTransformerCoordinates.java | 31 ++- .../features/domain/SchemaBase.java | 24 ++- .../features/domain/SchemaVariants.java | 70 ++++++ .../domain/transform/VariantsResolver.java | 189 +++++++++++++++++ .../domain/transform/WithoutInternal.java | 64 ++++++ .../transform/VariantsResolverSpec.groovy | 199 ++++++++++++++++++ 15 files changed, 861 insertions(+), 81 deletions(-) create mode 100644 xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/SchemaVariants.java create mode 100644 xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/transform/VariantsResolver.java create mode 100644 xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/transform/WithoutInternal.java create mode 100644 xtraplatform-features/src/test/groovy/de/ii/xtraplatform/features/domain/transform/VariantsResolverSpec.groovy diff --git a/xtraplatform-crs/src/main/java/de/ii/xtraplatform/crs/domain/EpsgCrs.java b/xtraplatform-crs/src/main/java/de/ii/xtraplatform/crs/domain/EpsgCrs.java index f38063750..2e6f612bc 100644 --- a/xtraplatform-crs/src/main/java/de/ii/xtraplatform/crs/domain/EpsgCrs.java +++ b/xtraplatform-crs/src/main/java/de/ii/xtraplatform/crs/domain/EpsgCrs.java @@ -100,6 +100,19 @@ default Force getForceAxisOrder() { @Value.Auxiliary Optional getUriOverride(); + /** + * An alternative identifier under which this CRS is known in a community (e.g. the AdV identifier + * {@code urn:adv:crs:ETRS89_UTM32} for EPSG:25832), declared on the entries of the {@code + * additionalCrs} option of the CRS building block. Unlike {@link #getUriOverride()} — which + * echoes the identifier a request used — the alternative URI is only used when a feature encoding + * renders CRS identifiers on the wire (e.g. the GML {@code srsName} with {@code srsNameStyle: + * TEMPLATE}) and when decoding such identifiers on input. Auxiliary, i.e., excluded from {@code + * equals()}/{@code hashCode()}: instances differing only in this attribute represent the same + * CRS. + */ + @Value.Auxiliary + Optional getAlternativeUri(); + @JsonIgnore @Value.Lazy default boolean isCompoundCrs() { diff --git a/xtraplatform-features-gml/src/main/java/de/ii/xtraplatform/features/gml/domain/FeatureTokenDecoderGml.java b/xtraplatform-features-gml/src/main/java/de/ii/xtraplatform/features/gml/domain/FeatureTokenDecoderGml.java index 02d6bbb14..1e2f97f9d 100644 --- a/xtraplatform-features-gml/src/main/java/de/ii/xtraplatform/features/gml/domain/FeatureTokenDecoderGml.java +++ b/xtraplatform-features-gml/src/main/java/de/ii/xtraplatform/features/gml/domain/FeatureTokenDecoderGml.java @@ -17,6 +17,7 @@ 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.SchemaVariants; 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; @@ -25,12 +26,15 @@ import java.util.ArrayDeque; import java.util.ArrayList; import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; 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.Set; import java.util.regex.Matcher; import java.util.regex.Pattern; import javax.xml.namespace.QName; @@ -148,6 +152,12 @@ public class FeatureTokenDecoderGml */ private final Map aliasFormPathByPropertyPath; + /** + * Per geometry property (keyed by its technical full path), the srsName routing built from the + * schema's {@code variants} declaration by {@link #buildGeometryVariants}. + */ + private final Map geometryVariantsCache = new HashMap<>(); + private int depth = 0; private boolean inFeature = false; private boolean featureProcessed = false; @@ -259,10 +269,10 @@ private static final class Frame { boolean valueWrapped; /** - * For GEOMETRY_PROPERTY: the position-variant routing configured for this property in {@link - * FeatureTokenDecoderGmlInputProfile#getGeometryVariants()}, or {@code null} when none is - * configured. Consulted by {@code emitGeometryOrVertical} together with the raw srsName the - * geometry decoder captured. + * For GEOMETRY_PROPERTY: the position-variant routing derived from the property's {@code + * variants} declaration in the schema (see {@code buildGeometryVariants}), or {@code null} when + * none is declared. Consulted by {@code emitGeometryOrVertical} together with the raw srsName + * the geometry decoder captured. */ GmlGeometryVariants geometryVariants; @@ -331,12 +341,14 @@ public FeatureTokenDecoderGml( this.defaultCrs = headerCrs.isPresent() ? headerCrs : Optional.of(storageCrs); this.nullValue = nullValue; this.inputProfile = inputProfile; - this.geometryDecoder = - new GeometryDecoderGml( - inputProfile.getSrsNameMappings(), - inputProfile.getGeometryVariants().values().stream() - .flatMap(variants -> variants.getVerticalBySrsName().keySet().stream()) - .collect(java.util.stream.Collectors.toUnmodifiableSet())); + // The reference systems of position variants are declared in the schema: each variant + // property lists its verbatim identifiers in originalCrsIdentifiers and the CRS they denote in + // originalCrs (defaulting to nativeCrs, the CRS the positions are stored in). The input + // profile only contributes the alternative URIs of the requestable CRSs (ordinary geometries). + Map srsNameMappings = new LinkedHashMap<>(inputProfile.getSrsNameMappings()); + Set verticalSrsNames = new HashSet<>(); + collectVariantReferenceSystems(featureSchema, srsNameMappings, verticalSrsNames); + this.geometryDecoder = new GeometryDecoderGml(srsNameMappings, verticalSrsNames); this.buffer = new StringBuilder(); List wrappers = new ArrayList<>(2); @@ -661,7 +673,7 @@ && resolveVariableNameDiscriminator( // path so {@code emitGeometryOrVertical} routes the resulting Geometry (or 1D position) to // the right downstream slot. Frame frame = Frame.geometryProperty(prop, segment, segmentPathDepth); - frame.geometryVariants = geometryVariantsFor(prop); + frame.geometryVariants = geometryVariantsFor(prop, lookupOwner); frames.push(frame); Optional> optGeometry = geometryDecoder.decode(parser, crs, srsDimension); boolean waiting = geometryDecoder.isWaitingForInput(); @@ -1304,21 +1316,107 @@ private void rejectXsiType() { } /** - * Mirrors {@link #isValueWrapped}: the configuration is keyed by the property's technical full - * path, but a YAML config keyed by the alias-form path is recognised as well. + * Builds the srsName-keyed routing for a geometry property from its schema-level {@code variants} + * declaration: every {@code originalCrsIdentifiers} entry of a variant sibling routes to that + * sibling (together with the sibling's {@code falseEastingDifference}); every identifier of the + * vertical sibling routes to the vertical property. Cached per geometry property. */ - private GmlGeometryVariants geometryVariantsFor(FeatureSchema prop) { - Map geometryVariants = inputProfile.getGeometryVariants(); - if (geometryVariants.isEmpty()) { + private GmlGeometryVariants geometryVariantsFor(FeatureSchema prop, FeatureSchema lookupOwner) { + if (prop.getVariants().isEmpty()) { return null; } - String path = prop.getFullPathAsString(); - GmlGeometryVariants variants = geometryVariants.get(path); - if (variants != null) { - return variants; + return geometryVariantsCache.computeIfAbsent( + prop.getFullPathAsString(), ignore -> buildGeometryVariants(prop, lookupOwner)); + } + + private GmlGeometryVariants buildGeometryVariants(FeatureSchema prop, FeatureSchema lookupOwner) { + SchemaVariants variants = prop.getVariants().get(); + Map bySrsName = new LinkedHashMap<>(); + + Map shiftBySrsName = new LinkedHashMap<>(); + variants.getGeometryProperties().stream() + .map(name -> siblingProperty(lookupOwner, name)) + .filter(Objects::nonNull) + .forEach( + sibling -> + sibling + .getOriginalCrsIdentifiers() + .forEach( + identifier -> { + bySrsName.put(identifier, sibling.getName()); + sibling + .getFalseEastingDifference() + .filter(difference -> difference != 0) + .ifPresent(difference -> shiftBySrsName.put(identifier, difference)); + })); + + Map verticalBySrsName = new LinkedHashMap<>(); + variants + .getVerticalProperty() + .ifPresent( + verticalProperty -> { + FeatureSchema sibling = siblingProperty(lookupOwner, verticalProperty); + if (sibling != null) { + sibling + .getOriginalCrsIdentifiers() + .forEach(identifier -> verticalBySrsName.put(identifier, verticalProperty)); + } + }); + + return ImmutableGmlGeometryVariants.builder() + .bySrsName(bySrsName) + .shiftBySrsName(shiftBySrsName) + .verticalBySrsName(verticalBySrsName) + .srsNameProperty(variants.getCrsProperty()) + .build(); + } + + private FeatureSchema siblingProperty(FeatureSchema owner, String name) { + return owner == null + ? null + : owner.getProperties().stream() + .filter(prop -> prop.getName().equals(name)) + .findFirst() + .orElse(null); + } + + private static void collectVariantReferenceSystems( + FeatureSchema schema, Map srsNameMappings, Set verticalSrsNames) { + for (FeatureSchema child : schema.getProperties()) { + child + .getVariants() + .ifPresent( + variants -> { + variants + .getGeometryProperties() + .forEach( + name -> { + FeatureSchema sibling = + schema.getProperties().stream() + .filter(prop -> prop.getName().equals(name)) + .findFirst() + .orElse(null); + if (sibling != null && sibling.getNativeCrs().isPresent()) { + EpsgCrs wireCrs = + sibling.getOriginalCrs().orElse(sibling.getNativeCrs().get()); + sibling + .getOriginalCrsIdentifiers() + .forEach( + identifier -> srsNameMappings.putIfAbsent(identifier, wireCrs)); + } + }); + variants + .getVerticalProperty() + .flatMap( + name -> + schema.getProperties().stream() + .filter(prop -> prop.getName().equals(name)) + .findFirst()) + .ifPresent( + sibling -> verticalSrsNames.addAll(sibling.getOriginalCrsIdentifiers())); + }); + collectVariantReferenceSystems(child, srsNameMappings, verticalSrsNames); } - String aliasPath = aliasFormPathByPropertyPath.get(path); - return aliasPath != null ? geometryVariants.get(aliasPath) : null; } /** @@ -1338,9 +1436,8 @@ private void emitGeometryOrVertical(Optional> optGeometry) { // Coordinates whose srsName declares a false-easting difference (e.g. Gauss-Krüger without // the zone prefix) are shifted so the emitted geometry conforms to the mapped CRS. - if (optGeometry.isPresent() && rawSrsName != null) { - Double falseEastingDifference = - inputProfile.getSrsNameFalseEastingDifferences().get(rawSrsName); + if (optGeometry.isPresent() && rawSrsName != null && variants != null) { + Double falseEastingDifference = variants.getShiftBySrsName().get(rawSrsName); if (falseEastingDifference != null && falseEastingDifference != 0) { optGeometry = Optional.of( 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 index 6d183c25e..961aa0a2f 100644 --- 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 @@ -23,22 +23,15 @@ 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. + * Reverse-mapping from the {@code srsName} value seen on an ordinary geometry to the resolved + * {@link EpsgCrs} — the {@code alternativeUri} declarations of the requestable CRSs (CRS building + * block). Required for input shaped after ALKIS NAS, which uses AdV URN forms such as {@code + * urn:adv:crs:ETRS89_UTM32} that the built-in EPSG / OGC URN parser cannot resolve. The + * identifiers of position variants are not part of this map; they are declared in the {@code + * FeatureSchema} ({@code originalCrsIdentifiers}). */ Map getSrsNameMappings(); - /** - * Per wire {@code srsName}, the difference between the false easting of the mapped CRS and the - * false easting used by coordinates carrying that srsName (e.g. 3000000 for German Gauss-Krüger - * coordinates written without the zone prefix, mapped to a zone-prefixed EPSG CRS). Added to the - * easting (first ordinate) of every decoded position so the emitted coordinates conform to the - * mapped CRS; the encoder subtracts it on output. Only srsNames with a non-zero difference are - * present. - */ - Map getSrsNameFalseEastingDifferences(); - /** * 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. @@ -186,14 +179,6 @@ default String getFeatureMemberElementName() { */ Set getObjectTypeSuffixedProperties(); - /** - * Reverse of {@code GmlConfiguration#positionVariants}: per geometry property — keyed by the - * property's technical full path; the alias-form path is honored as well, mirroring {@link - * #getValueWrap()} — the routing of positions in non-native CRSs to CRS-specific sibling - * properties. See {@link GmlGeometryVariants}. - */ - Map getGeometryVariants(); - static FeatureTokenDecoderGmlInputProfile empty() { return ImmutableFeatureTokenDecoderGmlInputProfile.builder().build(); } diff --git a/xtraplatform-features-gml/src/main/java/de/ii/xtraplatform/features/gml/domain/GmlGeometryVariants.java b/xtraplatform-features-gml/src/main/java/de/ii/xtraplatform/features/gml/domain/GmlGeometryVariants.java index d0de2eea5..610718dc8 100644 --- a/xtraplatform-features-gml/src/main/java/de/ii/xtraplatform/features/gml/domain/GmlGeometryVariants.java +++ b/xtraplatform-features-gml/src/main/java/de/ii/xtraplatform/features/gml/domain/GmlGeometryVariants.java @@ -20,8 +20,11 @@ * its own schema property; the verbatim srsName is stored alongside so the encoder can reproduce * it. * - *

All property values name sibling properties of the geometry property this instance is - * configured for (same parent object, usually the feature type itself). + *

All property values name sibling properties of the geometry property this instance was built + * for (same parent object, usually the feature type itself). Instances are derived at decode time + * from the {@code variants} declaration on the geometry property in the {@code FeatureSchema} and + * the {@code originalCrsIdentifiers} / {@code falseEastingDifference} of the referenced sibling + * properties. */ @Value.Immutable public interface GmlGeometryVariants { @@ -33,6 +36,13 @@ public interface GmlGeometryVariants { */ Map getBySrsName(); + /** + * Wire {@code srsName} → the {@code falseEastingDifference} of the variant property it routes to. + * Added to the easting of the decoded position so the emitted geometry conforms to the storage + * CRS. Only srsNames with a non-zero difference are present. + */ + Map getShiftBySrsName(); + /** * Wire {@code srsName} → scalar (FLOAT) property for 1D positions. The single coordinate value is * emitted verbatim at the mapped property; no geometry is built. diff --git a/xtraplatform-features-gml/src/test/groovy/de/ii/xtraplatform/features/gml/domain/FeatureTokenDecoderGmlPositionVariantsSpec.groovy b/xtraplatform-features-gml/src/test/groovy/de/ii/xtraplatform/features/gml/domain/FeatureTokenDecoderGmlPositionVariantsSpec.groovy index c31a45c2a..cbecb12d9 100644 --- a/xtraplatform-features-gml/src/test/groovy/de/ii/xtraplatform/features/gml/domain/FeatureTokenDecoderGmlPositionVariantsSpec.groovy +++ b/xtraplatform-features-gml/src/test/groovy/de/ii/xtraplatform/features/gml/domain/FeatureTokenDecoderGmlPositionVariantsSpec.groovy @@ -13,6 +13,7 @@ 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.ImmutableSchemaVariants import de.ii.xtraplatform.features.domain.SchemaBase import de.ii.xtraplatform.features.domain.SchemaMapping import de.ii.xtraplatform.features.domain.pipeline.FeatureEventHandlerSimple @@ -25,10 +26,11 @@ import spock.lang.Specification import javax.xml.namespace.QName /** - * Position-variant routing (see {@link GmlGeometryVariants}): positions in a foreign CRS are - * routed by their verbatim srsName to a variant property (with the configured false-easting - * difference applied), 1D positions to a scalar property, and the srsName itself to a companion - * property. Positions without a routed srsName take the normal path. + * Position-variant routing (see {@link GmlGeometryVariants}), driven entirely by the schema: + * positions are routed by their verbatim srsName to the variant property that lists the srsName in + * its originalCrsIdentifiers (with the property's falseEastingDifference applied), 1D positions to + * the vertical property, and the srsName itself to the crsProperty. Positions without a routed + * srsName take the normal path. */ class FeatureTokenDecoderGmlPositionVariantsSpec extends Specification { @@ -57,28 +59,30 @@ class FeatureTokenDecoderGmlPositionVariantsSpec extends Specification { .putProperties2("pos_gk3", new ImmutableFeatureSchema.Builder() .sourcePath("position_gk3") .type(SchemaBase.Type.GEOMETRY) - .geometryType(GeometryType.POINT)) + .geometryType(GeometryType.POINT) + .nativeCrs(EpsgCrs.of(5677)) + .addOriginalCrsIdentifiers(GK3_HE100) + .falseEastingDifference(3000000d)) .putProperties2("pos_h", new ImmutableFeatureSchema.Builder() .sourcePath("position_h") - .type(SchemaBase.Type.FLOAT)) + .type(SchemaBase.Type.FLOAT) + .addOriginalCrsIdentifiers(DHHN92)) .putProperties2("geo", new ImmutableFeatureSchema.Builder() .sourcePath("position") .type(SchemaBase.Type.GEOMETRY) .geometryType(GeometryType.POINT) - .alias("position")) + .alias("position") + .variants(new ImmutableSchemaVariants.Builder() + .crsProperty("pos_srs") + .verticalProperty("pos_h") + .addGeometryProperties("pos_gk3") + .build())) .build() } static FeatureTokenDecoderGmlInputProfile profile() { ImmutableFeatureTokenDecoderGmlInputProfile.builder() .useAlias(true) - .putSrsNameMappings(GK3_HE100, EpsgCrs.of(5677)) - .putSrsNameFalseEastingDifferences(GK3_HE100, 3000000d) - .putGeometryVariants("geo", ImmutableGmlGeometryVariants.builder() - .putBySrsName(GK3_HE100, "pos_gk3") - .putVerticalBySrsName(DHHN92, "pos_h") - .srsNameProperty("pos_srs") - .build()) .build() } diff --git a/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/app/FeatureProviderSqlFactory.java b/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/app/FeatureProviderSqlFactory.java index ce4a86248..b15464e85 100644 --- a/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/app/FeatureProviderSqlFactory.java +++ b/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/app/FeatureProviderSqlFactory.java @@ -33,6 +33,7 @@ import de.ii.xtraplatform.features.domain.transform.FeatureRefResolver; import de.ii.xtraplatform.features.domain.transform.ImplicitMappingResolver; import de.ii.xtraplatform.features.domain.transform.LabelTemplateResolver; +import de.ii.xtraplatform.features.domain.transform.VariantsResolver; import de.ii.xtraplatform.features.sql.domain.ConnectionInfoSql; import de.ii.xtraplatform.features.sql.domain.FeatureProviderSql; import de.ii.xtraplatform.features.sql.domain.FeatureProviderSqlData; @@ -198,6 +199,7 @@ public EntityData hydrateData(EntityData entityData) { new FeatureRefEmbedder(data.getId()), new FeatureRefResolver(connectors), new ImplicitMappingResolver(), + new VariantsResolver(), new ConstantsResolver(), new LabelTemplateResolver(data.getLabelTemplate()), new DefaultRolesResolver(), 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 df1406804..0c492c4d3 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 @@ -528,7 +528,7 @@ private Map getColumnOperations( // the write path (SRID of the geometry literal and transformation target). String[] storageCrs = propertySchema - .flatMap(FeatureSchema::getCrs) + .flatMap(FeatureSchema::getNativeCrs) .map( crs -> new String[] {String.valueOf(crs.getCode()), crs.getForceAxisOrder().name()}) 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 20b36d2cb..ba95389b4 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 @@ -238,20 +238,105 @@ default Type getType() { List getGeometryTypes(); /** - * @langEn The storage CRS of this geometry property, overriding the provider's `nativeCrs`. Only - * relevant for properties with `type: GEOMETRY` in SQL feature providers, when a feature type - * stores positions in more than one CRS in separate geometry columns. Geometries are read - * from and written to the column in this CRS. - * @langDe Das Koordinatenreferenzsystem, in dem diese Geometrieeigenschaft gespeichert ist, - * abweichend vom `nativeCrs` des Providers. Nur relevant für Eigenschaften mit `type: - * GEOMETRY` in SQL-Feature-Providern, wenn eine Objektart Positionen in mehreren + * @langEn The CRS in which this geometry property is stored in the provider, overriding the + * provider's `nativeCrs`. Only relevant for properties with `type: GEOMETRY` in SQL feature + * providers, when a feature type stores positions in more than one CRS in separate geometry + * columns. Geometries are read from and written to the column in this CRS. Note that in + * PostGIS the axis order of stored coordinates always follows the GIS convention (longitude + * or easting first), so the CRS of a stored geographic position is typically the `LON_LAT` + * variant (e.g. `{code: 4937, forceAxisOrder: LON_LAT}`). + * @langDe Das Koordinatenreferenzsystem, in dem diese Geometrieeigenschaft im Provider + * gespeichert ist, abweichend vom `nativeCrs` des Providers. Nur relevant für Eigenschaften + * mit `type: GEOMETRY` in SQL-Feature-Providern, wenn eine Objektart Positionen in mehreren * Koordinatenreferenzsystemen in separaten Geometriespalten speichert. Geometrien werden in - * diesem CRS aus der Spalte gelesen und in die Spalte geschrieben. + * diesem CRS aus der Spalte gelesen und in die Spalte geschrieben. In PostGIS folgt die + * Achsenreihenfolge gespeicherter Koordinaten immer der GIS-Konvention (Länge bzw. Rechtswert + * zuerst), das CRS einer gespeicherten geographischen Position ist daher typischerweise die + * `LON_LAT`-Variante (z.B. `{code: 4937, forceAxisOrder: LON_LAT}`). * @default null * @since v4.8 */ @Override - Optional getCrs(); + Optional getNativeCrs(); + + /** + * @langEn The CRS of the recorded positions that the `originalCrsIdentifiers` of this property + * denote, if it differs from `nativeCrs`. Only relevant for properties with the role + * `ORIGINAL_GEOMETRY`. Positions are transformed between `originalCrs` and `nativeCrs` when + * they are written to and read from the provider; feature encodings that represent the + * original positions (e.g. GML or JSON-FG with the `crs-original` profile) receive them in + * this CRS. Example: positions recorded in `urn:adv:crs:ETRS89_Lat-Lon-h` (EPSG:4937, + * latitude first) that are stored in the `LON_LAT` variant of EPSG:4937. + * @langDe Das Koordinatenreferenzsystem der erfassten Positionen, die die + * `originalCrsIdentifiers` dieser Eigenschaft bezeichnen, sofern es vom `nativeCrs` abweicht. + * Nur relevant für Eigenschaften mit der Rolle `ORIGINAL_GEOMETRY`. Positionen werden beim + * Schreiben in den und Lesen aus dem Provider zwischen `originalCrs` und `nativeCrs` + * transformiert; Feature-Kodierungen, die die ursprünglichen Positionen darstellen (z.B. GML + * oder JSON-FG mit dem Profil `crs-original`), erhalten sie in diesem CRS. Beispiel: in + * `urn:adv:crs:ETRS89_Lat-Lon-h` (EPSG:4937, Breite zuerst) erfasste Positionen, die in der + * `LON_LAT`-Variante von EPSG:4937 gespeichert sind. + * @default nativeCrs + * @since v4.8 + */ + Optional getOriginalCrs(); + + /** + * @langEn Declares sibling properties that store the position of this geometry property in other + * reference systems, for feature types that store the same logical position in one of several + * CRSs — including CRSs that cannot be expressed as a storage CRS (realizations that map to + * the same coordinate reference system, or 1D vertical reference systems). Only relevant for + * properties with `type: GEOMETRY` in SQL feature providers. All referenced properties are + * implicitly `internal`. See [Position Variants](#position-variants). + * @langDe Deklariert Nachbareigenschaften, die die Position dieser Geometrieeigenschaft in + * anderen Referenzsystemen speichern, für Objektarten, die dieselbe logische Position in + * einem von mehreren Koordinatenreferenzsystemen speichern — einschließlich Systemen, die + * nicht als Speicher-CRS ausgedrückt werden können (Realisierungen, die auf dasselbe + * Koordinatenreferenzsystem abgebildet werden, oder eindimensionale Höhenreferenzsysteme). + * Nur relevant für Eigenschaften mit `type: GEOMETRY` in SQL-Feature-Providern. Alle + * referenzierten Eigenschaften sind implizit `internal`. Siehe + * [Positionsvarianten](#position-variants). + * @default null + * @since v4.8 + */ + Optional getVariants(); + + /** + * @langEn The verbatim identifiers of the reference systems that are stored in this property. + * Only relevant for properties with the role `ORIGINAL_GEOMETRY` (2D/3D position variants; + * positions carrying one of these identifiers are routed to this property) or + * `ORIGINAL_HEIGHT` (1D position variants; the identifiers of the vertical reference + * systems). + * @langDe Die unveränderten Kennungen der Referenzsysteme, die in dieser Eigenschaft gespeichert + * werden. Nur relevant für Eigenschaften mit der Rolle `ORIGINAL_GEOMETRY` + * (2D/3D-Positionsvarianten; Positionen mit einer dieser Kennungen werden dieser Eigenschaft + * zugeordnet) oder `ORIGINAL_HEIGHT` (1D-Positionsvarianten; die Kennungen der + * Höhenreferenzsysteme). + * @default [] + * @since v4.8 + */ + List getOriginalCrsIdentifiers(); + + /** + * @langEn The difference between the false easting of the CRS the positions are stored in + * (`nativeCrs`) and the false easting used by coordinates carrying one of the + * `originalCrsIdentifiers` of this property. Only relevant for properties with the role + * `ORIGINAL_GEOMETRY`. When non-zero, the difference is added to the easting (the first + * ordinate) on input and subtracted on output, so the stored coordinates conform to + * `nativeCrs`. Example: German Gauss-Krüger coordinates written without the zone prefix use a + * false easting of 500000, while EPSG:5677 (zone 3, E-N) defines 3500000 — the difference is + * 3000000. + * @langDe Die Differenz zwischen dem False Easting des Speicher-CRS (`nativeCrs`) und dem False + * Easting der Koordinaten, die eine der `originalCrsIdentifiers` dieser Eigenschaft + * verwenden. Nur relevant für Eigenschaften mit der Rolle `ORIGINAL_GEOMETRY`. Bei einem Wert + * ungleich 0 wird die Differenz beim Einlesen zum Rechtswert (der ersten Ordinate) addiert + * und bei der Ausgabe subtrahiert, sodass die gespeicherten Koordinaten dem Speicher-CRS + * entsprechen. Beispiel: Gauß-Krüger-Koordinaten ohne Zonenkennzahl verwenden ein False + * Easting von 500000, EPSG:5677 (Zone 3, E-N) definiert 3500000 — die Differenz beträgt + * 3000000. + * @default null + * @since v4.8 + */ + Optional getFalseEastingDifference(); /** * @langEn Optional name for an object type, used for example in JSON Schema. @@ -344,6 +429,26 @@ default Type getType() { @Override Set getExcludedScopes(); + /** + * Whether the property is internal: read from the data source and available to feature encodings + * with special handling for the property, but not part of any public schema (returnables, + * receivables, queryables, sortables) and not encoded as a regular property in feature + * representations. Derived from the role: the members of a position-variants group ({@code + * ORIGINAL_GEOMETRY}, {@code ORIGINAL_HEIGHT}, {@code ORIGINAL_CRS_IDENTIFIER}) are internal. + */ + @JsonIgnore + @Value.Derived + @Value.Auxiliary + default boolean isInternal() { + return getRole() + .filter( + role -> + role == Role.ORIGINAL_GEOMETRY + || role == Role.ORIGINAL_HEIGHT + || role == Role.ORIGINAL_CRS_IDENTIFIER) + .isPresent(); + } + /** * @langEn For a property of type `FEATURE_REF` or `FEATURE_REF_ARRAY` where the target is always * a feature of another type in the same provider, declare the feature type identifier in @@ -399,6 +504,7 @@ default Type getType() { default boolean queryable() { return !isObject() && !isMultiSource() + && !isInternal() && !Objects.equals(getType(), Type.UNKNOWN) && !getExcludedScopes().contains(Scope.QUERYABLE); } @@ -412,6 +518,7 @@ default boolean sortable() { && !isObject() && !isArray() && !isMultiSource() + && !isInternal() && !Objects.equals(getType(), Type.BOOLEAN) && !Objects.equals(getType(), Type.UNKNOWN) && !getExcludedScopes().contains(Scope.SORTABLE); 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 8d8c99fd2..e9feca082 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 @@ -376,7 +376,8 @@ private FeatureTokenSource getFeatureTokenSourceTransformed( data.getNativeCrs().orElse(OgcCrs.CRS84), nativeCrsIs3d ? OgcCrs.CRS84h : OgcCrs.CRS84)); FeatureTokenTransformerCoordinates coordinatesMapper = - new FeatureTokenTransformerCoordinates(crsTransformer, crsTransformerWgs84); + new FeatureTokenTransformerCoordinates( + crsTransformer, crsTransformerWgs84, crsTransformerFactory); tokenSourceTransformed = tokenSourceTransformed.via(coordinatesMapper); } if (stepClean) { diff --git a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureTokenTransformerCoordinates.java b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureTokenTransformerCoordinates.java index 1d3ef1f9f..96917cf21 100644 --- a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureTokenTransformerCoordinates.java +++ b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureTokenTransformerCoordinates.java @@ -8,6 +8,8 @@ package de.ii.xtraplatform.features.domain; import de.ii.xtraplatform.crs.domain.CrsTransformer; +import de.ii.xtraplatform.crs.domain.CrsTransformerFactory; +import de.ii.xtraplatform.crs.domain.EpsgCrs; import de.ii.xtraplatform.geometries.domain.Geometry; import de.ii.xtraplatform.geometries.domain.transform.CoordinatesTransformation; import de.ii.xtraplatform.geometries.domain.transform.CoordinatesTransformer; @@ -19,23 +21,40 @@ public class FeatureTokenTransformerCoordinates extends FeatureTokenTransformer private final Optional crsTransformerTargetCrs; private final Optional crsTransformerWgs84; + private final CrsTransformerFactory crsTransformerFactory; public FeatureTokenTransformerCoordinates( Optional crsTransformerTargetCrs, - Optional crsTransformerWgs84) { + Optional crsTransformerWgs84, + CrsTransformerFactory crsTransformerFactory) { this.crsTransformerTargetCrs = crsTransformerTargetCrs; this.crsTransformerWgs84 = crsTransformerWgs84; + this.crsTransformerFactory = crsTransformerFactory; } @Override public void onGeometry(ModifiableContext context) { Geometry geometry = context.geometry(); if (geometry != null) { - // A geometry property with its own storage CRS (schema option `crs`) carries a position - // as-is in a non-native CRS — possibly 1D/3D or a CRS the query pipeline cannot transform. - // It is passed through untouched; formats either reproduce it verbatim (GML position - // variants) or suppress it. - if (context.schema().flatMap(SchemaBase::getCrs).isPresent()) { + // A geometry property that is stored in its own CRS (schema option `nativeCrs`) carries a + // position as-is in a non-native CRS — possibly 1D/3D or a CRS the query pipeline cannot + // transform. It is not transformed to the target CRS; when the property declares an + // `originalCrs` (the CRS of the recorded positions, e.g. the authority axis order of a + // geographic CRS whose stored coordinates follow the GIS axis order), the position is + // transformed back to it, so downstream formats reproduce the recorded position verbatim. + Optional propertyCrs = context.schema().flatMap(SchemaBase::getNativeCrs); + if (propertyCrs.isPresent()) { + Optional originalCrs = context.schema().flatMap(FeatureSchema::getOriginalCrs); + if (originalCrs.isPresent() && !originalCrs.get().equals(propertyCrs.get())) { + Optional toOriginal = + crsTransformerFactory.getTransformer(propertyCrs.get(), originalCrs.get()); + if (toOriginal.isPresent()) { + context.setGeometry( + geometry.accept( + new CoordinatesTransformer( + ImmutableCrsTransform.of(Optional.empty(), toOriginal.get())))); + } + } getDownstream().onGeometry(context); return; } diff --git a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/SchemaBase.java b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/SchemaBase.java index bc6a7a879..0d879cfa7 100644 --- a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/SchemaBase.java +++ b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/SchemaBase.java @@ -50,7 +50,27 @@ enum Role { SECONDARY_GEOMETRY, FILTER_GEOMETRY, EMBEDDED_FEATURE, - FEATURE_REF; + FEATURE_REF, + /** + * A 2D/3D position variant of a geometry property with a {@code variants} declaration: the + * position as recorded, in a reference system other than the CRS of the main geometry property. + * The property declares the CRS it is stored in in {@code nativeCrs}, optionally the CRS of the + * recorded positions in {@code originalCrs}, and the reference-system identifiers that route to + * it in {@code originalCrsIdentifiers}. Implicitly {@code internal}. + */ + ORIGINAL_GEOMETRY, + /** + * The single coordinate of a position variant in a 1D vertical reference system (see the {@code + * verticalProperty} of a {@code variants} declaration). The property declares the identifiers + * of the vertical reference systems that route to it in {@code originalCrsIdentifiers}. + * Implicitly {@code internal}. + */ + ORIGINAL_HEIGHT, + /** + * The verbatim identifier of the reference system of a position variant (see the {@code + * crsProperty} of a {@code variants} declaration). Implicitly {@code internal}. + */ + ORIGINAL_CRS_IDENTIFIER; private final String linkRelation; @@ -183,7 +203,7 @@ public static List allBut(Scope... scopes) { List getGeometryTypes(); - Optional getCrs(); + Optional getNativeCrs(); Optional getFormat(); diff --git a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/SchemaVariants.java b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/SchemaVariants.java new file mode 100644 index 000000000..065bed49b --- /dev/null +++ b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/SchemaVariants.java @@ -0,0 +1,70 @@ +/* + * 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 com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import java.util.List; +import java.util.Optional; +import org.immutables.value.Value; + +/** + * @langEn Some feature types store the same logical position in one of several CRSs — including + * CRSs that cannot be expressed as the storage CRS of the property (realizations that map to + * the same coordinate reference system, or 1D vertical reference systems). Each variant is + * stored in its own property; `variants` on the main geometry property declares which sibling + * properties hold the variants. All referenced properties must be siblings of the geometry + * property (properties of the same object) and are implicitly `internal`. + * @langDe Manche Objektarten speichern dieselbe logische Position in einem von mehreren + * Koordinatenreferenzsystemen — einschließlich Systemen, die nicht als Speicher-CRS der + * Eigenschaft ausgedrückt werden können (Realisierungen, die auf dasselbe + * Koordinatenreferenzsystem abgebildet werden, oder eindimensionale Höhenreferenzsysteme). Jede + * Variante wird in einer eigenen Eigenschaft gespeichert; `variants` an der + * Haupt-Geometrieeigenschaft deklariert, welche Nachbareigenschaften die Varianten enthalten. + * Alle referenzierten Eigenschaften müssen Nachbareigenschaften der Geometrieeigenschaft sein + * (Eigenschaften desselben Objekts) und sind implizit `internal`. + */ +@Value.Immutable +@Value.Style(builder = "new", deepImmutablesDetection = true) +@JsonDeserialize(builder = ImmutableSchemaVariants.Builder.class) +public interface SchemaVariants { + + /** + * @langEn A `STRING` property that stores the URI of the reference system of the position, + * verbatim as received (for example, the `srsName` of a GML geometry). + * @langDe Eine `STRING`-Eigenschaft, die die URI des Referenzsystems der Position unverändert + * speichert (zum Beispiel den `srsName` einer GML-Geometrie). + * @default null + * @since v4.8 + */ + Optional getCrsProperty(); + + /** + * @langEn A `FLOAT` property that stores the single coordinate of a position in a 1D vertical + * reference system. + * @langDe Eine `FLOAT`-Eigenschaft, die die einzelne Koordinate einer Position in einem + * eindimensionalen Höhenreferenzsystem speichert. + * @default null + * @since v4.8 + */ + Optional getVerticalProperty(); + + /** + * @langEn `GEOMETRY` properties that store 2D/3D positions in a CRS other than the CRS of the + * primary geometry property. Each referenced property must declare the CRS it is stored in in + * `nativeCrs` and the identifiers of its reference systems in `originalCrsIdentifiers`; + * positions are routed to the variant property that lists the identifier. + * @langDe `GEOMETRY`-Eigenschaften, die 2D/3D-Positionen in einem anderen + * Koordinatenreferenzsystem als dem der primären Geometrieeigenschaft speichern. Jede + * referenzierte Eigenschaft muss das CRS, in dem sie gespeichert ist, in `nativeCrs` sowie + * die Kennungen ihrer Referenzsysteme in `originalCrsIdentifiers` deklarieren; Positionen + * werden der Variante zugeordnet, die die Kennung auflistet. + * @default [] + * @since v4.8 + */ + List getGeometryProperties(); +} diff --git a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/transform/VariantsResolver.java b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/transform/VariantsResolver.java new file mode 100644 index 000000000..458932918 --- /dev/null +++ b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/transform/VariantsResolver.java @@ -0,0 +1,189 @@ +/* + * 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 com.google.common.base.Preconditions; +import de.ii.xtraplatform.features.domain.FeatureSchema; +import de.ii.xtraplatform.features.domain.ImmutableFeatureSchema; +import de.ii.xtraplatform.features.domain.SchemaBase.Role; +import de.ii.xtraplatform.features.domain.SchemaBase.Type; +import de.ii.xtraplatform.features.domain.SchemaVariants; +import de.ii.xtraplatform.features.domain.TypesResolver; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Stream; + +/** + * Validates {@code variants} declarations on geometry properties and completes the referenced + * sibling properties: each member of a variants group carries its role in the group ({@code + * ORIGINAL_CRS_IDENTIFIER}, {@code ORIGINAL_HEIGHT}, {@code ORIGINAL_GEOMETRY}) unless declared + * explicitly — the roles make the members internal. Runs once per provider start, after fragment + * resolution, so a {@code variants} block that arrives via a schema fragment is handled the same + * way as an inline one. + */ +public class VariantsResolver implements TypesResolver { + + @Override + public boolean needsResolving( + FeatureSchema property, boolean isFeature, boolean isInConcat, boolean isInCoalesce) { + Map impliedRoles = impliedRoles(property); + return impliedRoles.keySet().stream() + .anyMatch( + name -> { + FeatureSchema sibling = property.getPropertyMap().get(name); + return sibling == null || sibling.getRole().isEmpty(); + }); + } + + @Override + public FeatureSchema resolve(FeatureSchema property, List parents) { + property.getProperties().stream() + .filter(prop -> prop.getVariants().isPresent()) + .forEach(prop -> validate(property, prop)); + + Map impliedRoles = impliedRoles(property); + + ImmutableFeatureSchema.Builder builder = + new ImmutableFeatureSchema.Builder().from(property).propertyMap(new HashMap<>()); + + property + .getPropertyMap() + .forEach( + (name, prop) -> { + Role impliedRole = impliedRoles.get(prop.getName()); + if (impliedRole != null && prop.getRole().isEmpty()) { + builder.putPropertyMap( + name, + new ImmutableFeatureSchema.Builder().from(prop).role(impliedRole).build()); + } else { + builder.putPropertyMap(name, prop); + } + }); + + return builder.build(); + } + + /** The group members referenced from any {@code variants} declaration, with the implied role. */ + private Map impliedRoles(FeatureSchema parent) { + Map implied = new LinkedHashMap<>(); + parent.getProperties().stream() + .flatMap(prop -> prop.getVariants().stream()) + .forEach( + variants -> { + variants + .getCrsProperty() + .ifPresent(name -> implied.put(name, Role.ORIGINAL_CRS_IDENTIFIER)); + variants + .getVerticalProperty() + .ifPresent(name -> implied.put(name, Role.ORIGINAL_HEIGHT)); + variants + .getGeometryProperties() + .forEach(name -> implied.put(name, Role.ORIGINAL_GEOMETRY)); + }); + return implied; + } + + private Stream referencedProperties(SchemaVariants variants) { + return Stream.concat( + Stream.concat(variants.getCrsProperty().stream(), variants.getVerticalProperty().stream()), + variants.getGeometryProperties().stream()); + } + + private void validate(FeatureSchema parent, FeatureSchema geometryProperty) { + SchemaVariants variants = geometryProperty.getVariants().get(); + + Preconditions.checkState( + geometryProperty.isSpatial(), + "'variants' may only be declared on a property of type GEOMETRY. Path: %s.", + geometryProperty.getFullPathAsString()); + + referencedProperties(variants) + .forEach( + name -> + Preconditions.checkState( + parent.getProperties().stream().anyMatch(prop -> prop.getName().equals(name)), + "'variants' of property '%s' references '%s', which is not a property of the same object.", + geometryProperty.getFullPathAsString(), + name)); + + variants + .getCrsProperty() + .map(name -> sibling(parent, name)) + .ifPresent( + prop -> { + Preconditions.checkState( + prop.getType() == Type.STRING, + "The 'crsProperty' of a 'variants' declaration must be of type STRING. Found: %s. Path: %s.", + prop.getType(), + prop.getFullPathAsString()); + checkRole(prop, Role.ORIGINAL_CRS_IDENTIFIER); + }); + + variants + .getVerticalProperty() + .map(name -> sibling(parent, name)) + .ifPresent( + prop -> { + Preconditions.checkState( + prop.getType() == Type.FLOAT, + "The 'verticalProperty' of a 'variants' declaration must be of type FLOAT. Found: %s. Path: %s.", + prop.getType(), + prop.getFullPathAsString()); + checkRole(prop, Role.ORIGINAL_HEIGHT); + Preconditions.checkState( + !prop.getOriginalCrsIdentifiers().isEmpty(), + "The 'verticalProperty' of a 'variants' declaration must declare the identifiers of its vertical reference systems in 'originalCrsIdentifiers'. Path: %s.", + prop.getFullPathAsString()); + }); + + variants.getGeometryProperties().stream() + .map(name -> sibling(parent, name)) + .forEach( + prop -> { + Preconditions.checkState( + prop.isSpatial(), + "A geometry variant of a 'variants' declaration must be of type GEOMETRY. Found: %s. Path: %s.", + prop.getType(), + prop.getFullPathAsString()); + Preconditions.checkState( + prop.getNativeCrs().isPresent(), + "A geometry variant of a 'variants' declaration must declare the CRS it is stored in in 'nativeCrs'. Path: %s.", + prop.getFullPathAsString()); + checkRole(prop, Role.ORIGINAL_GEOMETRY); + Preconditions.checkState( + !prop.getOriginalCrsIdentifiers().isEmpty(), + "A geometry variant of a 'variants' declaration must declare the identifiers of its reference systems in 'originalCrsIdentifiers'. Path: %s.", + prop.getFullPathAsString()); + }); + + Preconditions.checkState( + geometryProperty.getFalseEastingDifference().isEmpty(), + "'falseEastingDifference' may only be declared on a geometry variant (role ORIGINAL_GEOMETRY), not on the main geometry property. Path: %s.", + geometryProperty.getFullPathAsString()); + } + + private void checkRole(FeatureSchema prop, Role expected) { + Optional role = prop.getRole(); + Preconditions.checkState( + role.isEmpty() || role.get() == expected, + "A property referenced from a 'variants' declaration must have the role %s (or none, the role is implied). Found: %s. Path: %s.", + expected, + role.orElse(null), + prop.getFullPathAsString()); + } + + private FeatureSchema sibling(FeatureSchema parent, String name) { + return parent.getProperties().stream() + .filter(prop -> prop.getName().equals(name)) + .findFirst() + .orElseThrow(); + } +} diff --git a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/transform/WithoutInternal.java b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/transform/WithoutInternal.java new file mode 100644 index 000000000..41ea55f62 --- /dev/null +++ b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/transform/WithoutInternal.java @@ -0,0 +1,64 @@ +/* + * 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 com.google.common.collect.ImmutableMap; +import de.ii.xtraplatform.features.domain.FeatureSchema; +import de.ii.xtraplatform.features.domain.ImmutableFeatureSchema; +import de.ii.xtraplatform.features.domain.SchemaVisitorTopDown; +import java.util.AbstractMap.SimpleImmutableEntry; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import java.util.Objects; +import java.util.stream.Collectors; + +/** + * Removes {@code internal} properties from a schema. Internal properties are read from the data + * source and are part of the provider-side schemas (unlike scope exclusions, which are applied + * there via {@link WithScope}), but they are not part of any public schema — apply this visitor + * when deriving a schema that is exposed to clients (JSON Schema, OpenAPI definitions). + */ +public class WithoutInternal implements SchemaVisitorTopDown { + + @Override + public FeatureSchema visit( + FeatureSchema schema, List parents, List visitedProperties) { + + if (schema.isInternal() && !parents.isEmpty()) { + return null; + } + + Map visitedPropertiesMap = + visitedProperties.stream() + .filter(Objects::nonNull) + .map( + featureSchema -> + new SimpleImmutableEntry<>(featureSchema.getFullPathAsString(), featureSchema)) + .collect( + ImmutableMap.toImmutableMap( + Entry::getKey, Entry::getValue, (first, second) -> second)); + List visitedConcat = + schema.getConcat().stream() + .map(concatSchema -> concatSchema.accept(this, parents)) + .filter(Objects::nonNull) + .collect(Collectors.toList()); + List visitedCoalesce = + schema.getCoalesce().stream() + .map(coalesceSchema -> coalesceSchema.accept(this, parents)) + .filter(Objects::nonNull) + .collect(Collectors.toList()); + + return new ImmutableFeatureSchema.Builder() + .from(schema) + .propertyMap(visitedPropertiesMap) + .concat(visitedConcat) + .coalesce(visitedCoalesce) + .build(); + } +} diff --git a/xtraplatform-features/src/test/groovy/de/ii/xtraplatform/features/domain/transform/VariantsResolverSpec.groovy b/xtraplatform-features/src/test/groovy/de/ii/xtraplatform/features/domain/transform/VariantsResolverSpec.groovy new file mode 100644 index 000000000..370538cf2 --- /dev/null +++ b/xtraplatform-features/src/test/groovy/de/ii/xtraplatform/features/domain/transform/VariantsResolverSpec.groovy @@ -0,0 +1,199 @@ +/* + * 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.crs.domain.EpsgCrs +import de.ii.xtraplatform.features.domain.FeatureSchema +import de.ii.xtraplatform.features.domain.ImmutableFeatureSchema +import de.ii.xtraplatform.features.domain.ImmutableSchemaVariants +import de.ii.xtraplatform.features.domain.SchemaBase +import spock.lang.Specification + +/** + * {@link VariantsResolver}: properties referenced from a {@code variants} declaration are marked + * {@code internal} and receive their implied role; invalid declarations (missing sibling, wrong + * type, geometry variant without a storage CRS or without identifiers) are rejected at provider + * start. + */ +class VariantsResolverSpec extends Specification { + + def resolver = new VariantsResolver() + + static ImmutableFeatureSchema.Builder type(Map props) { + def builder = new ImmutableFeatureSchema.Builder() + .name("ax_punktortau") + .sourcePath("/o14003") + .type(SchemaBase.Type.OBJECT) + props.each { name, prop -> builder.putProperties2(name, prop) } + builder + } + + static ImmutableFeatureSchema.Builder geometryWithVariants() { + new ImmutableFeatureSchema.Builder() + .sourcePath("position") + .type(SchemaBase.Type.GEOMETRY) + .role(SchemaBase.Role.PRIMARY_GEOMETRY) + .variants(new ImmutableSchemaVariants.Builder() + .crsProperty("pos_srs") + .verticalProperty("pos_h") + .addGeometryProperties("pos_gk3") + .build()) + } + + static Map validSiblings() { + [ + "pos_srs": new ImmutableFeatureSchema.Builder() + .sourcePath("position_srs") + .type(SchemaBase.Type.STRING), + "pos_gk3": new ImmutableFeatureSchema.Builder() + .sourcePath("position_gk3") + .type(SchemaBase.Type.GEOMETRY) + .nativeCrs(EpsgCrs.of(5677)) + .addOriginalCrsIdentifiers("urn:adv:crs:DE_DHDN_3GK3_HE100") + .falseEastingDifference(3000000d), + "pos_h" : new ImmutableFeatureSchema.Builder() + .sourcePath("position_h") + .type(SchemaBase.Type.FLOAT) + .addOriginalCrsIdentifiers("urn:adv:crs:DE_DHHN92_NH"), + ] + } + + def 'referenced siblings are marked internal, other properties are untouched'() { + given: + def props = validSiblings() + props["other"] = new ImmutableFeatureSchema.Builder() + .sourcePath("other") + .type(SchemaBase.Type.STRING) + props["position"] = geometryWithVariants() + def types = ["ax_punktortau": type(props).build() as FeatureSchema] + + expect: + resolver.needsResolving(types) + + when: + def resolved = resolver.resolve(types) + def resolvedType = resolved["ax_punktortau"] + + then: + resolvedType.getPropertyMap()["pos_srs"].isInternal() + resolvedType.getPropertyMap()["pos_gk3"].isInternal() + resolvedType.getPropertyMap()["pos_h"].isInternal() + !resolvedType.getPropertyMap()["other"].isInternal() + !resolvedType.getPropertyMap()["position"].isInternal() + + and: 'the group members receive their implied role' + resolvedType.getPropertyMap()["pos_srs"].getRole() == Optional.of(SchemaBase.Role.ORIGINAL_CRS_IDENTIFIER) + resolvedType.getPropertyMap()["pos_gk3"].getRole() == Optional.of(SchemaBase.Role.ORIGINAL_GEOMETRY) + resolvedType.getPropertyMap()["pos_h"].getRole() == Optional.of(SchemaBase.Role.ORIGINAL_HEIGHT) + + and: 'the resolved types need no further resolution' + !resolver.needsResolving(resolved) + } + + def 'internal properties are neither queryable nor sortable'() { + given: + def props = validSiblings() + props["position"] = geometryWithVariants() + def types = ["ax_punktortau": type(props).build() as FeatureSchema] + + when: + def resolvedType = resolver.resolve(types)["ax_punktortau"] + + then: + !resolvedType.getPropertyMap()["pos_srs"].queryable() + !resolvedType.getPropertyMap()["pos_srs"].sortable() + !resolvedType.getPropertyMap()["pos_h"].queryable() + !resolvedType.getPropertyMap()["pos_h"].sortable() + } + + def 'a variants declaration referencing a missing sibling is rejected'() { + given: + def props = validSiblings() + props.remove("pos_gk3") + props["position"] = geometryWithVariants() + def types = ["ax_punktortau": type(props).build() as FeatureSchema] + + when: + resolver.resolve(types) + + then: + def e = thrown(IllegalStateException) + e.message.contains("pos_gk3") + } + + def 'a geometry variant without a storage CRS is rejected'() { + given: + def props = validSiblings() + props["pos_gk3"] = new ImmutableFeatureSchema.Builder() + .sourcePath("position_gk3") + .type(SchemaBase.Type.GEOMETRY) + props["position"] = geometryWithVariants() + def types = ["ax_punktortau": type(props).build() as FeatureSchema] + + when: + resolver.resolve(types) + + then: + def e = thrown(IllegalStateException) + e.message.contains("nativeCrs") + } + + def 'a geometry variant without originalCrsIdentifiers is rejected'() { + given: + def props = validSiblings() + props["pos_gk3"] = new ImmutableFeatureSchema.Builder() + .sourcePath("position_gk3") + .type(SchemaBase.Type.GEOMETRY) + .nativeCrs(EpsgCrs.of(5677)) + props["position"] = geometryWithVariants() + def types = ["ax_punktortau": type(props).build() as FeatureSchema] + + when: + resolver.resolve(types) + + then: + def e = thrown(IllegalStateException) + e.message.contains("originalCrsIdentifiers") + } + + def 'a conflicting explicit role on a group member is rejected'() { + given: + def props = validSiblings() + props["pos_h"] = new ImmutableFeatureSchema.Builder() + .sourcePath("position_h") + .type(SchemaBase.Type.FLOAT) + .role(SchemaBase.Role.ORIGINAL_GEOMETRY) + .addOriginalCrsIdentifiers("urn:adv:crs:DE_DHHN92_NH") + props["position"] = geometryWithVariants() + def types = ["ax_punktortau": type(props).build() as FeatureSchema] + + when: + resolver.resolve(types) + + then: + def e = thrown(IllegalStateException) + e.message.contains("ORIGINAL_HEIGHT") + } + + def 'a crsProperty that is not a STRING is rejected'() { + given: + def props = validSiblings() + props["pos_srs"] = new ImmutableFeatureSchema.Builder() + .sourcePath("position_srs") + .type(SchemaBase.Type.INTEGER) + props["position"] = geometryWithVariants() + def types = ["ax_punktortau": type(props).build() as FeatureSchema] + + when: + resolver.resolve(types) + + then: + def e = thrown(IllegalStateException) + e.message.contains("STRING") + } +} From 3b0149c8a0cff31ad17934988999ea7cc37800b7 Mon Sep 17 00:00:00 2001 From: Clemens Portele Date: Thu, 16 Jul 2026 15:38:50 +0200 Subject: [PATCH 5/7] fix NOT and IS NULL filter predicates on joined properties matching nothing Predicates on properties that require a join are encoded as INNER-join semi-joins (A.id IN (SELECT ...)). Negations derived from them were unsatisfiable or wrong: - not() was pushed into the subquery by string surgery, negating the inner predicate ("some related row does not match") instead of the whole predicate ("no related row matches"); features without any related row were dropped by the INNER join. The whole semi-join is now negated (NOT (...)), which is exact since the outer operand is never null. This changes the result of filters like NOT role = 'x' on multi-valued properties from "has some other value" to "has no value 'x'", the consistent reading of logical negation. - isNull() applied IS NULL to the joined column inside the subquery, where it can never match. "No value" is now encoded as NOT (A.id IN (SELECT ... WHERE IS NOT NULL)). Also return a 400 instead of a 500 for filter coordinates that cannot be transformed: a failed transform yields a CoordinateTuple with isNull()=true, not a null reference; CqlCoordinateChecker now checks it instead of failing with a NullPointerException. --- .../cql/app/CqlCoordinateChecker.java | 5 +-- .../cql/app/CqlCoordinateCheckerSpec.groovy | 8 ++--- .../features/sql/app/FilterEncoderSql.java | 36 +++++++++++++++++++ .../FilterEncoderSqlInResultSetSpec.groovy | 36 +++++++++++++++++++ .../sql/app/FilterEncoderSqlSpec.groovy | 22 ++++++++++-- 5 files changed, 99 insertions(+), 8 deletions(-) diff --git a/xtraplatform-cql/src/main/java/de/ii/xtraplatform/cql/app/CqlCoordinateChecker.java b/xtraplatform-cql/src/main/java/de/ii/xtraplatform/cql/app/CqlCoordinateChecker.java index fef01d3f3..747f89da4 100644 --- a/xtraplatform-cql/src/main/java/de/ii/xtraplatform/cql/app/CqlCoordinateChecker.java +++ b/xtraplatform-cql/src/main/java/de/ii/xtraplatform/cql/app/CqlCoordinateChecker.java @@ -152,7 +152,8 @@ private void checkPosition(Position pos) { crsTransformerFilterToNative.ifPresent( t -> { CoordinateTuple transformed = t.transform(pos.x(), pos.y()); - if (Objects.isNull(transformed)) { + // a failed transform yields a tuple with isNull()=true, not a null reference + if (Objects.isNull(transformed) || transformed.isNull()) { throw new IllegalArgumentException( String.format( "Filter is invalid. Coordinate '%s' cannot be transformed to %s.", @@ -164,7 +165,7 @@ private void checkPosition(Position pos) { Position posCrs84 = pos; if (crsTransformerFilterToCrs84.isPresent()) { CoordinateTuple transformed = crsTransformerFilterToCrs84.get().transform(pos.x(), pos.y()); - if (Objects.nonNull(transformed)) { + if (Objects.nonNull(transformed) && !transformed.isNull()) { posCrs84 = Position.ofXY(transformed.getX(), transformed.getY()); } } diff --git a/xtraplatform-cql/src/test/groovy/de/ii/xtraplatform/cql/app/CqlCoordinateCheckerSpec.groovy b/xtraplatform-cql/src/test/groovy/de/ii/xtraplatform/cql/app/CqlCoordinateCheckerSpec.groovy index 5ee539d88..ebb47bb47 100644 --- a/xtraplatform-cql/src/test/groovy/de/ii/xtraplatform/cql/app/CqlCoordinateCheckerSpec.groovy +++ b/xtraplatform-cql/src/test/groovy/de/ii/xtraplatform/cql/app/CqlCoordinateCheckerSpec.groovy @@ -214,7 +214,7 @@ class CqlCoordinateCheckerSpec extends Specification { xCoordinate | yCoordinate | spatialLiteral | ex (double) 8.00 | (double) 5 | LineString.of(new double[]{xCoordinate, yCoordinate, xCoordinate, yCoordinate}) | IllegalArgumentException - (double) 0.50 | (double) 1000 | LineString.of(new double[]{xCoordinate, yCoordinate, xCoordinate, yCoordinate}) | NullPointerException + (double) 0.50 | (double) 1000 | LineString.of(new double[]{xCoordinate, yCoordinate, xCoordinate, yCoordinate}) | IllegalArgumentException } @@ -241,7 +241,7 @@ class CqlCoordinateCheckerSpec extends Specification { xCoordinate | yCoordinate | spatialLiteral | ex (double) 11.00 | (double) 56.00 | MultiPoint.of(List.of(Point.of(xCoordinate, yCoordinate))) | IllegalArgumentException - (double) 0.50 | (double) 1000 | MultiPoint.of(List.of(Point.of(xCoordinate, yCoordinate))) | NullPointerException + (double) 0.50 | (double) 1000 | MultiPoint.of(List.of(Point.of(xCoordinate, yCoordinate))) | IllegalArgumentException } @@ -269,7 +269,7 @@ class CqlCoordinateCheckerSpec extends Specification { xCoordinate1 | yCoordinate1 | xCoordinate2 | yCoordinate2 | spatialLiteral | ex (double) 8.00 | (double) 5 | (double) 4.40 | (double) 51.00 | MultiLineString.of(List.of(LineString.of(xCoordinate1, yCoordinate1, xCoordinate2, yCoordinate2))) | IllegalArgumentException - (double) 0.50 | (double) 1000 | (double) 0.03 | (double) 1 | MultiLineString.of(List.of(LineString.of(xCoordinate1, yCoordinate1, xCoordinate2, yCoordinate2))) | NullPointerException + (double) 0.50 | (double) 1000 | (double) 0.03 | (double) 1 | MultiLineString.of(List.of(LineString.of(xCoordinate1, yCoordinate1, xCoordinate2, yCoordinate2))) | IllegalArgumentException } @@ -299,7 +299,7 @@ class CqlCoordinateCheckerSpec extends Specification { xCoordinate1 | yCoordinate1 | xCoordinate2 | yCoordinate2 | xCoordinate3 | yCoordinate3 | xCoordinate4 | yCoordinate4 | ex (double) 8.00 | (double) 5 | (double) 4.40 | (double) 51.00 | (double) 8.00 | (double) 5 | (double) 4.40 | (double) 51.00 | IllegalArgumentException - (double) 0.50 | (double) 1000 | (double) 0.03 | (double) 1 | (double) 0.00 | (double) 100000 | (double) 0.03 | (double) 1 | NullPointerException + (double) 0.50 | (double) 1000 | (double) 0.03 | (double) 1 | (double) 0.00 | (double) 100000 | (double) 0.03 | (double) 1 | IllegalArgumentException } diff --git a/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/app/FilterEncoderSql.java b/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/app/FilterEncoderSql.java index 01e3e6286..8c595e3f7 100644 --- a/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/app/FilterEncoderSql.java +++ b/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/app/FilterEncoderSql.java @@ -1102,6 +1102,14 @@ public String visit(IsNull isNull, List children) { if (!operandHasSelect(mainExpression)) { // special case of a literal, we need to build the SQL expression mainExpression = String.format("%%1$s%1$s%%2$s", mainExpression); + } else if (mainExpression.contains("(SELECT")) { + // The property needs a join, so the operand is an EXISTS-style semi-join + // (A.id IN (SELECT ... WHERE )) built from INNER joins. Testing the joined + // column for NULL inside that subquery can never match: a feature without related rows + // contributes no subquery rows at all. "Property has no value" is the negation of + // "property has some value" (NOT EXISTS); the outer operand (A.) is never + // null, so the negation is exact. + return String.format("NOT (%s)", String.format(mainExpression, "", " IS NOT NULL")); } // mainExpression is either a literal value or a SELECT expression @@ -1503,6 +1511,16 @@ public String visit(Not not, List children) { String operator = LOGICAL_OPERATORS.get(not.getClass()); String operation = children.get(0); + if (operation.contains("(SELECT")) { + // The child predicate is (or contains) an EXISTS-style semi-join on a joined property + // (A.id IN (SELECT ... WHERE )). The string surgery below would push the + // negation into the subquery, negating the inner predicate (exists a related row that + // does NOT match) instead of the whole predicate (NO related row matches): features + // without any related row are dropped by the INNER join and multi-valued properties get + // any- instead of all-semantics. Wrap instead — the outer operand (A.) is never + // null, so NOT (...) is the exact logical negation. + return String.format("%s (%s)", operator, operation); + } Integer pos = null; Cql2Expression arg = not.getArgs().get(0); if (arg instanceof In) { @@ -2217,6 +2235,14 @@ public String visit(IsNull isNull, List children) { if (!operandHasSelect(mainExpression)) { // special case of a literal, we need to build the SQL expression mainExpression = String.format("%%1$s%1$s%%2$s", mainExpression); + } else if (mainExpression.contains("(SELECT")) { + // The property needs a join, so the operand is an EXISTS-style semi-join + // (A.id IN (SELECT ... WHERE )) built from INNER joins. Testing the joined + // column for NULL inside that subquery can never match: a feature without related rows + // contributes no subquery rows at all. "Property has no value" is the negation of + // "property has some value" (NOT EXISTS); the outer operand (A.) is never + // null, so the negation is exact. + return String.format("NOT (%s)", String.format(mainExpression, "", " IS NOT NULL")); } // mainExpression is either a literal value or a SELECT expression @@ -2620,6 +2646,16 @@ public String visit(Not not, List children) { String operator = LOGICAL_OPERATORS.get(not.getClass()); String operation = children.get(0); + if (operation.contains("(SELECT")) { + // The child predicate is (or contains) an EXISTS-style semi-join on a joined property + // (A.id IN (SELECT ... WHERE )). The string surgery below would push the + // negation into the subquery, negating the inner predicate (exists a related row that + // does NOT match) instead of the whole predicate (NO related row matches): features + // without any related row are dropped by the INNER join and multi-valued properties get + // any- instead of all-semantics. Wrap instead — the outer operand (A.) is never + // null, so NOT (...) is the exact logical negation. + return String.format("%s (%s)", operator, operation); + } Integer pos = null; Cql2Expression arg = not.getArgs().get(0); if (arg instanceof In) { diff --git a/xtraplatform-features-sql/src/test/groovy/de/ii/xtraplatform/features/sql/app/FilterEncoderSqlInResultSetSpec.groovy b/xtraplatform-features-sql/src/test/groovy/de/ii/xtraplatform/features/sql/app/FilterEncoderSqlInResultSetSpec.groovy index cd6cbd32e..6106dae79 100644 --- a/xtraplatform-features-sql/src/test/groovy/de/ii/xtraplatform/features/sql/app/FilterEncoderSqlInResultSetSpec.groovy +++ b/xtraplatform-features-sql/src/test/groovy/de/ii/xtraplatform/features/sql/app/FilterEncoderSqlInResultSetSpec.groovy @@ -11,6 +11,8 @@ import de.ii.xtraplatform.cql.app.CqlImpl import de.ii.xtraplatform.cql.domain.Eq import de.ii.xtraplatform.cql.domain.ImmutableInResultSet import de.ii.xtraplatform.cql.domain.InResultSet +import de.ii.xtraplatform.cql.domain.IsNull +import de.ii.xtraplatform.cql.domain.Not import de.ii.xtraplatform.cql.domain.Property import de.ii.xtraplatform.cql.domain.ScalarLiteral import de.ii.xtraplatform.crs.domain.OgcCrs @@ -105,6 +107,40 @@ class FilterEncoderSqlInResultSetSpec extends Specification { sql == "A.id IN (SELECT AA.id FROM externalprovider AA JOIN externalprovider_externalprovidername AB ON (AA.id=AB.externalprovider_fk) WHERE AB.externalprovidername IN (WITH _rs_0_s1 AS MATERIALIZED (SELECT A.id AS rs_value FROM externalprovider A WHERE A.id = 'foo') SELECT rs_value FROM _rs_0_s1))" } + def 'negated consumer on a junction property negates the whole semi-join'() { + given: 'a feature without any value must match the negation, so the negation may not be pushed into the INNER-joined subquery' + def filter = resolved(InResultSet.of("externalprovidername", "s1"), "simple", + Eq.of(Property.of("id"), ScalarLiteral.of("foo"))) + + when: + def sql = filterEncoder.encode(Not.of(filter), mappings["value_array"]) + + then: + sql == "NOT (A.id IN (SELECT AA.id FROM externalprovider AA JOIN externalprovider_externalprovidername AB ON (AA.id=AB.externalprovider_fk) WHERE AB.externalprovidername IN (WITH _rs_0_s1 AS MATERIALIZED (SELECT A.id AS rs_value FROM externalprovider A WHERE A.id = 'foo') SELECT rs_value FROM _rs_0_s1)))" + } + + def 'negated consumer on the id queryable'() { + given: + def filter = resolved(InResultSet.of("id", "s1"), "simple", null) + + when: + def sql = filterEncoder.encode(Not.of(filter), mappings["value_array"]) + + then: + sql == "NOT (A.id IN (WITH _rs_0_s1 AS MATERIALIZED (SELECT A.id AS rs_value FROM externalprovider A) SELECT rs_value FROM _rs_0_s1))" + } + + def 'isNull on a junction property matches features without any value'() { + given: 'the INNER-joined subquery has no rows for such features, so the encoding must be NOT EXISTS' + def filter = IsNull.of("externalprovidername") + + when: + def sql = filterEncoder.encode(filter, mappings["value_array"]) + + then: + sql == "NOT (A.id IN (SELECT AA.id FROM externalprovider AA JOIN externalprovider_externalprovidername AB ON (AA.id=AB.externalprovider_fk) WHERE AB.externalprovidername IS NOT NULL))" + } + def 'chained result sets nest recursively'() { given: 'the producer filter itself consumes another result set' def inner = resolved(InResultSet.of("id", "s1"), "simple", diff --git a/xtraplatform-features-sql/src/test/groovy/de/ii/xtraplatform/features/sql/app/FilterEncoderSqlSpec.groovy b/xtraplatform-features-sql/src/test/groovy/de/ii/xtraplatform/features/sql/app/FilterEncoderSqlSpec.groovy index fba86bff1..0235f341e 100644 --- a/xtraplatform-features-sql/src/test/groovy/de/ii/xtraplatform/features/sql/app/FilterEncoderSqlSpec.groovy +++ b/xtraplatform-features-sql/src/test/groovy/de/ii/xtraplatform/features/sql/app/FilterEncoderSqlSpec.groovy @@ -19,6 +19,7 @@ import de.ii.xtraplatform.blobs.domain.ResourceStore import de.ii.xtraplatform.cql.app.CqlFilterExamples import de.ii.xtraplatform.cql.app.CqlImpl import de.ii.xtraplatform.cql.domain.ScalarLiteral +import de.ii.xtraplatform.cql.domain.IsNull import de.ii.xtraplatform.cql.domain.Not import de.ii.xtraplatform.crs.domain.CrsTransformerFactory import de.ii.xtraplatform.crs.domain.OgcCrs @@ -335,7 +336,7 @@ class FilterEncoderSqlSpec extends Specification { def filter = Not.of(CqlFilterExamples.EXAMPLE_16) when: - String expected = "A.id IN (SELECT AA.id FROM building AA JOIN geometry AB ON (AA.id=AB.id) WHERE NOT ST_Intersects(AB.location, ST_GeomFromText('POLYGON((-10.0 -10.0,10.0 -10.0,10.0 10.0,-10.0 -10.0))',4326)))" + String expected = "NOT (A.id IN (SELECT AA.id FROM building AA JOIN geometry AB ON (AA.id=AB.id) WHERE ST_Intersects(AB.location, ST_GeomFromText('POLYGON((-10.0 -10.0,10.0 -10.0,10.0 10.0,-10.0 -10.0))',4326))))" String actual = filterEncoder.encode(filter, instanceContainer) @@ -544,6 +545,23 @@ class FilterEncoderSqlSpec extends Specification { } + def 'is Null test, joined property'() { + + given: 'a feature without any related row has no value, so it must match' + def instanceContainer = QuerySchemaFixtures.JOINED_GEOMETRY + def filter = IsNull.of("location") + + when: + String expected = "NOT (A.id IN (SELECT AA.id FROM building AA JOIN geometry AB ON (AA.id=AB.id) WHERE AB.location IS NOT NULL))" + + String actual = filterEncoder.encode(filter, instanceContainer) + + then: + + actual == expected + + } + def 'not in list test'() { given: @@ -738,7 +756,7 @@ class FilterEncoderSqlSpec extends Specification { def filter = Not.of(CqlFilterExamples.EXAMPLE_AContains_ValidFor_JOINED_GEOMETRY) when: - String expected = "A.id IN (SELECT AA.id FROM building AA JOIN geometry AB ON (AA.id=AB.id) WHERE NOT AB.location IN ('id','location') GROUP BY AA.id HAVING count(distinct AB.location) = 2)" + String expected = "NOT (A.id IN (SELECT AA.id FROM building AA JOIN geometry AB ON (AA.id=AB.id) WHERE AB.location IN ('id','location') GROUP BY AA.id HAVING count(distinct AB.location) = 2))" String actual = filterEncoder.encode(filter, instanceContainer) From ff02acfd132ec531ac2ca4bc1ad87f14e657073e Mon Sep 17 00:00:00 2001 From: Clemens Portele Date: Mon, 20 Jul 2026 16:54:10 +0200 Subject: [PATCH 6/7] fix wrong axis order of position variants when the target CRS equals the native CRS With skipUnusedPipelineSteps enabled, the COORDINATES pipeline step was skipped whenever the requested CRS equals the provider's native CRS. A geometry property that is stored in its own CRS and declares a differing originalCrs relies on that step to restore the recorded axis order of its positions, so such positions were emitted in the stored axis order while keeping the original srsName. The step is now kept for properties that declare a nativeCrs with a differing originalCrs, regardless of the requested CRS. --- ...rminePipelineStepsThatCannotBeSkipped.java | 14 ++- ...ipelineStepsThatCannotBeSkippedSpec.groovy | 98 +++++++++++++++++++ 2 files changed, 111 insertions(+), 1 deletion(-) create mode 100644 xtraplatform-features/src/test/groovy/de/ii/xtraplatform/features/domain/DeterminePipelineStepsThatCannotBeSkippedSpec.groovy diff --git a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/DeterminePipelineStepsThatCannotBeSkipped.java b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/DeterminePipelineStepsThatCannotBeSkipped.java index a2fd8e3c8..0d3c2db23 100644 --- a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/DeterminePipelineStepsThatCannotBeSkipped.java +++ b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/DeterminePipelineStepsThatCannotBeSkipped.java @@ -127,9 +127,13 @@ public Set visit( // at property level: determine needed steps based on schema information // coordinate processing is needed if a target CRS differs from the native CRS or geometries - // are simplified + // are simplified; a geometry property stored in its own CRS (property-level `nativeCrs`) + // that declares a differing `originalCrs` needs it regardless of the target CRS — the + // COORDINATES step restores the recorded axis order of such positions (see + // FeatureTokenTransformerCoordinates) if (!targetCrs.equals(nativeCrs) || (simplifyGeometries) + || requiresOriginalCrsRestore(schema) || (!(OgcCrs.CRS84.equals(nativeCrs) || OgcCrs.CRS84h.equals(nativeCrs)) && supportSecondaryGeometry && schema.isSecondaryGeometry())) { @@ -180,4 +184,12 @@ public Set visit( return steps.build(); } + + private static boolean requiresOriginalCrsRestore(FeatureSchema schema) { + return schema.getNativeCrs().isPresent() + && schema + .getOriginalCrs() + .filter(originalCrs -> !originalCrs.equals(schema.getNativeCrs().get())) + .isPresent(); + } } diff --git a/xtraplatform-features/src/test/groovy/de/ii/xtraplatform/features/domain/DeterminePipelineStepsThatCannotBeSkippedSpec.groovy b/xtraplatform-features/src/test/groovy/de/ii/xtraplatform/features/domain/DeterminePipelineStepsThatCannotBeSkippedSpec.groovy new file mode 100644 index 000000000..f0705c754 --- /dev/null +++ b/xtraplatform-features/src/test/groovy/de/ii/xtraplatform/features/domain/DeterminePipelineStepsThatCannotBeSkippedSpec.groovy @@ -0,0 +1,98 @@ +/* + * 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.crs.domain.EpsgCrs +import de.ii.xtraplatform.features.domain.FeatureStream.PipelineSteps +import de.ii.xtraplatform.geometries.domain.GeometryType +import spock.lang.Specification + +class DeterminePipelineStepsThatCannotBeSkippedSpec extends Specification { + + static final EpsgCrs NATIVE_CRS = EpsgCrs.of(25832) + + static FeatureSchema featureType(ImmutableFeatureSchema.Builder geometry) { + return new ImmutableFeatureSchema.Builder() + .name("test") + .type(SchemaBase.Type.OBJECT) + .putPropertyMap("position", geometry.build()) + .build() + } + + static ImmutableFeatureSchema.Builder geometry() { + return new ImmutableFeatureSchema.Builder() + .name("position") + .type(SchemaBase.Type.GEOMETRY) + .geometryType(GeometryType.POINT) + .sourcePath("position") + } + + static Set keepSteps(FeatureSchema schema, EpsgCrs targetCrs) { + FeatureQuery query = ImmutableFeatureQuery.builder().type("test").build() + + return schema.accept(new DeterminePipelineStepsThatCannotBeSkipped( + query, + "test", + Optional.empty(), + NATIVE_CRS, + targetCrs, + false, + false, + false, + true, + false)) + } + + def "no coordinate processing when the target CRS is the native CRS"() { + given: + def schema = featureType(geometry()) + + when: + def steps = keepSteps(schema, NATIVE_CRS) + + then: + !steps.contains(PipelineSteps.COORDINATES) + } + + def "coordinate processing when the target CRS differs from the native CRS"() { + given: + def schema = featureType(geometry()) + + when: + def steps = keepSteps(schema, EpsgCrs.of(4326)) + + then: + steps.contains(PipelineSteps.COORDINATES) + } + + def "coordinate processing for a position variant that restores its original axis order, even when the target CRS is the native CRS"() { + given: "a geometry stored in its own CRS in GIS axis order with a differing original CRS (authority axis order)" + def schema = featureType(geometry() + .nativeCrs(EpsgCrs.of(4937, EpsgCrs.Force.LON_LAT)) + .originalCrs(EpsgCrs.of(4937))) + + when: + def steps = keepSteps(schema, NATIVE_CRS) + + then: + steps.contains(PipelineSteps.COORDINATES) + } + + def "no coordinate processing for a position variant whose original CRS equals its storage CRS when the target CRS is the native CRS"() { + given: + def schema = featureType(geometry() + .nativeCrs(EpsgCrs.of(5677)) + .originalCrs(EpsgCrs.of(5677))) + + when: + def steps = keepSteps(schema, NATIVE_CRS) + + then: + !steps.contains(PipelineSteps.COORDINATES) + } +} From 125fd78e9bec67014ee524dc02e6d34afdae03ef Mon Sep 17 00:00:00 2001 From: Clemens Portele Date: Tue, 21 Jul 2026 10:43:32 +0200 Subject: [PATCH 7/7] features: rename the variants declaration to crsVariants The variants concept is specific to geometry properties that store the same logical position in several CRSs; the generic names did not reflect that. - configuration key on geometry properties: variants -> crsVariants - SchemaVariants -> CrsVariants - VariantsResolver -> CrsVariantsResolver - FeatureSchema.getVariants() -> getCrsVariants() - validation messages and documentation updated accordingly --- .../gml/domain/FeatureTokenDecoderGml.java | 19 +++++----- .../gml/domain/GmlGeometryVariants.java | 4 +- ...TokenDecoderGmlPositionVariantsSpec.groovy | 4 +- .../sql/app/FeatureProviderSqlFactory.java | 4 +- .../{SchemaVariants.java => CrsVariants.java} | 12 +++--- .../features/domain/FeatureSchema.java | 2 +- .../features/domain/SchemaBase.java | 10 ++--- ...Resolver.java => CrsVariantsResolver.java} | 38 ++++++++++--------- ....groovy => CrsVariantsResolverSpec.groovy} | 12 +++--- 9 files changed, 54 insertions(+), 51 deletions(-) rename xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/{SchemaVariants.java => CrsVariants.java} (89%) rename xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/transform/{VariantsResolver.java => CrsVariantsResolver.java} (77%) rename xtraplatform-features/src/test/groovy/de/ii/xtraplatform/features/domain/transform/{VariantsResolverSpec.groovy => CrsVariantsResolverSpec.groovy} (94%) diff --git a/xtraplatform-features-gml/src/main/java/de/ii/xtraplatform/features/gml/domain/FeatureTokenDecoderGml.java b/xtraplatform-features-gml/src/main/java/de/ii/xtraplatform/features/gml/domain/FeatureTokenDecoderGml.java index 1e2f97f9d..037ce4e80 100644 --- a/xtraplatform-features-gml/src/main/java/de/ii/xtraplatform/features/gml/domain/FeatureTokenDecoderGml.java +++ b/xtraplatform-features-gml/src/main/java/de/ii/xtraplatform/features/gml/domain/FeatureTokenDecoderGml.java @@ -11,13 +11,13 @@ import com.fasterxml.aalto.AsyncXMLStreamReader; import com.fasterxml.aalto.stax.InputFactoryImpl; import de.ii.xtraplatform.crs.domain.EpsgCrs; +import de.ii.xtraplatform.features.domain.CrsVariants; 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.SchemaVariants; 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; @@ -154,7 +154,7 @@ public class FeatureTokenDecoderGml /** * Per geometry property (keyed by its technical full path), the srsName routing built from the - * schema's {@code variants} declaration by {@link #buildGeometryVariants}. + * schema's {@code crsVariants} declaration by {@link #buildGeometryVariants}. */ private final Map geometryVariantsCache = new HashMap<>(); @@ -1316,13 +1316,14 @@ private void rejectXsiType() { } /** - * Builds the srsName-keyed routing for a geometry property from its schema-level {@code variants} - * declaration: every {@code originalCrsIdentifiers} entry of a variant sibling routes to that - * sibling (together with the sibling's {@code falseEastingDifference}); every identifier of the - * vertical sibling routes to the vertical property. Cached per geometry property. + * Builds the srsName-keyed routing for a geometry property from its schema-level {@code + * crsVariants} declaration: every {@code originalCrsIdentifiers} entry of a variant sibling + * routes to that sibling (together with the sibling's {@code falseEastingDifference}); every + * identifier of the vertical sibling routes to the vertical property. Cached per geometry + * property. */ private GmlGeometryVariants geometryVariantsFor(FeatureSchema prop, FeatureSchema lookupOwner) { - if (prop.getVariants().isEmpty()) { + if (prop.getCrsVariants().isEmpty()) { return null; } return geometryVariantsCache.computeIfAbsent( @@ -1330,7 +1331,7 @@ private GmlGeometryVariants geometryVariantsFor(FeatureSchema prop, FeatureSchem } private GmlGeometryVariants buildGeometryVariants(FeatureSchema prop, FeatureSchema lookupOwner) { - SchemaVariants variants = prop.getVariants().get(); + CrsVariants variants = prop.getCrsVariants().get(); Map bySrsName = new LinkedHashMap<>(); Map shiftBySrsName = new LinkedHashMap<>(); @@ -1384,7 +1385,7 @@ private static void collectVariantReferenceSystems( FeatureSchema schema, Map srsNameMappings, Set verticalSrsNames) { for (FeatureSchema child : schema.getProperties()) { child - .getVariants() + .getCrsVariants() .ifPresent( variants -> { variants diff --git a/xtraplatform-features-gml/src/main/java/de/ii/xtraplatform/features/gml/domain/GmlGeometryVariants.java b/xtraplatform-features-gml/src/main/java/de/ii/xtraplatform/features/gml/domain/GmlGeometryVariants.java index 610718dc8..9c927c811 100644 --- a/xtraplatform-features-gml/src/main/java/de/ii/xtraplatform/features/gml/domain/GmlGeometryVariants.java +++ b/xtraplatform-features-gml/src/main/java/de/ii/xtraplatform/features/gml/domain/GmlGeometryVariants.java @@ -22,8 +22,8 @@ * *

All property values name sibling properties of the geometry property this instance was built * for (same parent object, usually the feature type itself). Instances are derived at decode time - * from the {@code variants} declaration on the geometry property in the {@code FeatureSchema} and - * the {@code originalCrsIdentifiers} / {@code falseEastingDifference} of the referenced sibling + * from the {@code crsVariants} declaration on the geometry property in the {@code FeatureSchema} + * and the {@code originalCrsIdentifiers} / {@code falseEastingDifference} of the referenced sibling * properties. */ @Value.Immutable diff --git a/xtraplatform-features-gml/src/test/groovy/de/ii/xtraplatform/features/gml/domain/FeatureTokenDecoderGmlPositionVariantsSpec.groovy b/xtraplatform-features-gml/src/test/groovy/de/ii/xtraplatform/features/gml/domain/FeatureTokenDecoderGmlPositionVariantsSpec.groovy index cbecb12d9..1dc5d1fae 100644 --- a/xtraplatform-features-gml/src/test/groovy/de/ii/xtraplatform/features/gml/domain/FeatureTokenDecoderGmlPositionVariantsSpec.groovy +++ b/xtraplatform-features-gml/src/test/groovy/de/ii/xtraplatform/features/gml/domain/FeatureTokenDecoderGmlPositionVariantsSpec.groovy @@ -13,7 +13,7 @@ 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.ImmutableSchemaVariants +import de.ii.xtraplatform.features.domain.ImmutableCrsVariants import de.ii.xtraplatform.features.domain.SchemaBase import de.ii.xtraplatform.features.domain.SchemaMapping import de.ii.xtraplatform.features.domain.pipeline.FeatureEventHandlerSimple @@ -72,7 +72,7 @@ class FeatureTokenDecoderGmlPositionVariantsSpec extends Specification { .type(SchemaBase.Type.GEOMETRY) .geometryType(GeometryType.POINT) .alias("position") - .variants(new ImmutableSchemaVariants.Builder() + .crsVariants(new ImmutableCrsVariants.Builder() .crsProperty("pos_srs") .verticalProperty("pos_h") .addGeometryProperties("pos_gk3") diff --git a/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/app/FeatureProviderSqlFactory.java b/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/app/FeatureProviderSqlFactory.java index b15464e85..f0277bf25 100644 --- a/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/app/FeatureProviderSqlFactory.java +++ b/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/app/FeatureProviderSqlFactory.java @@ -28,12 +28,12 @@ import de.ii.xtraplatform.features.domain.SchemaFragmentResolver; import de.ii.xtraplatform.features.domain.SchemaReferenceResolver; import de.ii.xtraplatform.features.domain.TypesResolver; +import de.ii.xtraplatform.features.domain.transform.CrsVariantsResolver; import de.ii.xtraplatform.features.domain.transform.DefaultRolesResolver; import de.ii.xtraplatform.features.domain.transform.FeatureRefEmbedder; import de.ii.xtraplatform.features.domain.transform.FeatureRefResolver; import de.ii.xtraplatform.features.domain.transform.ImplicitMappingResolver; import de.ii.xtraplatform.features.domain.transform.LabelTemplateResolver; -import de.ii.xtraplatform.features.domain.transform.VariantsResolver; import de.ii.xtraplatform.features.sql.domain.ConnectionInfoSql; import de.ii.xtraplatform.features.sql.domain.FeatureProviderSql; import de.ii.xtraplatform.features.sql.domain.FeatureProviderSqlData; @@ -199,7 +199,7 @@ public EntityData hydrateData(EntityData entityData) { new FeatureRefEmbedder(data.getId()), new FeatureRefResolver(connectors), new ImplicitMappingResolver(), - new VariantsResolver(), + new CrsVariantsResolver(), new ConstantsResolver(), new LabelTemplateResolver(data.getLabelTemplate()), new DefaultRolesResolver(), diff --git a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/SchemaVariants.java b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/CrsVariants.java similarity index 89% rename from xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/SchemaVariants.java rename to xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/CrsVariants.java index 065bed49b..b3d055f99 100644 --- a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/SchemaVariants.java +++ b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/CrsVariants.java @@ -16,22 +16,22 @@ * @langEn Some feature types store the same logical position in one of several CRSs — including * CRSs that cannot be expressed as the storage CRS of the property (realizations that map to * the same coordinate reference system, or 1D vertical reference systems). Each variant is - * stored in its own property; `variants` on the main geometry property declares which sibling - * properties hold the variants. All referenced properties must be siblings of the geometry - * property (properties of the same object) and are implicitly `internal`. + * stored in its own property; `crsVariants` on the main geometry property declares which + * sibling properties hold the variants. All referenced properties must be siblings of the + * geometry property (properties of the same object) and are implicitly `internal`. * @langDe Manche Objektarten speichern dieselbe logische Position in einem von mehreren * Koordinatenreferenzsystemen — einschließlich Systemen, die nicht als Speicher-CRS der * Eigenschaft ausgedrückt werden können (Realisierungen, die auf dasselbe * Koordinatenreferenzsystem abgebildet werden, oder eindimensionale Höhenreferenzsysteme). Jede - * Variante wird in einer eigenen Eigenschaft gespeichert; `variants` an der + * Variante wird in einer eigenen Eigenschaft gespeichert; `crsVariants` an der * Haupt-Geometrieeigenschaft deklariert, welche Nachbareigenschaften die Varianten enthalten. * Alle referenzierten Eigenschaften müssen Nachbareigenschaften der Geometrieeigenschaft sein * (Eigenschaften desselben Objekts) und sind implizit `internal`. */ @Value.Immutable @Value.Style(builder = "new", deepImmutablesDetection = true) -@JsonDeserialize(builder = ImmutableSchemaVariants.Builder.class) -public interface SchemaVariants { +@JsonDeserialize(builder = ImmutableCrsVariants.Builder.class) +public interface CrsVariants { /** * @langEn A `STRING` property that stores the URI of the reference system of the position, 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 ba95389b4..acffd228d 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 @@ -298,7 +298,7 @@ default Type getType() { * @default null * @since v4.8 */ - Optional getVariants(); + Optional getCrsVariants(); /** * @langEn The verbatim identifiers of the reference systems that are stored in this property. diff --git a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/SchemaBase.java b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/SchemaBase.java index 0d879cfa7..60563f0fd 100644 --- a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/SchemaBase.java +++ b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/SchemaBase.java @@ -52,7 +52,7 @@ enum Role { EMBEDDED_FEATURE, FEATURE_REF, /** - * A 2D/3D position variant of a geometry property with a {@code variants} declaration: the + * A 2D/3D position variant of a geometry property with a {@code crsVariants} declaration: the * position as recorded, in a reference system other than the CRS of the main geometry property. * The property declares the CRS it is stored in in {@code nativeCrs}, optionally the CRS of the * recorded positions in {@code originalCrs}, and the reference-system identifiers that route to @@ -61,14 +61,14 @@ enum Role { ORIGINAL_GEOMETRY, /** * The single coordinate of a position variant in a 1D vertical reference system (see the {@code - * verticalProperty} of a {@code variants} declaration). The property declares the identifiers - * of the vertical reference systems that route to it in {@code originalCrsIdentifiers}. - * Implicitly {@code internal}. + * verticalProperty} of a {@code crsVariants} declaration). The property declares the + * identifiers of the vertical reference systems that route to it in {@code + * originalCrsIdentifiers}. Implicitly {@code internal}. */ ORIGINAL_HEIGHT, /** * The verbatim identifier of the reference system of a position variant (see the {@code - * crsProperty} of a {@code variants} declaration). Implicitly {@code internal}. + * crsProperty} of a {@code crsVariants} declaration). Implicitly {@code internal}. */ ORIGINAL_CRS_IDENTIFIER; diff --git a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/transform/VariantsResolver.java b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/transform/CrsVariantsResolver.java similarity index 77% rename from xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/transform/VariantsResolver.java rename to xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/transform/CrsVariantsResolver.java index 458932918..97f8c98d7 100644 --- a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/transform/VariantsResolver.java +++ b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/transform/CrsVariantsResolver.java @@ -8,11 +8,11 @@ package de.ii.xtraplatform.features.domain.transform; import com.google.common.base.Preconditions; +import de.ii.xtraplatform.features.domain.CrsVariants; import de.ii.xtraplatform.features.domain.FeatureSchema; import de.ii.xtraplatform.features.domain.ImmutableFeatureSchema; import de.ii.xtraplatform.features.domain.SchemaBase.Role; import de.ii.xtraplatform.features.domain.SchemaBase.Type; -import de.ii.xtraplatform.features.domain.SchemaVariants; import de.ii.xtraplatform.features.domain.TypesResolver; import java.util.HashMap; import java.util.LinkedHashMap; @@ -22,14 +22,14 @@ import java.util.stream.Stream; /** - * Validates {@code variants} declarations on geometry properties and completes the referenced + * Validates {@code crsVariants} declarations on geometry properties and completes the referenced * sibling properties: each member of a variants group carries its role in the group ({@code * ORIGINAL_CRS_IDENTIFIER}, {@code ORIGINAL_HEIGHT}, {@code ORIGINAL_GEOMETRY}) unless declared * explicitly — the roles make the members internal. Runs once per provider start, after fragment - * resolution, so a {@code variants} block that arrives via a schema fragment is handled the same + * resolution, so a {@code crsVariants} block that arrives via a schema fragment is handled the same * way as an inline one. */ -public class VariantsResolver implements TypesResolver { +public class CrsVariantsResolver implements TypesResolver { @Override public boolean needsResolving( @@ -46,7 +46,7 @@ public boolean needsResolving( @Override public FeatureSchema resolve(FeatureSchema property, List parents) { property.getProperties().stream() - .filter(prop -> prop.getVariants().isPresent()) + .filter(prop -> prop.getCrsVariants().isPresent()) .forEach(prop -> validate(property, prop)); Map impliedRoles = impliedRoles(property); @@ -71,11 +71,13 @@ public FeatureSchema resolve(FeatureSchema property, List parents return builder.build(); } - /** The group members referenced from any {@code variants} declaration, with the implied role. */ + /** + * The group members referenced from any {@code crsVariants} declaration, with the implied role. + */ private Map impliedRoles(FeatureSchema parent) { Map implied = new LinkedHashMap<>(); parent.getProperties().stream() - .flatMap(prop -> prop.getVariants().stream()) + .flatMap(prop -> prop.getCrsVariants().stream()) .forEach( variants -> { variants @@ -91,18 +93,18 @@ private Map impliedRoles(FeatureSchema parent) { return implied; } - private Stream referencedProperties(SchemaVariants variants) { + private Stream referencedProperties(CrsVariants variants) { return Stream.concat( Stream.concat(variants.getCrsProperty().stream(), variants.getVerticalProperty().stream()), variants.getGeometryProperties().stream()); } private void validate(FeatureSchema parent, FeatureSchema geometryProperty) { - SchemaVariants variants = geometryProperty.getVariants().get(); + CrsVariants variants = geometryProperty.getCrsVariants().get(); Preconditions.checkState( geometryProperty.isSpatial(), - "'variants' may only be declared on a property of type GEOMETRY. Path: %s.", + "'crsVariants' may only be declared on a property of type GEOMETRY. Path: %s.", geometryProperty.getFullPathAsString()); referencedProperties(variants) @@ -110,7 +112,7 @@ private void validate(FeatureSchema parent, FeatureSchema geometryProperty) { name -> Preconditions.checkState( parent.getProperties().stream().anyMatch(prop -> prop.getName().equals(name)), - "'variants' of property '%s' references '%s', which is not a property of the same object.", + "'crsVariants' of property '%s' references '%s', which is not a property of the same object.", geometryProperty.getFullPathAsString(), name)); @@ -121,7 +123,7 @@ private void validate(FeatureSchema parent, FeatureSchema geometryProperty) { prop -> { Preconditions.checkState( prop.getType() == Type.STRING, - "The 'crsProperty' of a 'variants' declaration must be of type STRING. Found: %s. Path: %s.", + "The 'crsProperty' of a 'crsVariants' declaration must be of type STRING. Found: %s. Path: %s.", prop.getType(), prop.getFullPathAsString()); checkRole(prop, Role.ORIGINAL_CRS_IDENTIFIER); @@ -134,13 +136,13 @@ private void validate(FeatureSchema parent, FeatureSchema geometryProperty) { prop -> { Preconditions.checkState( prop.getType() == Type.FLOAT, - "The 'verticalProperty' of a 'variants' declaration must be of type FLOAT. Found: %s. Path: %s.", + "The 'verticalProperty' of a 'crsVariants' declaration must be of type FLOAT. Found: %s. Path: %s.", prop.getType(), prop.getFullPathAsString()); checkRole(prop, Role.ORIGINAL_HEIGHT); Preconditions.checkState( !prop.getOriginalCrsIdentifiers().isEmpty(), - "The 'verticalProperty' of a 'variants' declaration must declare the identifiers of its vertical reference systems in 'originalCrsIdentifiers'. Path: %s.", + "The 'verticalProperty' of a 'crsVariants' declaration must declare the identifiers of its vertical reference systems in 'originalCrsIdentifiers'. Path: %s.", prop.getFullPathAsString()); }); @@ -150,17 +152,17 @@ private void validate(FeatureSchema parent, FeatureSchema geometryProperty) { prop -> { Preconditions.checkState( prop.isSpatial(), - "A geometry variant of a 'variants' declaration must be of type GEOMETRY. Found: %s. Path: %s.", + "A geometry variant of a 'crsVariants' declaration must be of type GEOMETRY. Found: %s. Path: %s.", prop.getType(), prop.getFullPathAsString()); Preconditions.checkState( prop.getNativeCrs().isPresent(), - "A geometry variant of a 'variants' declaration must declare the CRS it is stored in in 'nativeCrs'. Path: %s.", + "A geometry variant of a 'crsVariants' declaration must declare the CRS it is stored in in 'nativeCrs'. Path: %s.", prop.getFullPathAsString()); checkRole(prop, Role.ORIGINAL_GEOMETRY); Preconditions.checkState( !prop.getOriginalCrsIdentifiers().isEmpty(), - "A geometry variant of a 'variants' declaration must declare the identifiers of its reference systems in 'originalCrsIdentifiers'. Path: %s.", + "A geometry variant of a 'crsVariants' declaration must declare the identifiers of its reference systems in 'originalCrsIdentifiers'. Path: %s.", prop.getFullPathAsString()); }); @@ -174,7 +176,7 @@ private void checkRole(FeatureSchema prop, Role expected) { Optional role = prop.getRole(); Preconditions.checkState( role.isEmpty() || role.get() == expected, - "A property referenced from a 'variants' declaration must have the role %s (or none, the role is implied). Found: %s. Path: %s.", + "A property referenced from a 'crsVariants' declaration must have the role %s (or none, the role is implied). Found: %s. Path: %s.", expected, role.orElse(null), prop.getFullPathAsString()); diff --git a/xtraplatform-features/src/test/groovy/de/ii/xtraplatform/features/domain/transform/VariantsResolverSpec.groovy b/xtraplatform-features/src/test/groovy/de/ii/xtraplatform/features/domain/transform/CrsVariantsResolverSpec.groovy similarity index 94% rename from xtraplatform-features/src/test/groovy/de/ii/xtraplatform/features/domain/transform/VariantsResolverSpec.groovy rename to xtraplatform-features/src/test/groovy/de/ii/xtraplatform/features/domain/transform/CrsVariantsResolverSpec.groovy index 370538cf2..5998de553 100644 --- a/xtraplatform-features/src/test/groovy/de/ii/xtraplatform/features/domain/transform/VariantsResolverSpec.groovy +++ b/xtraplatform-features/src/test/groovy/de/ii/xtraplatform/features/domain/transform/CrsVariantsResolverSpec.groovy @@ -10,19 +10,19 @@ package de.ii.xtraplatform.features.domain.transform import de.ii.xtraplatform.crs.domain.EpsgCrs import de.ii.xtraplatform.features.domain.FeatureSchema import de.ii.xtraplatform.features.domain.ImmutableFeatureSchema -import de.ii.xtraplatform.features.domain.ImmutableSchemaVariants +import de.ii.xtraplatform.features.domain.ImmutableCrsVariants import de.ii.xtraplatform.features.domain.SchemaBase import spock.lang.Specification /** - * {@link VariantsResolver}: properties referenced from a {@code variants} declaration are marked + * {@link CrsVariantsResolver}: properties referenced from a {@code crsVariants} declaration are marked * {@code internal} and receive their implied role; invalid declarations (missing sibling, wrong * type, geometry variant without a storage CRS or without identifiers) are rejected at provider * start. */ -class VariantsResolverSpec extends Specification { +class CrsVariantsResolverSpec extends Specification { - def resolver = new VariantsResolver() + def resolver = new CrsVariantsResolver() static ImmutableFeatureSchema.Builder type(Map props) { def builder = new ImmutableFeatureSchema.Builder() @@ -38,7 +38,7 @@ class VariantsResolverSpec extends Specification { .sourcePath("position") .type(SchemaBase.Type.GEOMETRY) .role(SchemaBase.Role.PRIMARY_GEOMETRY) - .variants(new ImmutableSchemaVariants.Builder() + .crsVariants(new ImmutableCrsVariants.Builder() .crsProperty("pos_srs") .verticalProperty("pos_h") .addGeometryProperties("pos_gk3") @@ -111,7 +111,7 @@ class VariantsResolverSpec extends Specification { !resolvedType.getPropertyMap()["pos_h"].sortable() } - def 'a variants declaration referencing a missing sibling is rejected'() { + def 'a crsVariants declaration referencing a missing sibling is rejected'() { given: def props = validSiblings() props.remove("pos_gk3")