This is an automated email from the ASF dual-hosted git repository.
Fokko pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/parquet-java.git
The following commit(s) were added to refs/heads/master by this push:
new efec55565 GH-3679: Allow TimestampType to annotate FLBA(12) (#3680)
efec55565 is described below
commit efec5556522052b67090f842594bc78fc0a3e330
Author: Divjot Arora <[email protected]>
AuthorDate: Tue Sep 22 09:19:57 2026 +0200
GH-3679: Allow TimestampType to annotate FLBA(12) (#3680)
* Allow TimestampType to annotate FLBA(12)
* address PR comments
* address comments
* Add InterOp read test for FLBA(12) TIMESTAMP file
Co-authored-by: Isaac
* Fix FLBA timestamp tests
* Update timestamp validation assertions
---
.../parquet/arrow/schema/TestSchemaConverter.java | 4 +-
.../apache/parquet/schema/PrimitiveComparator.java | 29 ++++
.../parquet/schema/PrimitiveStringifier.java | 83 +++++++++-
.../org/apache/parquet/schema/PrimitiveType.java | 6 +
.../main/java/org/apache/parquet/schema/Types.java | 22 +--
.../parquet/column/statistics/TestStatistics.java | 28 ++++
.../column/columnindex/TestBinaryTruncator.java | 13 ++
.../column/columnindex/TestColumnIndexBuilder.java | 47 ++++++
.../parquet/schema/TestPrimitiveComparator.java | 42 ++++++
.../parquet/schema/TestPrimitiveStringifier.java | 72 ++++++++-
.../apache/parquet/schema/TestTypeBuilders.java | 46 +++++-
.../schema/TestTypeBuildersWithLogicalTypes.java | 50 +++++-
.../java/org/apache/parquet/bytes/BytesUtils.java | 8 +
.../format/converter/ParquetMetadataConverter.java | 9 +-
.../converter/TestParquetMetadataConverter.java | 25 +++
.../hadoop/TestInterOpReadFlba12Timestamp.java | 167 +++++++++++++++++++++
.../parquet/statistics/TestColumnIndexes.java | 6 +
.../apache/parquet/statistics/TestStatistics.java | 10 +-
18 files changed, 635 insertions(+), 32 deletions(-)
diff --git
a/parquet-arrow/src/test/java/org/apache/parquet/arrow/schema/TestSchemaConverter.java
b/parquet-arrow/src/test/java/org/apache/parquet/arrow/schema/TestSchemaConverter.java
index 04c359d2f..4d8a21a8d 100644
---
a/parquet-arrow/src/test/java/org/apache/parquet/arrow/schema/TestSchemaConverter.java
+++
b/parquet-arrow/src/test/java/org/apache/parquet/arrow/schema/TestSchemaConverter.java
@@ -649,7 +649,7 @@ public class TestSchemaConverter {
.named("a"))
.named("root")))
.isInstanceOf(IllegalStateException.class)
- .hasMessage("TIMESTAMP(MILLIS,false) can only annotate [INT64]");
+ .hasMessage("TIMESTAMP(MILLIS,false) can only annotate [INT64,
FIXED_LEN_BYTE_ARRAY(12)]");
}
@Test
@@ -660,6 +660,6 @@ public class TestSchemaConverter {
.named("a"))
.named("root")))
.isInstanceOf(IllegalStateException.class)
- .hasMessage("TIMESTAMP(MICROS,false) can only annotate [INT64]");
+ .hasMessage("TIMESTAMP(MICROS,false) can only annotate [INT64,
FIXED_LEN_BYTE_ARRAY(12)]");
}
}
diff --git
a/parquet-column/src/main/java/org/apache/parquet/schema/PrimitiveComparator.java
b/parquet-column/src/main/java/org/apache/parquet/schema/PrimitiveComparator.java
index 9d22d2531..8ef548a99 100644
---
a/parquet-column/src/main/java/org/apache/parquet/schema/PrimitiveComparator.java
+++
b/parquet-column/src/main/java/org/apache/parquet/schema/PrimitiveComparator.java
@@ -20,6 +20,7 @@ package org.apache.parquet.schema;
import java.io.Serializable;
import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
import java.util.Comparator;
import org.apache.parquet.io.api.Binary;
@@ -278,6 +279,34 @@ public abstract class PrimitiveComparator<T> implements
Comparator<T>, Serializa
}
};
+ /**
+ * Comparator for timestamps encoded as 12-byte little-endian
two's-complement values.
+ */
+ static final PrimitiveComparator<Binary>
BINARY_AS_SIGNED_TIMESTAMP_COMPARATOR = new BinaryComparator() {
+ @Override
+ int compareBinary(Binary b1, Binary b2) {
+ if (b1.length() != 12 || b2.length() != 12) {
+ throw new IllegalArgumentException(
+ "Timestamp binary length must be 12 bytes, got " + b1.length() + "
and " + b2.length());
+ }
+ ByteBuffer bb1 = b1.toByteBuffer().slice();
+ bb1.order(ByteOrder.LITTLE_ENDIAN);
+ ByteBuffer bb2 = b2.toByteBuffer().slice();
+ bb2.order(ByteOrder.LITTLE_ENDIAN);
+ // Signed comparison of the high 4 bytes followed by unsigned comparison
of the low 8 bytes.
+ int hiResult = Integer.compare(bb1.getInt(8), bb2.getInt(8));
+ if (hiResult != 0) {
+ return hiResult;
+ }
+ return Long.compareUnsigned(bb1.getLong(0), bb2.getLong(0));
+ }
+
+ @Override
+ public String toString() {
+ return "BINARY_AS_SIGNED_TIMESTAMP_COMPARATOR";
+ }
+ };
+
/**
* This comparator is for comparing two float16 values represented in 2
bytes binary.
*/
diff --git
a/parquet-column/src/main/java/org/apache/parquet/schema/PrimitiveStringifier.java
b/parquet-column/src/main/java/org/apache/parquet/schema/PrimitiveStringifier.java
index 800078112..aa5b1eab5 100644
---
a/parquet-column/src/main/java/org/apache/parquet/schema/PrimitiveStringifier.java
+++
b/parquet-column/src/main/java/org/apache/parquet/schema/PrimitiveStringifier.java
@@ -29,10 +29,12 @@ import java.math.BigDecimal;
import java.math.BigInteger;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
+import java.time.DateTimeException;
import java.time.Instant;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.util.concurrent.TimeUnit;
+import org.apache.parquet.bytes.BytesUtils;
import org.apache.parquet.io.api.Binary;
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.io.ParseException;
@@ -249,7 +251,7 @@ public abstract class PrimitiveStringifier {
return toFormattedString(getInstant(value));
}
- private String toFormattedString(Instant instant) {
+ final String toFormattedString(Instant instant) {
return formatter.format(instant);
}
@@ -266,6 +268,33 @@ public abstract class PrimitiveStringifier {
}
}
+ /**
+ * Stringifier implementation for timestamps that handles both int64 and
FLBA(12) carriers.
+ * Values outside of Instant's supported range render as raw integers rather
than human-readable
+ * timestamps.
+ */
+ private abstract static class TimestampStringifier extends DateStringifier {
+ private TimestampStringifier(String name, String format) {
+ super(name, format);
+ }
+
+ @Override
+ public String stringify(Binary value) {
+ if (value == null) {
+ return BINARY_NULL;
+ }
+
+ BigInteger units = new
BigInteger(BytesUtils.reverse(value.getBytesUnsafe()));
+ try {
+ return toFormattedString(getInstant(units));
+ } catch (ArithmeticException | DateTimeException e) {
+ return units.toString();
+ }
+ }
+
+ abstract Instant getInstant(BigInteger value);
+ }
+
static final PrimitiveStringifier DATE_STRINGIFIER = new
DateStringifier("DATE_STRINGIFIER", "yyyy-MM-dd") {
@Override
Instant getInstant(int value) {
@@ -274,56 +303,96 @@ public abstract class PrimitiveStringifier {
;
};
+ // Converts a count of time units since epoch, held as a BigInteger (the
96-bit FLBA(12) carrier),
+ // into an Instant. Throws ArithmeticException if the seconds portion
overflows long. Throws
+ // DateTimeException if the combined seconds/nanos portion does not fit in
an Instant.
+ private static Instant instantFromUnits(BigInteger units, TimeUnit unit) {
+ BigInteger[] secondsAndUnits =
units.divideAndRemainder(BigInteger.valueOf(unit.convert(1, SECONDS)));
+ long seconds = secondsAndUnits[0].longValueExact();
+ long nanos = secondsAndUnits[1].longValue() * unit.toNanos(1);
+ return Instant.ofEpochSecond(seconds, nanos);
+ }
+
static final PrimitiveStringifier TIMESTAMP_MILLIS_STRINGIFIER =
- new DateStringifier("TIMESTAMP_MILLIS_STRINGIFIER",
"yyyy-MM-dd'T'HH:mm:ss.SSS") {
+ new TimestampStringifier("TIMESTAMP_MILLIS_STRINGIFIER",
"yyyy-MM-dd'T'HH:mm:ss.SSS") {
@Override
Instant getInstant(long value) {
return Instant.ofEpochMilli(value);
}
+
+ @Override
+ Instant getInstant(BigInteger value) {
+ return instantFromUnits(value, MILLISECONDS);
+ }
};
static final PrimitiveStringifier TIMESTAMP_MICROS_STRINGIFIER =
- new DateStringifier("TIMESTAMP_MICROS_STRINGIFIER",
"yyyy-MM-dd'T'HH:mm:ss.SSSSSS") {
+ new TimestampStringifier("TIMESTAMP_MICROS_STRINGIFIER",
"yyyy-MM-dd'T'HH:mm:ss.SSSSSS") {
@Override
Instant getInstant(long value) {
return Instant.ofEpochSecond(
MICROSECONDS.toSeconds(value), MICROSECONDS.toNanos(value %
SECONDS.toMicros(1)));
}
+
+ @Override
+ Instant getInstant(BigInteger value) {
+ return instantFromUnits(value, MICROSECONDS);
+ }
};
static final PrimitiveStringifier TIMESTAMP_NANOS_STRINGIFIER =
- new DateStringifier("TIMESTAMP_NANOS_STRINGIFIER",
"yyyy-MM-dd'T'HH:mm:ss.SSSSSSSSS") {
+ new TimestampStringifier("TIMESTAMP_NANOS_STRINGIFIER",
"yyyy-MM-dd'T'HH:mm:ss.SSSSSSSSS") {
@Override
Instant getInstant(long value) {
return Instant.ofEpochSecond(
NANOSECONDS.toSeconds(value), NANOSECONDS.toNanos(value %
SECONDS.toNanos(1)));
}
+
+ @Override
+ Instant getInstant(BigInteger value) {
+ return instantFromUnits(value, NANOSECONDS);
+ }
};
static final PrimitiveStringifier TIMESTAMP_MILLIS_UTC_STRINGIFIER =
- new DateStringifier("TIMESTAMP_MILLIS_UTC_STRINGIFIER",
"yyyy-MM-dd'T'HH:mm:ss.SSSZ") {
+ new TimestampStringifier("TIMESTAMP_MILLIS_UTC_STRINGIFIER",
"yyyy-MM-dd'T'HH:mm:ss.SSSZ") {
@Override
Instant getInstant(long value) {
return Instant.ofEpochMilli(value);
}
+
+ @Override
+ Instant getInstant(BigInteger value) {
+ return instantFromUnits(value, MILLISECONDS);
+ }
};
static final PrimitiveStringifier TIMESTAMP_MICROS_UTC_STRINGIFIER =
- new DateStringifier("TIMESTAMP_MICROS_UTC_STRINGIFIER",
"yyyy-MM-dd'T'HH:mm:ss.SSSSSSZ") {
+ new TimestampStringifier("TIMESTAMP_MICROS_UTC_STRINGIFIER",
"yyyy-MM-dd'T'HH:mm:ss.SSSSSSZ") {
@Override
Instant getInstant(long value) {
return Instant.ofEpochSecond(
MICROSECONDS.toSeconds(value), MICROSECONDS.toNanos(value %
SECONDS.toMicros(1)));
}
+
+ @Override
+ Instant getInstant(BigInteger value) {
+ return instantFromUnits(value, MICROSECONDS);
+ }
};
static final PrimitiveStringifier TIMESTAMP_NANOS_UTC_STRINGIFIER =
- new DateStringifier("TIMESTAMP_NANOS_UTC_STRINGIFIER",
"yyyy-MM-dd'T'HH:mm:ss.SSSSSSSSSZ") {
+ new TimestampStringifier("TIMESTAMP_NANOS_UTC_STRINGIFIER",
"yyyy-MM-dd'T'HH:mm:ss.SSSSSSSSSZ") {
@Override
Instant getInstant(long value) {
return Instant.ofEpochSecond(
NANOSECONDS.toSeconds(value), NANOSECONDS.toNanos(value %
SECONDS.toNanos(1)));
}
+
+ @Override
+ Instant getInstant(BigInteger value) {
+ return instantFromUnits(value, NANOSECONDS);
+ }
};
private abstract static class TimeStringifier extends PrimitiveStringifier {
diff --git
a/parquet-column/src/main/java/org/apache/parquet/schema/PrimitiveType.java
b/parquet-column/src/main/java/org/apache/parquet/schema/PrimitiveType.java
index e04c54a7d..c359c9f41 100644
--- a/parquet-column/src/main/java/org/apache/parquet/schema/PrimitiveType.java
+++ b/parquet-column/src/main/java/org/apache/parquet/schema/PrimitiveType.java
@@ -444,6 +444,12 @@ public final class PrimitiveType extends Type {
: of(PrimitiveComparator.BINARY_AS_FLOAT16_COMPARATOR);
}
+ @Override
+ public Optional<PrimitiveComparator> visit(
+ LogicalTypeAnnotation.TimestampLogicalTypeAnnotation
timestampLogicalType) {
+ return
of(PrimitiveComparator.BINARY_AS_SIGNED_TIMESTAMP_COMPARATOR);
+ }
+
@Override
public Optional<PrimitiveComparator> visit(
LogicalTypeAnnotation.UnknownLogicalTypeAnnotation
unknownLogicalTypeAnnotation) {
diff --git a/parquet-column/src/main/java/org/apache/parquet/schema/Types.java
b/parquet-column/src/main/java/org/apache/parquet/schema/Types.java
index 7b02a6f02..2d6f0cbf8 100644
--- a/parquet-column/src/main/java/org/apache/parquet/schema/Types.java
+++ b/parquet-column/src/main/java/org/apache/parquet/schema/Types.java
@@ -417,7 +417,7 @@ public class Types {
@Override
public Optional<AllowedPhysicalTypes> visit(
LogicalTypeAnnotation.TimestampLogicalTypeAnnotation
timestampLogicalType) {
- return AllowedPhysicalTypes.of(PrimitiveTypeName.INT64);
+ return AllowedPhysicalTypes.fixed(12, PrimitiveTypeName.INT64);
}
@Override
@@ -463,13 +463,16 @@ public class Types {
return Optional.of(new AllowedPhysicalTypes(EnumSet.of(first, rest),
NOT_SET));
}
- private static Optional<AllowedPhysicalTypes> fixed(int requiredLength) {
- return Optional.of(
- new
AllowedPhysicalTypes(EnumSet.of(PrimitiveTypeName.FIXED_LEN_BYTE_ARRAY),
requiredLength));
+ private static Optional<AllowedPhysicalTypes> fixed(int requiredLength,
PrimitiveTypeName... otherTypes) {
+ return Optional.of(new AllowedPhysicalTypes(
+ EnumSet.of(PrimitiveTypeName.FIXED_LEN_BYTE_ARRAY, otherTypes),
requiredLength));
}
private boolean accepts(PrimitiveTypeName type, int length) {
- return types.contains(type) && (requiredLength == NOT_SET || length ==
requiredLength);
+ return types.contains(type)
+ && (type != PrimitiveTypeName.FIXED_LEN_BYTE_ARRAY
+ || requiredLength == NOT_SET
+ || length == requiredLength);
}
private boolean isEmpty() {
@@ -478,10 +481,11 @@ public class Types {
@Override
public String toString() {
- if (requiredLength != NOT_SET) {
- return PrimitiveTypeName.FIXED_LEN_BYTE_ARRAY + "(" + requiredLength
+ ")";
- }
- return types.stream().map(Enum::name).collect(Collectors.joining(",
"));
+ return types.stream()
+ .map(type -> type == PrimitiveTypeName.FIXED_LEN_BYTE_ARRAY &&
requiredLength != NOT_SET
+ ? type + "(" + requiredLength + ")"
+ : type.name())
+ .collect(Collectors.joining(", "));
}
}
diff --git
a/parquet-column/src/test/java/org/apache/parquet/column/statistics/TestStatistics.java
b/parquet-column/src/test/java/org/apache/parquet/column/statistics/TestStatistics.java
index 92c5cfed0..e4783b700 100644
---
a/parquet-column/src/test/java/org/apache/parquet/column/statistics/TestStatistics.java
+++
b/parquet-column/src/test/java/org/apache/parquet/column/statistics/TestStatistics.java
@@ -22,6 +22,8 @@ import static java.lang.Double.doubleToLongBits;
import static java.lang.Float.floatToIntBits;
import static org.apache.parquet.bytes.BytesUtils.intToBytes;
import static org.apache.parquet.bytes.BytesUtils.longToBytes;
+import static org.apache.parquet.schema.LogicalTypeAnnotation.TimeUnit.NANOS;
+import static org.apache.parquet.schema.LogicalTypeAnnotation.timestampType;
import static org.apache.parquet.schema.PrimitiveType.PrimitiveTypeName.BINARY;
import static
org.apache.parquet.schema.PrimitiveType.PrimitiveTypeName.BOOLEAN;
import static org.apache.parquet.schema.PrimitiveType.PrimitiveTypeName.DOUBLE;
@@ -943,6 +945,32 @@ public class TestStatistics {
.hasMessage("isSmallerThan is not supported by
org.apache.parquet.column.statistics.NoopStatistics");
}
+ @Test
+ public void testFlba12TimestampStats() {
+ PrimitiveType type = Types.required(FIXED_LEN_BYTE_ARRAY)
+ .length(12)
+ .as(timestampType(true, NANOS))
+ .named("ts");
+ BinaryStatistics stats = (BinaryStatistics) Statistics.createStats(type);
+
+ // -1 (all 0xFF) — just before epoch, negative value
+ byte[] negOne = new byte[12];
+ for (int i = 0; i < 12; i++) negOne[i] = (byte) 0xFF;
+ // +1 (0x01 followed by zeros) — just after epoch, positive value
+ byte[] posOne = new byte[12];
+ posOne[0] = 1;
+ // epoch (all 0x00)
+ byte[] epoch = new byte[12];
+
+ stats.updateStats(Binary.fromConstantByteArray(posOne));
+ stats.updateStats(Binary.fromConstantByteArray(negOne));
+ stats.updateStats(Binary.fromConstantByteArray(epoch));
+
+ // min must be the negative value (-1), max must be the positive value (+1)
+
assertThat(stats.genericGetMin()).isEqualTo(Binary.fromConstantByteArray(negOne));
+
assertThat(stats.genericGetMax()).isEqualTo(Binary.fromConstantByteArray(posOne));
+ }
+
@Test
public void testBinaryIsSmallerThanNoOverflowForLargeValues() {
BinaryStatistics stats = new BinaryStatistics();
diff --git
a/parquet-column/src/test/java/org/apache/parquet/internal/column/columnindex/TestBinaryTruncator.java
b/parquet-column/src/test/java/org/apache/parquet/internal/column/columnindex/TestBinaryTruncator.java
index 6b7e79305..716c01770 100644
---
a/parquet-column/src/test/java/org/apache/parquet/internal/column/columnindex/TestBinaryTruncator.java
+++
b/parquet-column/src/test/java/org/apache/parquet/internal/column/columnindex/TestBinaryTruncator.java
@@ -36,6 +36,8 @@ import java.nio.charset.StandardCharsets;
import java.util.Comparator;
import java.util.Random;
import org.apache.parquet.io.api.Binary;
+import org.apache.parquet.schema.LogicalTypeAnnotation;
+import org.apache.parquet.schema.LogicalTypeAnnotation.TimeUnit;
import org.apache.parquet.schema.PrimitiveStringifier;
import org.apache.parquet.schema.PrimitiveType;
import org.apache.parquet.schema.Types;
@@ -91,6 +93,17 @@ public class TestBinaryTruncator {
testTruncator(Types.required(INT96).named("test_int96"), false);
}
+ @Test
+ public void testFlba12Timestamp() {
+ BinaryTruncator truncator =
BinaryTruncator.getTruncator(Types.required(FIXED_LEN_BYTE_ARRAY)
+ .length(12)
+ .as(LogicalTypeAnnotation.timestampType(true, TimeUnit.NANOS))
+ .named("test_fixed_timestamp"));
+ Binary value = Binary.fromConstantByteArray(new byte[] {0, 1, 2, 3, 4, 5,
6, 7, 8, 9, 10, 11});
+ assertThat(truncator.truncateMin(value, 4)).isSameAs(value);
+ assertThat(truncator.truncateMax(value, 4)).isSameAs(value);
+ }
+
@Test
public void testStringTruncate() {
BinaryTruncator truncator =
diff --git
a/parquet-column/src/test/java/org/apache/parquet/internal/column/columnindex/TestColumnIndexBuilder.java
b/parquet-column/src/test/java/org/apache/parquet/internal/column/columnindex/TestColumnIndexBuilder.java
index c8061afa4..d94308920 100644
---
a/parquet-column/src/test/java/org/apache/parquet/internal/column/columnindex/TestColumnIndexBuilder.java
+++
b/parquet-column/src/test/java/org/apache/parquet/internal/column/columnindex/TestColumnIndexBuilder.java
@@ -37,12 +37,15 @@ import static
org.apache.parquet.filter2.predicate.FilterApi.notIn;
import static org.apache.parquet.filter2.predicate.FilterApi.or;
import static org.apache.parquet.filter2.predicate.FilterApi.userDefined;
import static org.apache.parquet.filter2.predicate.LogicalInverter.invert;
+import static org.apache.parquet.schema.LogicalTypeAnnotation.TimeUnit.NANOS;
+import static org.apache.parquet.schema.LogicalTypeAnnotation.timestampType;
import static org.apache.parquet.schema.OriginalType.DECIMAL;
import static org.apache.parquet.schema.OriginalType.UINT_8;
import static org.apache.parquet.schema.OriginalType.UTF8;
import static org.apache.parquet.schema.PrimitiveType.PrimitiveTypeName.BINARY;
import static
org.apache.parquet.schema.PrimitiveType.PrimitiveTypeName.BOOLEAN;
import static org.apache.parquet.schema.PrimitiveType.PrimitiveTypeName.DOUBLE;
+import static
org.apache.parquet.schema.PrimitiveType.PrimitiveTypeName.FIXED_LEN_BYTE_ARRAY;
import static org.apache.parquet.schema.PrimitiveType.PrimitiveTypeName.FLOAT;
import static org.apache.parquet.schema.PrimitiveType.PrimitiveTypeName.INT32;
import static org.apache.parquet.schema.PrimitiveType.PrimitiveTypeName.INT64;
@@ -2016,6 +2019,50 @@ public class TestColumnIndexBuilder {
assertCorrectNullCounts(ci6, 0, 1);
}
+ @Test
+ public void testBuildFlba12Timestamp() {
+ // FLBA(12) TIMESTAMP column with negative (pre-1970) and positive
(post-1970) values.
+ // The LE signed comparator must order negatives before zero before
positives.
+ PrimitiveType type = Types.required(FIXED_LEN_BYTE_ARRAY)
+ .length(12)
+ .as(timestampType(true, NANOS))
+ .named("ts");
+ ColumnIndexBuilder builder = ColumnIndexBuilder.getBuilder(type,
Integer.MAX_VALUE);
+ assertThat(builder).isInstanceOf(BinaryColumnIndexBuilder.class);
+
+ // -1 (all 0xFF) encodes a value just before epoch — smallest in the test
+ byte[] negOne = new byte[12];
+ for (int i = 0; i < 12; i++) negOne[i] = (byte) 0xFF;
+ // 0 (all 0x00) — epoch
+ byte[] epoch = new byte[12];
+ // +1 (0x01 followed by zeros) — just after epoch
+ byte[] posOne = new byte[12];
+ posOne[0] = 1;
+
+ StatsBuilder sb = new StatsBuilder();
+ // Page 0: only negative values, min=-1 max=-1
+ builder.add(sb.stats(type, Binary.fromConstantByteArray(negOne)));
+ // Page 1: straddles epoch, min=-1 max=+1
+ builder.add(sb.stats(type, Binary.fromConstantByteArray(negOne),
Binary.fromConstantByteArray(posOne)));
+ // Page 2: only positive values, min=epoch max=+1
+ builder.add(sb.stats(type, Binary.fromConstantByteArray(epoch),
Binary.fromConstantByteArray(posOne)));
+
+ ColumnIndex columnIndex = builder.build();
+ assertThat(columnIndex).isNotNull();
+ // min of page 0 must equal -1
+ assertCorrectValues(
+ columnIndex.getMinValues(),
+ Binary.fromConstantByteArray(negOne),
+ Binary.fromConstantByteArray(negOne),
+ Binary.fromConstantByteArray(epoch));
+ // max of page 0 must equal -1 (all values are negative)
+ assertCorrectValues(
+ columnIndex.getMaxValues(),
+ Binary.fromConstantByteArray(negOne),
+ Binary.fromConstantByteArray(posOne),
+ Binary.fromConstantByteArray(posOne));
+ }
+
@Test
public void testBuildWithoutNullCountsIsNotRejected() {
PrimitiveType type = Types.required(INT32).named("test_col");
diff --git
a/parquet-column/src/test/java/org/apache/parquet/schema/TestPrimitiveComparator.java
b/parquet-column/src/test/java/org/apache/parquet/schema/TestPrimitiveComparator.java
index 35de0c8ca..c07fdb810 100644
---
a/parquet-column/src/test/java/org/apache/parquet/schema/TestPrimitiveComparator.java
+++
b/parquet-column/src/test/java/org/apache/parquet/schema/TestPrimitiveComparator.java
@@ -21,6 +21,7 @@ package org.apache.parquet.schema;
import static
org.apache.parquet.schema.PrimitiveComparator.BINARY_AS_FLOAT16_COMPARATOR;
import static
org.apache.parquet.schema.PrimitiveComparator.BINARY_AS_FLOAT16_IEEE_754_TOTAL_ORDER_COMPARATOR;
import static
org.apache.parquet.schema.PrimitiveComparator.BINARY_AS_SIGNED_INTEGER_COMPARATOR;
+import static
org.apache.parquet.schema.PrimitiveComparator.BINARY_AS_SIGNED_TIMESTAMP_COMPARATOR;
import static org.apache.parquet.schema.PrimitiveComparator.BOOLEAN_COMPARATOR;
import static org.apache.parquet.schema.PrimitiveComparator.DOUBLE_COMPARATOR;
import static
org.apache.parquet.schema.PrimitiveComparator.DOUBLE_IEEE_754_TOTAL_ORDER_COMPARATOR;
@@ -433,6 +434,47 @@ public class TestPrimitiveComparator {
}
}
+ @Test
+ public void testBinaryAsSignedIntegerLE12Comparator() {
+ // 12-byte LE encodings: byte[0]=LSB, byte[11]=MSB/sign
+ // large negative: 0x80 00..00 (most negative 96-bit value)
+ byte[] largeNeg = new byte[12];
+ largeNeg[11] = (byte) 0x80;
+ // -256: 0x00 FF FF..FF (little-endian)
+ byte[] negTwoFiftySix = new byte[12];
+ for (int i = 1; i < 12; i++) negTwoFiftySix[i] = (byte) 0xFF;
+ // -1: all 0xFF
+ byte[] negOne = new byte[12];
+ for (int i = 0; i < 12; i++) negOne[i] = (byte) 0xFF;
+ // 0: all 0x00
+ byte[] zero = new byte[12];
+ // +1: 0x01 followed by 0x00s
+ byte[] posOne = new byte[12];
+ posOne[0] = 1;
+ // +256: byte[0]=0x00, byte[1]=0x01, rest 0x00 — order vs +1 is decided at
byte 1 (high byte);
+ // an LSB-first comparator would wrongly rank +256 < +1 (seeing byte[0]=0
< 1 first)
+ byte[] posTwoFiftySix = new byte[12];
+ posTwoFiftySix[1] = 1;
+ // +2^63: low 8 bytes have bit 63 set (LE: bytes 0..6=0x00, byte 7=0x80),
high 4 bytes=0x00.
+ // Sits between small positives and largePos; fails if the lower 8 bytes
are compared signed.
+ byte[] posTwo63 = new byte[12];
+ posTwo63[7] = (byte) 0x80;
+ // large positive: 0x7F FF..FF (most positive 96-bit value)
+ byte[] largePos = new byte[12];
+ for (int i = 0; i < 12; i++) largePos[i] = (byte) 0xFF;
+ largePos[11] = 0x7F;
+ testObjectComparator(
+ BINARY_AS_SIGNED_TIMESTAMP_COMPARATOR,
+ Binary.fromConstantByteArray(largeNeg),
+ Binary.fromConstantByteArray(negTwoFiftySix),
+ Binary.fromConstantByteArray(negOne),
+ Binary.fromConstantByteArray(zero),
+ Binary.fromConstantByteArray(posOne),
+ Binary.fromConstantByteArray(posTwoFiftySix),
+ Binary.fromConstantByteArray(posTwo63),
+ Binary.fromConstantByteArray(largePos));
+ }
+
private void checkThrowingUnsupportedException(PrimitiveComparator<?>
comparator, Class<?> exclude) {
if (Integer.TYPE != exclude) {
assertThatThrownBy(() -> comparator.compare(0, 0))
diff --git
a/parquet-column/src/test/java/org/apache/parquet/schema/TestPrimitiveStringifier.java
b/parquet-column/src/test/java/org/apache/parquet/schema/TestPrimitiveStringifier.java
index f0da51fc0..bbc6f5a78 100644
---
a/parquet-column/src/test/java/org/apache/parquet/schema/TestPrimitiveStringifier.java
+++
b/parquet-column/src/test/java/org/apache/parquet/schema/TestPrimitiveStringifier.java
@@ -45,12 +45,14 @@ import static
org.assertj.core.api.Assertions.assertThatThrownBy;
import java.math.BigInteger;
import java.nio.ByteBuffer;
+import java.util.Arrays;
import java.util.Calendar;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.TimeZone;
import java.util.concurrent.TimeUnit;
+import org.apache.parquet.bytes.BytesUtils;
import org.apache.parquet.io.api.Binary;
import org.junit.jupiter.api.Test;
@@ -193,7 +195,29 @@ public class TestPrimitiveStringifier {
assertThat(stringifier.stringify(cal.getTimeInMillis()))
.isEqualTo(withZoneString("1948-11-23T20:19:01.009",
timezoneAmendment));
- checkThrowingUnsupportedException(stringifier, Long.TYPE);
+ // The FIXED_LEN_BYTE_ARRAY(12) carrier renders identically to the int64
carrier. Includes a
+ // pre-epoch (negative) value to exercise the signed little-endian
decoding.
+ assertThat(stringifier.stringify(toFlba12Timestamp(0L)))
+ .isEqualTo(withZoneString("1970-01-01T00:00:00.000",
timezoneAmendment));
+ cal.clear();
+ cal.set(2017, Calendar.DECEMBER, 15, 10, 9, 54);
+ cal.set(Calendar.MILLISECOND, 120);
+
assertThat(stringifier.stringify(toFlba12Timestamp(cal.getTimeInMillis())))
+ .isEqualTo(withZoneString("2017-12-15T10:09:54.120",
timezoneAmendment));
+ cal.clear();
+ cal.set(1948, Calendar.NOVEMBER, 23, 20, 19, 1);
+ cal.set(Calendar.MILLISECOND, 9);
+
assertThat(stringifier.stringify(toFlba12Timestamp(cal.getTimeInMillis())))
+ .isEqualTo(withZoneString("1948-11-23T20:19:01.009",
timezoneAmendment));
+
+ // Test FLBA(12) timestamps that overflow the Instant range.
+ BigInteger overflowsLong = BigInteger.valueOf(2).pow(94).negate();
+
assertThat(stringifier.stringify(toFlba12Timestamp(overflowsLong))).isEqualTo(overflowsLong.toString());
+ BigInteger overflowsInstant = BigInteger.valueOf(2).pow(70);
+ assertThat(stringifier.stringify(toFlba12Timestamp(overflowsInstant)))
+ .isEqualTo(overflowsInstant.toString());
+
+ checkThrowingUnsupportedException(stringifier, Long.TYPE, Binary.class);
}
}
@@ -221,7 +245,21 @@ public class TestPrimitiveStringifier {
assertThat(stringifier.stringify(micros))
.isEqualTo(withZoneString("1848-03-15T09:23:59.764999",
timezoneAmendment));
- checkThrowingUnsupportedException(stringifier, Long.TYPE);
+ // The FIXED_LEN_BYTE_ARRAY(12) carrier renders identically to the int64
carrier, including
+ // the pre-epoch (negative) value above which exercises the signed
little-endian decoding.
+ assertThat(stringifier.stringify(toFlba12Timestamp(0L)))
+ .isEqualTo(withZoneString("1970-01-01T00:00:00.000000",
timezoneAmendment));
+ assertThat(stringifier.stringify(toFlba12Timestamp(micros)))
+ .isEqualTo(withZoneString("1848-03-15T09:23:59.764999",
timezoneAmendment));
+
+ // Test FLBA(12) timestamps that overflow the Instant range.
+ BigInteger overflowsLong = BigInteger.valueOf(2).pow(94).negate();
+
assertThat(stringifier.stringify(toFlba12Timestamp(overflowsLong))).isEqualTo(overflowsLong.toString());
+ BigInteger overflowsInstant = BigInteger.valueOf(2).pow(80);
+ assertThat(stringifier.stringify(toFlba12Timestamp(overflowsInstant)))
+ .isEqualTo(overflowsInstant.toString());
+
+ checkThrowingUnsupportedException(stringifier, Long.TYPE, Binary.class);
}
}
@@ -248,7 +286,21 @@ public class TestPrimitiveStringifier {
assertThat(stringifier.stringify(nanos))
.isEqualTo(withZoneString("1848-03-15T09:23:59.764999999",
timezoneAmendment));
- checkThrowingUnsupportedException(stringifier, Long.TYPE);
+ // The FIXED_LEN_BYTE_ARRAY(12) carrier renders identically to the int64
carrier, including
+ // the pre-epoch (negative) value above which exercises the signed
little-endian decoding.
+ assertThat(stringifier.stringify(toFlba12Timestamp(0L)))
+ .isEqualTo(withZoneString("1970-01-01T00:00:00.000000000",
timezoneAmendment));
+ assertThat(stringifier.stringify(toFlba12Timestamp(nanos)))
+ .isEqualTo(withZoneString("1848-03-15T09:23:59.764999999",
timezoneAmendment));
+
+ // Test FLBA(12) timestamps that overflow the Instant range.
+ BigInteger overflowsLong = BigInteger.valueOf(2).pow(94).negate();
+
assertThat(stringifier.stringify(toFlba12Timestamp(overflowsLong))).isEqualTo(overflowsLong.toString());
+ BigInteger overflowsInstant = BigInteger.valueOf(2).pow(90);
+ assertThat(stringifier.stringify(toFlba12Timestamp(overflowsInstant)))
+ .isEqualTo(overflowsInstant.toString());
+
+ checkThrowingUnsupportedException(stringifier, Long.TYPE, Binary.class);
}
}
@@ -417,6 +469,20 @@ public class TestPrimitiveStringifier {
return Binary.fromConstantByteArray(array);
}
+ private Binary toFlba12Timestamp(long units) {
+ return toFlba12Timestamp(BigInteger.valueOf(units));
+ }
+
+ private Binary toFlba12Timestamp(BigInteger units) {
+ byte[] bigEndian = units.toByteArray();
+ assertThat(bigEndian.length).isLessThanOrEqualTo(12);
+
+ byte[] padded = new byte[12];
+ Arrays.fill(padded, 0, 12 - bigEndian.length, (byte) (units.signum() < 0 ?
0xFF : 0x00));
+ System.arraycopy(bigEndian, 0, padded, 12 - bigEndian.length,
bigEndian.length);
+ return Binary.fromConstantByteArray(BytesUtils.reverse(padded));
+ }
+
private void checkThrowingUnsupportedException(PrimitiveStringifier
stringifier, Class<?>... excludes) {
Set<Class<?>> set = new HashSet<>(List.of(excludes));
if (!set.contains(Integer.TYPE)) {
diff --git
a/parquet-column/src/test/java/org/apache/parquet/schema/TestTypeBuilders.java
b/parquet-column/src/test/java/org/apache/parquet/schema/TestTypeBuilders.java
index 81e921b17..1a0a3d686 100644
---
a/parquet-column/src/test/java/org/apache/parquet/schema/TestTypeBuilders.java
+++
b/parquet-column/src/test/java/org/apache/parquet/schema/TestTypeBuilders.java
@@ -531,7 +531,8 @@ public class TestTypeBuilders {
@Test
public void testInt64Annotations() {
- OriginalType[] types = new OriginalType[] {TIME_MICROS, TIMESTAMP_MILLIS,
TIMESTAMP_MICROS, UINT_64, INT_64};
+ // Test the non-timestamp annotations for INT64. Timestamps are tested
separately below.
+ OriginalType[] types = new OriginalType[] {TIME_MICROS, UINT_64, INT_64};
for (OriginalType logicalType : types) {
PrimitiveType expected = new PrimitiveType(REQUIRED, INT64, "col",
logicalType);
PrimitiveType date = Types.required(INT64).as(logicalType).named("col");
@@ -541,7 +542,8 @@ public class TestTypeBuilders {
@Test
public void testInt64AnnotationsRejectNonInt64() {
- OriginalType[] types = new OriginalType[] {TIME_MICROS, TIMESTAMP_MILLIS,
TIMESTAMP_MICROS, UINT_64, INT_64};
+ // Test the non-timestamp annotations for INT64. Timestamps are tested
separately below.
+ OriginalType[] types = new OriginalType[] {TIME_MICROS, UINT_64, INT_64};
for (final OriginalType logicalType : types) {
PrimitiveTypeName[] nonInt64 = new PrimitiveTypeName[] {BOOLEAN, INT32,
INT96, DOUBLE, FLOAT, BINARY};
for (final PrimitiveTypeName type : nonInt64) {
@@ -558,6 +560,46 @@ public class TestTypeBuilders {
}
}
+ @Test
+ public void testTimestampAnnotations() {
+ // Test timestamp annotations for both INT64 and FLBA(12).
+ OriginalType[] types = new OriginalType[] {TIMESTAMP_MILLIS,
TIMESTAMP_MICROS};
+ for (OriginalType logicalType : types) {
+ PrimitiveType expectedInt64 = new PrimitiveType(REQUIRED, INT64, "col",
logicalType);
+ PrimitiveType dateInt64 =
Types.required(INT64).as(logicalType).named("col");
+ assertThat(dateInt64).isEqualTo(expectedInt64);
+
+ PrimitiveType expectedFlba12 = new PrimitiveType(REQUIRED,
FIXED_LEN_BYTE_ARRAY, 12, "col", logicalType);
+ PrimitiveType dateFlba12 = Types.required(FIXED_LEN_BYTE_ARRAY)
+ .length(12)
+ .as(logicalType)
+ .named("col");
+ assertThat(dateFlba12).isEqualTo(expectedFlba12);
+ }
+ }
+
+ @Test
+ public void testTimestampAnnotationsRejectNonTimestamp() {
+ // Test timestamp annotations for both INT64 and FLBA(12).
+ OriginalType[] types = new OriginalType[] {TIMESTAMP_MILLIS,
TIMESTAMP_MICROS};
+ for (OriginalType logicalType : types) {
+ // Invalid primitive types are rejected.
+ PrimitiveTypeName[] nonTimestamp = new PrimitiveTypeName[] {BOOLEAN,
INT32, INT96, DOUBLE, FLOAT, BINARY};
+ for (PrimitiveTypeName type : nonTimestamp) {
+ assertThatThrownBy(() ->
Types.required(type).as(logicalType).named("col"))
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessage(logicalType + " can only annotate [INT64,
FIXED_LEN_BYTE_ARRAY(12)]");
+ }
+ // Invalid FLBA lengths are rejected.
+ assertThatThrownBy(() -> Types.required(FIXED_LEN_BYTE_ARRAY)
+ .length(11)
+ .as(logicalType)
+ .named("col"))
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessage(logicalType + " can only annotate [INT64,
FIXED_LEN_BYTE_ARRAY(12)]");
+ }
+ }
+
@Test
public void testIntervalAnnotation() {
PrimitiveType expected = new PrimitiveType(REQUIRED, FIXED_LEN_BYTE_ARRAY,
12, "interval", INTERVAL);
diff --git
a/parquet-column/src/test/java/org/apache/parquet/schema/TestTypeBuildersWithLogicalTypes.java
b/parquet-column/src/test/java/org/apache/parquet/schema/TestTypeBuildersWithLogicalTypes.java
index 1fc46d7fd..fbc79cc81 100644
---
a/parquet-column/src/test/java/org/apache/parquet/schema/TestTypeBuildersWithLogicalTypes.java
+++
b/parquet-column/src/test/java/org/apache/parquet/schema/TestTypeBuildersWithLogicalTypes.java
@@ -294,9 +294,6 @@ public class TestTypeBuildersWithLogicalTypes {
LogicalTypeAnnotation[] types = new LogicalTypeAnnotation[] {
timeType(true, MICROS), timeType(false, MICROS),
timeType(true, NANOS), timeType(false, NANOS),
- timestampType(true, MILLIS), timestampType(false, MILLIS),
- timestampType(true, MICROS), timestampType(false, MICROS),
- timestampType(true, NANOS), timestampType(false, NANOS),
intType(64, true), intType(64, false)
};
for (LogicalTypeAnnotation logicalType : types) {
@@ -311,9 +308,6 @@ public class TestTypeBuildersWithLogicalTypes {
LogicalTypeAnnotation[] types = new LogicalTypeAnnotation[] {
timeType(true, MICROS), timeType(false, MICROS),
timeType(true, NANOS), timeType(false, NANOS),
- timestampType(true, MILLIS), timestampType(false, MILLIS),
- timestampType(true, MICROS), timestampType(false, MICROS),
- timestampType(true, NANOS), timestampType(false, NANOS),
intType(64, true), intType(64, false)
};
for (final LogicalTypeAnnotation logicalType : types) {
@@ -332,6 +326,50 @@ public class TestTypeBuildersWithLogicalTypes {
}
}
+ @Test
+ public void testTimestampAnnotations() {
+ LogicalTypeAnnotation[] types = new LogicalTypeAnnotation[] {
+ timestampType(true, MILLIS), timestampType(false, MILLIS),
+ timestampType(true, MICROS), timestampType(false, MICROS),
+ timestampType(true, NANOS), timestampType(false, NANOS)
+ };
+ for (LogicalTypeAnnotation logicalType : types) {
+ PrimitiveType expectedInt64 = new PrimitiveType(REQUIRED, INT64, "col",
logicalType);
+
assertThat(Types.required(INT64).as(logicalType).named("col")).isEqualTo(expectedInt64);
+
+ PrimitiveType expectedFlba12 =
+ new PrimitiveType(REQUIRED, FIXED_LEN_BYTE_ARRAY, 12, "col",
logicalType, null);
+ assertThat(Types.required(FIXED_LEN_BYTE_ARRAY)
+ .length(12)
+ .as(logicalType)
+ .named("col"))
+ .isEqualTo(expectedFlba12);
+ }
+ }
+
+ @Test
+ public void testTimestampAnnotationsRejectInvalidTypes() {
+ LogicalTypeAnnotation[] types = new LogicalTypeAnnotation[] {
+ timestampType(true, MILLIS), timestampType(false, MILLIS),
+ timestampType(true, MICROS), timestampType(false, MICROS),
+ timestampType(true, NANOS), timestampType(false, NANOS)
+ };
+ for (LogicalTypeAnnotation logicalType : types) {
+ PrimitiveTypeName[] invalidTypes = new PrimitiveTypeName[] {BOOLEAN,
INT32, INT96, DOUBLE, FLOAT, BINARY};
+ for (PrimitiveTypeName type : invalidTypes) {
+ assertThatThrownBy(() ->
Types.required(type).as(logicalType).named("col"))
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessage(logicalType + " can only annotate [INT64,
FIXED_LEN_BYTE_ARRAY(12)]");
+ }
+ assertThatThrownBy(() -> Types.required(FIXED_LEN_BYTE_ARRAY)
+ .length(1)
+ .as(logicalType)
+ .named("col"))
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessage(logicalType + " can only annotate [INT64,
FIXED_LEN_BYTE_ARRAY(12)]");
+ }
+ }
+
@Test
public void testIntervalAnnotationRejectsNonFixed() {
PrimitiveTypeName[] nonFixed = new PrimitiveTypeName[] {BOOLEAN, INT32,
INT64, INT96, DOUBLE, FLOAT, BINARY};
diff --git
a/parquet-common/src/main/java/org/apache/parquet/bytes/BytesUtils.java
b/parquet-common/src/main/java/org/apache/parquet/bytes/BytesUtils.java
index b8373a898..07bcc2890 100644
--- a/parquet-common/src/main/java/org/apache/parquet/bytes/BytesUtils.java
+++ b/parquet-common/src/main/java/org/apache/parquet/bytes/BytesUtils.java
@@ -345,4 +345,12 @@ public class BytesUtils {
public static boolean bytesToBool(byte[] bytes) {
return ((int) (bytes[0] & 255) != 0);
}
+
+ public static byte[] reverse(byte[] bytes) {
+ byte[] reversed = new byte[bytes.length];
+ for (int i = 0; i < bytes.length; i++) {
+ reversed[i] = bytes[bytes.length - 1 - i];
+ }
+ return reversed;
+ }
}
diff --git
a/parquet-hadoop/src/main/java/org/apache/parquet/format/converter/ParquetMetadataConverter.java
b/parquet-hadoop/src/main/java/org/apache/parquet/format/converter/ParquetMetadataConverter.java
index 8111deb4c..4252523fa 100644
---
a/parquet-hadoop/src/main/java/org/apache/parquet/format/converter/ParquetMetadataConverter.java
+++
b/parquet-hadoop/src/main/java/org/apache/parquet/format/converter/ParquetMetadataConverter.java
@@ -320,7 +320,14 @@ public class ParquetMetadataConverter {
element.setRepetition_type(toParquetRepetition(primitiveType.getRepetition()));
element.setType(getType(primitiveType.getPrimitiveTypeName()));
if (primitiveType.getLogicalTypeAnnotation() != null) {
-
element.setConverted_type(convertToConvertedType(primitiveType.getLogicalTypeAnnotation()));
+ // The TimestampType logical type may have a converted type, but
only for the INT64
+ // physical type.
+ boolean suppressConvertedType =
primitiveType.getLogicalTypeAnnotation()
+ instanceof
LogicalTypeAnnotation.TimestampLogicalTypeAnnotation
+ && primitiveType.getPrimitiveTypeName() !=
PrimitiveTypeName.INT64;
+ if (!suppressConvertedType) {
+
element.setConverted_type(convertToConvertedType(primitiveType.getLogicalTypeAnnotation()));
+ }
element.setLogicalType(convertToLogicalType(primitiveType.getLogicalTypeAnnotation()));
}
if (primitiveType.getDecimalMetadata() != null) {
diff --git
a/parquet-hadoop/src/test/java/org/apache/parquet/format/converter/TestParquetMetadataConverter.java
b/parquet-hadoop/src/test/java/org/apache/parquet/format/converter/TestParquetMetadataConverter.java
index 80760a032..46ffccf7f 100644
---
a/parquet-hadoop/src/test/java/org/apache/parquet/format/converter/TestParquetMetadataConverter.java
+++
b/parquet-hadoop/src/test/java/org/apache/parquet/format/converter/TestParquetMetadataConverter.java
@@ -453,6 +453,18 @@ public class TestParquetMetadataConverter {
.required(PrimitiveTypeName.INT64)
.as(timestampType(true, NANOS))
.named("aTimestampUtcNanos")
+ .required(PrimitiveTypeName.FIXED_LEN_BYTE_ARRAY)
+ .length(12)
+ .as(timestampType(true, MILLIS))
+ .named("aTimestampFlbaMillis")
+ .required(PrimitiveTypeName.FIXED_LEN_BYTE_ARRAY)
+ .length(12)
+ .as(timestampType(true, MICROS))
+ .named("aTimestampFlbaMicros")
+ .required(PrimitiveTypeName.FIXED_LEN_BYTE_ARRAY)
+ .length(12)
+ .as(timestampType(true, NANOS))
+ .named("aTimestampFlbaNanos")
.required(PrimitiveTypeName.INT32)
.as(timeType(false, MILLIS))
.named("aTimeNonUtcMillis")
@@ -473,6 +485,19 @@ public class TestParquetMetadataConverter {
.named("aTimeUtcNanos")
.named("Message");
List<SchemaElement> parquetSchema =
parquetMetadataConverter.toParquetSchema(expected);
+ // FLBA(12) MILLIS/MICROS must not write a legacy converted_type (it is
INT64-only).
+ SchemaElement flbaMillis = parquetSchema.stream()
+ .filter(e -> "aTimestampFlbaMillis".equals(e.getName()))
+ .findFirst()
+ .get();
+ assertThat(flbaMillis.isSetConverted_type()).isFalse();
+ assertThat(flbaMillis.isSetLogicalType()).isTrue();
+ SchemaElement flbaMicros = parquetSchema.stream()
+ .filter(e -> "aTimestampFlbaMicros".equals(e.getName()))
+ .findFirst()
+ .get();
+ assertThat(flbaMicros.isSetConverted_type()).isFalse();
+ assertThat(flbaMicros.isSetLogicalType()).isTrue();
MessageType schema =
parquetMetadataConverter.fromParquetSchema(parquetSchema, null);
assertThat(schema).isEqualTo(expected);
}
diff --git
a/parquet-hadoop/src/test/java/org/apache/parquet/hadoop/TestInterOpReadFlba12Timestamp.java
b/parquet-hadoop/src/test/java/org/apache/parquet/hadoop/TestInterOpReadFlba12Timestamp.java
new file mode 100644
index 000000000..56cfb954a
--- /dev/null
+++
b/parquet-hadoop/src/test/java/org/apache/parquet/hadoop/TestInterOpReadFlba12Timestamp.java
@@ -0,0 +1,167 @@
+/*
+ * 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.parquet.hadoop;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.io.IOException;
+import org.apache.hadoop.conf.Configuration;
+import org.apache.hadoop.fs.Path;
+import org.apache.parquet.example.data.Group;
+import org.apache.parquet.hadoop.example.GroupReadSupport;
+import org.apache.parquet.hadoop.metadata.BlockMetaData;
+import org.apache.parquet.hadoop.metadata.ColumnChunkMetaData;
+import org.apache.parquet.hadoop.util.HadoopInputFile;
+import org.apache.parquet.io.api.Binary;
+import org.apache.parquet.schema.ColumnOrder;
+import org.apache.parquet.schema.LogicalTypeAnnotation;
+import org.apache.parquet.schema.MessageType;
+import org.apache.parquet.schema.PrimitiveStringifier;
+import org.apache.parquet.schema.PrimitiveType;
+import org.apache.parquet.schema.PrimitiveType.PrimitiveTypeName;
+import org.junit.jupiter.api.Test;
+
+public class TestInterOpReadFlba12Timestamp {
+ private static final String FILE = "flba12_timestamp.parquet";
+ private static final String CHANGESET =
"56653c437c8092f704a092d0d1d4e600124cd49f";
+
+ // The six timestamps stored in the file, in row order.
+ private static final String[] EXPECTED_TIMESTAMPS = {
+ "1970-01-01T00:00:00", // row 0: epoch
+ "1970-01-01T00:00:01", // row 1: +1 s
+ "1969-12-31T23:59:59", // row 2: -1 s (pre-1970, negative)
+ "2262-04-11T23:47:16", // row 3: near the INT64-nanos max boundary
+ "9999-12-31T23:59:59", // row 4: far future (NANOS needs > 64 bits)
+ "0001-01-01T00:00:00", // row 5: far past (NANOS is < -2^63)
+ };
+ // Row indices of the minimum (year 0001) and maximum (year 9999) timestamps.
+ private static final int MIN_ROW = 5;
+ private static final int MAX_ROW = 4;
+
+ // Fractional-second digit counts the UTC PrimitiveStringifier renders per
unit.
+ private static final int MILLIS_FRACTION_DIGITS = 3;
+ private static final int MICROS_FRACTION_DIGITS = 6;
+ private static final int NANOS_FRACTION_DIGITS = 9;
+
+ private final InterOpTester interop = new InterOpTester();
+
+ // Stringify the expected timestamp value for the given row with the
required fractional digits.
+ private static String expected(int row, int fractionDigits) {
+ StringBuilder sb = new StringBuilder(EXPECTED_TIMESTAMPS[row]).append('.');
+ for (int i = 0; i < fractionDigits; i++) {
+ sb.append('0');
+ }
+ return sb.append("+0000").toString();
+ }
+
+ @Test
+ public void testInterOpReadFlba12TimestampParquetFile() throws IOException {
+ Path filePath = interop.GetInterOpFile(FILE, CHANGESET);
+
+ final int expectRows = EXPECTED_TIMESTAMPS.length;
+
+ PrimitiveStringifier millisStringifier = null;
+ PrimitiveStringifier microsStringifier = null;
+ PrimitiveStringifier nanosStringifier = null;
+
+ try (ParquetFileReader reader =
+ ParquetFileReader.open(HadoopInputFile.fromPath(filePath, new
Configuration()))) {
+ MessageType schema = reader.getFooter().getFileMetaData().getSchema();
+ BlockMetaData block = reader.getFooter().getBlocks().get(0);
+
+ // timestamp_millis column
+ PrimitiveType millisType =
schema.getType("timestamp_millis").asPrimitiveType();
+
assertThat(millisType.getPrimitiveTypeName()).isEqualTo(PrimitiveTypeName.FIXED_LEN_BYTE_ARRAY);
+ assertThat(millisType.getTypeLength()).isEqualTo(12);
+ assertThat(millisType.getLogicalTypeAnnotation())
+ .isEqualTo(LogicalTypeAnnotation.timestampType(true,
LogicalTypeAnnotation.TimeUnit.MILLIS));
+
assertThat(millisType.columnOrder()).isEqualTo(ColumnOrder.typeDefined());
+ millisStringifier = millisType.stringifier();
+
+ // timestamp_micros column
+ PrimitiveType microsType =
schema.getType("timestamp_micros").asPrimitiveType();
+
assertThat(microsType.getPrimitiveTypeName()).isEqualTo(PrimitiveTypeName.FIXED_LEN_BYTE_ARRAY);
+ assertThat(microsType.getTypeLength()).isEqualTo(12);
+ assertThat(microsType.getLogicalTypeAnnotation())
+ .isEqualTo(LogicalTypeAnnotation.timestampType(true,
LogicalTypeAnnotation.TimeUnit.MICROS));
+
assertThat(microsType.columnOrder()).isEqualTo(ColumnOrder.typeDefined());
+ microsStringifier = microsType.stringifier();
+
+ // timestamp_nanos column
+ PrimitiveType nanosType =
schema.getType("timestamp_nanos").asPrimitiveType();
+
assertThat(nanosType.getPrimitiveTypeName()).isEqualTo(PrimitiveTypeName.FIXED_LEN_BYTE_ARRAY);
+ assertThat(nanosType.getTypeLength()).isEqualTo(12);
+ assertThat(nanosType.getLogicalTypeAnnotation())
+ .isEqualTo(LogicalTypeAnnotation.timestampType(true,
LogicalTypeAnnotation.TimeUnit.NANOS));
+ assertThat(nanosType.columnOrder()).isEqualTo(ColumnOrder.typeDefined());
+ nanosStringifier = nanosType.stringifier();
+
+ // Statistics: min = year 0001, max = year 9999.
+ ColumnChunkMetaData millisCol = block.getColumns().get(0);
+ assertThat(millisStringifier.stringify(Binary.fromConstantByteArray(
+ millisCol.getStatistics().getMinBytes())))
+ .isEqualTo(expected(MIN_ROW, MILLIS_FRACTION_DIGITS));
+ assertThat(millisStringifier.stringify(Binary.fromConstantByteArray(
+ millisCol.getStatistics().getMaxBytes())))
+ .isEqualTo(expected(MAX_ROW, MILLIS_FRACTION_DIGITS));
+
+ ColumnChunkMetaData microsCol = block.getColumns().get(1);
+ assertThat(microsStringifier.stringify(Binary.fromConstantByteArray(
+ microsCol.getStatistics().getMinBytes())))
+ .isEqualTo(expected(MIN_ROW, MICROS_FRACTION_DIGITS));
+ assertThat(microsStringifier.stringify(Binary.fromConstantByteArray(
+ microsCol.getStatistics().getMaxBytes())))
+ .isEqualTo(expected(MAX_ROW, MICROS_FRACTION_DIGITS));
+
+ ColumnChunkMetaData nanosCol = block.getColumns().get(2);
+ assertThat(nanosStringifier.stringify(Binary.fromConstantByteArray(
+ nanosCol.getStatistics().getMinBytes())))
+ .isEqualTo(expected(MIN_ROW, NANOS_FRACTION_DIGITS));
+ assertThat(nanosStringifier.stringify(Binary.fromConstantByteArray(
+ nanosCol.getStatistics().getMaxBytes())))
+ .isEqualTo(expected(MAX_ROW, NANOS_FRACTION_DIGITS));
+ }
+
+ try (ParquetReader<Group> reader =
+ ParquetReader.builder(new GroupReadSupport(), filePath).build()) {
+ for (int i = 0; i < expectRows; ++i) {
+ Group group = reader.read();
+ assertThat(group)
+ .as("Should not reach end of file before " + expectRows + " rows")
+ .isNotNull();
+ assertThat(group.getFieldRepetitionCount(0))
+ .as("timestamp_millis should not be null at row " + i)
+ .isNotEqualTo(0);
+ assertThat(millisStringifier.stringify(group.getBinary(0, 0)))
+ .isEqualTo(expected(i, MILLIS_FRACTION_DIGITS));
+ assertThat(group.getFieldRepetitionCount(1))
+ .as("timestamp_micros should not be null at row " + i)
+ .isNotEqualTo(0);
+ assertThat(microsStringifier.stringify(group.getBinary(1, 0)))
+ .isEqualTo(expected(i, MICROS_FRACTION_DIGITS));
+ assertThat(group.getFieldRepetitionCount(2))
+ .as("timestamp_nanos should not be null at row " + i)
+ .isNotEqualTo(0);
+ assertThat(nanosStringifier.stringify(group.getBinary(2, 0)))
+ .isEqualTo(expected(i, NANOS_FRACTION_DIGITS));
+ }
+ }
+ }
+}
diff --git
a/parquet-hadoop/src/test/java/org/apache/parquet/statistics/TestColumnIndexes.java
b/parquet-hadoop/src/test/java/org/apache/parquet/statistics/TestColumnIndexes.java
index 0b2435f11..35b2f4337 100644
---
a/parquet-hadoop/src/test/java/org/apache/parquet/statistics/TestColumnIndexes.java
+++
b/parquet-hadoop/src/test/java/org/apache/parquet/statistics/TestColumnIndexes.java
@@ -115,6 +115,10 @@ public class TestColumnIndexes {
.as(OriginalType.INTERVAL)
.named("interval"),
Types.optional(FIXED_LEN_BYTE_ARRAY).length(16).as(uuidType()).named("uuid"),
+ Types.optional(FIXED_LEN_BYTE_ARRAY)
+ .length(12)
+ .as(timestampType(true, TimeUnit.NANOS))
+ .named("timestamp-nanos-flba"),
Types.optional(BINARY).as(stringType()).named("always-null"));
private static List<Supplier<?>> buildGenerators(int recordCount, Random
random) {
@@ -219,6 +223,8 @@ public class TestColumnIndexes {
new RandomValues.FixedGenerator(random.nextLong(), 12), random,
recordCount, fieldIndex++),
sortedOrRandom(
new RandomValues.FixedGenerator(random.nextLong(), 16), random,
recordCount, fieldIndex++),
+ sortedOrRandom(
+ new RandomValues.FixedGenerator(random.nextLong(), 12), random,
recordCount, fieldIndex++),
null);
}
diff --git
a/parquet-hadoop/src/test/java/org/apache/parquet/statistics/TestStatistics.java
b/parquet-hadoop/src/test/java/org/apache/parquet/statistics/TestStatistics.java
index d5cb7b953..5dc6c1f80 100644
---
a/parquet-hadoop/src/test/java/org/apache/parquet/statistics/TestStatistics.java
+++
b/parquet-hadoop/src/test/java/org/apache/parquet/statistics/TestStatistics.java
@@ -381,7 +381,8 @@ public class TestStatistics {
new RandomValues.LongGenerator(random.nextLong()),
new RandomValues.LongGenerator(random.nextLong()),
new RandomValues.FixedGenerator(random.nextLong(), 12),
- new RandomValues.FixedGenerator(random.nextLong(), 2));
+ new RandomValues.FixedGenerator(random.nextLong(), 2),
+ new RandomValues.FixedGenerator(random.nextLong(), 12));
}
private static MessageType buildSchema(long seed) {
@@ -451,7 +452,12 @@ public class TestStatistics {
Types.optional(FIXED_LEN_BYTE_ARRAY)
.length(2)
.named("float16")
- .withLogicalTypeAnnotation(LogicalTypeAnnotation.float16Type()));
+ .withLogicalTypeAnnotation(LogicalTypeAnnotation.float16Type()),
+ Types.optional(FIXED_LEN_BYTE_ARRAY)
+ .length(12)
+ .named("timestamp-nanos-flba")
+ .withLogicalTypeAnnotation(
+ LogicalTypeAnnotation.timestampType(true,
LogicalTypeAnnotation.TimeUnit.NANOS)));
}
private static int calculatePrecision(int byteCnt) {