Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -372,6 +372,11 @@ protected boolean advanceParser() {
default:
// advanceParser(in);
}
} catch (IllegalArgumentException e) {
// Already a rejection of the input in its own words — a read-only property, an empty value,
// an unsupported xsi:type. The document parsed; adding "Could not parse GML" would describe
// the wrong problem, so it is passed through unchanged.
throw e;
} catch (Exception e) {
throw new IllegalArgumentException("Could not parse GML: " + e.getMessage(), e);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,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.pipeline.FeatureEventHandlerEmptyValues;
import de.ii.xtraplatform.features.domain.pipeline.FeatureEventHandlerReadOnly;
import de.ii.xtraplatform.features.domain.pipeline.FeatureEventHandlerSimple.ModifiableContext;
import de.ii.xtraplatform.features.domain.pipeline.FeatureTokenBufferSimple;
Expand Down Expand Up @@ -633,7 +634,10 @@ protected void init() {
this.context = createContext();
this.downstream =
new FeatureTokenBufferSimple<>(
FeatureEventHandlerReadOnly.of(getDownstream(), inputProfile.getReadOnlyProperties()),
FeatureEventHandlerReadOnly.of(
FeatureEventHandlerEmptyValues.of(
getDownstream(), inputProfile.getRejectEmptyValues()),
inputProfile.getReadOnlyProperties()),
context);
}

Expand Down Expand Up @@ -702,6 +706,11 @@ private boolean advanceParser() {
// ignore: DTD, SPACE, NAMESPACE, NOTATION_DECLARATION, ENTITY_DECLARATION,
// PROCESSING_INSTRUCTION, COMMENT, CDATA. ATTRIBUTE is implicit in START_ELEMENT.
}
} catch (IllegalArgumentException e) {
// Already a rejection of the input in its own words — a read-only property, an empty value,
// an unsupported xsi:type. The document parsed; adding "Could not parse GML" would describe
// the wrong problem, so it is passed through unchanged.
throw e;
} catch (Exception e) {
throw new IllegalArgumentException("Could not parse GML: " + e.getMessage(), e);
}
Expand Down Expand Up @@ -1197,9 +1206,18 @@ private void onEndElement() throws XMLStreamException, java.io.IOException {
context.setValueType(Type.STRING);
downstream.onValue(context);
} else if (frame.pendingXlinkHrefFallback != null) {
// The fallback still wins over an empty value, as it did when the value was dropped.
context.setValue(frame.pendingXlinkHrefFallback);
context.setValueType(Type.STRING);
downstream.onValue(context);
} else {
// The property element was there and carried no value — content that is empty or only
// whitespace, the latter never having reached the buffer. That states an empty value, not
// an absent one, the way a JSON member does with "": dropping it here silently discarded
// what the document states and left the empty value invisible to everything downstream.
context.setValue("");
context.setValueType(Type.STRING);
downstream.onValue(context);
}
} else if (frame != null && frame.kind == FrameKind.OBJECT_PROPERTY) {
// Re-track the OBJECT_PROPERTY's own path before emitting onObjectEnd — nested child
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,17 @@ default String getFeatureMemberElementName() {
*/
Set<String> getReadOnlyProperties();

/**
* Whether a value that is a string with no characters or with only whitespace makes the document
* invalid. The check applies to the value a decoder resolves for a property, so an element that
* carries its value in {@code xlink:href} and an element that states {@code xsi:nil} need no case
* of their own.
*/
@Value.Default
default boolean getRejectEmptyValues() {
return false;
}

static FeatureTokenDecoderGmlInputProfile empty() {
return ImmutableFeatureTokenDecoderGmlInputProfile.builder().build();
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
/*
* 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.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.streams.app.ReactiveRx
import de.ii.xtraplatform.streams.domain.Reactive
import spock.lang.Shared
import spock.lang.Specification

import javax.xml.namespace.QName

/**
* A property element that is present but carries no value states an empty value, the way a JSON
* member does with {@code ""}. The decoder used to drop it, which made the empty value of a scalar
* property invisible; these specs pin the emission and the {@code rejectEmptyValues} check built on
* it, together with the whitespace that separates child elements, which is not a value of its own.
*/
class FeatureTokenDecoderGmlEmptyValuesSpec extends Specification {

static final String NS = "http://example.com/ns/1.0"
static final Map<String, String> NAMESPACES = [
"ex" : NS,
"gml" : "http://www.opengis.net/gml/3.2",
"xlink": "http://www.w3.org/1999/xlink",
"xsi" : "http://www.w3.org/2001/XMLSchema-instance"
]

@Shared Reactive reactive
@Shared Reactive.Runner runner

def setupSpec() {
reactive = new ReactiveRx()
runner = reactive.runner("test-empty-values")
}

def cleanupSpec() {
runner.close()
}

private FeatureTokenDecoderGml decoder(boolean rejectEmptyValues) {
def schema = new ImmutableFeatureSchema.Builder()
.name("party")
.sourcePath("/party")
.type(SchemaBase.Type.OBJECT)
.putProperties2("oid", new ImmutableFeatureSchema.Builder()
.sourcePath("objid")
.type(SchemaBase.Type.STRING)
.role(SchemaBase.Role.ID)
.alias("id"))
.putProperties2("givenName", new ImmutableFeatureSchema.Builder()
.sourcePath("given_name")
.type(SchemaBase.Type.STRING)
.alias("givenName"))
.putProperties2("note", new ImmutableFeatureSchema.Builder()
.sourcePath("note")
.type(SchemaBase.Type.STRING)
.alias("note"))
.build()
return new FeatureTokenDecoderGml(
NAMESPACES,
[new QName(NS, "Party")],
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(),
ImmutableFeatureTokenDecoderGmlInputProfile.builder()
.useAlias(true)
.rejectEmptyValues(rejectEmptyValues)
.build())
}

private static String feature(String properties) {
return '<ex:Party xmlns:ex="' + NS + '"' +
' xmlns:gml="http://www.opengis.net/gml/3.2"' +
' xmlns:xlink="http://www.w3.org/1999/xlink"' +
' xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"' +
' gml:id="p1">' + properties + '</ex:Party>'
}

private List<Object> run(String properties, boolean rejectEmptyValues = false) {
return Reactive.Source.inputStream(new ByteArrayInputStream(feature(properties).getBytes('UTF-8')))
.via(decoder(rejectEmptyValues))
.to(Reactive.Sink.reduce([], (list, element) -> { list << element; return list }))
.on(runner).run().toCompletableFuture().join() as List<Object>
}

private static List<String> valueOf(List<Object> tokens, String property) {
def values = []
for (int i = 0; i < tokens.size() - 1; i++) {
if (tokens[i] instanceof List && tokens[i] == [property]) {
values << tokens[i + 1]
}
}
return values
}

def 'a property element with no content states an empty value'() {
when:
def tokens = run('<ex:givenName></ex:givenName>')

then:
valueOf(tokens, 'givenName') == ['']
}

def 'a self-closing property element states an empty value'() {
when:
def tokens = run('<ex:givenName/>')

then:
valueOf(tokens, 'givenName') == ['']
}

def 'content of only whitespace states an empty value'() {
when:
def tokens = run('<ex:givenName> </ex:givenName>')

then: 'whitespace never reaches the character buffer, so it arrives as no content at all'
valueOf(tokens, 'givenName') == ['']
}

def 'content is unaffected'() {
when:
def tokens = run('<ex:givenName>Alex</ex:givenName>')

then:
valueOf(tokens, 'givenName') == ['Alex']
}

def 'the whitespace that separates property elements is not a value'() {
when:
def tokens = run('\n <ex:givenName>Alex</ex:givenName>\n <ex:note>a note</ex:note>\n')

then: 'the indentation between the properties reaches neither of them nor the feature'
valueOf(tokens, 'givenName') == ['Alex']
valueOf(tokens, 'note') == ['a note']
}

def 'an empty value is rejected under rejectEmptyValues'() {
when:
run('<ex:givenName></ex:givenName>', true)

then:
Throwable e = thrown()
rootCause(e) instanceof IllegalArgumentException
rootCause(e).message.contains("'givenName' has an empty value")
}

def 'the rejection is not dressed up as a parse failure'() {
when:
run('<ex:givenName></ex:givenName>', true)

then: 'the document parsed; only the value was rejected, so the message says only that'
Throwable e = thrown()
def reported = e.cause ?: e
reported.message.startsWith("The property 'givenName' has an empty value")
!reported.message.contains('Could not parse GML')
}

def 'a self-closing property element is rejected under rejectEmptyValues'() {
when:
run('<ex:givenName/>', true)

then:
Throwable e = thrown()
rootCause(e) instanceof IllegalArgumentException
}

def 'content of only whitespace is rejected under rejectEmptyValues'() {
when:
run('<ex:givenName> </ex:givenName>', true)

then:
Throwable e = thrown()
rootCause(e) instanceof IllegalArgumentException
}

def 'a property with content passes under rejectEmptyValues'() {
when:
def tokens = run('<ex:givenName>Alex</ex:givenName>', true)

then:
notThrown(Throwable)
valueOf(tokens, 'givenName') == ['Alex']
}

def 'a reference carries its value in xlink:href and is not empty'() {
when:
def tokens = run('<ex:note xlink:href="https://example.com/notes/1"/>', true)

then:
notThrown(Throwable)
valueOf(tokens, 'note') == ['https://example.com/notes/1']
}

def 'xsi:nil states the absence of a value, not an empty one'() {
when:
def tokens = run('<ex:note xsi:nil="true"/>', true)

then: 'no value is decoded at all, so there is nothing to reject'
notThrown(Throwable)
valueOf(tokens, 'note') == []
}

private static Throwable rootCause(Throwable e) {
Throwable cause = e
while (cause.getCause() != null && cause.getCause() != cause) {
cause = cause.getCause()
}
cause
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
import de.ii.xtraplatform.features.domain.FeatureSchema;
import de.ii.xtraplatform.features.domain.SchemaBase.Type;
import de.ii.xtraplatform.features.domain.SchemaMapping;
import de.ii.xtraplatform.features.domain.pipeline.FeatureEventHandlerEmptyValues;
import de.ii.xtraplatform.features.domain.pipeline.FeatureEventHandlerReadOnly;
import de.ii.xtraplatform.features.domain.pipeline.FeatureEventHandlerSimple.ModifiableContext;
import de.ii.xtraplatform.features.domain.pipeline.FeatureTokenBufferSimple;
Expand Down Expand Up @@ -49,6 +50,7 @@ public class FeatureTokenDecoderGeoJson
private final Axes axes;
private final GeometryDecoderJson geometryDecoder;
private final Set<String> readOnlyProperties;
private final boolean rejectEmptyValues;

private boolean started;
private int depth = -1;
Expand Down Expand Up @@ -81,18 +83,31 @@ public FeatureTokenDecoderGeoJson(
this(nullValue, crs, axes, supportedCrs, Set.of());
}

public FeatureTokenDecoderGeoJson(
Optional<String> nullValue,
EpsgCrs crs,
Axes axes,
List<EpsgCrs> supportedCrs,
Set<String> readOnlyProperties) {
this(nullValue, crs, axes, supportedCrs, readOnlyProperties, false);
}

/**
* @param supportedCrs the coordinate reference systems that a {@code coordRefSys} member in the
* document may declare; an empty list does not restrict them
* @param rejectEmptyValues whether a value that is a string with no characters or with only
* whitespace makes the document invalid
*/
public FeatureTokenDecoderGeoJson(
Optional<String> nullValue,
EpsgCrs crs,
Axes axes,
List<EpsgCrs> supportedCrs,
Set<String> readOnlyProperties) {
Set<String> readOnlyProperties,
boolean rejectEmptyValues) {
super();
this.readOnlyProperties = readOnlyProperties;
this.rejectEmptyValues = rejectEmptyValues;
try {
this.parser = JSON_FACTORY.createNonBlockingByteArrayParser();
} catch (IOException e) {
Expand All @@ -110,7 +125,10 @@ protected void init() {
this.context = createContext();
this.downstream =
new FeatureTokenBufferSimple<>(
FeatureEventHandlerReadOnly.of(getDownstream(), readOnlyProperties), context);
FeatureEventHandlerReadOnly.of(
FeatureEventHandlerEmptyValues.of(getDownstream(), rejectEmptyValues),
readOnlyProperties),
context);
}

@Override
Expand Down
Loading
Loading