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 608dcb016..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; @@ -258,6 +268,14 @@ private static final class Frame { */ boolean valueWrapped; + /** + * 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; + /** * 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 +341,14 @@ public FeatureTokenDecoderGml( this.defaultCrs = headerCrs.isPresent() ? headerCrs : Optional.of(storageCrs); this.nullValue = nullValue; this.inputProfile = inputProfile; - this.geometryDecoder = new GeometryDecoderGml(inputProfile.getSrsNameMappings()); + // 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); @@ -468,13 +493,15 @@ 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; } - rejectXsiType(); + if (!isValueWrapChainElement()) { + rejectXsiType(); + } if (!inFeature && depth < featureRootDepth) { String expected = wrapperElementNames.get(depth); @@ -581,11 +608,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; } @@ -647,17 +670,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, lookupOwner); + 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(); @@ -730,8 +755,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; } @@ -1251,11 +1276,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++) { @@ -1269,6 +1315,189 @@ 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. + */ + private GmlGeometryVariants geometryVariantsFor(FeatureSchema prop, FeatureSchema lookupOwner) { + if (prop.getVariants().isEmpty()) { + return null; + } + 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); + } + } + + /** + * 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 && variants != null) { + Double falseEastingDifference = variants.getShiftBySrsName().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..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,9 +23,12 @@ 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(); 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..610718dc8 --- /dev/null +++ b/xtraplatform-features-gml/src/main/java/de/ii/xtraplatform/features/gml/domain/GmlGeometryVariants.java @@ -0,0 +1,58 @@ +/* + * 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 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 { + + /** + * 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} → 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. + */ + 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..cbecb12d9 --- /dev/null +++ b/xtraplatform-features-gml/src/test/groovy/de/ii/xtraplatform/features/gml/domain/FeatureTokenDecoderGmlPositionVariantsSpec.groovy @@ -0,0 +1,187 @@ +/* + * 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.ImmutableSchemaVariants +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}), 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 { + + 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) + .nativeCrs(EpsgCrs.of(5677)) + .addOriginalCrsIdentifiers(GK3_HE100) + .falseEastingDifference(3000000d)) + .putProperties2("pos_h", new ImmutableFeatureSchema.Builder() + .sourcePath("position_h") + .type(SchemaBase.Type.FLOAT) + .addOriginalCrsIdentifiers(DHHN92)) + .putProperties2("geo", new ImmutableFeatureSchema.Builder() + .sourcePath("position") + .type(SchemaBase.Type.GEOMETRY) + .geometryType(GeometryType.POINT) + .alias("position") + .variants(new ImmutableSchemaVariants.Builder() + .crsProperty("pos_srs") + .verticalProperty("pos_h") + .addGeometryProperties("pos_gk3") + .build())) + .build() + } + + static FeatureTokenDecoderGmlInputProfile profile() { + ImmutableFeatureTokenDecoderGmlInputProfile.builder() + .useAlias(true) + .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-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 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/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 f80c710cf..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 @@ -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::getNativeCrs) + .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-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]); } } 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..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 @@ -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,107 @@ default Type getType() { @Override List getGeometryTypes(); + /** + * @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. 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 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. *

For properties that should be mapped as links, the value `Link` can still be used. This @@ -326,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 @@ -381,6 +504,7 @@ default Type getType() { default boolean queryable() { return !isObject() && !isMultiSource() + && !isInternal() && !Objects.equals(getType(), Type.UNKNOWN) && !getExcludedScopes().contains(Scope.QUERYABLE); } @@ -394,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 00e848b0c..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,18 +21,44 @@ 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 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; + } + 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..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 @@ -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; @@ -49,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; @@ -182,6 +203,8 @@ public static List allBut(Scope... scopes) { List getGeometryTypes(); + Optional getNativeCrs(); + Optional getFormat(); Optional getRefType(); 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") + } +} 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[] + } +}