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 93cba3fe9..0d8cb9c18 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 @@ -51,6 +51,7 @@ "role", "valueType", "geometryType", + "geometryTypes", "objectType", "label", "alias", @@ -198,6 +199,19 @@ default Type getType() { @Override Optional getGeometryType(); + /** + * @langEn Multiple admissible geometry types for properties with `type: GEOMETRY`. Use this + * instead of `geometryType` when more than one geometry type is allowed (e.g. `[POINT, + * MULTI_POINT]`). Values are the same as for `geometryType`. + * @langDe Mehrere zulässige Geometrietypen für Eigenschaften mit `type: GEOMETRY`. Wird anstelle + * von `geometryType` verwendet, wenn mehr als ein Geometrietyp erlaubt ist (z.B. `[POINT, + * MULTI_POINT]`). Werte siehe `geometryType`. + * @default [] + * @since v4.8 + */ + @Override + List getGeometryTypes(); + /** * @langEn Optional name for an object type, used for example in JSON Schema. For properties that * should be mapped as links according to *RFC 8288*, use `Link`. @@ -805,6 +819,21 @@ default void concatConstraints() { } } + @Value.Check + default void warnOnConflictingGeometryTypes() { + if (getGeometryType().isPresent() && !getGeometryTypes().isEmpty()) { + List types = getGeometryTypes(); + boolean consistent = types.size() == 1 && types.get(0) == getGeometryType().get(); + if (!consistent) { + LOGGER.warn( + "Both 'geometryType' ({}) and 'geometryTypes' ({}) are set on property '{}'; 'geometryTypes' takes precedence.", + getGeometryType().get(), + types, + getFullPathAsString()); + } + } + } + @Value.Check default void disallowFlattening() { Preconditions.checkState( 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 7fd323be8..96a8dc731 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 @@ -7,8 +7,6 @@ */ package de.ii.xtraplatform.features.domain; -import static de.ii.xtraplatform.geometries.domain.GeometryType.ANY; - import com.fasterxml.jackson.annotation.JsonIgnore; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableSet; @@ -149,6 +147,8 @@ public static List allBut(Scope... scopes) { Optional getGeometryType(); + List getGeometryTypes(); + Optional getFormat(); Optional getRefType(); @@ -373,27 +373,36 @@ default List getPrimaryGeometries() { @JsonIgnore @Value.Derived @Value.Auxiliary - default List getGeometryTypes() { + default List collectEffectiveGeometryTypes() { return getPrimaryGeometries().stream() - .map(SchemaBase::getGeometryType) - .filter(Optional::isPresent) - .map(Optional::get) + .map(SchemaBase::getEffectiveGeometryTypes) + .flatMap(List::stream) .distinct() .collect(Collectors.toList()); } + @JsonIgnore + @Value.Derived + @Value.Auxiliary + default List getEffectiveGeometryTypes() { + return getGeometryTypes().isEmpty() + ? List.of(getGeometryType().orElse(GeometryType.ANY)) + : getGeometryTypes(); + } + @JsonIgnore @Value.Derived @Value.Auxiliary default GeometryType getEffectiveGeometryType() { - return getGeometryTypes().stream().reduce((a, b) -> ANY).orElse(ANY); + return GeometryType.effectiveType( + isSpatial() ? getEffectiveGeometryTypes() : collectEffectiveGeometryTypes()); } @JsonIgnore @Value.Derived @Value.Auxiliary default Optional getEffectiveGeometryDimension() { - return getGeometryTypes().stream() + return collectEffectiveGeometryTypes().stream() .map(GeometryType::getGeometryDimension) .reduce( (a, b) -> { diff --git a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/SchemaDeriver.java b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/SchemaDeriver.java index ffff2da98..26ff2906d 100644 --- a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/SchemaDeriver.java +++ b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/SchemaDeriver.java @@ -338,8 +338,7 @@ protected T deriveValueSchema(FeatureSchema schema) { break; case GEOMETRY: valueSchema = - getSchemaForGeometry( - schema.getGeometryType().orElse(GeometryType.ANY), label, description, role); + getSchemaForGeometry(schema.getEffectiveGeometryTypes(), label, description, role); break; case OBJECT: case OBJECT_ARRAY: @@ -438,7 +437,7 @@ protected abstract T getSchemaForLiteralType( Optional codelistId); protected abstract T getSchemaForGeometry( - GeometryType geometryType, + List geometryTypes, Optional title, Optional description, Optional role); diff --git a/xtraplatform-features/src/test/groovy/de/ii/xtraplatform/features/domain/FeatureSchemaGeometryTypesSpec.groovy b/xtraplatform-features/src/test/groovy/de/ii/xtraplatform/features/domain/FeatureSchemaGeometryTypesSpec.groovy new file mode 100644 index 000000000..0cf1bfd13 --- /dev/null +++ b/xtraplatform-features/src/test/groovy/de/ii/xtraplatform/features/domain/FeatureSchemaGeometryTypesSpec.groovy @@ -0,0 +1,183 @@ +/* + * Copyright 2026 interactive instruments GmbH + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +package de.ii.xtraplatform.features.domain + +import de.ii.xtraplatform.geometries.domain.GeometryType +import spock.lang.Specification + +class FeatureSchemaGeometryTypesSpec extends Specification { + + static FeatureSchema geom(GeometryType single, List multi) { + def b = new ImmutableFeatureSchema.Builder() + .name("g") + .type(SchemaBase.Type.GEOMETRY) + .sourcePath("g") + if (single != null) { + b.geometryType(single) + } + if (multi != null) { + b.geometryTypes(multi) + } + return b.build() + } + + def "property with no geometry types: effective is ANY, list contains ANY"() { + when: + def schema = geom(null, null) + + then: + schema.effectiveGeometryType == GeometryType.ANY + schema.effectiveGeometryTypes == [GeometryType.ANY] + } + + def "property with only geometryType: effective is that type"() { + when: + def schema = geom(GeometryType.POINT, null) + + then: + schema.effectiveGeometryType == GeometryType.POINT + schema.effectiveGeometryTypes == [GeometryType.POINT] + } + + def "property with single entry in geometryTypes: effective is that entry"() { + when: + def schema = geom(null, [GeometryType.MULTI_POLYGON]) + + then: + schema.effectiveGeometryType == GeometryType.MULTI_POLYGON + schema.effectiveGeometryTypes == [GeometryType.MULTI_POLYGON] + } + + def "property with two simple-feature entries: effective is ANY"() { + when: + def schema = geom(null, [GeometryType.POINT, GeometryType.MULTI_POINT]) + + then: + schema.effectiveGeometryType == GeometryType.ANY + schema.effectiveGeometryTypes.toSet() == + [GeometryType.POINT, GeometryType.MULTI_POINT].toSet() + } + + def "property with non-simple-feature entry: effective is ANY_EXTENDED"() { + when: + def schema = geom(null, [GeometryType.LINE_STRING, GeometryType.CIRCULAR_STRING]) + + then: + schema.effectiveGeometryType == GeometryType.ANY_EXTENDED + } + + def "property with three curve entries: effective is ANY_EXTENDED"() { + when: + def schema = geom(null, [ + GeometryType.LINE_STRING, + GeometryType.CIRCULAR_STRING, + GeometryType.COMPOUND_CURVE + ]) + + then: + schema.effectiveGeometryType == GeometryType.ANY_EXTENDED + } + + def "property with both fields set consistently: result is that single type"() { + when: + def schema = geom(GeometryType.POINT, [GeometryType.POINT]) + + then: + schema.effectiveGeometryType == GeometryType.POINT + } + + def "property with both fields set differently: geometryTypes wins"() { + when: + def schema = geom(GeometryType.POINT, [GeometryType.POINT, GeometryType.MULTI_POINT]) + + then: + schema.effectiveGeometryType == GeometryType.ANY + } + + /** Builds a concat'd feature whose branches each carry one PRIMARY_GEOMETRY of the given type. */ + static FeatureSchema concatFeature(GeometryType... branchTypes) { + def branches = branchTypes.toList().withIndex().collect { GeometryType t, int i -> + new ImmutableFeatureSchema.Builder() + .name("branch${i}" as String) + .sourcePath("/branch${i}" as String) + .putProperties2("id", new ImmutableFeatureSchema.Builder() + .sourcePath("id") + .type(SchemaBase.Type.INTEGER) + .role(SchemaBase.Role.ID)) + .putProperties2("geometry", new ImmutableFeatureSchema.Builder() + .sourcePath("geom") + .type(SchemaBase.Type.GEOMETRY) + .geometryType(t) + .role(SchemaBase.Role.PRIMARY_GEOMETRY)) + .build() + } + return new ImmutableFeatureSchema.Builder() + .name("test") + .type(SchemaBase.Type.OBJECT_ARRAY) + .concat(branches) + .build() + } + + def "non-concat feature: getPrimaryGeometries() has 0 or 1 entry"() { + given: + def withGeom = new ImmutableFeatureSchema.Builder() + .name("test").type(SchemaBase.Type.OBJECT).sourcePath("/t") + .putProperties2("id", new ImmutableFeatureSchema.Builder() + .sourcePath("id").type(SchemaBase.Type.INTEGER).role(SchemaBase.Role.ID)) + .putProperties2("geometry", new ImmutableFeatureSchema.Builder() + .sourcePath("geom").type(SchemaBase.Type.GEOMETRY) + .geometryType(GeometryType.POINT).role(SchemaBase.Role.PRIMARY_GEOMETRY)) + .build() + def withoutGeom = new ImmutableFeatureSchema.Builder() + .name("test").type(SchemaBase.Type.OBJECT).sourcePath("/t") + .putProperties2("id", new ImmutableFeatureSchema.Builder() + .sourcePath("id").type(SchemaBase.Type.INTEGER).role(SchemaBase.Role.ID)) + .build() + + expect: + withGeom.primaryGeometries.size() == 1 + withGeom.collectEffectiveGeometryTypes() == [GeometryType.POINT] + withGeom.effectiveGeometryType == GeometryType.POINT + withoutGeom.primaryGeometries.isEmpty() + withoutGeom.collectEffectiveGeometryTypes().isEmpty() + withoutGeom.effectiveGeometryType == GeometryType.ANY + } + + def "concat with two branches, same primary geometry type: list has 2 entries, effective is that type"() { + when: + def schema = concatFeature(GeometryType.MULTI_POLYGON, GeometryType.MULTI_POLYGON) + + then: + schema.primaryGeometries.size() == 2 + schema.collectEffectiveGeometryTypes() == [GeometryType.MULTI_POLYGON] + schema.effectiveGeometryType == GeometryType.MULTI_POLYGON + } + + def "concat with branches POINT + MULTI_POINT: list has 2 entries, effective is ANY"() { + when: + def schema = concatFeature(GeometryType.POINT, GeometryType.MULTI_POINT) + + then: + schema.primaryGeometries.size() == 2 + schema.collectEffectiveGeometryTypes().toSet() == + [GeometryType.POINT, GeometryType.MULTI_POINT].toSet() + schema.effectiveGeometryType == GeometryType.ANY + } + + def "concat with three differing simple-feature branches: effective is ANY"() { + when: + def schema = concatFeature( + GeometryType.POINT, GeometryType.LINE_STRING, GeometryType.POLYGON) + + then: + schema.primaryGeometries.size() == 3 + schema.collectEffectiveGeometryTypes().toSet() == + [GeometryType.POINT, GeometryType.LINE_STRING, GeometryType.POLYGON].toSet() + schema.effectiveGeometryType == GeometryType.ANY + } +} diff --git a/xtraplatform-geometries/src/main/java/de/ii/xtraplatform/geometries/domain/GeometryType.java b/xtraplatform-geometries/src/main/java/de/ii/xtraplatform/geometries/domain/GeometryType.java index 4d18159bd..7a203cd07 100644 --- a/xtraplatform-geometries/src/main/java/de/ii/xtraplatform/geometries/domain/GeometryType.java +++ b/xtraplatform-geometries/src/main/java/de/ii/xtraplatform/geometries/domain/GeometryType.java @@ -7,6 +7,7 @@ */ package de.ii.xtraplatform.geometries.domain; +import java.util.Collection; import java.util.Objects; import java.util.Optional; import java.util.Set; @@ -61,6 +62,21 @@ public static boolean onlySimpleFeatureGeometries(Set geometryType return geometryTypes.stream().allMatch(GeometryType::isSimpleFeature); } + /** + * Collapses a list of admissible geometry types to a single effective type: empty -> {@code ANY}; + * one entry -> that entry; more than one -> {@code ANY} when all entries are simple-feature + * types, otherwise {@code ANY_EXTENDED}. + */ + public static GeometryType effectiveType(Collection geometryTypes) { + if (geometryTypes.isEmpty()) { + return ANY; + } + if (geometryTypes.size() == 1) { + return geometryTypes.iterator().next(); + } + return onlySimpleFeatureGeometries(Set.copyOf(geometryTypes)) ? ANY : ANY_EXTENDED; + } + public Optional getGeometryDimension() { return Optional.ofNullable(geometryDimension); } diff --git a/xtraplatform-jsonschema/src/main/java/de/ii/xtraplatform/jsonschema/domain/JsonSchemaBuildingBlocks.java b/xtraplatform-jsonschema/src/main/java/de/ii/xtraplatform/jsonschema/domain/JsonSchemaBuildingBlocks.java index b661374c3..1d97da8bf 100644 --- a/xtraplatform-jsonschema/src/main/java/de/ii/xtraplatform/jsonschema/domain/JsonSchemaBuildingBlocks.java +++ b/xtraplatform-jsonschema/src/main/java/de/ii/xtraplatform/jsonschema/domain/JsonSchemaBuildingBlocks.java @@ -26,8 +26,6 @@ public interface JsonSchemaBuildingBlocks { new ImmutableJsonSchemaGeometry.Builder().format("geometry-circularstring").build(); JsonSchemaGeometry COMPOUND_CURVE = new ImmutableJsonSchemaGeometry.Builder().format("geometry-compoundcurve").build(); - JsonSchemaGeometry CURVE = - new ImmutableJsonSchemaGeometry.Builder().format("geometry-curve").build(); JsonSchemaGeometry MULTI_LINE_STRING = new ImmutableJsonSchemaGeometry.Builder().format("geometry-multilinestring").build(); JsonSchemaGeometry LINE_STRING_OR_MULTI_LINE_STRING = @@ -40,8 +38,6 @@ public interface JsonSchemaBuildingBlocks { new ImmutableJsonSchemaGeometry.Builder().format("geometry-polygon").build(); JsonSchemaGeometry CURVE_POLYGON = new ImmutableJsonSchemaGeometry.Builder().format("geometry-curvepolygon").build(); - JsonSchemaGeometry SURFACE = - new ImmutableJsonSchemaGeometry.Builder().format("geometry-surface").build(); JsonSchemaGeometry POLYHEDRAL_SURFACE = new ImmutableJsonSchemaGeometry.Builder().format("geometry-polyhedralsurface").build(); JsonSchemaGeometry MULTI_POLYGON = diff --git a/xtraplatform-jsonschema/src/main/java/de/ii/xtraplatform/jsonschema/domain/JsonSchemaGeometry.java b/xtraplatform-jsonschema/src/main/java/de/ii/xtraplatform/jsonschema/domain/JsonSchemaGeometry.java index c7350210e..ff5eb9185 100644 --- a/xtraplatform-jsonschema/src/main/java/de/ii/xtraplatform/jsonschema/domain/JsonSchemaGeometry.java +++ b/xtraplatform-jsonschema/src/main/java/de/ii/xtraplatform/jsonschema/domain/JsonSchemaGeometry.java @@ -7,9 +7,12 @@ */ package de.ii.xtraplatform.jsonschema.domain; +import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.databind.annotation.JsonDeserialize; import com.google.common.hash.Funnel; import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Optional; import org.immutables.value.Value; @Value.Immutable @@ -20,9 +23,14 @@ public abstract class JsonSchemaGeometry extends JsonSchema { public static final Funnel FUNNEL = (from, into) -> { into.putString(from.getFormat(), StandardCharsets.UTF_8); + from.getGeometryTypes() + .ifPresent(types -> types.forEach(t -> into.putString(t, StandardCharsets.UTF_8))); }; public abstract String getFormat(); + @JsonProperty("x-ldproxy-geometryTypes") + public abstract Optional> getGeometryTypes(); + public abstract static class Builder extends JsonSchema.Builder {} }