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 0904560f5..8584611df 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 @@ -420,7 +420,8 @@ public FeatureTokenDecoderGml( Map srsNameMappings = new LinkedHashMap<>(inputProfile.getSrsNameMappings()); Set verticalSrsNames = new HashSet<>(); collectVariantReferenceSystems(featureSchema, srsNameMappings, verticalSrsNames); - this.geometryDecoder = new GeometryDecoderGml(srsNameMappings, verticalSrsNames); + this.geometryDecoder = + new GeometryDecoderGml(srsNameMappings, verticalSrsNames, inputProfile.getSupportedCrs()); this.buffer = new StringBuilder(); List wrappers = new ArrayList<>(2); 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 4c394e68a..883874dcb 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 @@ -187,6 +187,13 @@ default String getFeatureMemberElementName() { */ Set getObjectTypeSuffixedProperties(); + /** + * The coordinate reference systems that a {@code srsName} attribute in the document may declare. + * An empty list does not restrict them; a value that resolves to another coordinate reference + * system is rejected. + */ + List getSupportedCrs(); + static FeatureTokenDecoderGmlInputProfile empty() { return ImmutableFeatureTokenDecoderGmlInputProfile.builder().build(); } 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 0759496bc..c0dddb9ce 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,6 +130,8 @@ static class Frame { private final Deque stack = new ArrayDeque<>(); private final Map srsNameMappings; + // the coordinate reference systems that a srsName may resolve to; empty does not restrict them + private final List supportedCrs; private final Set verticalSrsNames; private boolean waitingForInput = false; private Geometry result; @@ -164,7 +166,7 @@ public GeometryDecoderGml() { * built-in parsers cannot handle. */ public GeometryDecoderGml(Map srsNameMappings) { - this(srsNameMappings, Set.of()); + this(srsNameMappings, Set.of(), List.of()); } /** @@ -173,8 +175,20 @@ public GeometryDecoderGml(Map srsNameMappings) { * {@link #getVerticalValue()}) instead of being decoded into a {@link Geometry}. */ public GeometryDecoderGml(Map srsNameMappings, Set verticalSrsNames) { + this(srsNameMappings, verticalSrsNames, List.of()); + } + + /** + * @param supportedCrs the coordinate reference systems that a {@code srsName} attribute may + * resolve to; an empty list does not restrict them + */ + public GeometryDecoderGml( + Map srsNameMappings, + Set verticalSrsNames, + List supportedCrs) { this.srsNameMappings = srsNameMappings == null ? Map.of() : srsNameMappings; this.verticalSrsNames = verticalSrsNames == null ? Set.of() : verticalSrsNames; + this.supportedCrs = supportedCrs == null ? List.of() : supportedCrs; } /** The verbatim srsName of the outermost geometry element of the last completed decode. */ @@ -353,7 +367,8 @@ private boolean handleStart( this.rawSrsName = parser.getAttributeValue(null, "srsName"); this.verticalMode = rawSrsName != null && verticalSrsNames.contains(rawSrsName); } - Optional explicitCrs = parseSrsName(parser, srsNameMappings); + Optional explicitCrs = + parseSrsName(parser, srsNameMappings).map(this::checkSupported); f.crs = explicitCrs.or(() -> defaultCrs).or(this::inheritedCrs); OptionalInt dim = parseSrsDimension(parser); if (dim.isEmpty()) { @@ -540,6 +555,22 @@ private OptionalInt inheritedSrsDimension() { return OptionalInt.empty(); } + /** + * A coordinate reference system declared on a geometry must be one of the supported ones. Only + * the code is compared, the axis order is a property of the encoding, not of the coordinate + * reference system. + */ + private EpsgCrs checkSupported(EpsgCrs crs) { + if (!supportedCrs.isEmpty() + && supportedCrs.stream().noneMatch(supported -> supported.getCode() == crs.getCode())) { + throw new IllegalArgumentException( + String.format( + "The coordinate reference system '%s' in a 'srsName' attribute is not supported here. Supported coordinate reference systems: %s.", + crs.toUriString(), supportedCrs.stream().map(EpsgCrs::toUriString).toList())); + } + return crs; + } + private static Optional parseSrsName( AsyncXMLStreamReader parser, Map srsNameMappings) { String srsName = parser.getAttributeValue(null, "srsName"); diff --git a/xtraplatform-features-json/src/main/java/de/ii/xtraplatform/features/json/domain/FeatureTokenDecoderGeoJson.java b/xtraplatform-features-json/src/main/java/de/ii/xtraplatform/features/json/domain/FeatureTokenDecoderGeoJson.java index 5b137dfb1..9e284b721 100644 --- a/xtraplatform-features-json/src/main/java/de/ii/xtraplatform/features/json/domain/FeatureTokenDecoderGeoJson.java +++ b/xtraplatform-features-json/src/main/java/de/ii/xtraplatform/features/json/domain/FeatureTokenDecoderGeoJson.java @@ -26,6 +26,7 @@ import de.ii.xtraplatform.geometries.domain.transcode.json.GeometryDecoderJson; import java.io.IOException; import java.nio.charset.StandardCharsets; +import java.util.List; import java.util.Objects; import java.util.Optional; @@ -44,6 +45,7 @@ public class FeatureTokenDecoderGeoJson private final Optional nullValue; private final EpsgCrs crs; private final Axes axes; + private final GeometryDecoderJson geometryDecoder; private boolean started; private int depth = -1; @@ -68,6 +70,15 @@ public class FeatureTokenDecoderGeoJson downstream; public FeatureTokenDecoderGeoJson(Optional nullValue, EpsgCrs crs, Axes axes) { + this(nullValue, crs, axes, List.of()); + } + + /** + * @param supportedCrs the coordinate reference systems that a {@code coordRefSys} member in the + * document may declare; an empty list does not restrict them + */ + public FeatureTokenDecoderGeoJson( + Optional nullValue, EpsgCrs crs, Axes axes, List supportedCrs) { super(); try { this.parser = JSON_FACTORY.createNonBlockingByteArrayParser(); @@ -78,6 +89,7 @@ public FeatureTokenDecoderGeoJson(Optional nullValue, EpsgCrs crs, Axes this.nullValue = nullValue; this.crs = crs; this.axes = axes; + this.geometryDecoder = new GeometryDecoderJson(false, supportedCrs); } @Override @@ -146,7 +158,7 @@ public boolean advanceParser() { if (geometryDepth == 0) { JsonNode geomNode = OBJECT_MAPPER.readTree(geometryBuffer.asParser()); Geometry geometry = - new GeometryDecoderJson().decode(geomNode, Optional.of(crs), Optional.of(axes)); + geometryDecoder.decode(geomNode, Optional.of(crs), Optional.of(axes)); context.setGeometry(geometry); context.pathTracker().track(geometryFieldName, 0); downstream.onGeometry(context); diff --git a/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/domain/FeatureProviderSql.java b/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/domain/FeatureProviderSql.java index 70ca24798..2110d7475 100644 --- a/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/domain/FeatureProviderSql.java +++ b/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/domain/FeatureProviderSql.java @@ -582,7 +582,8 @@ protected boolean onStartup() throws InterruptedException { ? dbmsAdapters.get(getData().getConnectionInfo().getDialect()).getDefaultSchemas() : getData().getConnectionInfo().getSchemas(); this.sourceSchemaValidator = - new SourceSchemaValidatorSql(validationSchemas, this::getSqlClient); + new SourceSchemaValidatorSql( + validationSchemas, getData().getNativeTimeZone(), this::getSqlClient); this.pathParser3 = createPathParser3(getData().getSourcePathDefaults(), cql, subdecoders); diff --git a/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/infra/db/SchemaInfo.java b/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/infra/db/SchemaInfo.java index 55baee4ae..c4d4cb9d8 100644 --- a/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/infra/db/SchemaInfo.java +++ b/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/infra/db/SchemaInfo.java @@ -10,7 +10,9 @@ import de.ii.xtraplatform.features.domain.SchemaBase.Type; import de.ii.xtraplatform.features.domain.Tuple; import de.ii.xtraplatform.features.sql.domain.ValueTypeMapping; +import java.sql.JDBCType; import java.util.Collection; +import java.util.Locale; import java.util.Objects; import java.util.Optional; import org.slf4j.Logger; @@ -106,6 +108,34 @@ public boolean isColumnTemporal(String table, String name) { .isPresent(); } + /** + * Whether the column has a time zone, e.g. PostgreSQL {@code timestamptz}. Drivers differ in the + * JDBC type they report for such columns, so the database-specific type name is checked, too. + */ + public boolean isColumnTemporalWithTimeZone(String table, String name) { + return getColumn(table, name) + .map(Column::getColumnDataType) + .filter( + t -> + Objects.equals(t.getJavaSqlType(), JDBCType.TIMESTAMP_WITH_TIMEZONE) + || Objects.equals(t.getJavaSqlType(), JDBCType.TIME_WITH_TIMEZONE) + || hasTimeZoneInName(t.getDatabaseSpecificTypeName()) + || hasTimeZoneInName(t.getName())) + .isPresent(); + } + + private static boolean hasTimeZoneInName(String typeName) { + if (Objects.isNull(typeName)) { + return false; + } + // "timestamptz"/"timetz" (PostgreSQL), "TIMESTAMP WITH TIME ZONE" and Oracle's + // "TIMESTAMP WITH LOCAL TIME ZONE"; "timestamp without time zone" must not match + String name = typeName.toLowerCase(Locale.ROOT); + return name.endsWith("tz") + || name.contains("with time zone") + || name.contains("with local time zone"); + } + public Optional getColumn(String tableName, String columnName) { return getColumn(tableName, columnName, false, false); } diff --git a/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/infra/db/SourceSchemaValidatorSql.java b/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/infra/db/SourceSchemaValidatorSql.java index cc0c2e1e6..fcd6fd3e5 100644 --- a/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/infra/db/SourceSchemaValidatorSql.java +++ b/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/infra/db/SourceSchemaValidatorSql.java @@ -17,8 +17,12 @@ import de.ii.xtraplatform.features.sql.domain.SqlClient; import de.ii.xtraplatform.features.sql.domain.SqlRelation; import java.io.IOException; +import java.time.ZoneId; +import java.time.ZoneOffset; import java.util.ArrayList; import java.util.List; +import java.util.Objects; +import java.util.Optional; import java.util.function.Supplier; import java.util.stream.Collectors; import org.slf4j.Logger; @@ -35,12 +39,17 @@ public class SourceSchemaValidatorSql implements SourceSchemaValidator schemas; + private final Optional nativeTimeZone; private Supplier sqlClient; - public SourceSchemaValidatorSql(List schemas, Supplier sqlClient) { + public SourceSchemaValidatorSql( + List schemas, Optional nativeTimeZone, Supplier sqlClient) { this.schemas = schemas; + this.nativeTimeZone = nativeTimeZone; this.sqlClient = sqlClient; } @@ -213,6 +222,22 @@ private ValidationResult validate( tableSchema.getName(), "datetime")); } + + if (attribute.isTemporal() + && hasNonUtcTimeZone() + && schemaInfo.isColumnTemporalWithTimeZone( + tableSchema.getName(), attribute.getName())) { + result.addWarnings( + String.format( + COLUMN_WITH_TIME_ZONE, + String.format( + "Potentially incorrect datetime values for property '%s' in type '%s'", + attribute.getSourcePath().orElse("???"), typeName), + attribute.getName(), + tableSchema.getName(), + nativeTimeZone.get(), + nativeTimeZone.get())); + } } } }); @@ -220,6 +245,14 @@ private ValidationResult validate( return result.build(); } + // The values of a column with a time zone are read in UTC (the time zone of every connection), + // so only a native time zone other than UTC changes their meaning. + private boolean hasNonUtcTimeZone() { + return nativeTimeZone + .filter(zone -> !Objects.equals(zone.getRules(), ZoneOffset.UTC.getRules())) + .isPresent(); + } + private List getAllUsedTables(List schemaSql) { return schemaSql.stream() .flatMap(schemaSql1 -> schemaSql1.getAllObjects().stream()) diff --git a/xtraplatform-features-sql/src/test/groovy/de/ii/xtraplatform/features/sql/infra/db/SchemaInfoSpec.groovy b/xtraplatform-features-sql/src/test/groovy/de/ii/xtraplatform/features/sql/infra/db/SchemaInfoSpec.groovy new file mode 100644 index 000000000..e1085f415 --- /dev/null +++ b/xtraplatform-features-sql/src/test/groovy/de/ii/xtraplatform/features/sql/infra/db/SchemaInfoSpec.groovy @@ -0,0 +1,93 @@ +/* + * 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.sql.infra.db + +import schemacrawler.crawl.MutableColumn +import schemacrawler.crawl.MutableColumnDataType +import schemacrawler.crawl.MutableTable +import schemacrawler.schema.Column +import schemacrawler.schema.ColumnDataType +import schemacrawler.schema.DataTypeType +import schemacrawler.schema.JavaSqlType +import schemacrawler.schema.JavaSqlTypeGroup +import schemacrawler.schema.Table +import schemacrawler.schemacrawler.SchemaReference +import spock.lang.Specification + +import java.sql.JDBCType +import java.sql.Timestamp + +/** + * Detection of columns with a time zone. Only the values of such a column carry an offset in the + * data source, so the provider option "nativeTimeZone" must not be applied to them; the source + * schema validator reports the combination as a warning. Drivers disagree about the JDBC type they + * report for these columns, hence the fallback to the type name. + */ +class SchemaInfoSpec extends Specification { + + def 'temporal columns with and without a time zone'() { + + given: 'a table with the temporal column types of the supported dialects' + SchemaReference schema = new SchemaReference("catalog", "schema") + Table table = new MutableTable(schema, "table1") + addColumn(schema, table, "tstz", "timestamptz", JDBCType.TIMESTAMP) + addColumn(schema, table, "tstz_jdbc", "timestamptz", JDBCType.TIMESTAMP_WITH_TIMEZONE) + addColumn(schema, table, "tstz_name", "TIMESTAMP WITH TIME ZONE", JDBCType.TIMESTAMP) + addColumn(schema, table, "tstz_local", "TIMESTAMP(6) WITH LOCAL TIME ZONE", JDBCType.TIMESTAMP) + addColumn(schema, table, "ttz", "timetz", JDBCType.TIME) + addColumn(schema, table, "ts", "timestamp", JDBCType.TIMESTAMP) + addColumn(schema, table, "ts_name", "timestamp without time zone", JDBCType.TIMESTAMP) + addColumn(schema, table, "d", "date", JDBCType.DATE) + SchemaInfo schemaInfo = new SchemaInfo([table]) + + expect: 'the columns that carry an offset are detected, independently of the JDBC type' + schemaInfo.isColumnTemporalWithTimeZone("table1", "tstz") + schemaInfo.isColumnTemporalWithTimeZone("table1", "tstz_jdbc") + schemaInfo.isColumnTemporalWithTimeZone("table1", "tstz_name") + schemaInfo.isColumnTemporalWithTimeZone("table1", "tstz_local") + schemaInfo.isColumnTemporalWithTimeZone("table1", "ttz") + + and: 'columns without a time zone are not, in particular not "without time zone"' + !schemaInfo.isColumnTemporalWithTimeZone("table1", "ts") + !schemaInfo.isColumnTemporalWithTimeZone("table1", "ts_name") + !schemaInfo.isColumnTemporalWithTimeZone("table1", "d") + + and: 'an unknown column is not reported' + !schemaInfo.isColumnTemporalWithTimeZone("table1", "unknown") + !schemaInfo.isColumnTemporalWithTimeZone("unknown", "ts") + + and: 'both are temporal columns' + schemaInfo.isColumnTemporal("table1", "tstz") + schemaInfo.isColumnTemporal("table1", "ts") + } + + def 'a column that is not temporal has no time zone'() { + + given: 'a table with a string and a geometry column' + SchemaReference schema = new SchemaReference("catalog", "schema") + Table table = new MutableTable(schema, "table1") + addColumn(schema, table, "s", "varchar", JDBCType.VARCHAR) + addColumn(schema, table, "geom", "geometry", JDBCType.OTHER) + SchemaInfo schemaInfo = new SchemaInfo([table]) + + expect: + !schemaInfo.isColumnTemporalWithTimeZone("table1", "s") + !schemaInfo.isColumnTemporalWithTimeZone("table1", "geom") + } + + // the schemacrawler.crawl classes are not public, so every member access goes through Groovy's + // dynamic dispatch (as in SchemaGeneratorSqlSpec) and the variables are typed as the interfaces + private static void addColumn( + SchemaReference schema, Table table, String name, String typeName, JDBCType jdbcType) { + Column column = new MutableColumn(table, name) + ColumnDataType columnDataType = new MutableColumnDataType(schema, typeName, DataTypeType.system) + columnDataType.setJavaSqlType(new JavaSqlType(jdbcType, Timestamp.class, JavaSqlTypeGroup.temporal)) + column.setColumnDataType(columnDataType) + table.addColumn(column) + } +} 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 69d38496b..d4f797a9f 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 @@ -191,7 +191,10 @@ public CompletionStage runWith( } if (stepMetadata) { - source = source.via(new FeatureTokenTransformerMetadata(resultBuilder)); + source = + source.via( + new FeatureTokenTransformerMetadata( + resultBuilder, data.getNativeTimeZone().orElse(ZoneId.of("UTC")))); } FeatureTokenTransformerAudit auditTransformer = null; @@ -288,7 +291,10 @@ public CompletionStage> runWith( source = source.via(new FeatureTokenTransformerWeakETag(resultBuilder)); } if (stepMetadata) { - source = source.via(new FeatureTokenTransformerMetadata(resultBuilder)); + source = + source.via( + new FeatureTokenTransformerMetadata( + resultBuilder, data.getNativeTimeZone().orElse(ZoneId.of("UTC")))); } FeatureTokenTransformerAudit auditTransformer = null; diff --git a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureTokenTransformerMetadata.java b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureTokenTransformerMetadata.java index 4aa5a6fc1..c9bdd80ad 100644 --- a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureTokenTransformerMetadata.java +++ b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureTokenTransformerMetadata.java @@ -15,18 +15,62 @@ import java.time.LocalDate; import java.time.LocalDateTime; import java.time.OffsetDateTime; -import java.time.ZoneOffset; +import java.time.ZoneId; import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeFormatterBuilder; +import java.time.temporal.ChronoField; import java.time.temporal.TemporalAccessor; import java.util.Objects; import java.util.Optional; import java.util.function.Consumer; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; public class FeatureTokenTransformerMetadata extends FeatureTokenTransformer { + private static final Logger LOGGER = + LoggerFactory.getLogger(FeatureTokenTransformerMetadata.class); + + // Accepts the timestamp forms that the feature providers deliver: a date, a date and time with + // 'T' or a space as separator, an optional fraction of a second with any number of digits, and an + // optional time-zone offset in any of the ISO forms ('Z', '+HH', '+HHmm', '+HH:MM'). + private static final DateTimeFormatter FLEXIBLE_PARSER = + new DateTimeFormatterBuilder() + .append(DateTimeFormatter.ISO_LOCAL_DATE) + .optionalStart() + .optionalStart() + .appendLiteral('T') + .optionalEnd() + .optionalStart() + .appendLiteral(' ') + .optionalEnd() + .appendValue(ChronoField.HOUR_OF_DAY, 2) + .appendLiteral(':') + .appendValue(ChronoField.MINUTE_OF_HOUR, 2) + .optionalStart() + .appendLiteral(':') + .appendValue(ChronoField.SECOND_OF_MINUTE, 2) + .optionalEnd() + .optionalStart() + .appendFraction(ChronoField.NANO_OF_SECOND, 0, 9, true) + .optionalEnd() + .optionalEnd() + .optionalStart() + .appendOffsetId() + .optionalEnd() + .optionalStart() + .appendOffset("+HHmm", "Z") + .optionalEnd() + .optionalStart() + .appendOffset("+HH", "Z") + .optionalEnd() + .toFormatter(); + private final Consumer lastModifiedSetter; private final Consumer spatialExtentSetter; private final Consumer> temporalExtentSetter; + // the time zone of the provider, applied to values without a time zone + private final ZoneId defaultTimeZone; private Optional crs; private double[][] minMax = null; private String start = ""; @@ -34,16 +78,20 @@ public class FeatureTokenTransformerMetadata extends FeatureTokenTransformer { private boolean isSingleFeature = false; private String lastModified = ""; - public FeatureTokenTransformerMetadata(ImmutableResult.Builder resultBuilder) { + public FeatureTokenTransformerMetadata( + ImmutableResult.Builder resultBuilder, ZoneId defaultTimeZone) { this.lastModifiedSetter = resultBuilder::lastModified; this.spatialExtentSetter = resultBuilder::spatialExtent; this.temporalExtentSetter = resultBuilder::temporalExtent; + this.defaultTimeZone = defaultTimeZone; } - public FeatureTokenTransformerMetadata(ImmutableResultReduced.Builder resultBuilder) { + public FeatureTokenTransformerMetadata( + ImmutableResultReduced.Builder resultBuilder, ZoneId defaultTimeZone) { this.lastModifiedSetter = resultBuilder::lastModified; this.spatialExtentSetter = resultBuilder::spatialExtent; this.temporalExtentSetter = resultBuilder::temporalExtent; + this.defaultTimeZone = defaultTimeZone; } @Override @@ -89,28 +137,40 @@ public void onEnd(ModifiableContext context) { } catch (Throwable ignore) { } - try { - if (!lastModified.isEmpty()) { - lastModifiedSetter.accept(Instant.parse(lastModified)); + if (!lastModified.isEmpty()) { + try { + // the value may be without a time zone, if it has not been normalized by a DATE_FORMAT + // transformation (which is applied to every DATETIME property that has no other + // transformation) + lastModifiedSetter.accept(parseTemporal(lastModified)); + } catch (Throwable e) { + // the last modification time is used for conditional requests, so a value that cannot be + // parsed must not be ignored silently + if (LOGGER.isWarnEnabled()) { + LOGGER.warn( + "Could not parse the last modification time '{}' of the feature, the value is ignored. Reason: {}", + lastModified, + e.getMessage()); + } } - } catch (Throwable ignore) { } super.onEnd(context); } // The primary instant/interval properties may be DATETIME or DATE; a date is interpreted as - // start of day UTC. - private static Instant parseTemporal(String value) { + // start of day. A value without a time zone is interpreted in the time zone of the provider + // ("nativeTimeZone", UTC unless configured otherwise). + private Instant parseTemporal(String value) { TemporalAccessor ta = - DateTimeFormatter.ofPattern("yyyy-MM-dd[['T'][' ']HH:mm:ss[.SSS]][X]") - .parseBest(value, OffsetDateTime::from, LocalDateTime::from, LocalDate::from); + FLEXIBLE_PARSER.parseBest( + value, OffsetDateTime::from, LocalDateTime::from, LocalDate::from); if (ta instanceof OffsetDateTime) { return ((OffsetDateTime) ta).toInstant(); } else if (ta instanceof LocalDateTime) { - return ((LocalDateTime) ta).toInstant(ZoneOffset.UTC); + return ((LocalDateTime) ta).atZone(defaultTimeZone).toInstant(); } - return ((LocalDate) ta).atStartOfDay(ZoneOffset.UTC).toInstant(); + return ((LocalDate) ta).atStartOfDay(defaultTimeZone).toInstant(); } @Override diff --git a/xtraplatform-geometries/src/main/java/de/ii/xtraplatform/geometries/domain/transcode/json/GeometryDecoderJson.java b/xtraplatform-geometries/src/main/java/de/ii/xtraplatform/geometries/domain/transcode/json/GeometryDecoderJson.java index 4fe4427a5..f79bb6c08 100644 --- a/xtraplatform-geometries/src/main/java/de/ii/xtraplatform/geometries/domain/transcode/json/GeometryDecoderJson.java +++ b/xtraplatform-geometries/src/main/java/de/ii/xtraplatform/geometries/domain/transcode/json/GeometryDecoderJson.java @@ -50,15 +50,22 @@ public class GeometryDecoderJson extends AbstractGeometryDecoder { GeometryType.GEOMETRY_COLLECTION); private final boolean geoJsonOnly; + // the coordinate reference systems that a 'coordRefSys' member may declare; an empty list does + // not restrict them + private final List supportedCrs; public GeometryDecoderJson() { - super(); - this.geoJsonOnly = false; + this(false, List.of()); } public GeometryDecoderJson(boolean geoJsonOnly) { + this(geoJsonOnly, List.of()); + } + + public GeometryDecoderJson(boolean geoJsonOnly, List supportedCrs) { super(); this.geoJsonOnly = geoJsonOnly; + this.supportedCrs = supportedCrs; } @SuppressWarnings({ @@ -186,7 +193,7 @@ public Geometry decode(JsonNode node, Optional crs, Optional a Optional coordRefSys = crs; if (node.has("coordRefSys")) { - coordRefSys = parseCoordRefSys(node); + coordRefSys = parseCoordRefSys(node.get("coordRefSys")); } return switch (geometryType) { @@ -426,7 +433,7 @@ private double[] flatten(List coords) { private Optional parseCoordRefSys(JsonParser parser) throws IOException { JsonToken token = parser.nextToken(); if (token == JsonToken.VALUE_STRING) { - return Optional.of(EpsgCrs.fromString(parser.getText())); + return Optional.of(checkSupported(EpsgCrs.fromString(parser.getText()))); } else if (token == JsonToken.START_OBJECT) { Optional crs = Optional.empty(); while (token != JsonToken.END_OBJECT) { @@ -436,7 +443,7 @@ private Optional parseCoordRefSys(JsonParser parser) throws IOException if (token != JsonToken.VALUE_STRING) { throw new IOException("Expected string value for 'href', but got: " + token); } - crs = Optional.of(EpsgCrs.fromString(parser.getText())); + crs = Optional.of(checkSupported(EpsgCrs.fromString(parser.getText()))); } } return crs; @@ -464,7 +471,7 @@ private Optional parseCoordRefSys(JsonParser parser) throws IOException private Optional parseCoordRefSys(JsonNode node) throws IOException { if (node.isTextual()) { - return Optional.of(EpsgCrs.fromString(node.asText())); + return Optional.of(checkSupported(EpsgCrs.fromString(node.asText()))); } else if (node.isObject() && node.has("href")) { return parseCoordRefSys(node.get("href")); } else if (node.isArray()) { @@ -487,6 +494,22 @@ private Optional parseCoordRefSys(JsonNode node) throws IOException { } } + /** + * A coordinate reference system declared in the document must be one of the supported ones. Only + * the code is compared, the axis order is a property of the encoding, not of the coordinate + * reference system. + */ + private EpsgCrs checkSupported(EpsgCrs crs) throws IOException { + if (!supportedCrs.isEmpty() + && supportedCrs.stream().noneMatch(supported -> supported.getCode() == crs.getCode())) { + throw new IOException( + String.format( + "The coordinate reference system '%s' in 'coordRefSys' is not supported here. Supported coordinate reference systems: %s.", + crs.toUriString(), supportedCrs.stream().map(EpsgCrs::toUriString).toList())); + } + return crs; + } + private static List> applyCrs( List> geometries, Optional coordRefSys) { CrsSetter crsSetter = new CrsSetter(coordRefSys);