|
12 | 12 | import java.sql.ResultSet; |
13 | 13 | import java.sql.Statement; |
14 | 14 | import java.sql.Timestamp; |
| 15 | +import java.sql.Types; |
15 | 16 | import java.time.Instant; |
16 | 17 | import java.time.LocalDateTime; |
17 | 18 | import java.time.OffsetDateTime; |
|
26 | 27 | import java.util.stream.Stream; |
27 | 28 | import lombok.SneakyThrows; |
28 | 29 | import lombok.val; |
| 30 | +import org.junit.jupiter.api.AfterAll; |
| 31 | +import org.junit.jupiter.api.BeforeAll; |
29 | 32 | import org.junit.jupiter.api.Test; |
30 | 33 | import org.junit.jupiter.api.extension.ExtendWith; |
31 | 34 | import org.junit.jupiter.params.ParameterizedTest; |
|
35 | 38 | /** |
36 | 39 | * Integration tests for timezone and timestamp handling in DataCloud JDBC driver. |
37 | 40 | * |
38 | | - * Tests the timezone precedence order: |
| 41 | + * <p>Tests the timezone precedence order: |
39 | 42 | * 1. Calendar parameter (per-call setting) |
40 | 43 | * 2. Arrow metadata timezone (from Hyper - TIMESTAMPTZ columns) |
41 | 44 | * 3. Session timezone (query setting time_zone) |
42 | 45 | * 4. System default |
| 46 | + * |
| 47 | + * <p>Also contains the PreparedStatement parameter-setting matrix: every combination of setter |
| 48 | + * method, Java type, and SQL cast target ({@code ?::timestamp} / {@code ?::timestamptz}). |
43 | 49 | */ |
44 | 50 | @ExtendWith(LocalHyperTestBase.class) |
45 | 51 | public class TimeZoneIntegrationTest { |
46 | 52 |
|
| 53 | + // ── Parameter matrix constants ──────────────────────────────────────────────────────────── |
| 54 | + // Input: UTC instant 2024-06-15T21:30:45.123456Z |
| 55 | + // In JVM TZ (LA, UTC-7 in June): wall-clock = "2024-06-15 14:30:45.123456" |
| 56 | + private static final Instant MATRIX_INPUT_INSTANT = Instant.parse("2024-06-15T21:30:45.123456Z"); |
| 57 | + private static final LocalDateTime MATRIX_INPUT_LDT = |
| 58 | + LocalDateTime.ofInstant(MATRIX_INPUT_INSTANT, ZoneId.of("America/Los_Angeles")); // 14:30:45 |
| 59 | + private static final OffsetDateTime MATRIX_INPUT_ODT = |
| 60 | + OffsetDateTime.ofInstant(MATRIX_INPUT_INSTANT, ZoneOffset.UTC); |
| 61 | + private static final ZonedDateTime MATRIX_INPUT_ZDT = ZonedDateTime.ofInstant(MATRIX_INPUT_INSTANT, ZoneOffset.UTC); |
| 62 | + private static final Timestamp MATRIX_INPUT_TS = Timestamp.from(MATRIX_INPUT_INSTANT); |
| 63 | + |
| 64 | + /** 14:30:45 — JVM (LA) wall-clock digits, stored as a naive literal. */ |
| 65 | + private static final LocalDateTime MATRIX_WALL_CLOCK_LA = |
| 66 | + LocalDateTime.ofInstant(MATRIX_INPUT_INSTANT, ZoneId.of("America/Los_Angeles")); |
| 67 | + |
| 68 | + /** 21:30:45 — UTC wall-clock digits (= the instant read in UTC). */ |
| 69 | + private static final LocalDateTime MATRIX_WALL_CLOCK_UTC = |
| 70 | + LocalDateTime.ofInstant(MATRIX_INPUT_INSTANT, ZoneOffset.UTC); |
| 71 | + |
| 72 | + /** 21:30:45Z — the original UTC instant, preserved exactly. */ |
| 73 | + private static final OffsetDateTime MATRIX_INSTANT_UTC = |
| 74 | + OffsetDateTime.ofInstant(MATRIX_INPUT_INSTANT, ZoneOffset.UTC); |
| 75 | + |
| 76 | + /** |
| 77 | + * 14:30:45Z — wall-clock literal (14:30) encoded as naive UTC epoch, then cast to timestamptz |
| 78 | + * with session TZ=UTC. Diverges from PG which sends the true UTC epoch (21:30:45Z). |
| 79 | + */ |
| 80 | + private static final OffsetDateTime MATRIX_WALL_CLOCK_AS_UTC = |
| 81 | + OffsetDateTime.of(MATRIX_WALL_CLOCK_LA, ZoneOffset.UTC); |
| 82 | + |
| 83 | + private static TimeZone matrixOriginalTimeZone; |
| 84 | + |
| 85 | + @BeforeAll |
| 86 | + static void pinJvmTimezoneForMatrix() { |
| 87 | + matrixOriginalTimeZone = TimeZone.getDefault(); |
| 88 | + TimeZone.setDefault(TimeZone.getTimeZone("America/Los_Angeles")); |
| 89 | + } |
| 90 | + |
| 91 | + @AfterAll |
| 92 | + static void restoreJvmTimezoneAfterMatrix() { |
| 93 | + TimeZone.setDefault(matrixOriginalTimeZone); |
| 94 | + } |
| 95 | + |
47 | 96 | @Test |
48 | 97 | @SneakyThrows |
49 | 98 | public void testSessionTimezoneResolution() { |
@@ -679,4 +728,208 @@ public void testTimestampRoundtrip(String castType, String sessionTz, String wri |
679 | 728 | } |
680 | 729 | } |
681 | 730 | } |
| 731 | + |
| 732 | + // ── PreparedStatement parameter-setting matrix ──────────────────────────────────────────── |
| 733 | + |
| 734 | + @FunctionalInterface |
| 735 | + interface ParameterSetter { |
| 736 | + void set(PreparedStatement pstmt) throws Exception; |
| 737 | + } |
| 738 | + |
| 739 | + /** |
| 740 | + * Matrix of all supported setter/type/SQL-cast combinations. |
| 741 | + * |
| 742 | + * <p>Each row: (description, setter, castType, expectedLDT, expectedODT) |
| 743 | + * <ul> |
| 744 | + * <li>{@code expectedLDT} non-null → {@code ?::timestamp}: asserts |
| 745 | + * {@code getObject(LocalDateTime.class)}</li> |
| 746 | + * <li>{@code expectedODT} non-null → {@code ?::timestamptz}: asserts |
| 747 | + * {@code getObject(OffsetDateTime.class)}</li> |
| 748 | + * </ul> |
| 749 | + * |
| 750 | + * <p>JVM TZ is pinned to {@code America/Los_Angeles} (UTC-7 in June) so that |
| 751 | + * timezone-sensitive differences are visible. Session TZ is UTC for all queries. |
| 752 | + * |
| 753 | + * <h2>JDBC 4.2 Spec mapping (Table B-4)</h2> |
| 754 | + * <pre> |
| 755 | + * java.sql.Timestamp → TIMESTAMP (wall-clock in JVM TZ) |
| 756 | + * java.time.Instant → TIMESTAMP_WITH_TIMEZONE (UTC epoch) |
| 757 | + * java.time.LocalDateTime → TIMESTAMP (LDT digits stored as-is) |
| 758 | + * java.time.OffsetDateTime → TIMESTAMP_WITH_TIMEZONE (UTC epoch) |
| 759 | + * java.time.ZonedDateTime → TIMESTAMP_WITH_TIMEZONE (UTC epoch) |
| 760 | + * </pre> |
| 761 | + */ |
| 762 | + static Stream<Arguments> parameterMatrixCases() { |
| 763 | + return Stream.of( |
| 764 | + // ── setTimestamp (no calendar) ───────────────────────────────────────────── |
| 765 | + // Wall-clock normalization: JVM TZ (LA) wall-clock = 14:30:45 stored as naive literal. |
| 766 | + Arguments.of( |
| 767 | + "setTimestamp(ts) → ?::timestamp", |
| 768 | + (ParameterSetter) pstmt -> pstmt.setTimestamp(1, MATRIX_INPUT_TS), |
| 769 | + "timestamp", |
| 770 | + MATRIX_WALL_CLOCK_LA, |
| 771 | + null), |
| 772 | + // ?::timestamptz: Hyper interprets naive literal 14:30:45 in session UTC → 14:30:45Z. |
| 773 | + // (PG JDBC diverges: sends true UTC epoch 21:30:45Z instead.) |
| 774 | + Arguments.of( |
| 775 | + "setTimestamp(ts) → ?::timestamptz", |
| 776 | + (ParameterSetter) pstmt -> pstmt.setTimestamp(1, MATRIX_INPUT_TS), |
| 777 | + "timestamptz", |
| 778 | + null, |
| 779 | + MATRIX_WALL_CLOCK_AS_UTC), |
| 780 | + |
| 781 | + // ── setTimestamp with Calendar UTC ───────────────────────────────────────── |
| 782 | + // Calendar UTC wall-clock = 21:30:45. Stored as naive literal 21:30:45. |
| 783 | + Arguments.of( |
| 784 | + "setTimestamp(ts, calUTC) → ?::timestamp", |
| 785 | + (ParameterSetter) pstmt -> pstmt.setTimestamp( |
| 786 | + 1, MATRIX_INPUT_TS, Calendar.getInstance(TimeZone.getTimeZone("UTC"))), |
| 787 | + "timestamp", |
| 788 | + MATRIX_WALL_CLOCK_UTC, |
| 789 | + null), |
| 790 | + |
| 791 | + // ── setObject(Timestamp) ─────────────────────────────────────────────────── |
| 792 | + // Delegates to setTimestamp(ts). JVM wall-clock 14:30:45. |
| 793 | + Arguments.of( |
| 794 | + "setObject(Timestamp) → ?::timestamp", |
| 795 | + (ParameterSetter) pstmt -> pstmt.setObject(1, MATRIX_INPUT_TS), |
| 796 | + "timestamp", |
| 797 | + MATRIX_WALL_CLOCK_LA, |
| 798 | + null), |
| 799 | + |
| 800 | + // ── setObject(Instant) ──────────────────────────────────────────────────── |
| 801 | + // JDBC 4.2: Instant → TIMESTAMP_WITH_TIMEZONE. UTC epoch stored exactly. |
| 802 | + Arguments.of( |
| 803 | + "setObject(Instant) → ?::timestamptz", |
| 804 | + (ParameterSetter) pstmt -> pstmt.setObject(1, MATRIX_INPUT_INSTANT), |
| 805 | + "timestamptz", |
| 806 | + null, |
| 807 | + MATRIX_INSTANT_UTC), |
| 808 | + Arguments.of( |
| 809 | + "setObject(Instant) → ?::timestamp", |
| 810 | + (ParameterSetter) pstmt -> pstmt.setObject(1, MATRIX_INPUT_INSTANT), |
| 811 | + "timestamp", |
| 812 | + MATRIX_WALL_CLOCK_UTC, |
| 813 | + null), |
| 814 | + |
| 815 | + // ── setObject(LocalDateTime) ────────────────────────────────────────────── |
| 816 | + // JDBC 4.2: LocalDateTime → TIMESTAMP. LDT digits stored as-is (no TZ shift). |
| 817 | + // Recommended write path for wall-clock (TIMESTAMP without timezone) values. |
| 818 | + Arguments.of( |
| 819 | + "setObject(LocalDateTime) → ?::timestamp", |
| 820 | + (ParameterSetter) pstmt -> pstmt.setObject(1, MATRIX_INPUT_LDT), |
| 821 | + "timestamp", |
| 822 | + MATRIX_WALL_CLOCK_LA, |
| 823 | + null), |
| 824 | + |
| 825 | + // ── setObject(OffsetDateTime) ───────────────────────────────────────────── |
| 826 | + // JDBC 4.2: OffsetDateTime → TIMESTAMP_WITH_TIMEZONE. UTC epoch stored. |
| 827 | + // Recommended write path for exact-instant (TIMESTAMPTZ) values. |
| 828 | + Arguments.of( |
| 829 | + "setObject(OffsetDateTime) → ?::timestamptz", |
| 830 | + (ParameterSetter) pstmt -> pstmt.setObject(1, MATRIX_INPUT_ODT), |
| 831 | + "timestamptz", |
| 832 | + null, |
| 833 | + MATRIX_INSTANT_UTC), |
| 834 | + Arguments.of( |
| 835 | + "setObject(OffsetDateTime) → ?::timestamp", |
| 836 | + (ParameterSetter) pstmt -> pstmt.setObject(1, MATRIX_INPUT_ODT), |
| 837 | + "timestamp", |
| 838 | + MATRIX_WALL_CLOCK_UTC, |
| 839 | + null), |
| 840 | + |
| 841 | + // ── setObject(ZonedDateTime) ────────────────────────────────────────────── |
| 842 | + // JDBC 4.2: ZonedDateTime → TIMESTAMP_WITH_TIMEZONE. UTC epoch stored. |
| 843 | + Arguments.of( |
| 844 | + "setObject(ZonedDateTime) → ?::timestamptz", |
| 845 | + (ParameterSetter) pstmt -> pstmt.setObject(1, MATRIX_INPUT_ZDT), |
| 846 | + "timestamptz", |
| 847 | + null, |
| 848 | + MATRIX_INSTANT_UTC), |
| 849 | + |
| 850 | + // ── setObject(ts, Types.TIMESTAMP) ──────────────────────────────────────── |
| 851 | + // Explicit TIMESTAMP type hint. JVM wall-clock 14:30:45. |
| 852 | + Arguments.of( |
| 853 | + "setObject(ts, TIMESTAMP) → ?::timestamp", |
| 854 | + (ParameterSetter) pstmt -> pstmt.setObject(1, MATRIX_INPUT_TS, Types.TIMESTAMP), |
| 855 | + "timestamp", |
| 856 | + MATRIX_WALL_CLOCK_LA, |
| 857 | + null), |
| 858 | + |
| 859 | + // ── setObject(ts, Types.TIMESTAMP_WITH_TIMEZONE) ────────────────────────── |
| 860 | + // Explicit TIMESTAMPTZ type hint. UTC epoch from Timestamp.toInstant() stored. |
| 861 | + // Use this when you have a java.sql.Timestamp but need TIMESTAMPTZ roundtrip. |
| 862 | + Arguments.of( |
| 863 | + "setObject(ts, TIMESTAMP_WITH_TIMEZONE) → ?::timestamptz", |
| 864 | + (ParameterSetter) pstmt -> pstmt.setObject(1, MATRIX_INPUT_TS, Types.TIMESTAMP_WITH_TIMEZONE), |
| 865 | + "timestamptz", |
| 866 | + null, |
| 867 | + MATRIX_INSTANT_UTC)); |
| 868 | + } |
| 869 | + |
| 870 | + @ParameterizedTest(name = "{0}") |
| 871 | + @MethodSource("parameterMatrixCases") |
| 872 | + @SneakyThrows |
| 873 | + void verifyParameterMatrix( |
| 874 | + String description, |
| 875 | + ParameterSetter setter, |
| 876 | + String castType, |
| 877 | + LocalDateTime expectedLDT, |
| 878 | + OffsetDateTime expectedODT) { |
| 879 | + |
| 880 | + Properties props = new Properties(); |
| 881 | + props.setProperty("querySetting.time_zone", "UTC"); |
| 882 | + |
| 883 | + try (DataCloudConnection conn = LocalHyperTestBase.getHyperQueryConnection(props)) { |
| 884 | + String sql = "SELECT (?::" + castType + ") AS val"; |
| 885 | + try (PreparedStatement pstmt = conn.prepareStatement(sql)) { |
| 886 | + setter.set(pstmt); |
| 887 | + try (ResultSet rs = pstmt.executeQuery()) { |
| 888 | + assertThat(rs.next()).isTrue(); |
| 889 | + |
| 890 | + if (expectedLDT != null) { |
| 891 | + assertThat(rs.getObject("val", LocalDateTime.class)) |
| 892 | + .as("%s — getObject(LocalDateTime.class)", description) |
| 893 | + .isEqualTo(expectedLDT); |
| 894 | + } |
| 895 | + |
| 896 | + if (expectedODT != null) { |
| 897 | + assertThat(rs.getObject("val", OffsetDateTime.class)) |
| 898 | + .as("%s — getObject(OffsetDateTime.class)", description) |
| 899 | + .isEqualTo(expectedODT); |
| 900 | + } |
| 901 | + } |
| 902 | + } |
| 903 | + } |
| 904 | + } |
| 905 | + |
| 906 | + @Test |
| 907 | + @SneakyThrows |
| 908 | + void nullTimestampParameterStoresSqlNull() { |
| 909 | + try (DataCloudConnection conn = LocalHyperTestBase.getHyperQueryConnection(new Properties())) { |
| 910 | + try (PreparedStatement pstmt = conn.prepareStatement("SELECT (?::timestamp) AS val")) { |
| 911 | + pstmt.setNull(1, Types.TIMESTAMP); |
| 912 | + try (ResultSet rs = pstmt.executeQuery()) { |
| 913 | + assertThat(rs.next()).isTrue(); |
| 914 | + assertThat(rs.getObject("val")).isNull(); |
| 915 | + assertThat(rs.wasNull()).isTrue(); |
| 916 | + } |
| 917 | + } |
| 918 | + } |
| 919 | + } |
| 920 | + |
| 921 | + @Test |
| 922 | + @SneakyThrows |
| 923 | + void nullTimestampTZParameterStoresSqlNull() { |
| 924 | + try (DataCloudConnection conn = LocalHyperTestBase.getHyperQueryConnection(new Properties())) { |
| 925 | + try (PreparedStatement pstmt = conn.prepareStatement("SELECT (?::timestamptz) AS val")) { |
| 926 | + pstmt.setNull(1, Types.TIMESTAMP_WITH_TIMEZONE); |
| 927 | + try (ResultSet rs = pstmt.executeQuery()) { |
| 928 | + assertThat(rs.next()).isTrue(); |
| 929 | + assertThat(rs.getObject("val")).isNull(); |
| 930 | + assertThat(rs.wasNull()).isTrue(); |
| 931 | + } |
| 932 | + } |
| 933 | + } |
| 934 | + } |
682 | 935 | } |
0 commit comments