diff --git a/core/src/main/java/org/apache/iceberg/DefaultValueParser.java b/core/src/main/java/org/apache/iceberg/DefaultValueParser.java new file mode 100644 index 000000000000..1f693303c965 --- /dev/null +++ b/core/src/main/java/org/apache/iceberg/DefaultValueParser.java @@ -0,0 +1,412 @@ +/* + * 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.iceberg; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.JsonNode; +import java.io.IOException; +import java.io.StringWriter; +import java.math.BigDecimal; +import java.nio.ByteBuffer; +import java.util.Iterator; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.UUID; +import org.apache.iceberg.data.GenericRecord; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.relocated.com.google.common.collect.Iterables; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.relocated.com.google.common.io.BaseEncoding; +import org.apache.iceberg.types.Type; +import org.apache.iceberg.types.Types; +import org.apache.iceberg.util.ByteBuffers; +import org.apache.iceberg.util.DateTimeUtil; +import org.apache.iceberg.util.JsonUtil; + +public class DefaultValueParser { + private DefaultValueParser() {} + + private static final String KEYS = "keys"; + private static final String VALUES = "values"; + + public static Object fromJson(Type type, JsonNode defaultValue) { + if (defaultValue == null || defaultValue.isNull()) { + return null; + } + + switch (type.typeId()) { + case BOOLEAN: + Preconditions.checkArgument( + defaultValue.isBoolean(), "Cannot parse default as a %s value: %s", type, defaultValue); + return defaultValue.booleanValue(); + case INTEGER: + Preconditions.checkArgument( + defaultValue.isIntegralNumber() && defaultValue.canConvertToInt(), + "Cannot parse default as a %s value: %s", + type, + defaultValue); + return defaultValue.intValue(); + case LONG: + Preconditions.checkArgument( + defaultValue.isIntegralNumber() && defaultValue.canConvertToLong(), + "Cannot parse default as a %s value: %s", + type, + defaultValue); + return defaultValue.longValue(); + case FLOAT: + Preconditions.checkArgument( + defaultValue.isFloatingPointNumber(), + "Cannot parse default as a %s value: %s", + type, + defaultValue); + return defaultValue.floatValue(); + case DOUBLE: + Preconditions.checkArgument( + defaultValue.isFloatingPointNumber(), + "Cannot parse default as a %s value: %s", + type, + defaultValue); + return defaultValue.doubleValue(); + case DECIMAL: + Preconditions.checkArgument( + defaultValue.isTextual(), "Cannot parse default as a %s value: %s", type, defaultValue); + BigDecimal retDecimal; + try { + retDecimal = new BigDecimal(defaultValue.textValue()); + } catch (NumberFormatException e) { + throw new IllegalArgumentException( + String.format("Cannot parse default as a %s value: %s", type, defaultValue), e); + } + Preconditions.checkArgument( + retDecimal.scale() == ((Types.DecimalType) type).scale(), + "Cannot parse default as a %s value: %s, the scale doesn't match", + type, + defaultValue); + return retDecimal; + case STRING: + Preconditions.checkArgument( + defaultValue.isTextual(), "Cannot parse default as a %s value: %s", type, defaultValue); + return defaultValue.textValue(); + case UUID: + Preconditions.checkArgument( + defaultValue.isTextual() && defaultValue.textValue().length() == 36, + "Cannot parse default as a %s value: %s", + type, + defaultValue); + UUID uuid; + try { + uuid = UUID.fromString(defaultValue.textValue()); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException( + String.format("Cannot parse default as a %s value: %s", type, defaultValue), e); + } + return uuid; + case DATE: + Preconditions.checkArgument( + defaultValue.isTextual(), "Cannot parse default as a %s value: %s", type, defaultValue); + return DateTimeUtil.isoDateToDays(defaultValue.textValue()); + case TIME: + Preconditions.checkArgument( + defaultValue.isTextual(), "Cannot parse default as a %s value: %s", type, defaultValue); + return DateTimeUtil.isoTimeToMicros(defaultValue.textValue()); + case TIMESTAMP: + Preconditions.checkArgument( + defaultValue.isTextual(), "Cannot parse default as a %s value: %s", type, defaultValue); + if (((Types.TimestampType) type).shouldAdjustToUTC()) { + String timestampTz = defaultValue.textValue(); + Preconditions.checkArgument( + DateTimeUtil.isUTCTimestamptz(timestampTz), + "Cannot parse default as a %s value: %s, offset must be +00:00", + type, + defaultValue); + return DateTimeUtil.isoTimestamptzToMicros(timestampTz); + } else { + return DateTimeUtil.isoTimestampToMicros(defaultValue.textValue()); + } + case FIXED: + Preconditions.checkArgument( + defaultValue.isTextual(), "Cannot parse default as a %s value: %s", type, defaultValue); + int defaultLength = defaultValue.textValue().length(); + int fixedLength = ((Types.FixedType) type).length(); + Preconditions.checkArgument( + defaultLength == fixedLength * 2, + "Cannot parse default %s value: %s, incorrect length: %s", + type, + defaultValue, + defaultLength); + byte[] fixedBytes = + BaseEncoding.base16().decode(defaultValue.textValue().toUpperCase(Locale.ROOT)); + return ByteBuffer.wrap(fixedBytes); + case BINARY: + Preconditions.checkArgument( + defaultValue.isTextual(), "Cannot parse default as a %s value: %s", type, defaultValue); + byte[] binaryBytes = + BaseEncoding.base16().decode(defaultValue.textValue().toUpperCase(Locale.ROOT)); + return ByteBuffer.wrap(binaryBytes); + case LIST: + return listFromJson(type, defaultValue); + case MAP: + return mapFromJson(type, defaultValue); + case STRUCT: + return structFromJson(type, defaultValue); + default: + throw new UnsupportedOperationException(String.format("Type: %s is not supported", type)); + } + } + + private static StructLike structFromJson(Type type, JsonNode defaultValue) { + Preconditions.checkArgument( + defaultValue.isObject(), "Cannot parse default as a %s value: %s", type, defaultValue); + Types.StructType struct = type.asStructType(); + StructLike defaultRecord = GenericRecord.create(struct); + + List fields = struct.fields(); + for (int pos = 0; pos < fields.size(); pos += 1) { + Types.NestedField field = fields.get(pos); + String idString = String.valueOf(field.fieldId()); + if (defaultValue.has(idString)) { + defaultRecord.set(pos, fromJson(field.type(), defaultValue.get(idString))); + } + } + return defaultRecord; + } + + private static Map mapFromJson(Type type, JsonNode defaultValue) { + Preconditions.checkArgument( + defaultValue.isObject() + && defaultValue.has(KEYS) + && defaultValue.has(VALUES) + && defaultValue.get(KEYS).isArray() + && defaultValue.get(VALUES).isArray(), + "Cannot parse %s to a %s value", + defaultValue, + type); + JsonNode keys = defaultValue.get(KEYS); + JsonNode values = defaultValue.get(VALUES); + Preconditions.checkArgument( + keys.size() == values.size(), "Cannot parse default as a %s value: %s", type, defaultValue); + + ImmutableMap.Builder mapBuilder = ImmutableMap.builder(); + + Iterator keyIter = keys.iterator(); + Type keyType = type.asMapType().keyType(); + Iterator valueIter = values.iterator(); + Type valueType = type.asMapType().valueType(); + + while (keyIter.hasNext()) { + mapBuilder.put(fromJson(keyType, keyIter.next()), fromJson(valueType, valueIter.next())); + } + + return mapBuilder.build(); + } + + private static List listFromJson(Type type, JsonNode defaultValue) { + Preconditions.checkArgument( + defaultValue.isArray(), "Cannot parse default as a %s value: %s", type, defaultValue); + Type elementType = type.asListType().elementType(); + return Lists.newArrayList(Iterables.transform(defaultValue, e -> fromJson(elementType, e))); + } + + public static Object fromJson(Type type, String defaultValue) { + try { + JsonNode defaultValueJN = JsonUtil.mapper().readTree(defaultValue); + return fromJson(type, defaultValueJN); + } catch (IOException e) { + throw new IllegalArgumentException( + String.format("Failed to parse default as a %s value: %s", type, defaultValue), e); + } + } + + public static String toJson(Type type, Object defaultValue) { + return toJson(type, defaultValue, false); + } + + public static String toJson(Type type, Object defaultValue, boolean pretty) { + try { + StringWriter writer = new StringWriter(); + JsonGenerator generator = JsonUtil.factory().createGenerator(writer); + if (pretty) { + generator.useDefaultPrettyPrinter(); + } + toJson(type, defaultValue, generator); + generator.flush(); + return writer.toString(); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + @SuppressWarnings("checkstyle:MethodLength") + public static void toJson(Type type, Object defaultValue, JsonGenerator generator) + throws IOException { + if (defaultValue == null) { + generator.writeNull(); + return; + } + + switch (type.typeId()) { + case BOOLEAN: + Preconditions.checkArgument( + defaultValue instanceof Boolean, "Invalid default %s value: %s", type, defaultValue); + generator.writeBoolean((Boolean) defaultValue); + break; + case INTEGER: + Preconditions.checkArgument( + defaultValue instanceof Integer, "Invalid default %s value: %s", type, defaultValue); + generator.writeNumber((Integer) defaultValue); + break; + case LONG: + Preconditions.checkArgument( + defaultValue instanceof Long, "Invalid default %s value: %s", type, defaultValue); + generator.writeNumber((Long) defaultValue); + break; + case FLOAT: + Preconditions.checkArgument( + defaultValue instanceof Float, "Invalid default %s value: %s", type, defaultValue); + generator.writeNumber((Float) defaultValue); + break; + case DOUBLE: + Preconditions.checkArgument( + defaultValue instanceof Double, "Invalid default %s value: %s", type, defaultValue); + generator.writeNumber((Double) defaultValue); + break; + case DATE: + Preconditions.checkArgument( + defaultValue instanceof Integer, "Invalid default %s value: %s", type, defaultValue); + generator.writeString(DateTimeUtil.daysToIsoDate((Integer) defaultValue)); + break; + case TIME: + Preconditions.checkArgument( + defaultValue instanceof Long, "Invalid default %s value: %s", type, defaultValue); + generator.writeString(DateTimeUtil.microsToIsoTime((Long) defaultValue)); + break; + case TIMESTAMP: + Preconditions.checkArgument( + defaultValue instanceof Long, "Invalid default %s value: %s", type, defaultValue); + if (((Types.TimestampType) type).shouldAdjustToUTC()) { + generator.writeString(DateTimeUtil.microsToIsoTimestamptz((Long) defaultValue)); + } else { + generator.writeString(DateTimeUtil.microsToIsoTimestamp((Long) defaultValue)); + } + break; + case STRING: + Preconditions.checkArgument( + defaultValue instanceof CharSequence, + "Invalid default %s value: %s", + type, + defaultValue); + generator.writeString(((CharSequence) defaultValue).toString()); + break; + case UUID: + Preconditions.checkArgument( + defaultValue instanceof UUID, "Invalid default %s value: %s", type, defaultValue); + generator.writeString(defaultValue.toString()); + break; + case FIXED: + Preconditions.checkArgument( + defaultValue instanceof ByteBuffer, "Invalid default %s value: %s", type, defaultValue); + ByteBuffer byteBufferValue = (ByteBuffer) defaultValue; + int expectedLength = ((Types.FixedType) type).length(); + Preconditions.checkArgument( + byteBufferValue.remaining() == expectedLength, + "Invalid default %s value, incorrect length: %s", + type, + byteBufferValue.remaining()); + generator.writeString( + BaseEncoding.base16().encode(ByteBuffers.toByteArray(byteBufferValue))); + break; + case BINARY: + Preconditions.checkArgument( + defaultValue instanceof ByteBuffer, "Invalid default %s value: %s", type, defaultValue); + generator.writeString( + BaseEncoding.base16().encode(ByteBuffers.toByteArray((ByteBuffer) defaultValue))); + break; + case DECIMAL: + Preconditions.checkArgument( + defaultValue instanceof BigDecimal + && ((BigDecimal) defaultValue).scale() == ((Types.DecimalType) type).scale(), + "Invalid default %s value: %s", + type, + defaultValue); + BigDecimal decimalValue = (BigDecimal) defaultValue; + if (decimalValue.scale() >= 0) { + generator.writeString(decimalValue.toPlainString()); + } else { + generator.writeString(decimalValue.toString()); + } + break; + case LIST: + Preconditions.checkArgument( + defaultValue instanceof List, "Invalid default %s value: %s", type, defaultValue); + List defaultList = (List) defaultValue; + Type elementType = type.asListType().elementType(); + generator.writeStartArray(); + for (Object element : defaultList) { + toJson(elementType, element, generator); + } + generator.writeEndArray(); + break; + case MAP: + Preconditions.checkArgument( + defaultValue instanceof Map, "Invalid default %s value: %s", type, defaultValue); + Map defaultMap = (Map) defaultValue; + Type keyType = type.asMapType().keyType(); + Type valueType = type.asMapType().valueType(); + + List valueList = Lists.newArrayListWithExpectedSize(defaultMap.size()); + generator.writeStartObject(); + generator.writeArrayFieldStart(KEYS); + for (Map.Entry entry : defaultMap.entrySet()) { + toJson(keyType, entry.getKey(), generator); + valueList.add(entry.getValue()); + } + generator.writeEndArray(); + generator.writeArrayFieldStart(VALUES); + for (Object value : valueList) { + toJson(valueType, value, generator); + } + generator.writeEndArray(); + generator.writeEndObject(); + break; + case STRUCT: + Preconditions.checkArgument( + defaultValue instanceof StructLike, "Invalid default %s value: %s", type, defaultValue); + Types.StructType structType = type.asStructType(); + List fields = structType.fields(); + StructLike defaultStruct = (StructLike) defaultValue; + + generator.writeStartObject(); + for (int i = 0; i < defaultStruct.size(); i++) { + Types.NestedField field = fields.get(i); + int fieldId = field.fieldId(); + Object fieldDefaultValue = defaultStruct.get(i, Object.class); + if (fieldDefaultValue != null) { + generator.writeFieldName(String.valueOf(fieldId)); + toJson(field.type(), fieldDefaultValue, generator); + } + } + generator.writeEndObject(); + break; + default: + throw new UnsupportedOperationException(String.format("Type: %s is not supported", type)); + } + } +} diff --git a/core/src/main/java/org/apache/iceberg/util/DateTimeUtil.java b/core/src/main/java/org/apache/iceberg/util/DateTimeUtil.java index a3d5b219de92..29214ff8831d 100644 --- a/core/src/main/java/org/apache/iceberg/util/DateTimeUtil.java +++ b/core/src/main/java/org/apache/iceberg/util/DateTimeUtil.java @@ -24,6 +24,8 @@ import java.time.LocalTime; import java.time.OffsetDateTime; import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeFormatterBuilder; import java.time.temporal.ChronoUnit; public class DateTimeUtil { @@ -83,4 +85,52 @@ public static long microsFromTimestamptz(OffsetDateTime dateTime) { public static String formatTimestampMillis(long millis) { return Instant.ofEpochMilli(millis).toString().replace("Z", "+00:00"); } + + public static String daysToIsoDate(int days) { + return dateFromDays(days).format(DateTimeFormatter.ISO_LOCAL_DATE); + } + + public static String microsToIsoTime(long micros) { + return timeFromMicros(micros).format(DateTimeFormatter.ISO_LOCAL_TIME); + } + + public static String microsToIsoTimestamptz(long micros) { + LocalDateTime localDateTime = timestampFromMicros(micros); + DateTimeFormatter zeroOffsetFormatter = + new DateTimeFormatterBuilder() + .parseCaseInsensitive() + .append(DateTimeFormatter.ISO_LOCAL_DATE_TIME) + .appendOffset("+HH:MM:ss", "+00:00") + .toFormatter(); + return localDateTime.atOffset(ZoneOffset.UTC).format(zeroOffsetFormatter); + } + + public static String microsToIsoTimestamp(long micros) { + LocalDateTime localDateTime = timestampFromMicros(micros); + return localDateTime.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME); + } + + public static int isoDateToDays(String dateString) { + return daysFromDate(LocalDate.parse(dateString, DateTimeFormatter.ISO_LOCAL_DATE)); + } + + public static long isoTimeToMicros(String timeString) { + return microsFromTime(LocalTime.parse(timeString, DateTimeFormatter.ISO_LOCAL_TIME)); + } + + public static long isoTimestamptzToMicros(String timestampString) { + return microsFromTimestamptz( + OffsetDateTime.parse(timestampString, DateTimeFormatter.ISO_DATE_TIME)); + } + + public static boolean isUTCTimestamptz(String timestampString) { + OffsetDateTime offsetDateTime = + OffsetDateTime.parse(timestampString, DateTimeFormatter.ISO_DATE_TIME); + return offsetDateTime.getOffset().equals(ZoneOffset.UTC); + } + + public static long isoTimestampToMicros(String timestampString) { + return microsFromTimestamp( + LocalDateTime.parse(timestampString, DateTimeFormatter.ISO_LOCAL_DATE_TIME)); + } } diff --git a/core/src/test/java/org/apache/iceberg/TestDefaultValueParser.java b/core/src/test/java/org/apache/iceberg/TestDefaultValueParser.java new file mode 100644 index 000000000000..eacc8c2fee56 --- /dev/null +++ b/core/src/test/java/org/apache/iceberg/TestDefaultValueParser.java @@ -0,0 +1,181 @@ +/* + * 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.iceberg; + +import static org.apache.iceberg.types.Types.NestedField.optional; +import static org.apache.iceberg.types.Types.NestedField.required; + +import java.io.IOException; +import java.util.Locale; +import org.apache.iceberg.types.Type; +import org.apache.iceberg.types.Types; +import org.apache.iceberg.util.JsonUtil; +import org.junit.Assert; +import org.junit.Test; + +public class TestDefaultValueParser { + + @Test + public void testValidDefaults() throws IOException { + Object[][] typesWithDefaults = + new Object[][] { + {Types.BooleanType.get(), "null"}, + {Types.BooleanType.get(), "true"}, + {Types.IntegerType.get(), "1"}, + {Types.LongType.get(), "9999999"}, + {Types.FloatType.get(), "1.23"}, + {Types.DoubleType.get(), "123.456"}, + {Types.DateType.get(), "\"2007-12-03\""}, + {Types.TimeType.get(), "\"10:15:30\""}, + {Types.TimestampType.withoutZone(), "\"2007-12-03T10:15:30\""}, + {Types.TimestampType.withZone(), "\"2007-12-03T10:15:30+00:00\""}, + {Types.StringType.get(), "\"foo\""}, + {Types.UUIDType.get(), "\"eb26bdb1-a1d8-4aa6-990e-da940875492c\""}, + {Types.FixedType.ofLength(2), "\"111f\""}, + {Types.BinaryType.get(), "\"0000ff\""}, + {Types.DecimalType.of(9, 4), "\"123.4500\""}, + {Types.DecimalType.of(9, 0), "\"2\""}, + {Types.DecimalType.of(9, -20), "\"2E+20\""}, + {Types.ListType.ofOptional(1, Types.IntegerType.get()), "[1, 2, 3]"}, + { + Types.MapType.ofOptional(2, 3, Types.IntegerType.get(), Types.StringType.get()), + "{\"keys\": [1, 2], \"values\": [\"foo\", \"bar\"]}" + }, + { + Types.StructType.of( + required(4, "f1", Types.IntegerType.get()), + optional(5, "f2", Types.StringType.get())), + "{\"4\": 1, \"5\": \"bar\"}" + }, + // deeply nested complex types + { + Types.ListType.ofOptional( + 6, + Types.StructType.of( + required(7, "f1", Types.IntegerType.get()), + optional(8, "f2", Types.StringType.get()))), + "[{\"7\": 1, \"8\": \"bar\"}, {\"7\": 2, \"8\": " + "\"foo\"}]" + }, + { + Types.MapType.ofOptional( + 9, + 10, + Types.IntegerType.get(), + Types.StructType.of( + required(11, "f1", Types.IntegerType.get()), + optional(12, "f2", Types.StringType.get()))), + "{\"keys\": [1, 2], \"values\": [{\"11\": 1, \"12\": \"bar\"}, {\"11\": 2, \"12\": \"foo\"}]}" + }, + { + Types.StructType.of( + required( + 13, + "f1", + Types.StructType.of( + optional(14, "ff1", Types.IntegerType.get()), + optional(15, "ff2", Types.StringType.get()))), + optional( + 16, + "f2", + Types.StructType.of( + optional(17, "ff1", Types.StringType.get()), + optional(18, "ff2", Types.IntegerType.get())))), + "{\"13\": {\"14\": 1, \"15\": \"bar\"}, \"16\": {\"17\": \"bar\", \"18\": 1}}" + }, + }; + + for (Object[] typeWithDefault : typesWithDefaults) { + Type type = (Type) typeWithDefault[0]; + String defaultValue = (String) typeWithDefault[1]; + + String roundTripDefaultValue = defaultValueParseAndUnParseRoundTrip(type, defaultValue); + jsonStringEquals( + defaultValue.toLowerCase(Locale.ROOT), roundTripDefaultValue.toLowerCase(Locale.ROOT)); + } + } + + @Test + public void testInvalidFixed() { + Type expectedType = Types.FixedType.ofLength(2); + String defaultJson = "\"111ff\""; + Exception exception = + Assert.assertThrows( + IllegalArgumentException.class, + () -> defaultValueParseAndUnParseRoundTrip(expectedType, defaultJson)); + Assert.assertTrue(exception.getMessage().startsWith("Cannot parse default fixed[2] value")); + } + + @Test + public void testInvalidUUID() { + Type expectedType = Types.UUIDType.get(); + String defaultJson = "\"eb26bdb1-a1d8-4aa6-990e-da940875492c-abcde\""; + Exception exception = + Assert.assertThrows( + IllegalArgumentException.class, + () -> defaultValueParseAndUnParseRoundTrip(expectedType, defaultJson)); + Assert.assertTrue(exception.getMessage().startsWith("Cannot parse default as a uuid value")); + } + + @Test + public void testInvalidMap() { + Type expectedType = + Types.MapType.ofOptional(1, 2, Types.IntegerType.get(), Types.StringType.get()); + String defaultJson = "{\"keys\": [1, 2, 3], \"values\": [\"foo\", \"bar\"]}"; + Exception exception = + Assert.assertThrows( + IllegalArgumentException.class, + () -> defaultValueParseAndUnParseRoundTrip(expectedType, defaultJson)); + Assert.assertTrue( + exception.getMessage().startsWith("Cannot parse default as a map value")); + } + + @Test + public void testInvalidDecimal() { + Type expectedType = Types.DecimalType.of(5, 2); + String defaultJson = "123.456"; + Exception exception = + Assert.assertThrows( + IllegalArgumentException.class, + () -> defaultValueParseAndUnParseRoundTrip(expectedType, defaultJson)); + Assert.assertTrue( + exception.getMessage().startsWith("Cannot parse default as a decimal(5, 2) value")); + } + + @Test + public void testInvalidTimestamptz() { + Type expectedType = Types.TimestampType.withZone(); + String defaultJson = "\"2007-12-03T10:15:30+01:00\""; + Exception exception = + Assert.assertThrows( + IllegalArgumentException.class, + () -> defaultValueParseAndUnParseRoundTrip(expectedType, defaultJson)); + Assert.assertTrue( + exception.getMessage().startsWith("Cannot parse default as a timestamptz value")); + } + + // serialize to json and deserialize back should return the same result + private static String defaultValueParseAndUnParseRoundTrip(Type type, String defaultValue) { + Object javaDefaultValue = DefaultValueParser.fromJson(type, defaultValue); + return DefaultValueParser.toJson(type, javaDefaultValue); + } + + private static void jsonStringEquals(String s1, String s2) throws IOException { + Assert.assertEquals(JsonUtil.mapper().readTree(s1), JsonUtil.mapper().readTree(s2)); + } +} diff --git a/format/spec.md b/format/spec.md index c5d312865ea7..2ce1e6c9b513 100644 --- a/format/spec.md +++ b/format/spec.md @@ -1162,15 +1162,15 @@ This serialization scheme is for storing single values as individual binary valu | **`long`** | **`JSON long`** | `34` | | | **`float`** | **`JSON number`** | `1.0` | | | **`double`** | **`JSON number`** | `1.0` | | -| **`decimal(P,S)`** | **`JSON number`** | `14.20` | Stores the decimal as a number with S places after the decimal | +| **`decimal(P,S)`** | **`JSON string`** | `"14.20"`, `"2E+20"` | Stores the string representation of the decimal value, specifically, for values with a positive scale, the number of digits to the right of the decimal point is used to indicate scale, for values with a negative scale, the scientific notation is used and the exponent must equal the negated scale | | **`date`** | **`JSON string`** | `"2017-11-16"` | Stores ISO-8601 standard date | | **`time`** | **`JSON string`** | `"22:31:08.123456"` | Stores ISO-8601 standard time with microsecond precision | | **`timestamp`** | **`JSON string`** | `"2017-11-16T22:31:08.123456"` | Stores ISO-8601 standard timestamp with microsecond precision; must not include a zone offset | -| **`timestamptz`** | **`JSON string`** | `"2017-11-16T22:31:08.123456-07:00"` | Stores ISO-8601 standard timestamp with microsecond precision; must include a zone offset | +| **`timestamptz`** | **`JSON string`** | `"2017-11-16T22:31:08.123456+00:00"` | Stores ISO-8601 standard timestamp with microsecond precision; must include a zone offset and it must be '+00:00' | | **`string`** | **`JSON string`** | `"iceberg"` | | | **`uuid`** | **`JSON string`** | `"f79c3e09-677c-4bbd-a479-3f349cb785e7"` | Stores the lowercase uuid string | -| **`fixed(L)`** | **`JSON string`** | `"0x00010203"` | Stored as a hexadecimal string, prefixed by `0x` | -| **`binary`** | **`JSON string`** | `"0x00010203"` | Stored as a hexadecimal string, prefixed by `0x` | +| **`fixed(L)`** | **`JSON string`** | `"000102ff"` | Stored as a hexadecimal string | +| **`binary`** | **`JSON string`** | `"000102ff"` | Stored as a hexadecimal string | | **`struct`** | **`JSON object by field ID`** | `{"1": 1, "2": "bar"}` | Stores struct fields using the field ID as the JSON field name; field values are stored using this JSON single-value format | | **`list`** | **`JSON array of values`** | `[1, 2, 3]` | Stores a JSON array of values that are serialized using this JSON single-value format | | **`map`** | **`JSON object of key and value arrays`** | `{ "keys": ["a", "b"], "values": [1, 2] }` | Stores arrays of keys and values; individual keys and values are serialized using this JSON single-value format |