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


Reply via email to