From 19669fe92a76ca6633a100138be7ce51fd4fd7d4 Mon Sep 17 00:00:00 2001 From: Clemens Portele Date: Fri, 19 Jun 2026 08:42:38 +0200 Subject: [PATCH] fix empty object emitted for optional property with only null values MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FeatureTokenTransformerRemoveEmptyOptionals defers an optional object and opens it lazily when a sub-property has to be emitted. It kept null values of any property marked required, so a required-but-null sub-property forced its optional wrapper open — emitted as an empty object ({} in GeoJSON, an empty element in GML) instead of being omitted. onValue/onGeometry now treat a property as required only if it and every wrapping property up to the feature are required, reusing the rule the context already exposes via isRequired(schema, parentSchemas). A required sub-property inside an optional object is therefore not effectively required: when all of the object's values are null the wrapper is dropped, while a wrapper that still carries non-null content drops only its null required siblings. The change is at the token level, so both the GeoJSON and GML encoders benefit. Adds spec coverage for the all-null and partial cases. --- ...eTokenTransformerRemoveEmptyOptionals.java | 25 +++++- ...TransformerRemoveEmptyOptionalsSpec.groovy | 28 +++++++ .../domain/FeatureSchemaFixtures.groovy | 17 ++++ .../domain/FeatureTokenFixtures.groovy | 80 +++++++++++++++++++ 4 files changed, 148 insertions(+), 2 deletions(-) diff --git a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureTokenTransformerRemoveEmptyOptionals.java b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureTokenTransformerRemoveEmptyOptionals.java index eb4abe5da..b4bd7e7e9 100644 --- a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureTokenTransformerRemoveEmptyOptionals.java +++ b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureTokenTransformerRemoveEmptyOptionals.java @@ -107,7 +107,7 @@ public void onGeometry(ModifiableContext context) } if (Objects.nonNull(context.geometry()) - || context.schema().get().isRequired() + || isEffectivelyRequired(context) || !removeNullValues.getOrDefault(context.type(), true)) { openIfNecessary(context); @@ -122,7 +122,7 @@ public void onValue(ModifiableContext context) { } if (Objects.nonNull(context.value()) - || context.schema().get().isRequired() + || isEffectivelyRequired(context) || !removeNullValues.getOrDefault(context.type(), true)) { openIfNecessary(context); @@ -130,6 +130,27 @@ public void onValue(ModifiableContext context) { } } + /** + * A null value is kept only if its property is effectively required, i.e. the property itself and + * every wrapping property up to (but excluding) the feature are required. A required property + * inside an optional object must not keep that object alive when all of its values are null: in + * that case the optional wrapper, together with the null value, is removed. Without this check + * the wrapping property is only ever looked at indirectly, so an optional object whose required + * sub-properties are all null is emitted as an empty object instead of being omitted. + */ + private boolean isEffectivelyRequired(ModifiableContext context) { + if (context.schema().isEmpty() || !context.schema().get().isRequired()) { + return false; + } + + List parentSchemas = context.parentSchemas(); + + return parentSchemas.size() <= 1 + || parentSchemas.stream() + .limit(parentSchemas.size() - 1L) + .allMatch(FeatureSchema::isRequired); + } + private void openIfNecessary(ModifiableContext context) { if (!schemaStack.isEmpty()) { List previousPath = context.path(); diff --git a/xtraplatform-features/src/test/groovy/de/ii/xtraplatform/features/domain/FeatureTokenTransformerRemoveEmptyOptionalsSpec.groovy b/xtraplatform-features/src/test/groovy/de/ii/xtraplatform/features/domain/FeatureTokenTransformerRemoveEmptyOptionalsSpec.groovy index 88c8ffee4..b7de86277 100644 --- a/xtraplatform-features/src/test/groovy/de/ii/xtraplatform/features/domain/FeatureTokenTransformerRemoveEmptyOptionalsSpec.groovy +++ b/xtraplatform-features/src/test/groovy/de/ii/xtraplatform/features/domain/FeatureTokenTransformerRemoveEmptyOptionalsSpec.groovy @@ -88,6 +88,34 @@ class FeatureTokenTransformerRemoveEmptyOptionalsSpec extends Specification { } + def 'single feature optional object with only null required sub-properties'() { + + given: + + when: + + FeatureTokenFixtures.SINGLE_FEATURE_NESTED_OBJECT_REQUIRED_NULLS.forEach(token -> tokenReader.onToken(token)) + + then: + + tokens == FeatureTokenFixtures.SINGLE_FEATURE + + } + + def 'single feature optional object with a non-null and a null required sub-property'() { + + given: + + when: + + FeatureTokenFixtures.SINGLE_FEATURE_NESTED_OBJECT_REQUIRED_NULLS_PARTIAL.forEach(token -> tokenReader.onToken(token)) + + then: + + tokens == FeatureTokenFixtures.SINGLE_FEATURE_NESTED_OBJECT_REQUIRED_NULLS_PARTIAL_REDUCED + + } + def 'single feature value array'() { given: diff --git a/xtraplatform-features/src/testFixtures/groovy/de/ii/xtraplatform/features/domain/FeatureSchemaFixtures.groovy b/xtraplatform-features/src/testFixtures/groovy/de/ii/xtraplatform/features/domain/FeatureSchemaFixtures.groovy index 370ce285c..60b4affe8 100644 --- a/xtraplatform-features/src/testFixtures/groovy/de/ii/xtraplatform/features/domain/FeatureSchemaFixtures.groovy +++ b/xtraplatform-features/src/testFixtures/groovy/de/ii/xtraplatform/features/domain/FeatureSchemaFixtures.groovy @@ -429,6 +429,23 @@ class FeatureSchemaFixtures { .required(true) .build()) .sourcePath("name")) + .putProperties2("optional_with_required", + new ImmutableFeatureSchema.Builder() + .type(Type.OBJECT) + .putProperties2("numerator", + new ImmutableFeatureSchema.Builder() + .type(Type.FLOAT) + .constraints(new ImmutableSchemaConstraints.Builder() + .required(true) + .build()) + .sourcePath("numerator")) + .putProperties2("denominator", + new ImmutableFeatureSchema.Builder() + .type(Type.FLOAT) + .constraints(new ImmutableSchemaConstraints.Builder() + .required(true) + .build()) + .sourcePath("denominator"))) .build() public static final SchemaMapping BIOTOP_MAPPING = new ImmutableSchemaMapping.Builder() diff --git a/xtraplatform-features/src/testFixtures/groovy/de/ii/xtraplatform/features/domain/FeatureTokenFixtures.groovy b/xtraplatform-features/src/testFixtures/groovy/de/ii/xtraplatform/features/domain/FeatureTokenFixtures.groovy index e4a60a6d9..dfbbb79b5 100644 --- a/xtraplatform-features/src/testFixtures/groovy/de/ii/xtraplatform/features/domain/FeatureTokenFixtures.groovy +++ b/xtraplatform-features/src/testFixtures/groovy/de/ii/xtraplatform/features/domain/FeatureTokenFixtures.groovy @@ -227,6 +227,86 @@ class FeatureTokenFixtures { FeatureTokenType.INPUT_END ] + public static final List SINGLE_FEATURE_NESTED_OBJECT_REQUIRED_NULLS = [ + FeatureTokenType.INPUT, + true, + FeatureTokenType.FEATURE, + FeatureTokenType.VALUE, + ["id"], + "24", + Type.STRING, + FeatureTokenType.OBJECT, + ["optional_with_required"], + FeatureTokenType.VALUE, + ["optional_with_required", "numerator"], + null, + Type.FLOAT, + FeatureTokenType.VALUE, + ["optional_with_required", "denominator"], + null, + Type.FLOAT, + FeatureTokenType.OBJECT_END, + ["optional_with_required"], + FeatureTokenType.VALUE, + ["kennung"], + "611320001-1", + Type.STRING, + FeatureTokenType.FEATURE_END, + FeatureTokenType.INPUT_END + ] + + public static final List SINGLE_FEATURE_NESTED_OBJECT_REQUIRED_NULLS_PARTIAL = [ + FeatureTokenType.INPUT, + true, + FeatureTokenType.FEATURE, + FeatureTokenType.VALUE, + ["id"], + "24", + Type.STRING, + FeatureTokenType.OBJECT, + ["optional_with_required"], + FeatureTokenType.VALUE, + ["optional_with_required", "numerator"], + "0.5", + Type.FLOAT, + FeatureTokenType.VALUE, + ["optional_with_required", "denominator"], + null, + Type.FLOAT, + FeatureTokenType.OBJECT_END, + ["optional_with_required"], + FeatureTokenType.VALUE, + ["kennung"], + "611320001-1", + Type.STRING, + FeatureTokenType.FEATURE_END, + FeatureTokenType.INPUT_END + ] + + public static final List SINGLE_FEATURE_NESTED_OBJECT_REQUIRED_NULLS_PARTIAL_REDUCED = [ + FeatureTokenType.INPUT, + true, + FeatureTokenType.FEATURE, + FeatureTokenType.VALUE, + ["id"], + "24", + Type.STRING, + FeatureTokenType.OBJECT, + ["optional_with_required"], + FeatureTokenType.VALUE, + ["optional_with_required", "numerator"], + "0.5", + Type.FLOAT, + FeatureTokenType.OBJECT_END, + ["optional_with_required"], + FeatureTokenType.VALUE, + ["kennung"], + "611320001-1", + Type.STRING, + FeatureTokenType.FEATURE_END, + FeatureTokenType.INPUT_END + ] + public static final List SINGLE_FEATURE_VALUE_ARRAY = [ FeatureTokenType.INPUT, true,