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 da1cc1c2df747d3db371611bbf9fc29bb41f3774 Merge: 28032e87b4 996e1106a9 Author: Martin Desruisseaux <martin.desruisse...@geomatys.com> AuthorDate: Tue Feb 28 20:22:37 2023 +0100 Merge remote-tracking branch 'origin/fix/sql-temporal' into geoapi-4.0. The pull request has been modified for applying the mapping specified by JDBC 4.2. The specialized classes are kept as fallbacks when JDBC 4.2 is not well supported. .../apache/sis/internal/metadata/sql/Dialect.java | 31 ++- .../java/org/apache/sis/test/sql/TestDatabase.java | 24 ++- .../apache/sis/internal/sql/feature/Database.java | 31 ++- .../sis/internal/sql/feature/ValueGetter.java | 139 +++++++++--- .../sis/internal/sql/feature/package-info.java | 2 +- .../apache/sis/internal/sql/postgis/Postgres.java | 9 +- .../sis/internal/sql/postgis/package-info.java | 2 +- .../sql/feature/TemporalValueGetterTest.java | 238 +++++++++++++++++++++ .../org/apache/sis/storage/sql/SQLStoreTest.java | 104 +++------ .../apache/sis/storage/sql/TestOnAllDatabases.java | 99 +++++++++ .../org/apache/sis/test/suite/SQLTestSuite.java | 1 + 11 files changed, 538 insertions(+), 142 deletions(-) diff --cc core/sis-metadata/src/main/java/org/apache/sis/internal/metadata/sql/Dialect.java index e529787701,e529787701..cefb01a032 --- a/core/sis-metadata/src/main/java/org/apache/sis/internal/metadata/sql/Dialect.java +++ b/core/sis-metadata/src/main/java/org/apache/sis/internal/metadata/sql/Dialect.java @@@ -27,7 -27,7 +27,7 @@@ import org.apache.sis.util.CharSequence * * @author Martin Desruisseaux (Geomatys) * @author Johann Sorel (Geomatys) -- * @version 1.1 ++ * @version 1.4 * @since 0.7 */ public enum Dialect { @@@ -36,37 -36,37 +36,40 @@@ * * @see DatabaseMetaData#supportsANSI92EntryLevelSQL() */ -- ANSI(null, false, true), ++ ANSI(null, false, true, true), /** * The database uses Derby syntax. This is ANSI, with some constraints that PostgreSQL does not have * (for example column with {@code UNIQUE} constraint must explicitly be specified as {@code NOT NULL}). ++ * Furthermore, conversions to {@link java.time} objects are not supported. ++ * ++ * <a href="https://issues.apache.org/jira/browse/DERBY-6445">DERBY-6445</a> */ -- DERBY("derby", false, true), ++ DERBY("derby", false, true, false), /** * The database uses HSQL syntax. This is ANSI, but does not allow {@code INSERT} statements inserting many lines. * It also have a {@code SHUTDOWN} command which is specific to HSQLDB. */ -- HSQL("hsqldb", false, true), ++ HSQL("hsqldb", false, true, true), /** * The database uses PostgreSQL syntax. This is ANSI, but provided an a separated * enumeration value because it allows a few additional commands like {@code VACUUM}. */ -- POSTGRESQL("postgresql", true, true), ++ POSTGRESQL("postgresql", true, true, true), /** * The database uses Oracle syntax. This is ANSI, but without {@code "AS"} keyword. */ -- ORACLE("oracle", false, true), ++ ORACLE("oracle", false, true, true), /** * The database uses SQLite syntax. This is ANSI, but with several limitations. * * @see <a href="https://www.sqlite.org/omitted.html">SQL Features That SQLite Does Not Implement</a> */ -- SQLITE("sqlite", false, false); ++ SQLITE("sqlite", false, false, false); /** * The protocol in JDBC URL, or {@code null} if unknown. @@@ -95,16 -95,16 +98,28 @@@ */ public final boolean supportsAlterTableWithAddConstraint; ++ /** ++ * Whether the JDBC driver supports conversions from objects to {@code java.time} API. ++ * The JDBC 4.2 specification provides a mapping from {@link java.sql.Types} to temporal objects. ++ * The specification suggests that {@link java.sql.ResultSet#getObject(int, Class)} should accept ++ * those temporal types in the {@link Class} argument, but not all drivers support that. ++ * ++ * @see <a href="https://jcp.org/aboutJava/communityprocess/maintenance/jsr221/JDBC4.2MR-January2014.pdf">JDBC Maintenance Release 4.2</a> ++ */ ++ public final boolean supportsJavaTime; ++ /** * Creates a new enumeration value for a SQL dialect for the given protocol. */ private Dialect(final String protocol, final boolean supportsTableInheritance, -- final boolean supportsAlterTableWithAddConstraint) ++ final boolean supportsAlterTableWithAddConstraint, ++ final boolean supportsJavaTime) { this.protocol = protocol; this.supportsTableInheritance = supportsTableInheritance; this.supportsAlterTableWithAddConstraint = supportsAlterTableWithAddConstraint; ++ this.supportsJavaTime = supportsJavaTime; } /** diff --cc core/sis-metadata/src/test/java/org/apache/sis/test/sql/TestDatabase.java index 57092f54ad,57092f54ad..a76ca252f6 --- a/core/sis-metadata/src/test/java/org/apache/sis/test/sql/TestDatabase.java +++ b/core/sis-metadata/src/test/java/org/apache/sis/test/sql/TestDatabase.java @@@ -26,6 -26,6 +26,7 @@@ import java.sql.SQLDataException import org.postgresql.PGProperty; import org.postgresql.ds.PGSimpleDataSource; import org.apache.derby.jdbc.EmbeddedDataSource; ++import org.apache.sis.internal.metadata.sql.Dialect; import org.apache.sis.internal.metadata.sql.LocalDataSource; import org.apache.sis.internal.metadata.sql.ScriptRunner; import org.apache.sis.test.TestCase; @@@ -62,7 -62,7 +63,7 @@@ import static org.junit.Assume.assumeTr * * @author Martin Desruisseaux (Geomatys) * @author Alexis Manin (Geomatys) -- * @version 1.2 ++ * @version 1.4 * @since 0.7 */ public class TestDatabase implements AutoCloseable { @@@ -88,11 -88,11 +89,18 @@@ */ public final DataSource source; ++ /** ++ * The SQL flavor used by the database, or {@code ANSI} if unspecified. ++ * May be used for identifying the database software. ++ */ ++ public final Dialect dialect; ++ /** * Creates a new test database for the given data source. */ -- private TestDatabase(final DataSource source) { -- this.source = source; ++ private TestDatabase(final DataSource source, final Dialect dialect) { ++ this.source = source; ++ this.dialect = dialect; } /** @@@ -107,13 -107,13 +115,13 @@@ */ public static TestDatabase create(final String name) throws SQLException { if (TEST_DATABASE != null) { -- return new TestDatabase(TEST_DATABASE); ++ return new TestDatabase(TEST_DATABASE, Dialect.ANSI); } final EmbeddedDataSource ds = new EmbeddedDataSource(); ds.setDatabaseName("memory:" + name); ds.setDataSourceName("Apache SIS test database"); ds.setCreateDatabase("create"); -- return new TestDatabase(ds) { ++ return new TestDatabase(ds, Dialect.DERBY) { @Override public void close() throws SQLException { final EmbeddedDataSource ds = (EmbeddedDataSource) source; ds.setCreateDatabase("no"); @@@ -157,7 -157,7 +165,7 @@@ ds = simple; pool = null; } -- return new TestDatabase(ds) { ++ return new TestDatabase(ds, Dialect.HSQL) { @Override public void close() throws SQLException { try (Connection c = ds.getConnection(); Statement s = c.createStatement()) { s.execute("SHUTDOWN"); @@@ -186,7 -186,7 +194,7 @@@ final String url = "jdbc:h2:mem:" + name + ";DB_CLOSE_DELAY=-1"; final org.h2.jdbcx.JdbcDataSource ds = new org.h2.jdbcx.JdbcDataSource(); ds.setURL(url); -- return new TestDatabase(ds) { ++ return new TestDatabase(ds, Dialect.ANSI) { @Override public void close() throws SQLException { try (Connection c = ds.getConnection(); Statement s = c.createStatement()) { s.execute("SHUTDOWN"); @@@ -250,7 -250,7 +258,7 @@@ assumeFalse("This test needs a PostgreSQL database named \"" + NAME + "\".", "3D000".equals(state)); throw e; } -- return new TestDatabase(ds) { ++ return new TestDatabase(ds, Dialect.POSTGRESQL) { @Override public void close() throws SQLException { final PGSimpleDataSource ds = (PGSimpleDataSource) source; try (Connection c = ds.getConnection()) { diff --cc storage/sis-sqlstore/src/main/java/org/apache/sis/internal/sql/feature/Database.java index 176c573e54,a643941ef3..2201d587e2 --- a/storage/sis-sqlstore/src/main/java/org/apache/sis/internal/sql/feature/Database.java +++ b/storage/sis-sqlstore/src/main/java/org/apache/sis/internal/sql/feature/Database.java @@@ -96,7 -96,7 +96,7 @@@ import org.apache.sis.util.Debug * @author Johann Sorel (Geomatys) * @author Martin Desruisseaux (Geomatys) * @author Alexis Manin (Geomatys) -- * @version 1.2 ++ * @version 1.4 * @since 1.1 */ public class Database<G> extends Syntax { @@@ -175,6 -175,6 +175,14 @@@ */ final boolean supportsCatalogs, supportsSchemas; ++ /** ++ * Whether the JDBC driver supports conversions from objects to {@code java.time} API. ++ * The JDBC 4.2 specification provides a mapping from {@link java.sql.Types} to temporal objects. ++ * The specification suggests that {@link java.sql.ResultSet#getObject(int, Class)} should accept ++ * those temporal types in the {@link Class} argument, but not all drivers support that. ++ */ ++ private final boolean supportsJavaTime; ++ /** * The converter from filters/expressions to the {@code WHERE} part of SQL statement. * This is initialized when first needed, then kept unmodified for the database lifetime. @@@ -214,11 -214,11 +222,12 @@@ * * @param source provider of (pooled) connections to the database. * @param metadata metadata about the database. ++ * @param dialect additional information not provided by {@code metadata}. * @param geomLibrary the factory to use for creating geometric objects. * @param listeners where to send warnings. * @throws SQLException if an error occurred while reading database metadata. */ -- protected Database(final DataSource source, final DatabaseMetaData metadata, ++ protected Database(final DataSource source, final DatabaseMetaData metadata, final Dialect dialect, final Geometries<G> geomLibrary, final StoreListeners listeners) throws SQLException { @@@ -246,6 -246,6 +255,7 @@@ this.tablesByNames = new FeatureNaming<>(); supportsCatalogs = metadata.supportsCatalogsInDataManipulation(); supportsSchemas = metadata.supportsSchemasInDataManipulation(); ++ supportsJavaTime = dialect.supportsJavaTime; } /** @@@ -270,11 -270,11 +280,12 @@@ { final DatabaseMetaData metadata = connection.getMetaData(); final Geometries<?> g = Geometries.implementation(geomLibrary); ++ final Dialect dialect = Dialect.guess(metadata); final Database<?> db; -- switch (Dialect.guess(metadata)) { -- case POSTGRESQL: db = new Postgres<>(source, connection, metadata, g, listeners); break; ++ switch (dialect) { ++ case POSTGRESQL: db = new Postgres<>(source, connection, metadata, dialect, g, listeners); break; default: { -- db = new Database<>(source, metadata, g, listeners); ++ db = new Database<>(source, metadata, dialect, g, listeners); break; } } @@@ -565,11 -565,11 +576,11 @@@ case Types.CHAR: case Types.VARCHAR: case Types.LONGVARCHAR: return ValueGetter.AsString.INSTANCE; -- case Types.DATE: return ValueGetter.AsDate.INSTANCE; -- case Types.TIME: return ValueGetter.AsLocalTime.INSTANCE; - case Types.TIMESTAMP: return ValueGetter.AsInstant.INSTANCE; - case Types.TIMESTAMP: return ValueGetter.AsLocalDateTime.INSTANCE; -- case Types.TIME_WITH_TIMEZONE: return ValueGetter.AsOffsetTime.INSTANCE; - case Types.TIMESTAMP_WITH_TIMEZONE: return ValueGetter.AsOffsetDateTime.INSTANCE; - case Types.TIMESTAMP_WITH_TIMEZONE: return ValueGetter.AsInstant.INSTANCE; ++ case Types.DATE: return supportsJavaTime ? ValueGetter.LOCAL_DATE : ValueGetter.AsLocalDate.INSTANCE; ++ case Types.TIME: return supportsJavaTime ? ValueGetter.LOCAL_TIME : ValueGetter.AsLocalTime.INSTANCE; ++ case Types.TIMESTAMP: return supportsJavaTime ? ValueGetter.LOCAL_DATE_TIME : ValueGetter.AsLocalDateTime.INSTANCE; ++ case Types.TIME_WITH_TIMEZONE: return supportsJavaTime ? ValueGetter.OFFSET_TIME : ValueGetter.AsOffsetTime.INSTANCE; ++ case Types.TIMESTAMP_WITH_TIMEZONE: return supportsJavaTime ? ValueGetter.OFFSET_DATE_TIME : ValueGetter.AsOffsetDateTime.INSTANCE; case Types.BLOB: return ValueGetter.AsBytes.INSTANCE; case Types.OTHER: case Types.JAVA_OBJECT: return getDefaultMapping(); diff --cc storage/sis-sqlstore/src/main/java/org/apache/sis/internal/sql/feature/ValueGetter.java index d50d2a0552,f7e03a28a8..1676b22bf3 --- a/storage/sis-sqlstore/src/main/java/org/apache/sis/internal/sql/feature/ValueGetter.java +++ b/storage/sis-sqlstore/src/main/java/org/apache/sis/internal/sql/feature/ValueGetter.java @@@ -16,7 -16,8 +16,6 @@@ */ package org.apache.sis.internal.sql.feature; - import java.util.Calendar; -import java.time.LocalDate; -import java.time.LocalDateTime; import java.util.Collection; import java.sql.Array; import java.sql.ResultSet; @@@ -24,8 -25,8 +23,9 @@@ import java.sql.SQLException import java.sql.Date; import java.sql.Time; import java.sql.Timestamp; --import java.time.Instant; import java.time.LocalTime; ++import java.time.LocalDate; ++import java.time.LocalDateTime; import java.time.OffsetDateTime; import java.time.OffsetTime; import java.time.ZoneOffset; @@@ -33,6 -34,6 +33,7 @@@ import java.math.BigDecimal import org.apache.sis.math.Vector; import org.apache.sis.util.Numbers; import org.apache.sis.util.ArgumentChecks; ++import org.apache.sis.internal.util.StandardDateFormat; import org.apache.sis.internal.util.UnmodifiableArrayList; @@@ -52,10 -53,10 +53,45 @@@ * * @author Alexis Manin (Geomatys) * @author Martin Desruisseaux (Geomatys) -- * @version 1.2 ++ * @version 1.4 * @since 1.1 */ --public abstract class ValueGetter<T> { ++public class ValueGetter<T> { ++ /** ++ * A getter of {@link LocalDate} values from the current row of a {@link ResultSet}. ++ * According JDBC 4.2 specification, {@link java.sql.Types#DATE} shall be mapped to ++ * this class. ++ */ ++ static final ValueGetter<LocalDate> LOCAL_DATE = new ValueGetter<>(LocalDate.class); ++ ++ /** ++ * A getter of {@link LocalTime} values from the current row of a {@link ResultSet}. ++ * According JDBC 4.2 specification, {@link java.sql.Types#TIME} without timezone ++ * shall be mapped to this class. ++ */ ++ static final ValueGetter<LocalTime> LOCAL_TIME = new ValueGetter<>(LocalTime.class); ++ ++ /** ++ * A getter of {@link LocalDateTime} values from the current row of a {@link ResultSet}. ++ * According JDBC 4.2 specification, {@link java.sql.Types#TIMESTAMP} without timezone ++ * shall be mapped to this class. ++ */ ++ static final ValueGetter<LocalDateTime> LOCAL_DATE_TIME = new ValueGetter<>(LocalDateTime.class); ++ ++ /** ++ * A getter of {@link OffsetTime} values from the current row of a {@link ResultSet}. ++ * According JDBC 4.2 specification, {@link java.sql.Types#TIME_WITH_TIMEZONE} shall ++ * be mapped to this class. ++ */ ++ static final ValueGetter<OffsetTime> OFFSET_TIME = new ValueGetter<>(OffsetTime.class); ++ ++ /** ++ * A getter of {@link OffsetDateTime} values from the current row of a {@link ResultSet}. ++ * According JDBC 4.2 specification, {@link java.sql.Types#TIMESTAMP_WITH_TIMEZONE} ++ * shall be mapped to this class. ++ */ ++ static final ValueGetter<OffsetDateTime> OFFSET_DATE_TIME = new ValueGetter<>(OffsetDateTime.class); ++ /** * The type of Java objects fetched from the column. * The value shall not be a primitive type; the wrapper class shall be used instead. @@@ -78,10 -79,10 +114,14 @@@ * The given result set must have its cursor position on the line to read. * This method does not modify the cursor position. * -- * <div class="note"><b>Note:</b> ++ * <h4>Usage note</h4> * The {@code stmts} is the same reference for all features created by a new {@link FeatureIterator} instance, * including its dependencies. But the {@code source} will vary depending on whether we are iterating over the -- * main feature or one of its dependencies.</div> ++ * main feature or one of its dependencies. ++ * ++ * <h4>Default implementation</h4> ++ * The default implementation delegates to {@link ResultSet#getObject(int, Class)}. ++ * This is okay if the database is known to support conversions to {@link #valueType}. * * @param stmts prepared statements for fetching CRS from SRID, or {@code null} if none. * @param source the result set from which to get the value. @@@ -89,7 -90,7 +129,9 @@@ * @return value in the given column. May be {@code null}. * @throws Exception if an error occurred. May be an SQL error, a WKB parsing error, <i>etc.</i> */ -- public abstract T getValue(InfoStatements stmts, ResultSet source, int columnIndex) throws Exception; ++ public T getValue(InfoStatements stmts, ResultSet source, int columnIndex) throws Exception { ++ return source.getObject(columnIndex, valueType); ++ } /** * A getter of {@link Object} values from the current row of a {@link ResultSet}. @@@ -282,19 -283,19 +324,22 @@@ } /** -- * A getter of {@link Date} values from the current row of a {@link ResultSet}. - * This getter delegates to {@link ResultSet#getDate(int)} and returns that value with no change. - * This getter delegates to {@link ResultSet#getDate(int)} then convert that value to {@link LocalDate java time API}. ++ * A getter of {@link LocalDate} values from the current row of a {@link ResultSet}. ++ * This getter delegates to {@link ResultSet#getDate(int)} then converts that value ++ * by a call to {@link Date#toLocalDate()}. + * - * @todo Delegate to {@link ResultSet#getDate(int, Calendar)} instead. ++ * <p>This is fallback used when {@link ResultSet#getObject(int, Class)} does not support the ++ * conversion from {@link java.sql.Types#DATE} to Java time API as specified by JDBC 4.2.</p> */ - static final class AsDate extends ValueGetter<Date> { - static final class AsDate extends ValueGetter<LocalDate> { ++ static final class AsLocalDate extends ValueGetter<LocalDate> { /** The unique instance of this accessor. */ -- public static final AsDate INSTANCE = new AsDate(); - private AsDate() {super(Date.class);} - private AsDate() {super(LocalDate.class);} ++ public static final AsLocalDate INSTANCE = new AsLocalDate(); ++ private AsLocalDate() {super(LocalDate.class);} /** Fetches the value from the specified column in the given result set. */ - @Override public Date getValue(InfoStatements stmts, ResultSet source, int columnIndex) throws SQLException { - return source.getDate(columnIndex); + @Override public LocalDate getValue(InfoStatements stmts, ResultSet source, int columnIndex) throws SQLException { - final Date rawValue = source.getDate(columnIndex); - if (rawValue == null) return null; - else return rawValue.toLocalDate(); ++ final Date date = source.getDate(columnIndex); ++ return (date != null) ? date.toLocalDate() : null; } } @@@ -303,7 -304,10 +348,8 @@@ * This getter delegates to {@link ResultSet#getTime(int)}, then converts the object * by a call to {@link Time#toLocalTime()}. * - * @todo Delegate to {@link ResultSet#getTime(int, Calendar)} instead. - * @todo java.sql.Time does not provide sub-second precision in its local time conversion. - * However, some databases support it. A better conversion function would be: - * {@code LocalTime.ofNanoOfDay(Math.multiplyExact(sqlTime.getTime(), 1_000_000L));} - * But it is less "standard". We should test across multiple database engines to verify the related impact. ++ * <p>This is fallback used when {@link ResultSet#getObject(int, Class)} does not support the ++ * conversion from {@link java.sql.Types#TIME} to Java time API as specified by JDBC 4.2.</p> */ static final class AsLocalTime extends ValueGetter<LocalTime> { /** The unique instance of this accessor. */ @@@ -313,26 -317,24 +359,33 @@@ /** Fetches the value from the specified column in the given result set. */ @Override public LocalTime getValue(InfoStatements stmts, ResultSet source, int columnIndex) throws SQLException { final Time time = source.getTime(columnIndex); -- return (time != null) ? time.toLocalTime() : null; ++ if (time == null) return null; ++ /* ++ * `Time.toLocalTime()` does not use sub-second precision. ++ * However, some databases provide millisecond precision. ++ */ ++ final int milli = (int) (time.getTime() % StandardDateFormat.MILLIS_PER_SECOND); ++ return time.toLocalTime().withNano(milli * StandardDateFormat.NANOS_PER_MILLISECOND); } } /** -- * A getter of {@link Instant} values from the current row of a {@link ResultSet}. -- * This getter delegates to {@link ResultSet#getTimestamp(int)}, then converts the - * object by a call to {@link Timestamp#toInstant()}. - * object by a call to {@link Timestamp#toLocalDateTime()}. ++ * A getter of {@link LocalDateTime} values from the current row of a {@link ResultSet}. ++ * This getter delegates to {@link ResultSet#getTimestamp(int)}, then converts the object ++ * by a call to {@link Timestamp#toLocalDateTime()}. + * - * @todo Delegate to {@link ResultSet#getTimestamp(int, Calendar)} instead. ++ * <p>This is fallback used when {@link ResultSet#getObject(int, Class)} does not support the ++ * conversion from {@link java.sql.Types#TIMESTAMP} to Java time API as specified by JDBC 4.2.</p> */ - static final class AsInstant extends ValueGetter<Instant> { + static final class AsLocalDateTime extends ValueGetter<LocalDateTime> { /** The unique instance of this accessor. */ - public static final AsInstant INSTANCE = new AsInstant(); - private AsInstant() {super(Instant.class);} + public static final AsLocalDateTime INSTANCE = new AsLocalDateTime(); + private AsLocalDateTime() {super(LocalDateTime.class);} /** Fetches the value from the specified column in the given result set. */ - @Override public Instant getValue(InfoStatements stmts, ResultSet source, int columnIndex) throws SQLException { + @Override public LocalDateTime getValue(InfoStatements stmts, ResultSet source, int columnIndex) throws SQLException { final Timestamp time = source.getTimestamp(columnIndex); - return (time != null) ? time.toInstant() : null; + return (time != null) ? time.toLocalDateTime() : null; } } @@@ -341,28 -343,40 +394,44 @@@ * This getter delegates to {@link ResultSet#getTimestamp(int)}, converts the object by a * call to {@link Timestamp#toInstant()} then apply the time zone offset. * - * @todo Delegate to {@link ResultSet#getTimestamp(int, Calendar)} instead. - * Note: This converter is only meant for "Timestamp with time zone" data types, that describe a fixed/absolute point in time. - * Semantically, an SQL "timestamp with time zone" should better match {@link OffsetDateTime}. - * However, none of the tested JDBC drivers (PostgreSQL and HSQLDB) return the zone offset given at insertion. - * They both return the timestamp UTC, meaning that in any cases, the original zone offset information in lost. - * Therefore, it is more straightforward to return an UTC Timestamp, i.e {@link Instant} anyway. ++ * <p>This is fallback used when {@link ResultSet#getObject(int, Class)} does not support conversion ++ * from {@link java.sql.Types#TIMESTAMP_WITH_TIMEZONE} to Java time API as specified by JDBC 4.2.</p> + * - * WARNING: timeStamps returned by HSQLDB looks flawed. To bypass the problem, we add a special case to check if the - * driver directly returns a java.time data type. If not, then we fallback to {@link Timestamp SQL Timestamps}. ++ * <h4>Implementation note</h4> ++ * PostgreSQL always return the time in the local time zone, while HSQLDB and H2 return the time as ++ * inserted in the database but ignoring the timezone offset. The latter implies that we don't know ++ * how convert a HSQLDB and H2 to local or UTC timezone. Current implementation assumes PostgreSQL ++ * behavior, which is the only one that we can map to {@link OffsetDateTime}. ++ * Specifying a {@link java.util.Calendar} seems to have no effect. */ - static final class AsInstant extends ValueGetter<Instant> { + static final class AsOffsetDateTime extends ValueGetter<OffsetDateTime> { /** The unique instance of this accessor. */ - public static final AsInstant INSTANCE = new AsInstant(); - private AsInstant() {super(Instant.class);} + public static final AsOffsetDateTime INSTANCE = new AsOffsetDateTime(); + private AsOffsetDateTime() {super(OffsetDateTime.class);} /** Fetches the value from the specified column in the given result set. */ - @Override public Instant getValue(InfoStatements stmts, ResultSet source, int columnIndex) throws SQLException { - final Object rawValue = source.getObject(columnIndex); - if (rawValue == null) return null; - else if (rawValue instanceof Instant) return (Instant) rawValue; - else if (rawValue instanceof OffsetDateTime) return (((OffsetDateTime) rawValue).toInstant()); - else return source.getTimestamp(columnIndex).toInstant(); + @Override public OffsetDateTime getValue(InfoStatements stmts, ResultSet source, int columnIndex) throws SQLException { + final Timestamp time = source.getTimestamp(columnIndex); + if (time == null) return null; - final int offsetMinute = time.getTimezoneOffset(); ++ final int offsetMinute = -time.getTimezoneOffset(); + return time.toInstant().atOffset(ZoneOffset.ofHoursMinutes(offsetMinute / 60, offsetMinute % 60)); } } /** -- * A getter of {@link OffsetDateTime} values from the current row of a {@link ResultSet}. -- * This getter delegates to {@link ResultSet#getTime(int)}, converts the object by a call -- * to {@link Time#toLocalTime()} then apply the time zone offset. ++ * A getter of {@link OffsetTime} values from the current row of a {@link ResultSet}. ++ * This getter delegates to {@link ResultSet#getTime(int)}, converts the object by a ++ * call to {@link Time#toLocalTime()} then apply the time zone offset. + * - * @todo As for {@link AsLocalTime}, sub-second precision is lost here. ++ * <p>This is fallback used when {@link ResultSet#getObject(int, Class)} does not support conversion ++ * from {@link java.sql.Types#TIME_WITH_TIMEZONE} to Java time API as specified by JDBC 4.2.</p> * - * @todo Delegate to {@link ResultSet#getTime(int, Calendar)} instead. - * Note: Timezone used on value insertion is lost here, we always return an UTC time. - * The main reason is that multiple database (Postgres, HSQLDB) engines convert times to UTC on save, - * so it is impossible to retrieve the information later. ++ * <h4>Implementation note</h4> ++ * PostgreSQL always return the time in the local time zone, while HSQLDB and H2 return the time as ++ * inserted in the database but ignoring the timezone offset. The latter implies that we don't know ++ * how convert a HSQLDB and H2 to local or UTC timezone. Current implementation assumes PostgreSQL ++ * behavior, which is the only one that we can map to {@link OffsetTime}. ++ * Specifying a {@link java.util.Calendar} seems to have no effect. */ static final class AsOffsetTime extends ValueGetter<OffsetTime> { /** The unique instance of this accessor. */ @@@ -371,10 -385,15 +440,12 @@@ /** Fetches the value from the specified column in the given result set. */ @Override public OffsetTime getValue(InfoStatements stmts, ResultSet source, int columnIndex) throws SQLException { - final Object rawValue = source.getObject(columnIndex); - if (rawValue == null) return null; - // Workaround/shortcut for HSQLDB and H2 - else if (rawValue instanceof OffsetTime) return (OffsetTime) rawValue; - else { - final Time time = source.getTime(columnIndex); - final long timeNanoseconds = Math.multiplyExact(time.getTime(), 1_000_000L); - return LocalTime.ofNanoOfDay(timeNanoseconds).atOffset(ZoneOffset.UTC); - } + final Time time = source.getTime(columnIndex); + if (time == null) return null; - final int offsetMinute = time.getTimezoneOffset(); - return time.toLocalTime().atOffset(ZoneOffset.ofHoursMinutes(offsetMinute / 60, offsetMinute % 60)); ++ final int offsetMinute = -time.getTimezoneOffset(); ++ final int milli = (int) (time.getTime() % StandardDateFormat.MILLIS_PER_SECOND); ++ return time.toLocalTime().withNano(milli * StandardDateFormat.NANOS_PER_MILLISECOND) ++ .atOffset(ZoneOffset.ofHoursMinutes(offsetMinute / 60, offsetMinute % 60)); } } diff --cc storage/sis-sqlstore/src/main/java/org/apache/sis/internal/sql/feature/package-info.java index 3dfb9f6293,3dfb9f6293..f9bed5e7b0 --- a/storage/sis-sqlstore/src/main/java/org/apache/sis/internal/sql/feature/package-info.java +++ b/storage/sis-sqlstore/src/main/java/org/apache/sis/internal/sql/feature/package-info.java @@@ -31,7 -31,7 +31,7 @@@ * @author Johann Sorel (Geomatys) * @author Martin Desruisseaux (Geomatys) * @author Alexis Manin (Geomatys) -- * @version 1.2 ++ * @version 1.4 * @since 1.0 */ package org.apache.sis.internal.sql.feature; diff --cc storage/sis-sqlstore/src/main/java/org/apache/sis/internal/sql/postgis/Postgres.java index a15b380991,a15b380991..386b4d2256 --- a/storage/sis-sqlstore/src/main/java/org/apache/sis/internal/sql/postgis/Postgres.java +++ b/storage/sis-sqlstore/src/main/java/org/apache/sis/internal/sql/postgis/Postgres.java @@@ -37,6 -37,6 +37,7 @@@ import org.apache.sis.internal.sql.feat import org.apache.sis.internal.sql.feature.ValueGetter; import org.apache.sis.internal.sql.feature.Resources; import org.apache.sis.internal.sql.feature.SelectionClauseWriter; ++import org.apache.sis.internal.metadata.sql.Dialect; import org.apache.sis.storage.event.StoreListeners; import org.apache.sis.util.Version; @@@ -48,7 -48,7 +49,7 @@@ * * @author Alexis Manin (Geomatys) * @author Martin Desruisseaux (Geomatys) -- * @version 1.2 ++ * @version 1.4 * @since 1.1 */ public final class Postgres<G> extends Database<G> { @@@ -67,14 -67,14 +68,16 @@@ * @param source provider of (pooled) connections to the database. * @param connection the connection to the database. Should be considered as read-only. * @param metadata metadata about the database for which a session is created. ++ * @param dialect additional information not provided by {@code metadata}. * @param geomLibrary the factory to use for creating geometric objects. * @param listeners where to send warnings. * @throws SQLException if an error occurred while reading database metadata. */ public Postgres(final DataSource source, final Connection connection, final DatabaseMetaData metadata, -- final Geometries<G> geomLibrary, final StoreListeners listeners) throws SQLException ++ final Dialect dialect, final Geometries<G> geomLibrary, final StoreListeners listeners) ++ throws SQLException { -- super(source, metadata, geomLibrary, listeners); ++ super(source, metadata, dialect, geomLibrary, listeners); Version version = null; try (Statement st = connection.createStatement(); ResultSet result = st.executeQuery("SELECT public.PostGIS_version();")) diff --cc storage/sis-sqlstore/src/main/java/org/apache/sis/internal/sql/postgis/package-info.java index 263c959238,263c959238..27cbaa85b3 --- a/storage/sis-sqlstore/src/main/java/org/apache/sis/internal/sql/postgis/package-info.java +++ b/storage/sis-sqlstore/src/main/java/org/apache/sis/internal/sql/postgis/package-info.java @@@ -24,7 -24,7 +24,7 @@@ * @author Alexis Manin (Geomatys) * @author Martin Desruisseaux (Geomatys) * @author Johann Sorel (Geomatys) -- * @version 1.2 ++ * @version 1.4 * @since 1.1 */ package org.apache.sis.internal.sql.postgis; diff --cc storage/sis-sqlstore/src/test/java/org/apache/sis/internal/sql/feature/TemporalValueGetterTest.java index 0000000000,ff6f65eff2..d90873b74c mode 000000,100644..100644 --- a/storage/sis-sqlstore/src/test/java/org/apache/sis/internal/sql/feature/TemporalValueGetterTest.java +++ b/storage/sis-sqlstore/src/test/java/org/apache/sis/internal/sql/feature/TemporalValueGetterTest.java @@@ -1,0 -1,277 +1,238 @@@ ++/* ++ * 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.internal.sql.feature; + + import java.sql.Connection; + import java.sql.ResultSet; -import java.sql.SQLException; + import java.sql.Statement; -import java.time.Instant; + import java.time.LocalDate; + import java.time.LocalDateTime; + import java.time.LocalTime; ++import java.time.OffsetDateTime; + import java.time.OffsetTime; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Objects; ++import java.time.ZoneOffset; + import java.util.function.BiPredicate; -import java.util.stream.Collectors; -import org.apache.sis.test.TestCase; ++import org.apache.sis.internal.metadata.sql.Dialect; ++import org.apache.sis.storage.sql.TestOnAllDatabases; + import org.apache.sis.test.sql.TestDatabase; -import org.junit.AfterClass; -import org.junit.Assert; -import org.junit.Assume; -import org.junit.AssumptionViolatedException; -import org.junit.BeforeClass; -import org.junit.Test; + -import static java.time.ZoneOffset.UTC; -import static java.time.ZoneOffset.ofHours; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNull; ++import static org.junit.Assert.*; ++ + + /** - * Check access and conversion to database datetime values. ++ * Tests accesses and conversions to database date and time values. ++ * ++ * @author Alexis Manin (Geomatys) ++ * @author Martin Desruisseaux (Geomatys) ++ * @version 1.4 ++ * @since 1.4 + */ -public class TemporalValueGetterTest extends TestCase { ++public final class TemporalValueGetterTest extends TestOnAllDatabases { ++ /** ++ * Whether the JDBC driver supports conversions from objects to {@code java.time} API. ++ */ ++ private boolean supportsJavaTime; + - private static final String DB_TITLE = "Test Temporal values"; - private static final String TEST_SCHEMA = "SIS_TEST_TEMPORAL"; - private static final String DEFAULT_DRIVER = "DEFAULT"; - public static final String PG_DRIVER = "POSTGRESQL"; - private static Map<String, TestDatabase> DATABASES; ++ /** ++ * Whether {@code ResultSet.getTime(int)} uses an unknown time zone. ++ * In such case, the fallbacks are unreliable and their tests should ++ * be disabled. ++ */ ++ private boolean unreliableFallback; + - @BeforeClass - public static void initTestDatabases() throws Exception { - if (DATABASES != null) throw new IllegalStateException("Test database should be null before initialization"); - DATABASES = new HashMap<>(); - DATABASES.put(DEFAULT_DRIVER, TestDatabase.create(DB_TITLE)); - DATABASES.put("HSQLDB", TestDatabase.createOnHSQLDB(DB_TITLE, true)); - DATABASES.put("H2", TestDatabase.createOnH2(DB_TITLE)); - try { - final TestDatabase db = TestDatabase.createOnPostgreSQL(TEST_SCHEMA, true); - DATABASES.put(PG_DRIVER, db); - } catch (AssumptionViolatedException e) { - // Ok, environment does not contain a PostGreSQL test database - out.println("[FINE] PostgreSQL database deactivated due to assumption requirement not met: "+e.getMessage()); - } ++ /** ++ * Creates a new test. ++ */ ++ public TemporalValueGetterTest() { + } + - @AfterClass - public static void disposeTestDatabases() throws Exception { - if (DATABASES != null) { - List<Exception> closeErrors = DATABASES.values().stream() - .map(db -> { - try { - db.close(); - return null; - } catch (Exception e) { - return e; - } - }) - .filter(Objects::nonNull) - .collect(Collectors.toList()); - if (!closeErrors.isEmpty()) { - final Exception base = closeErrors.get(0); - for (int i = 1 ; i < closeErrors.size() ; i++) { - base.addSuppressed(closeErrors.get(i)); ++ /** ++ * Runs all tests on a single database software. ++ * ++ * @throws Exception if the execution of an SQL query or the conversion of a value failed. ++ */ ++ @Override ++ protected void test(final TestDatabase database, final boolean noschema) throws Exception { ++ supportsJavaTime = database.dialect.supportsJavaTime; ++ try (Connection connection = database.source.getConnection()) { ++ if (!noschema) { ++ connection.setSchema(SCHEMA); ++ } ++ try (Statement stmt = connection.createStatement()) { ++ sqlDateToLocalDate(stmt); ++ sqlTimeToLocalTime(stmt); ++ sqlTimestampToLocalDateTime(stmt); ++ if (database.dialect != Dialect.DERBY) { // Derby does not support TIMESTAMP WITH TIME ZONE. ++ unreliableFallback = (database.dialect != Dialect.POSTGRESQL); ++ sqlTimeWithTimeZoneToOffsetTime(stmt); ++ sqlTimestampWithTimeZoneToOffsetDateTime(stmt); + } - throw base; + } + } + } + - @Test - public void sqlDateToLocalDate() { - testOnEachDatabase((driver, db) -> { - execute(driver, db, - "CREATE TABLE TEST_DATE (\"value\" DATE)", - "INSERT INTO TEST_DATE (\"value\") VALUES ('2022-05-01'), ('2022-05-31'), (null)" - ); - - ValueGetter<LocalDate> getter = ValueGetter.AsDate.INSTANCE; - execute(driver, db, "SELECT \"value\" FROM TEST_DATE", r -> { - r.next(); - assertEquals(LocalDate.of(2022, 5, 1), getter.getValue(null, r, 1)); - r.next(); - assertEquals(LocalDate.of(2022, 5, 31), getter.getValue(null, r, 1)); - r.next(); - // Ensure no NPE occurs, i.e the converter is resilient to null inputs - assertNull(getter.getValue(null, r, 1)); - assertFalse("Defense against test change: only 3 values should be present in the test table", r.next()); - }); - }); - } - - @Test - public void sqlTimestampToLocalDateTime() { - testOnEachDatabase((driver, db) -> { - execute(driver, db, - "CREATE TABLE TEST_TIMESTAMP_WITHOUT_TIMEZONE (\"value\" TIMESTAMP)", - "INSERT INTO TEST_TIMESTAMP_WITHOUT_TIMEZONE (\"value\") VALUES ('2022-05-01 01:01:01'), ('2022-05-31 23:59:59.999'), (null)" - ); - - ValueGetter<LocalDateTime> getter = ValueGetter.AsLocalDateTime.INSTANCE; - execute(driver, db, "SELECT \"value\" FROM TEST_TIMESTAMP_WITHOUT_TIMEZONE", r -> { - r.next(); - assertEquals(LocalDateTime.of(2022, 5, 1, 1, 1, 1), getter.getValue(null, r, 1)); - r.next(); - assertEquals(LocalDateTime.of(2022, 5, 31, 23, 59, 59, 999_000_000), getter.getValue(null, r, 1)); - r.next(); - // Ensure no NPE occurs, i.e the converter is resilient to null inputs - assertNull(getter.getValue(null, r, 1)); - assertFalse("Defense against test change: only 3 values should be present in the test table", r.next()); - }); - }); - } - - @Test - public void sqlTimestampWithTimeZoneToInstant() { - testOnEachDatabase((driver, db) -> { - Assume.assumeFalse("Derby does not support TIMESTAMP WITH TIME ZONE data type", DEFAULT_DRIVER.equals(driver)); - execute(driver, db, - "CREATE TABLE TEST_TIMESTAMP_WITH_TIMEZONE (\"value\" TIMESTAMP WITH TIME ZONE)", - "INSERT INTO TEST_TIMESTAMP_WITH_TIMEZONE (\"value\") VALUES ('2022-05-01 01:01:01+01:00'), ('2022-05-31 23:59:59.999+02:00'), (null)" - ); - - ValueGetter<Instant> getter = ValueGetter.AsInstant.INSTANCE; - execute(driver, db, "SELECT \"value\" FROM TEST_TIMESTAMP_WITH_TIMEZONE", r -> { - Instant[] expectedValues = { - LocalDateTime.of(2022, 5, 1, 1, 1, 1).atOffset(ofHours(1)).toInstant(), - LocalDateTime.of(2022, 5, 31, 23, 59, 59, 999_000_000).atOffset(ofHours(2)).toInstant(), - null - }; - assertContentEquals(expectedValues, r, 1, getter, "Timestamp with time zone conversion"); - }); - }); - } - + /** - * Note: It does not test sub-second precision, although PostGreSQL, HSQLDB and H2 support it. - * The conversion from {@link java.sql.Timestamp} makes it difficult to get back the information. - * See {@link ValueGetter.AsLocalTime} todo section for details. ++ * Returns the value getter than can be used for the test. ++ * The list of available value getters depends on the database driver. ++ * ++ * @param <V> type of values to get. ++ * @param main the preferred value getter. ++ * @param fallback the fallback to use when the main getter is not available. ++ * @return the value getters to use for the tests. + */ - @Test - public void sqlTimeToLocalTime() { - testOnEachDatabase(((driver, db) -> { - execute(driver, db, - "CREATE TABLE TEST_TIME (\"value\" TIME)", - "INSERT INTO TEST_TIME (\"value\") VALUES ('08:01:01'), ('09:30:30'), (null)"); - ValueGetter<LocalTime> getter = ValueGetter.AsLocalTime.INSTANCE; - execute(driver, db, "SELECT \"value\" FROM TEST_TIME", r -> { - LocalTime[] expectedValues = { - LocalTime.of(8, 1, 1), - LocalTime.of(9, 30, 30), - null - }; - assertContentEquals(expectedValues, r, 1, getter, "Time conversion"); - }); - })); - } - - @Test - public void sqlTimeWithTimeZoneToOffsetTime() { - testOnEachDatabase(((driver, db) -> { - Assume.assumeFalse("Derby does not support TIMESTAMP WITH TIME ZONE data type", DEFAULT_DRIVER.equals(driver)); - execute(driver, db, - "CREATE TABLE TEST_TIME_WITH_TIMEZONE (\"value\" TIME WITH TIME ZONE)", - "INSERT INTO TEST_TIME_WITH_TIMEZONE (\"value\") VALUES ('08:01:01+00:00'), ('09:30:30-01:00'), (null)"); - ValueGetter<OffsetTime> getter = ValueGetter.AsOffsetTime.INSTANCE; - execute(driver, db, "SELECT \"value\" FROM TEST_TIME_WITH_TIMEZONE", r -> { - OffsetTime[] expectedValues = { - LocalTime.of(8, 1, 1).atOffset(UTC), - LocalTime.of(10, 30, 30).atOffset(UTC), - null - }; - - // Databases don't properly preserve insert time zone, so we can only check if values represent the same - // instant from the start of day. - assertContentEquals(expectedValues, r, 1, getter, OffsetTime::isEqual, "Time conversion"); - }); - })); - } - - private <V> void assertContentEquals(V[] expected, ResultSet actual, int columnIndex, ValueGetter<V> valueConverter, String title) throws Exception { - assertContentEquals(expected, actual, columnIndex, valueConverter, Objects::equals, title); ++ @SuppressWarnings({"unchecked","rawtypes"}) // Generic array creation. ++ private <V> ValueGetter<V>[] getters(final ValueGetter<V> main, final ValueGetter<V> fallback) { ++ if (supportsJavaTime) { ++ if (unreliableFallback) { ++ return new ValueGetter[] {main}; ++ } else { ++ return new ValueGetter[] {main, fallback}; ++ } ++ } else { ++ return new ValueGetter[] {fallback}; ++ } + } + + /** - * Equivalent of {@link Assert#assertArrayEquals(String, Object[], Object[])}, except "actual values" are retrieved - * by parsing an {@link ResultSet SQL query result}. - * - * @param expected Values that should be returned by the query+converter combo. - * @param actual Result of an SQL query to validate. - * @param columnIndex Column to read values from in given result set. - * @param valueConverter How to map each SQL result value to JAVA API. Must not be null. - * @param comparisonFunction How to compare an expected element with. Must not be null. - * @param title An error message title for assertion failures. - * @param <V> Type of values to compare. ++ * Tests conversion from SQL {@code DATE} to {@link LocalDate}. + */ - private <V> void assertContentEquals(V[] expected, ResultSet actual, int columnIndex, ValueGetter<V> valueConverter, BiPredicate<V, V> comparisonFunction, String title) throws Exception { - final List<V> values = new ArrayList<>(expected.length); - while (values.size() < expected.length && actual.next()) { - values.add(valueConverter.getValue(null, actual, columnIndex)); - } - - assertEquals(title + ": Not enough values in query result", expected.length, values.size()); - assertFalse(title + ": Too many values in query result", actual.next()); - - for (int i = 0 ; i < expected.length ; i++) { - final V expectedValue = expected[i]; - final V actualValue = values.get(i); - - if (expectedValue == actualValue) continue; - - if (expectedValue == null || actualValue == null || !comparisonFunction.test(expectedValue, actualValue)) { - throw new AssertionError(String.format("%s: error at index %d -> Expected: %s, but was: %s", title, i, expectedValue, actualValue)); ++ private void sqlDateToLocalDate(final Statement stmt) throws Exception { ++ assertFalse(stmt.execute("CREATE TABLE TEST_DATE (\"value\" DATE)")); ++ assertFalse(stmt.execute("INSERT INTO TEST_DATE (\"value\") VALUES ('2022-05-01'), ('2022-05-31'), (null)")); ++ LocalDate[] expected = { ++ LocalDate.of(2022, 5, 1), ++ LocalDate.of(2022, 5, 31), ++ null // Ensure no NPE occurs, i.e the converter is resilient to null inputs. ++ }; ++ for (ValueGetter<LocalDate> getter : getters(ValueGetter.LOCAL_DATE, ValueGetter.AsLocalDate.INSTANCE)) { ++ try (ResultSet r = stmt.executeQuery("SELECT \"value\" FROM TEST_DATE")) { ++ assertContentEquals(expected, r, getter, null, "Date conversion"); + } + } + } + - private void testOnEachDatabase(DatabaseTester tester) { - DATABASES.forEach((driver, db) -> { - try { - tester.test(driver, db); - } catch (AssumptionViolatedException e) { - // OK, user has deactivated test upon some conditions - out.println("[FINE] Error ignored because of failed assumption. Driver="+driver+", reason="+e.getMessage()); - } catch (AssertionError e) { - throw new AssertionError("Test failed for driver: "+driver, e); - } catch (Exception e) { - throw new RuntimeException("Error while testing driver: "+driver, e); ++ /** ++ * Tests conversion from SQL {@code TIMESTAMP} to {@link LocalDateTime}. ++ */ ++ private void sqlTimestampToLocalDateTime(final Statement stmt) throws Exception { ++ assertFalse(stmt.execute("CREATE TABLE TEST_TIMESTAMP_WITHOUT_TIMEZONE (\"value\" TIMESTAMP)")); ++ assertFalse(stmt.execute("INSERT INTO TEST_TIMESTAMP_WITHOUT_TIMEZONE (\"value\") " ++ + "VALUES ('2022-05-01 01:01:01'), ('2022-05-31 23:59:59.999'), (null)")); ++ LocalDateTime[] expected = { ++ LocalDateTime.of(2022, 5, 1, 1, 1, 1), ++ LocalDateTime.of(2022, 5, 31, 23, 59, 59, 999_000_000), ++ null // Ensure no NPE occurs, i.e the converter is resilient to null inputs. ++ }; ++ for (ValueGetter<LocalDateTime> getter : getters(ValueGetter.LOCAL_DATE_TIME, ValueGetter.AsLocalDateTime.INSTANCE)) { ++ try (ResultSet r = stmt.executeQuery("SELECT \"value\" FROM TEST_TIMESTAMP_WITHOUT_TIMEZONE")) { ++ assertContentEquals(expected, r, getter, null, "Date and time conversion"); + } - }); ++ } + } + - @FunctionalInterface - private interface DatabaseTester { - void test(String driver, TestDatabase db) throws Exception; ++ /** ++ * Tests conversion from SQL {@code TIMESTAMP WITH TIME ZONE} to {@link OffsetDateTime}. ++ */ ++ private void sqlTimestampWithTimeZoneToOffsetDateTime(final Statement stmt) throws Exception { ++ assertFalse(stmt.execute("CREATE TABLE TEST_TIMESTAMP_WITH_TIMEZONE (\"value\" TIMESTAMP WITH TIME ZONE)")); ++ assertFalse(stmt.execute("INSERT INTO TEST_TIMESTAMP_WITH_TIMEZONE (\"value\") " ++ + "VALUES ('2022-05-01 01:01:01+01:00'), ('2022-05-31 23:59:59.999+02:00'), (null)")); ++ OffsetDateTime[] expected = { ++ LocalDateTime.of(2022, 5, 1, 1, 1, 1).atOffset(ZoneOffset.ofHours(1)), ++ LocalDateTime.of(2022, 5, 31, 23, 59, 59, 999_000_000).atOffset(ZoneOffset.ofHours(2)), ++ null ++ }; ++ for (ValueGetter<OffsetDateTime> getter : getters(ValueGetter.OFFSET_DATE_TIME, ValueGetter.AsOffsetDateTime.INSTANCE)) { ++ try (ResultSet r = stmt.executeQuery("SELECT \"value\" FROM TEST_TIMESTAMP_WITH_TIMEZONE")) { ++ /* ++ * Some databases do not properly preserve insert time zone, so we can ++ * only check if values represent the same instant from the start of day. ++ */ ++ assertContentEquals(expected, r, getter, OffsetDateTime::isEqual, ++ "Timestamp with time zone conversion"); ++ } ++ } + } + - @FunctionalInterface - private interface QueryAction { - void accept(ResultSet queryResult) throws Exception; ++ /** ++ * Tests conversion from SQL {@code TIME} to {@link LocalTime}. ++ */ ++ private void sqlTimeToLocalTime(final Statement stmt) throws Exception { ++ assertFalse(stmt.execute("CREATE TABLE TEST_TIME (\"value\" TIME)")); ++ assertFalse(stmt.execute("INSERT INTO TEST_TIME (\"value\") VALUES ('08:01:01'), ('09:30:30'), (null)")); ++ LocalTime[] expected = { ++ LocalTime.of(8, 1, 1), ++ LocalTime.of(9, 30, 30), ++ null ++ }; ++ for (ValueGetter<LocalTime> getter : getters(ValueGetter.LOCAL_TIME, ValueGetter.AsLocalTime.INSTANCE)) { ++ try (ResultSet r = stmt.executeQuery("SELECT \"value\" FROM TEST_TIME")) { ++ assertContentEquals(expected, r, getter, null, "Time conversion"); ++ } ++ } + } + - private static void execute(String driver, TestDatabase db, String firstQuery, String... moreQueries) throws SQLException { - try (Connection c = db.source.getConnection()) { - if (PG_DRIVER.equals(driver)) c.setSchema(TEST_SCHEMA); - try (Statement s = c.createStatement()) { - s.execute(firstQuery); - for (String q : moreQueries) s.execute(q); ++ /** ++ * Tests conversion from SQL {@code TIME} to {@link LocalTime}. ++ */ ++ private void sqlTimeWithTimeZoneToOffsetTime(final Statement stmt) throws Exception { ++ assertFalse(stmt.execute("CREATE TABLE TEST_TIME_WITH_TIMEZONE (\"value\" TIME WITH TIME ZONE)")); ++ assertFalse(stmt.execute("INSERT INTO TEST_TIME_WITH_TIMEZONE (\"value\") " ++ + "VALUES ('08:01:01+00:00'), ('09:30:30-01:00'), (null)")); ++ OffsetTime[] expected = { ++ LocalTime.of(8, 1, 1).atOffset(ZoneOffset.UTC), ++ LocalTime.of(10, 30, 30).atOffset(ZoneOffset.UTC), ++ null ++ }; ++ for (ValueGetter<OffsetTime> getter : getters(ValueGetter.OFFSET_TIME, ValueGetter.AsOffsetTime.INSTANCE)) { ++ try (ResultSet r = stmt.executeQuery("SELECT \"value\" FROM TEST_TIME_WITH_TIMEZONE")) { ++ /* ++ * Some databases do not properly preserve insert time zone, so we can ++ * only check if values represent the same instant from the start of day. ++ */ ++ assertContentEquals(expected, r, getter, OffsetTime::isEqual, "Time conversion"); + } + } + } + - private static void execute(String driver, TestDatabase db, String sqlQuery, QueryAction action) throws Exception { - try (Connection c = db.source.getConnection()) { - if (PG_DRIVER.equals(driver)) c.setSchema(TEST_SCHEMA); - try (Statement s = c.createStatement(); - ResultSet r = s.executeQuery(sqlQuery) - ) { - action.accept(r); ++ /** ++ * Compares the new rows of the given result set against the expected values. ++ * ++ * @param <V> type of values to compare. ++ * @param expected values that should be returned by the query after conversion by the getter. ++ * @param actual result of an SQL query to validate. ++ * @param getter how to map each SQL result value to Java objects. Must not be null. ++ * @param comparator how to compare values, or {@code null} for default comparison. ++ * @param title an error message for assertion failures. ++ */ ++ private static <V> void assertContentEquals(final V[] expected, final ResultSet actual, final ValueGetter<V> getter, ++ final BiPredicate<V,V> comparator, final String title) throws Exception ++ { ++ for (final V expectedValue : expected) { ++ assertTrue("Too few result rows", actual.next()); ++ final V actualValue = getter.getValue(null, actual, 1); ++ if (comparator != null && expectedValue != null && actualValue != null) { ++ if (comparator.test(expectedValue, actualValue)) { ++ continue; ++ } ++ // Otherwise `assertEquals(…)` will format an error message. + } ++ assertEquals(title, expectedValue, actualValue); + } ++ assertFalse("Too many result rows", actual.next()); + } + } diff --cc storage/sis-sqlstore/src/test/java/org/apache/sis/storage/sql/SQLStoreTest.java index e23cdb0fa0,e23cdb0fa0..927979c27f --- a/storage/sis-sqlstore/src/test/java/org/apache/sis/storage/sql/SQLStoreTest.java +++ b/storage/sis-sqlstore/src/test/java/org/apache/sis/storage/sql/SQLStoreTest.java @@@ -33,8 -33,8 +33,6 @@@ import org.apache.sis.internal.sql.feat import org.apache.sis.internal.sql.feature.TableReference; import org.apache.sis.test.sql.TestDatabase; import org.apache.sis.test.TestUtilities; --import org.apache.sis.test.TestCase; --import org.junit.Test; import static org.apache.sis.test.Assert.*; @@@ -53,15 -53,15 +51,10 @@@ import org.opengis.filter.SortOrder * * @author Martin Desruisseaux (Geomatys) * @author Alexis Manin (Geomatys) -- * @version 1.2 ++ * @version 1.4 * @since 1.1 */ --public final class SQLStoreTest extends TestCase { -- /** -- * The schema where will be stored the features to test. -- */ -- public static final String SCHEMA = "features"; -- ++public final class SQLStoreTest extends TestOnAllDatabases { /** * Data used in the {@code Features.sql} test file. */ @@@ -113,87 -113,87 +106,44 @@@ FF = DefaultFilterFactory.forFeatures(); } -- /** -- * Tests on Derby. -- * -- * @throws Exception if an error occurred while testing the database. -- */ -- @Test -- public void testOnDerby() throws Exception { -- test(TestDatabase.create("SQLStore"), true); -- } -- -- /** -- * Tests on HSQLDB. -- * -- * @throws Exception if an error occurred while testing the database. -- */ -- @Test -- public void testOnHSQLDB() throws Exception { -- test(TestDatabase.createOnHSQLDB("SQLStore", true), true); -- } -- -- /** -- * Tests on H2. -- * -- * @throws Exception if an error occurred while testing the database. -- */ -- @Test -- public void testOnH2() throws Exception { -- test(TestDatabase.createOnH2("SQLStore"), true); -- } -- -- /** -- * Tests on PostgreSQL. -- * -- * @throws Exception if an error occurred while testing the database. -- */ -- @Test -- public void testOnPostgreSQL() throws Exception { -- test(TestDatabase.createOnPostgreSQL(SCHEMA, true), false); -- } -- /** * Runs all tests on a single database software. A temporary schema is created at the beginning of this method * and deleted after all tests finished. The schema is created and populated by the {@code Features.sql} script. * -- * @param inMemory whether the test database is in memory. If {@code true}, then the schema will be created ++ * @param noschema whether the test database is in memory. If {@code true}, then the schema will be created * and will be the only schema to exist (ignoring system schema); i.e. we assume that there * is no ambiguity if we do not specify the schema in {@link SQLStore} constructor. */ -- private void test(final TestDatabase database, final boolean inMemory) -- throws Exception -- { ++ @Override ++ protected void test(final TestDatabase database, final boolean noschema) throws Exception { final String[] scripts = { "CREATE SCHEMA " + SCHEMA + ';', "file:Features.sql" }; -- if (!inMemory) { ++ if (!noschema) { scripts[0] = null; // Omit the "CREATE SCHEMA" statement if the schema already exists. } -- try (TestDatabase tmp = database) { // TODO: omit `tmp` with JDK16. -- database.executeSQL(SQLStoreTest.class, scripts); -- final StorageConnector connector = new StorageConnector(database.source); -- final ResourceDefinition table = ResourceDefinition.table(null, inMemory ? null : SCHEMA, "Cities"); -- testTableQuery(connector, table); -- /* -- * Verify using SQL statements instead of tables. -- */ -- verifyFetchCityTableAsQuery(connector); -- verifyNestedSQLQuery(connector); -- verifyLimitOffsetAndColumnSelectionFromQuery(connector); -- verifyDistinctQuery(connector); -- /* -- * Test on the table again, but with cyclic associations enabled. -- */ -- connector.setOption(SchemaModifier.OPTION, new SchemaModifier() { -- @Override public boolean isCyclicAssociationAllowed(TableReference dependency) { -- return true; -- } -- }); -- isCyclicAssociationAllowed = true; -- testTableQuery(connector, table); -- } ++ database.executeSQL(SQLStoreTest.class, scripts); ++ final StorageConnector connector = new StorageConnector(database.source); ++ final ResourceDefinition table = ResourceDefinition.table(null, noschema ? null : SCHEMA, "Cities"); ++ testTableQuery(connector, table); ++ /* ++ * Verify using SQL statements instead of tables. ++ */ ++ verifyFetchCityTableAsQuery(connector); ++ verifyNestedSQLQuery(connector); ++ verifyLimitOffsetAndColumnSelectionFromQuery(connector); ++ verifyDistinctQuery(connector); ++ /* ++ * Test on the table again, but with cyclic associations enabled. ++ */ ++ connector.setOption(SchemaModifier.OPTION, new SchemaModifier() { ++ @Override public boolean isCyclicAssociationAllowed(TableReference dependency) { ++ return true; ++ } ++ }); ++ isCyclicAssociationAllowed = true; ++ testTableQuery(connector, table); } /** diff --cc storage/sis-sqlstore/src/test/java/org/apache/sis/storage/sql/TestOnAllDatabases.java index 0000000000,0000000000..883b8a2366 new file mode 100644 --- /dev/null +++ b/storage/sis-sqlstore/src/test/java/org/apache/sis/storage/sql/TestOnAllDatabases.java @@@ -1,0 -1,0 +1,99 @@@ ++/* ++ * 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.storage.sql; ++ ++import org.apache.sis.test.TestCase; ++import org.apache.sis.test.sql.TestDatabase; ++import org.junit.Test; ++ ++ ++/** ++ * Base class of tests to be executed on all supported databases. ++ * ++ * @author Martin Desruisseaux (Geomatys) ++ * @version 1.4 ++ * @since 1.4 ++ */ ++public abstract class TestOnAllDatabases extends TestCase { ++ /** ++ * The schema where will be stored the features to test. ++ */ ++ public static final String SCHEMA = "features"; ++ ++ /** ++ * Creates a new test case. ++ */ ++ protected TestOnAllDatabases() { ++ } ++ ++ /** ++ * Tests on Derby. ++ * ++ * @throws Exception if an error occurred while testing the database. ++ */ ++ @Test ++ public void testOnDerby() throws Exception { ++ try (TestDatabase database = TestDatabase.create("SQLStore")) { ++ test(database, true); ++ } ++ } ++ ++ /** ++ * Tests on HSQLDB. ++ * ++ * @throws Exception if an error occurred while testing the database. ++ */ ++ @Test ++ public void testOnHSQLDB() throws Exception { ++ try (TestDatabase database = TestDatabase.createOnHSQLDB("SQLStore", true)) { ++ test(database, true); ++ } ++ } ++ ++ /** ++ * Tests on H2. ++ * ++ * @throws Exception if an error occurred while testing the database. ++ */ ++ @Test ++ public void testOnH2() throws Exception { ++ try (TestDatabase database = TestDatabase.createOnH2("SQLStore")) { ++ test(database, true); ++ } ++ } ++ ++ /** ++ * Tests on PostgreSQL. ++ * ++ * @throws Exception if an error occurred while testing the database. ++ */ ++ @Test ++ public void testOnPostgreSQL() throws Exception { ++ try (TestDatabase database = TestDatabase.createOnPostgreSQL(SCHEMA, true)) { ++ test(database, false); ++ } ++ } ++ ++ /** ++ * Runs all tests on a single database software. ++ * ++ * @param database factory for creating a test database. ++ * @param noschema whether the test database is created without schema. ++ * @throws Exception if an error occurred while executing the test. ++ */ ++ protected abstract void test(final TestDatabase database, final boolean noschema) throws Exception; ++}