|
32 | 32 | import org.junit.jupiter.api.Test; |
33 | 33 |
|
34 | 34 | import java.nio.ByteBuffer; |
| 35 | +import java.time.Instant; |
35 | 36 | import java.util.HashMap; |
36 | 37 | import java.util.List; |
37 | 38 | import java.util.Map; |
38 | 39 | import java.util.TimeZone; |
39 | 40 |
|
40 | 41 | import static org.apache.iceberg.types.Types.NestedField.required; |
41 | 42 | import static org.junit.jupiter.api.Assertions.assertEquals; |
| 43 | +import static org.junit.jupiter.api.Assertions.assertFalse; |
42 | 44 | import static org.junit.jupiter.api.Assertions.assertThrows; |
| 45 | +import static org.junit.jupiter.api.Assertions.assertTrue; |
43 | 46 |
|
44 | 47 | public class IcebergUtilTest { |
45 | 48 |
|
@@ -258,6 +261,123 @@ public void testParseMinMaxValueBySlotsWithIcebergTimestampTypes() { |
258 | 261 | } |
259 | 262 | } |
260 | 263 |
|
| 264 | + @Test |
| 265 | + public void testTimestamptzMinMaxDroppedWhenFileSpansDstTransition() { |
| 266 | + // America/Los_Angeles DST fall-back at 2023-11-05T09:00:00Z: PDT (-07:00) drops to PST (-08:00). A file whose |
| 267 | + // timestamptz values straddle that instant covers a backward (offset-decreasing) transition, so UTC->local |
| 268 | + // rewinds and the endpoint conversion is no longer exact; the file-level min/max must be dropped. |
| 269 | + Schema schema = new Schema( |
| 270 | + required(3, "ts_ntz", Types.TimestampType.withoutZone()), |
| 271 | + required(5, "ts_tz", Types.TimestampType.withZone())); |
| 272 | + List<SlotDescriptor> slots = List.of( |
| 273 | + new SlotDescriptor(new SlotId(3), "ts_ntz", DateType.DATETIME, true), |
| 274 | + new SlotDescriptor(new SlotId(5), "ts_tz", DateType.DATETIME, true)); |
| 275 | + slots.get(0).setColumn(new Column("ts_ntz", DateType.DATETIME, true)); |
| 276 | + slots.get(1).setColumn(new Column("ts_tz", DateType.DATETIME, true)); |
| 277 | + |
| 278 | + long lowMicros = Instant.parse("2023-11-05T08:30:00Z").getEpochSecond() * 1_000_000L; |
| 279 | + long highMicros = Instant.parse("2023-11-05T09:30:00Z").getEpochSecond() * 1_000_000L; |
| 280 | + Map<Integer, ByteBuffer> lowerBounds = Map.of( |
| 281 | + 3, org.apache.iceberg.types.Conversions.toByteBuffer(Types.TimestampType.withoutZone(), lowMicros), |
| 282 | + 5, org.apache.iceberg.types.Conversions.toByteBuffer(Types.TimestampType.withZone(), lowMicros)); |
| 283 | + Map<Integer, ByteBuffer> upperBounds = Map.of( |
| 284 | + 3, org.apache.iceberg.types.Conversions.toByteBuffer(Types.TimestampType.withoutZone(), highMicros), |
| 285 | + 5, org.apache.iceberg.types.Conversions.toByteBuffer(Types.TimestampType.withZone(), highMicros)); |
| 286 | + Map<Integer, Long> nullValueCounts = Map.of(3, 0L, 5, 0L); |
| 287 | + Map<Integer, Long> valueCounts = Map.of(3, 2L, 5, 2L); |
| 288 | + |
| 289 | + ConnectContext ctx = new ConnectContext(); |
| 290 | + ctx.getSessionVariable().setTimeZone("America/Los_Angeles"); |
| 291 | + ctx.setThreadLocalInfo(); |
| 292 | + try { |
| 293 | + Map<Integer, IcebergUtil.MinMaxValue> result = IcebergUtil.parseMinMaxValueBySlots( |
| 294 | + schema, lowerBounds, upperBounds, nullValueCounts, valueCounts, slots); |
| 295 | + // timestamp without time zone is unaffected and keeps its raw wall-clock micros. |
| 296 | + assertTrue(result.containsKey(3)); |
| 297 | + assertEquals(lowMicros, result.get(3).minValue); |
| 298 | + // timestamptz spanning the DST transition is dropped, so BE falls back to reading the file. |
| 299 | + assertFalse(result.containsKey(5)); |
| 300 | + |
| 301 | + Map<Integer, TExprMinMaxValue> thriftValues = IcebergUtil.toThriftMinMaxValueBySlots( |
| 302 | + schema, lowerBounds, upperBounds, nullValueCounts, valueCounts, slots); |
| 303 | + assertTrue(thriftValues.containsKey(3)); |
| 304 | + assertFalse(thriftValues.containsKey(5)); |
| 305 | + } finally { |
| 306 | + ConnectContext.remove(); |
| 307 | + } |
| 308 | + } |
| 309 | + |
| 310 | + @Test |
| 311 | + public void testTimestamptzMinMaxKeptWhenFileWithinSingleOffset() { |
| 312 | + // A file whose timestamptz values stay within one offset period (here PDT, summer 2023) has no offset |
| 313 | + // change inside its range, so endpoint UTC->local conversion is exact and the bounds are converted and kept. |
| 314 | + Schema schema = new Schema(required(5, "ts_tz", Types.TimestampType.withZone())); |
| 315 | + List<SlotDescriptor> slots = List.of( |
| 316 | + new SlotDescriptor(new SlotId(5), "ts_tz", DateType.DATETIME, true)); |
| 317 | + slots.get(0).setColumn(new Column("ts_tz", DateType.DATETIME, true)); |
| 318 | + |
| 319 | + long lowMicros = Instant.parse("2023-07-01T10:00:00Z").getEpochSecond() * 1_000_000L; |
| 320 | + long highMicros = Instant.parse("2023-07-01T11:00:00Z").getEpochSecond() * 1_000_000L; |
| 321 | + Map<Integer, ByteBuffer> lowerBounds = Map.of( |
| 322 | + 5, org.apache.iceberg.types.Conversions.toByteBuffer(Types.TimestampType.withZone(), lowMicros)); |
| 323 | + Map<Integer, ByteBuffer> upperBounds = Map.of( |
| 324 | + 5, org.apache.iceberg.types.Conversions.toByteBuffer(Types.TimestampType.withZone(), highMicros)); |
| 325 | + Map<Integer, Long> nullValueCounts = Map.of(5, 0L); |
| 326 | + Map<Integer, Long> valueCounts = Map.of(5, 2L); |
| 327 | + |
| 328 | + ConnectContext ctx = new ConnectContext(); |
| 329 | + ctx.getSessionVariable().setTimeZone("America/Los_Angeles"); |
| 330 | + ctx.setThreadLocalInfo(); |
| 331 | + try { |
| 332 | + Map<Integer, IcebergUtil.MinMaxValue> result = IcebergUtil.parseMinMaxValueBySlots( |
| 333 | + schema, lowerBounds, upperBounds, nullValueCounts, valueCounts, slots); |
| 334 | + assertTrue(result.containsKey(5)); |
| 335 | + // PDT offset is -07:00 across the whole range; endpoint conversion adds that offset to each bound. |
| 336 | + assertEquals(lowMicros + TimeZone.getTimeZone("America/Los_Angeles").getOffset( |
| 337 | + Instant.parse("2023-07-01T10:00:00Z").toEpochMilli()) * 1000L, result.get(5).minValue); |
| 338 | + assertEquals(highMicros + TimeZone.getTimeZone("America/Los_Angeles").getOffset( |
| 339 | + Instant.parse("2023-07-01T11:00:00Z").toEpochMilli()) * 1000L, result.get(5).maxValue); |
| 340 | + } finally { |
| 341 | + ConnectContext.remove(); |
| 342 | + } |
| 343 | + } |
| 344 | + |
| 345 | + @Test |
| 346 | + public void testTimestamptzMinMaxKeptAcrossSpringForward() { |
| 347 | + // America/Los_Angeles spring-forward at 2023-03-12T10:00:00Z: PST (-08:00) jumps to PDT (-07:00). The offset |
| 348 | + // increases, so UTC->local stays monotonic and the file's endpoints remain its true local min/max even though |
| 349 | + // it straddles the transition; the bounds are converted (each with its own instant's offset) and kept. |
| 350 | + Schema schema = new Schema(required(5, "ts_tz", Types.TimestampType.withZone())); |
| 351 | + List<SlotDescriptor> slots = List.of( |
| 352 | + new SlotDescriptor(new SlotId(5), "ts_tz", DateType.DATETIME, true)); |
| 353 | + slots.get(0).setColumn(new Column("ts_tz", DateType.DATETIME, true)); |
| 354 | + |
| 355 | + long lowMicros = Instant.parse("2023-03-12T09:30:00Z").getEpochSecond() * 1_000_000L; |
| 356 | + long highMicros = Instant.parse("2023-03-12T10:30:00Z").getEpochSecond() * 1_000_000L; |
| 357 | + Map<Integer, ByteBuffer> lowerBounds = Map.of( |
| 358 | + 5, org.apache.iceberg.types.Conversions.toByteBuffer(Types.TimestampType.withZone(), lowMicros)); |
| 359 | + Map<Integer, ByteBuffer> upperBounds = Map.of( |
| 360 | + 5, org.apache.iceberg.types.Conversions.toByteBuffer(Types.TimestampType.withZone(), highMicros)); |
| 361 | + Map<Integer, Long> nullValueCounts = Map.of(5, 0L); |
| 362 | + Map<Integer, Long> valueCounts = Map.of(5, 2L); |
| 363 | + |
| 364 | + ConnectContext ctx = new ConnectContext(); |
| 365 | + ctx.getSessionVariable().setTimeZone("America/Los_Angeles"); |
| 366 | + ctx.setThreadLocalInfo(); |
| 367 | + try { |
| 368 | + Map<Integer, IcebergUtil.MinMaxValue> result = IcebergUtil.parseMinMaxValueBySlots( |
| 369 | + schema, lowerBounds, upperBounds, nullValueCounts, valueCounts, slots); |
| 370 | + assertTrue(result.containsKey(5)); |
| 371 | + // low uses PST (-08:00), high uses PDT (-07:00); each endpoint converts with its own instant's offset. |
| 372 | + assertEquals(lowMicros + TimeZone.getTimeZone("America/Los_Angeles").getOffset( |
| 373 | + Instant.parse("2023-03-12T09:30:00Z").toEpochMilli()) * 1000L, result.get(5).minValue); |
| 374 | + assertEquals(highMicros + TimeZone.getTimeZone("America/Los_Angeles").getOffset( |
| 375 | + Instant.parse("2023-03-12T10:30:00Z").toEpochMilli()) * 1000L, result.get(5).maxValue); |
| 376 | + } finally { |
| 377 | + ConnectContext.remove(); |
| 378 | + } |
| 379 | + } |
| 380 | + |
261 | 381 | private static org.apache.iceberg.Table tableWithProperty(String key, String value) { |
262 | 382 | org.apache.iceberg.Table table = org.mockito.Mockito.mock(org.apache.iceberg.Table.class); |
263 | 383 | Map<String, String> props = new HashMap<>(); |
|
0 commit comments