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,