This is an automated email from the ASF dual-hosted git repository. desruisseaux pushed a commit to branch geoapi-4.0 in repository https://gitbox.apache.org/repos/asf/sis.git
commit c171717d169ae3f2f53227a7bab15219b41428e5 Author: Martin Desruisseaux <martin.desruisse...@geomatys.com> AuthorDate: Thu Apr 3 15:48:26 2025 +0200 Allow to specify a timezone when reading features from a Shapefile. If a timezone is specified, then hours are arbitrarily set to the middle of the day. The default stay no timezone, in which case dates are instances of `java.time.LocalDate`. --- .../org.apache.sis.metadata/main/module-info.java | 1 + .../main/org/apache/sis/temporal/TimeMethods.java | 160 ++++++++++++++++++--- .../org/apache/sis/temporal/TimeMethodsTest.java | 56 ++++++++ .../sis/storage/shapefile/ShapefileStore.java | 49 +++++-- .../apache/sis/storage/shapefile/dbf/DBFField.java | 31 +++- .../sis/storage/shapefile/dbf/DBFHeader.java | 5 +- .../sis/storage/shapefile/dbf/DBFReader.java | 5 +- .../sis/storage/shapefile/dbf/DBFIOTest.java | 6 +- .../apache/sis/storage/shapefile/dbf/Snippets.java | 8 +- 9 files changed, 270 insertions(+), 51 deletions(-) diff --git a/endorsed/src/org.apache.sis.metadata/main/module-info.java b/endorsed/src/org.apache.sis.metadata/main/module-info.java index 18306a6282..d160fb9e63 100644 --- a/endorsed/src/org.apache.sis.metadata/main/module-info.java +++ b/endorsed/src/org.apache.sis.metadata/main/module-info.java @@ -83,6 +83,7 @@ module org.apache.sis.metadata { org.apache.sis.storage.xml, org.apache.sis.storage.netcdf, org.apache.sis.storage.geotiff, + org.apache.sis.storage.shapefile, // In the "incubator" sub-project. org.apache.sis.cql; // In the "incubator" sub-project. exports org.apache.sis.metadata.privy to diff --git a/endorsed/src/org.apache.sis.metadata/main/org/apache/sis/temporal/TimeMethods.java b/endorsed/src/org.apache.sis.metadata/main/org/apache/sis/temporal/TimeMethods.java index 5cc49c0f66..c6efb883bf 100644 --- a/endorsed/src/org.apache.sis.metadata/main/org/apache/sis/temporal/TimeMethods.java +++ b/endorsed/src/org.apache.sis.metadata/main/org/apache/sis/temporal/TimeMethods.java @@ -18,7 +18,9 @@ package org.apache.sis.temporal; import java.util.Map; import java.util.Date; +import java.util.Optional; import java.util.function.Supplier; +import java.util.function.BiFunction; import java.util.function.BiPredicate; import java.time.Instant; import java.time.Year; @@ -27,6 +29,8 @@ import java.time.MonthDay; import java.time.LocalDate; import java.time.LocalDateTime; import java.time.ZonedDateTime; +import java.time.ZoneId; +import java.time.ZoneOffset; import java.time.LocalTime; import java.time.OffsetTime; import java.time.OffsetDateTime; @@ -96,6 +100,23 @@ public class TimeMethods<T> implements Serializable { */ public final transient Supplier<T> now; + /** + * Function to execute for getting another temporal object with the given timezone, or {@code null} if none. + * If the temporal object already has a timezone, then this function returns an object at the same instant. + * The returned object may be of a different class than the object given in input, especially if a timezone + * is added to a local time. If the temporal object is only a date with no time, this field is {@code null}. + * If the function supports only {@link ZoneOffset} and not the more generic {@link ZoneId} class, then the + * function returns {@code null}. + * + * @see #withZone(Object, ZoneId, boolean) + */ + public final transient BiFunction<T, ZoneId, Temporal> withZone; + + /** + * Whether the temporal object have a time zone, explicitly or implicitly. + */ + private final boolean hasZone; + /** * Creates a new set of operators. This method is for subclasses only. * For getting a {@code TimeMethods} instance, see {@link #find(Class)}. @@ -104,13 +125,17 @@ public class TimeMethods<T> implements Serializable { final BiPredicate<T,T> isBefore, final BiPredicate<T,T> isAfter, final BiPredicate<T,T> isEqual, - final Supplier<T> now) + final Supplier<T> now, + final BiFunction<T, ZoneId, Temporal> withZone, + final boolean hasZone) { this.type = type; this.isBefore = isBefore; this.isAfter = isAfter; this.isEqual = isEqual; this.now = now; + this.withZone = withZone; + this.hasZone = hasZone; } /** @@ -324,7 +349,7 @@ public class TimeMethods<T> implements Serializable { (self, other) -> ((Comparable) self).compareTo(other) < 0, (self, other) -> ((Comparable) self).compareTo(other) > 0, (self, other) -> ((Comparable) self).compareTo(other) == 0, - null); + null, null, false); } else { throw new DateTimeException(Errors.format(Errors.Keys.CannotCompareInstanceOf_2, type, type)); } @@ -345,7 +370,7 @@ public class TimeMethods<T> implements Serializable { (self, other) -> compare(BEFORE, type, self, other), (self, other) -> compare(AFTER, type, self, other), (self, other) -> compare(EQUAL, type, self, other), - null) + null, null, false) { @Override public boolean isDynamic() { return true; @@ -368,13 +393,55 @@ public class TimeMethods<T> implements Serializable { } /** - * Returns the current time as a temporal object. + * Returns the current time as a temporal object. This is the value returned by {@link #now}, + * except for the following types which are not {@link Temporal}: {@link Date}, {@link MonthDay} * - * @return the current time. - * @throws ClassCastException if the {@linkplain #type} is {@link Date} or {@link MonthDay}. + * @return the current time. Never {@code null}, but may be an instance of a class different than {@linkplain #type}. */ public final Temporal now() { - return (now != null) ? (Temporal) now.get() : ZonedDateTime.now(); + if (now != null) { + final T time = now.get(); + if (time instanceof Temporal) { + return (Temporal) time; + } else if (time instanceof Date) { + return ((Date) time).toInstant(); + } else if (time instanceof MonthDay) { + return LocalDate.now(); + } + } + return ZonedDateTime.now(); + } + + /** + * Returns the given temporal object with the given timezone. + * This method handles the following scenarios: + * + * <ul class="verbose"> + * <li> + * If the given temporal object already has a timezone, then an object with the specified timezone is returned. + * It may be of the same class or a different class, depending on whether the timezone is a {@link ZoneOffset}. + * </li><li> + * If the given temporal object is a local time and if {@code allowAdd} is {@code true}, then a different class + * of object with the given timezone is returned. Otherwise, an empty value is returned. + * </li><li> + * If the given temporal object is a {@link LocalDate} or {@link MonthDay} or any other class of object + * for which a timezone cannot be added, then this method returns an empty value. + * </li> + * </ul> + * + * @param time the temporal object to return with the specified timezone, or {@code null} if none. + * @param timezone the desired timezone. Cannot be {@code null}. + * @param allowAdd + * @return a temporal object with the specified timezone, if it was possible to apply a timezone. + */ + public static <T> Optional<Temporal> withZone(final T time, final ZoneId timezone, final boolean allowAdd) { + if (time != null) { + final TimeMethods<? super T> methods = find(Classes.getClass(time)); + if ((methods.hasZone | allowAdd) && methods.withZone != null) { + return Optional.ofNullable(methods.withZone.apply(time, timezone)); + } + } + return Optional.empty(); } /** @@ -383,10 +450,10 @@ public class TimeMethods<T> implements Serializable { */ @SuppressWarnings({"rawtypes", "unchecked"}) // For `Chrono*` interfaces, because they are parameterized. private static final TimeMethods<?>[] INTERFACES = { - new TimeMethods<>(ChronoZonedDateTime.class, ChronoZonedDateTime::isBefore, ChronoZonedDateTime::isAfter, ChronoZonedDateTime::isEqual, ZonedDateTime::now), - new TimeMethods<>(ChronoLocalDateTime.class, ChronoLocalDateTime::isBefore, ChronoLocalDateTime::isAfter, ChronoLocalDateTime::isEqual, LocalDateTime::now), - new TimeMethods<>( ChronoLocalDate.class, ChronoLocalDate::isBefore, ChronoLocalDate::isAfter, ChronoLocalDate::isEqual, LocalDate::now), - new TimeMethods<>( Date.class, Date:: before, Date:: after, Date::equals, Date::new) + new TimeMethods<>(ChronoZonedDateTime.class, ChronoZonedDateTime::isBefore, ChronoZonedDateTime::isAfter, ChronoZonedDateTime::isEqual, ZonedDateTime::now, ChronoZonedDateTime::withZoneSameInstant, true), + new TimeMethods<>(ChronoLocalDateTime.class, ChronoLocalDateTime::isBefore, ChronoLocalDateTime::isAfter, ChronoLocalDateTime::isEqual, LocalDateTime::now, ChronoLocalDateTime::atZone, false), + new TimeMethods<>( ChronoLocalDate.class, ChronoLocalDate::isBefore, ChronoLocalDate::isAfter, ChronoLocalDate::isEqual, LocalDate::now, null, false), + new TimeMethods<>( Date.class, Date:: before, Date:: after, Date::equals, Date::new, TimeMethods::atZone, true) }; /* @@ -396,8 +463,9 @@ public class TimeMethods<T> implements Serializable { /** * Operators for all supported temporal types for which there is no need to check for subclasses. - * Those classes are usually final, except when wanting to intentionally ignore all subclasses. - * Those types should be tested before {@link #INTERFACES} because this check is quick. + * Those classes should be final because they are compared by equality instead of "instance of". + * The two last entries are not final, but we really want to ignore all their subtypes. + * All those types should be tested before {@link #INTERFACES} because this check is quick. * * <h4>Implementation note</h4> * {@link Year}, {@link YearMonth}, {@link MonthDay}, {@link LocalTime} and {@link Instant} @@ -406,16 +474,16 @@ public class TimeMethods<T> implements Serializable { * the code working on generic {@link Comparable} needs to check for special cases again. */ private static final Map<Class<?>, TimeMethods<?>> FINAL_TYPES = Map.ofEntries( - entry(new TimeMethods<>(OffsetDateTime.class, OffsetDateTime::isBefore, OffsetDateTime::isAfter, OffsetDateTime::isEqual, OffsetDateTime::now)), - entry(new TimeMethods<>( ZonedDateTime.class, ZonedDateTime::isBefore, ZonedDateTime::isAfter, ZonedDateTime::isEqual, ZonedDateTime::now)), - entry(new TimeMethods<>( LocalDateTime.class, LocalDateTime::isBefore, LocalDateTime::isAfter, LocalDateTime::isEqual, LocalDateTime::now)), - entry(new TimeMethods<>( LocalDate.class, LocalDate::isBefore, LocalDate::isAfter, LocalDate::isEqual, LocalDate::now)), - entry(new TimeMethods<>( OffsetTime.class, OffsetTime::isBefore, OffsetTime::isAfter, OffsetTime::isEqual, OffsetTime::now)), - entry(new TimeMethods<>( LocalTime.class, LocalTime::isBefore, LocalTime::isAfter, LocalTime::equals, LocalTime::now)), - entry(new TimeMethods<>( Year.class, Year::isBefore, Year::isAfter, Year::equals, Year::now)), - entry(new TimeMethods<>( YearMonth.class, YearMonth::isBefore, YearMonth::isAfter, YearMonth::equals, YearMonth::now)), - entry(new TimeMethods<>( MonthDay.class, MonthDay::isBefore, MonthDay::isAfter, MonthDay::equals, MonthDay::now)), - entry(new TimeMethods<>( Instant.class, Instant::isBefore, Instant::isAfter, Instant::equals, Instant::now)), + entry(new TimeMethods<>(OffsetDateTime.class, OffsetDateTime::isBefore, OffsetDateTime::isAfter, OffsetDateTime::isEqual, OffsetDateTime::now, TimeMethods::withZoneSameInstant, true)), + entry(new TimeMethods<>( ZonedDateTime.class, ZonedDateTime::isBefore, ZonedDateTime::isAfter, ZonedDateTime::isEqual, ZonedDateTime::now, ZonedDateTime::withZoneSameInstant, true)), + entry(new TimeMethods<>( LocalDateTime.class, LocalDateTime::isBefore, LocalDateTime::isAfter, LocalDateTime::isEqual, LocalDateTime::now, LocalDateTime::atZone, false)), + entry(new TimeMethods<>( LocalDate.class, LocalDate::isBefore, LocalDate::isAfter, LocalDate::isEqual, LocalDate::now, null, false)), + entry(new TimeMethods<>( OffsetTime.class, OffsetTime::isBefore, OffsetTime::isAfter, OffsetTime::isEqual, OffsetTime::now, TimeMethods::withOffsetSameInstant, true)), + entry(new TimeMethods<>( LocalTime.class, LocalTime::isBefore, LocalTime::isAfter, LocalTime::equals, LocalTime::now, TimeMethods::atOffset, false)), + entry(new TimeMethods<>( Year.class, Year::isBefore, Year::isAfter, Year::equals, Year::now, null, false)), + entry(new TimeMethods<>( YearMonth.class, YearMonth::isBefore, YearMonth::isAfter, YearMonth::equals, YearMonth::now, null, false)), + entry(new TimeMethods<>( MonthDay.class, MonthDay::isBefore, MonthDay::isAfter, MonthDay::equals, MonthDay::now, null, false)), + entry(new TimeMethods<>( Instant.class, Instant::isBefore, Instant::isAfter, Instant::equals, Instant::now, Instant::atZone, true)), entry(fallback(Temporal.class)), // Frequently declared type. Intentionally no "instance of" checks. entry(fallback(Object.class))); // Not a final class, but to be used when the declared type is Object. @@ -427,6 +495,52 @@ public class TimeMethods<T> implements Serializable { return Map.entry(op.type, op); } + /** + * Returns the given date at a specific time zone. + */ + private static Temporal atZone(final Date date, final ZoneId timezone) { + final Instant time = date.toInstant(); + if (timezone instanceof ZoneOffset) { + return time.atOffset((ZoneOffset) timezone); + } else { + return time.atZone(timezone); + } + } + + /** + * Returns a temporal object with the specified timezone, keeping the same class if possible. + * This method may change the class of the temporal object. + */ + private static Temporal withZoneSameInstant(final OffsetDateTime time, final ZoneId timezone) { + if (timezone instanceof ZoneOffset) { + return time.withOffsetSameInstant((ZoneOffset) timezone); + } else { + return time.atZoneSameInstant(timezone); + } + } + + /** + * Returns a temporal object with the specified timezone, or {@code null} if not possible. + */ + private static Temporal withOffsetSameInstant(final OffsetTime time, final ZoneId timezone) { + if (timezone instanceof ZoneOffset) { + return time.withOffsetSameInstant((ZoneOffset) timezone); + } else { + return null; + } + } + + /** + * Returns a temporal object with the specified timezone, or {@code null} if not possible. + */ + private static Temporal atOffset(final LocalTime time, final ZoneId timezone) { + if (timezone instanceof ZoneOffset) { + return time.atOffset((ZoneOffset) timezone); + } else { + return null; + } + } + /** * Returns a string representation of this set of operations for debugging purposes. * diff --git a/endorsed/src/org.apache.sis.metadata/test/org/apache/sis/temporal/TimeMethodsTest.java b/endorsed/src/org.apache.sis.metadata/test/org/apache/sis/temporal/TimeMethodsTest.java new file mode 100644 index 0000000000..929ddb0711 --- /dev/null +++ b/endorsed/src/org.apache.sis.metadata/test/org/apache/sis/temporal/TimeMethodsTest.java @@ -0,0 +1,56 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.sis.temporal; + +import java.time.ZoneId; +import java.time.ZonedDateTime; + +// Test dependencies +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; +import org.apache.sis.test.TestCase; + + +/** + * Tests the {@link TimeMethods} class. + * + * @author Martin Desruisseaux (Geomatys) + */ +public final class TimeMethodsTest extends TestCase { + /** + * Creates a new test case. + */ + public TimeMethodsTest() { + } + + /** + * Tests {@link TimeMethods#withZone(Object, ZoneId)}. + */ + @Test + public void testWithZone() { + final ZoneId source = ZoneId.of("America/New_York"); + final var time = ZonedDateTime.of(2025, 4, 3, 8, 50, 0, 0, source); + final var local = time.toLocalDateTime(); + assertTrue(TimeMethods.withZone(local, source, false).isEmpty()); + assertEquals(time, TimeMethods.withZone(local, source, true).orElseThrow()); + + final ZoneId target = ZoneId.of("CET"); + final var expected = ZonedDateTime.of(2025, 4, 3, 8+6, 50, 0, 0, target); + assertEquals(expected, TimeMethods.withZone(time, target, true).orElseThrow()); + assertEquals(expected, TimeMethods.withZone(time, target, false).orElseThrow()); + } +} diff --git a/incubator/src/org.apache.sis.storage.shapefile/main/org/apache/sis/storage/shapefile/ShapefileStore.java b/incubator/src/org.apache.sis.storage.shapefile/main/org/apache/sis/storage/shapefile/ShapefileStore.java index da6d5986a1..19e8bef9f8 100644 --- a/incubator/src/org.apache.sis.storage.shapefile/main/org/apache/sis/storage/shapefile/ShapefileStore.java +++ b/incubator/src/org.apache.sis.storage.shapefile/main/org/apache/sis/storage/shapefile/ShapefileStore.java @@ -24,7 +24,12 @@ import java.nio.channels.WritableByteChannel; import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; import java.nio.file.*; +import java.time.Instant; import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.time.ZoneId; +import java.time.ZonedDateTime; import java.util.AbstractMap; import java.util.ArrayList; import java.util.Arrays; @@ -103,6 +108,7 @@ import org.apache.sis.storage.shapefile.shp.ShapeType; import org.apache.sis.storage.shapefile.shp.ShapeWriter; import org.apache.sis.storage.shapefile.shx.IndexWriter; import org.apache.sis.util.ArraysExt; +import org.apache.sis.util.Classes; import org.apache.sis.util.Utilities; import org.apache.sis.util.collection.BackingStoreException; @@ -134,6 +140,8 @@ public final class ShapefileStore extends DataStore implements WritableFeatureSe private final Path shpPath; private final ShpFiles files; private final Charset userDefinedCharSet; + private final ZoneId timezone; + /** * Internal class to inherit AbstractFeatureSet. */ @@ -152,6 +160,7 @@ public final class ShapefileStore extends DataStore implements WritableFeatureSe public ShapefileStore(Path path) { this.shpPath = path; this.userDefinedCharSet = null; + this.timezone = null; this.files = new ShpFiles(shpPath); } @@ -165,6 +174,8 @@ public final class ShapefileStore extends DataStore implements WritableFeatureSe public ShapefileStore(StorageConnector cnx) throws IllegalArgumentException, DataStoreException { this.shpPath = cnx.getStorageAs(Path.class); this.userDefinedCharSet = cnx.getOption(OptionKey.ENCODING); + var tz = cnx.getOption(OptionKey.TIMEZONE); + this.timezone = (tz != null) ? tz.toZoneId() : null; this.files = new ShpFiles(shpPath); } @@ -364,7 +375,7 @@ public final class ShapefileStore extends DataStore implements WritableFeatureSe //read dbf for attributes final Path dbfFile = files.getDbf(false); if (dbfFile != null) { - try (DBFReader reader = new DBFReader(ShpFiles.openReadChannel(dbfFile), charset, null)) { + try (DBFReader reader = new DBFReader(ShpFiles.openReadChannel(dbfFile), charset, timezone, null)) { final DBFHeader header = reader.getHeader(); this.dbfHeader = header; boolean hasId = false; @@ -440,7 +451,7 @@ public final class ShapefileStore extends DataStore implements WritableFeatureSe final DBFReader dbfreader; try { shpreader = readShp ? new ShapeReader(ShpFiles.openReadChannel(files.shpFile), filter) : null; - dbfreader = (dbfPropertiesIndex.length > 0) ? new DBFReader(ShpFiles.openReadChannel(files.getDbf(false)), charset, dbfPropertiesIndex) : null; + dbfreader = (dbfPropertiesIndex.length > 0) ? new DBFReader(ShpFiles.openReadChannel(files.getDbf(false)), charset, timezone, dbfPropertiesIndex) : null; } catch (IOException ex) { throw new DataStoreException("Faild to open shp and dbf files.", ex); } @@ -640,6 +651,17 @@ public final class ShapefileStore extends DataStore implements WritableFeatureSe throw new DataStoreException("Update type is possible only when files do not exist. It can be used to create a new shapefile but not to update one."); } + final Class<?>[] supportedDateTypes; // All types other than the one at index 0 will lost information. + if (timezone == null) { + supportedDateTypes = new Class<?>[] { + LocalDate.class, LocalDateTime.class + }; + } else { + supportedDateTypes = new Class<?>[] { + LocalDate.class, LocalDateTime.class, OffsetDateTime.class, ZonedDateTime.class, Instant.class + }; + } + lock.writeLock().lock(); try { final ShapeHeader shpHeader = new ShapeHeader(); @@ -681,21 +703,24 @@ public final class ShapefileStore extends DataStore implements WritableFeatureSe } } else if (String.class.isAssignableFrom(valueClass)) { - dbfHeader.fields = ArraysExt.append(dbfHeader.fields, new DBFField(attName, (char) DBFField.TYPE_CHAR, 0, length, 0, charset)); + dbfHeader.fields = ArraysExt.append(dbfHeader.fields, new DBFField(attName, DBFField.TYPE_CHAR, 0, length, 0, charset, null)); } else if (Byte.class.isAssignableFrom(valueClass)) { - dbfHeader.fields = ArraysExt.append(dbfHeader.fields, new DBFField(attName, (char) DBFField.TYPE_NUMBER, 0, 4, 0, null)); + dbfHeader.fields = ArraysExt.append(dbfHeader.fields, new DBFField(attName, DBFField.TYPE_NUMBER, 0, 4, 0, null, null)); } else if (Short.class.isAssignableFrom(valueClass)) { - dbfHeader.fields = ArraysExt.append(dbfHeader.fields, new DBFField(attName, (char) DBFField.TYPE_NUMBER, 0, 6, 0, null)); + dbfHeader.fields = ArraysExt.append(dbfHeader.fields, new DBFField(attName, DBFField.TYPE_NUMBER, 0, 6, 0, null, null)); } else if (Integer.class.isAssignableFrom(valueClass)) { - dbfHeader.fields = ArraysExt.append(dbfHeader.fields, new DBFField(attName, (char) DBFField.TYPE_NUMBER, 0, 9, 0, null)); + dbfHeader.fields = ArraysExt.append(dbfHeader.fields, new DBFField(attName, DBFField.TYPE_NUMBER, 0, 9, 0, null, null)); } else if (Long.class.isAssignableFrom(valueClass)) { - dbfHeader.fields = ArraysExt.append(dbfHeader.fields, new DBFField(attName, (char) DBFField.TYPE_NUMBER, 0, 19, 0, null)); + dbfHeader.fields = ArraysExt.append(dbfHeader.fields, new DBFField(attName, DBFField.TYPE_NUMBER, 0, 19, 0, null, null)); } else if (Float.class.isAssignableFrom(valueClass)) { - dbfHeader.fields = ArraysExt.append(dbfHeader.fields, new DBFField(attName, (char) DBFField.TYPE_NUMBER, 0, 11, 6, null)); + dbfHeader.fields = ArraysExt.append(dbfHeader.fields, new DBFField(attName, DBFField.TYPE_NUMBER, 0, 11, 6, null, null)); } else if (Double.class.isAssignableFrom(valueClass)) { - dbfHeader.fields = ArraysExt.append(dbfHeader.fields, new DBFField(attName, (char) DBFField.TYPE_NUMBER, 0, 33, 18, null)); - } else if (LocalDate.class.isAssignableFrom(valueClass)) { - dbfHeader.fields = ArraysExt.append(dbfHeader.fields, new DBFField(attName, (char) DBFField.TYPE_DATE, 0, 20, 0, null)); + dbfHeader.fields = ArraysExt.append(dbfHeader.fields, new DBFField(attName, DBFField.TYPE_NUMBER, 0, 33, 18, null, null)); + } else if (Classes.isAssignableToAny(valueClass, supportedDateTypes)) { + dbfHeader.fields = ArraysExt.append(dbfHeader.fields, new DBFField(attName, DBFField.TYPE_DATE, 0, 20, 0, null, timezone)); + if (!(LocalDate.class.isAssignableFrom(valueClass))) { // TODO: use `index != 0` instead. + LOGGER.log(Level.WARNING, "Shapefile writing, field {0} will lost the time component of the date", pt.getName()); + } } else { LOGGER.log(Level.WARNING, "Shapefile writing, field {0} is not supported", pt.getName()); } @@ -1122,7 +1147,7 @@ public final class ShapefileStore extends DataStore implements WritableFeatureSe try (ShapeReader reader = new ShapeReader(ShpFiles.openReadChannel(files.shpFile), null)) { shpHeader = new ShapeHeader(reader.getHeader()); } - try (DBFReader reader = new DBFReader(ShpFiles.openReadChannel(files.dbfFile), charset, null)) { + try (DBFReader reader = new DBFReader(ShpFiles.openReadChannel(files.dbfFile), charset, timezone, null)) { dbfHeader = new DBFHeader(reader.getHeader()); } diff --git a/incubator/src/org.apache.sis.storage.shapefile/main/org/apache/sis/storage/shapefile/dbf/DBFField.java b/incubator/src/org.apache.sis.storage.shapefile/main/org/apache/sis/storage/shapefile/dbf/DBFField.java index a54797040f..055d9b8486 100644 --- a/incubator/src/org.apache.sis.storage.shapefile/main/org/apache/sis/storage/shapefile/dbf/DBFField.java +++ b/incubator/src/org.apache.sis.storage.shapefile/main/org/apache/sis/storage/shapefile/dbf/DBFField.java @@ -21,9 +21,15 @@ import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; import java.text.NumberFormat; import java.time.LocalDate; +import java.time.LocalTime; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.time.temporal.Temporal; +import java.time.temporal.TemporalQueries; import java.util.Locale; import org.apache.sis.io.stream.ChannelDataInput; import org.apache.sis.io.stream.ChannelDataOutput; +import org.apache.sis.temporal.TimeMethods; /** @@ -121,6 +127,8 @@ public final class DBFField { //used by decimal format only; private NumberFormat format; + private final ZoneId timezone; + /** * Field constructor. * @@ -131,13 +139,14 @@ public final class DBFField { * @param fieldDecimals number of decimals for floating points * @param charset String base field encoding, can be null, default is ISO_LATIN1 */ - public DBFField(String fieldName, char fieldType, int fieldAddress, int fieldLength, int fieldDecimals, Charset charset) { + public DBFField(String fieldName, char fieldType, int fieldAddress, int fieldLength, int fieldDecimals, Charset charset, ZoneId timezone) { this.fieldName = fieldName; this.fieldType = fieldType; this.fieldAddress = fieldAddress; this.fieldLength = fieldLength; this.fieldDecimals = fieldDecimals; this.charset = charset == null ? StandardCharsets.ISO_8859_1 : charset; + this.timezone = timezone; switch (Character.toUpperCase(fieldType)) { case TYPE_BINARY : valueClass = Long.class; reader = this::readBinary; writer = this::writeBinary; break; @@ -183,7 +192,7 @@ public final class DBFField { * @return dbf field description * @throws IOException if an error occured while parsing field */ - public static DBFField read(ChannelDataInput channel, Charset charset) throws IOException { + public static DBFField read(ChannelDataInput channel, Charset charset, ZoneId timezone) throws IOException { byte[] n = channel.readBytes(11); int nameSize = 0; for (int i = 0; i < n.length && n[i] != 0; i++,nameSize++); @@ -193,7 +202,7 @@ public final class DBFField { final int fieldLength = channel.readUnsignedByte(); final int fieldDecimals = channel.readUnsignedByte(); channel.seek(channel.getStreamPosition() + 14); - return new DBFField(fieldName, fieldType, fieldAddress, fieldLength, fieldDecimals, charset); + return new DBFField(fieldName, fieldType, fieldAddress, fieldLength, fieldDecimals, charset, timezone); } /** @@ -249,7 +258,12 @@ public final class DBFField { final int year = Integer.parseUnsignedInt(str,0,4,10); final int month = Integer.parseUnsignedInt(str,4,6,10); final int day = Integer.parseUnsignedInt(str,6,8,10); - return LocalDate.of(year, month, day); + final var date = LocalDate.of(year, month, day); + if (timezone != null) { + // Arbitrarily set the hours to the middle of the day. + return ZonedDateTime.of(date, LocalTime.NOON, timezone); + } + return date; } private Object readNumber(ChannelDataInput channel) throws IOException { @@ -361,7 +375,14 @@ public final class DBFField { } private void writeDate(ChannelDataOutput channel, Object value) throws IOException { - final LocalDate date = (LocalDate) value; + Temporal temporal = (Temporal) value; + if (timezone != null) { + temporal = TimeMethods.withZone(temporal, timezone, false).orElse(temporal); + } + final LocalDate date = temporal.query(TemporalQueries.localDate()); + if (date == null) { + throw new IllegalArgumentException("Not a supported temporal object: " + value); + } final StringBuilder sb = new StringBuilder(); String year = Integer.toString(date.getYear()); String month = Integer.toString(date.getMonthValue()); diff --git a/incubator/src/org.apache.sis.storage.shapefile/main/org/apache/sis/storage/shapefile/dbf/DBFHeader.java b/incubator/src/org.apache.sis.storage.shapefile/main/org/apache/sis/storage/shapefile/dbf/DBFHeader.java index fd80fa55b3..566f1e3244 100644 --- a/incubator/src/org.apache.sis.storage.shapefile/main/org/apache/sis/storage/shapefile/dbf/DBFHeader.java +++ b/incubator/src/org.apache.sis.storage.shapefile/main/org/apache/sis/storage/shapefile/dbf/DBFHeader.java @@ -20,6 +20,7 @@ import java.io.IOException; import java.nio.ByteOrder; import java.nio.charset.Charset; import java.time.LocalDate; +import java.time.ZoneId; import org.apache.sis.io.stream.ChannelDataInput; import org.apache.sis.io.stream.ChannelDataOutput; @@ -94,7 +95,7 @@ public final class DBFHeader { * @param charset field text encoding * @throws IOException if an error occured while parsing header */ - public void read(ChannelDataInput channel, Charset charset) throws IOException { + public void read(ChannelDataInput channel, Charset charset, ZoneId timezone) throws IOException { channel.buffer.order(ByteOrder.LITTLE_ENDIAN); if (channel.readByte()!= 0x03) { throw new IOException("Unvalid database III magic"); @@ -110,7 +111,7 @@ public final class DBFHeader { fields = new DBFField[(headerSize - FIELD_SIZE - 1) / FIELD_SIZE]; for (int i = 0; i < fields.length; i++) { - fields[i] = DBFField.read(channel, charset); + fields[i] = DBFField.read(channel, charset, timezone); } if (channel.readByte()!= FIELD_DESCRIPTOR_TERMINATOR) { throw new IOException("Unvalid database III field descriptor terminator"); diff --git a/incubator/src/org.apache.sis.storage.shapefile/main/org/apache/sis/storage/shapefile/dbf/DBFReader.java b/incubator/src/org.apache.sis.storage.shapefile/main/org/apache/sis/storage/shapefile/dbf/DBFReader.java index 40e7c97f0e..e5c128738f 100644 --- a/incubator/src/org.apache.sis.storage.shapefile/main/org/apache/sis/storage/shapefile/dbf/DBFReader.java +++ b/incubator/src/org.apache.sis.storage.shapefile/main/org/apache/sis/storage/shapefile/dbf/DBFReader.java @@ -18,6 +18,7 @@ package org.apache.sis.storage.shapefile.dbf; import java.io.IOException; import java.nio.charset.Charset; +import java.time.ZoneId; import org.apache.sis.io.stream.ChannelDataInput; @@ -50,10 +51,10 @@ public final class DBFReader implements AutoCloseable { * @param fieldsToRead fields index in the header to decode, other fields will be skipped. must be in increment order. * @throws IOException if a decoding error occurs on the header */ - public DBFReader(ChannelDataInput channel, Charset charset, int[] fieldsToRead) throws IOException { + public DBFReader(ChannelDataInput channel, Charset charset, ZoneId timezone, int[] fieldsToRead) throws IOException { this.channel = channel; this.header = new DBFHeader(); - this.header.read(channel, charset); + this.header.read(channel, charset, timezone); this.fieldsToRead = fieldsToRead; } diff --git a/incubator/src/org.apache.sis.storage.shapefile/test/org/apache/sis/storage/shapefile/dbf/DBFIOTest.java b/incubator/src/org.apache.sis.storage.shapefile/test/org/apache/sis/storage/shapefile/dbf/DBFIOTest.java index e79943fae2..c8d40ba919 100644 --- a/incubator/src/org.apache.sis.storage.shapefile/test/org/apache/sis/storage/shapefile/dbf/DBFIOTest.java +++ b/incubator/src/org.apache.sis.storage.shapefile/test/org/apache/sis/storage/shapefile/dbf/DBFIOTest.java @@ -64,7 +64,7 @@ public class DBFIOTest { final String path = "/org/apache/sis/storage/shapefile/point.dbf"; final ChannelDataInput cdi = openRead(path); - try (DBFReader reader = new DBFReader(cdi, StandardCharsets.UTF_8, null)) { + try (DBFReader reader = new DBFReader(cdi, StandardCharsets.UTF_8, null, null)) { final DBFHeader header = reader.getHeader(); assertEquals(123, header.lastUpdate.getYear()-1900); assertEquals(10, header.lastUpdate.getMonthValue()); @@ -135,7 +135,7 @@ public class DBFIOTest { final ChannelDataOutput cdo = openWrite(tempFile); try { - try (DBFReader reader = new DBFReader(cdi, StandardCharsets.US_ASCII, null); + try (DBFReader reader = new DBFReader(cdi, StandardCharsets.US_ASCII, null, null); DBFWriter writer = new DBFWriter(cdo)) { writer.writeHeader(reader.getHeader()); @@ -163,7 +163,7 @@ public class DBFIOTest { final String path = "/org/apache/sis/storage/shapefile/point.dbf"; final ChannelDataInput cdi = openRead(path); - try (DBFReader reader = new DBFReader(cdi, StandardCharsets.UTF_8, new int[]{1,3})) { + try (DBFReader reader = new DBFReader(cdi, StandardCharsets.UTF_8, null, new int[] {1,3})) { final DBFHeader header = reader.getHeader(); final Object[] record1 = reader.next(); diff --git a/incubator/src/org.apache.sis.storage.shapefile/test/org/apache/sis/storage/shapefile/dbf/Snippets.java b/incubator/src/org.apache.sis.storage.shapefile/test/org/apache/sis/storage/shapefile/dbf/Snippets.java index b0f688c185..9f8a856427 100644 --- a/incubator/src/org.apache.sis.storage.shapefile/test/org/apache/sis/storage/shapefile/dbf/Snippets.java +++ b/incubator/src/org.apache.sis.storage.shapefile/test/org/apache/sis/storage/shapefile/dbf/Snippets.java @@ -41,7 +41,7 @@ final class Snippets { //open a channel StorageConnector cnx = new StorageConnector(Paths.get("/path/to/file.dbf")); ChannelDataInput channel = cnx.getStorageAs(ChannelDataInput.class); - try (DBFReader reader = new DBFReader(channel, StandardCharsets.UTF_8, null)) { + try (var reader = new DBFReader(channel, StandardCharsets.UTF_8, null, null)) { //print the DBase fields DBFHeader header = reader.getHeader(); @@ -78,9 +78,9 @@ final class Snippets { DBFHeader header = new DBFHeader(); header.lastUpdate = LocalDate.now(); header.fields = new DBFField[] { - new DBFField("id", DBFField.TYPE_NUMBER, 0, 8, 0, charset), - new DBFField("desc", DBFField.TYPE_CHAR, 0, 255, 0, charset), - new DBFField("value", DBFField.TYPE_NUMBER, 0, 11, 6, charset) + new DBFField("id", DBFField.TYPE_NUMBER, 0, 8, 0, charset, null), + new DBFField("desc", DBFField.TYPE_CHAR, 0, 255, 0, charset, null), + new DBFField("value", DBFField.TYPE_NUMBER, 0, 11, 6, charset, null) }; //write records