This is an automated email from the ASF dual-hosted git repository.
gangwu pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/iceberg-cpp.git
The following commit(s) were added to refs/heads/main by this push:
new 8b2cc7f5 feat: bind literals with right type after serde (#562)
8b2cc7f5 is described below
commit 8b2cc7f55468538108eac48e4fc85d869b174f10
Author: Innocent Djiofack <[email protected]>
AuthorDate: Wed Mar 18 07:30:55 2026 -0700
feat: bind literals with right type after serde (#562)
---
src/iceberg/CMakeLists.txt | 1 +
src/iceberg/expression/json_serde.cc | 149 +++++++++++++++++++++-
src/iceberg/expression/literal.cc | 54 +++++++-
src/iceberg/meson.build | 1 +
src/iceberg/test/expression_json_test.cc | 127 +++++++++++++++++++
src/iceberg/test/literal_test.cc | 31 +++++
src/iceberg/test/transform_util_test.cc | 210 +++++++++++++++++++++++++++++++
src/iceberg/util/string_util.cc | 46 +++++++
src/iceberg/util/string_util.h | 9 ++
src/iceberg/util/transform_util.cc | 142 +++++++++++++++++++++
src/iceberg/util/transform_util.h | 39 ++++++
11 files changed, 799 insertions(+), 10 deletions(-)
diff --git a/src/iceberg/CMakeLists.txt b/src/iceberg/CMakeLists.txt
index 21e87bee..ada9b473 100644
--- a/src/iceberg/CMakeLists.txt
+++ b/src/iceberg/CMakeLists.txt
@@ -108,6 +108,7 @@ set(ICEBERG_SOURCES
util/murmurhash3_internal.cc
util/property_util.cc
util/snapshot_util.cc
+ util/string_util.cc
util/temporal_util.cc
util/timepoint.cc
util/transform_util.cc
diff --git a/src/iceberg/expression/json_serde.cc
b/src/iceberg/expression/json_serde.cc
index 9aea284d..38e7a8e2 100644
--- a/src/iceberg/expression/json_serde.cc
+++ b/src/iceberg/expression/json_serde.cc
@@ -17,6 +17,7 @@
* under the License.
*/
+#include <limits>
#include <string>
#include <vector>
@@ -298,10 +299,150 @@ Result<nlohmann::json> ToJson(const Literal& literal) {
}
}
-Result<Literal> LiteralFromJson(const nlohmann::json& json, const Type*
/*type*/) {
- // TODO(gangwu): implement type-aware literal parsing equivalent to Java's
- // SingleValueParser.fromJson(type, node).
- return LiteralFromJson(json);
+Result<Literal> LiteralFromJson(const nlohmann::json& json, const Type* type) {
+ // If {"type": "literal", "value": <actual>} wrapper is present, unwrap it
first.
+ if (json.is_object() && json.contains(kType) &&
+ json[kType].get<std::string>() == kLiteral && json.contains(kValue)) {
+ return LiteralFromJson(json[kValue], type);
+ }
+ // If no type context is provided, fall back to untyped parsing.
+ if (type == nullptr) return LiteralFromJson(json);
+
+ // Type-aware parsing equivalent to Java's SingleValueParser.fromJson(type,
node).
+ switch (type->type_id()) {
+ case TypeId::kBoolean:
+ if (!json.is_boolean()) [[unlikely]] {
+ return JsonParseError("Cannot parse {} as a boolean value",
SafeDumpJson(json));
+ }
+ return Literal::Boolean(json.get<bool>());
+
+ case TypeId::kInt: {
+ if (!json.is_number_integer()) [[unlikely]] {
+ return JsonParseError("Cannot parse {} as an int value",
SafeDumpJson(json));
+ }
+ auto val = json.get<int64_t>();
+ if (val < std::numeric_limits<int32_t>::min() ||
+ val > std::numeric_limits<int32_t>::max()) [[unlikely]] {
+ return JsonParseError("Cannot parse {} as an int value: out of range",
+ SafeDumpJson(json));
+ }
+ return Literal::Int(static_cast<int32_t>(val));
+ }
+
+ case TypeId::kLong:
+ if (!json.is_number_integer()) [[unlikely]] {
+ return JsonParseError("Cannot parse {} as a long value",
SafeDumpJson(json));
+ }
+ return Literal::Long(json.get<int64_t>());
+
+ case TypeId::kFloat:
+ if (!json.is_number_float()) [[unlikely]] {
+ return JsonParseError("Cannot parse {} as a float value",
SafeDumpJson(json));
+ }
+ return Literal::Float(json.get<float>());
+
+ case TypeId::kDouble:
+ if (!json.is_number_float()) [[unlikely]] {
+ return JsonParseError("Cannot parse {} as a double value",
SafeDumpJson(json));
+ }
+ return Literal::Double(json.get<double>());
+
+ case TypeId::kString:
+ if (!json.is_string()) [[unlikely]] {
+ return JsonParseError("Cannot parse {} as a string value",
SafeDumpJson(json));
+ }
+ return Literal::String(json.get<std::string>());
+
+ case TypeId::kDate: {
+ if (!json.is_string()) [[unlikely]] {
+ return JsonParseError("Cannot parse {} as a date value",
SafeDumpJson(json));
+ }
+ ICEBERG_ASSIGN_OR_RAISE(auto days,
+
TransformUtil::ParseDay(json.get<std::string>()));
+ return Literal::Date(days);
+ }
+
+ case TypeId::kTime: {
+ if (!json.is_string()) [[unlikely]] {
+ return JsonParseError("Cannot parse {} as a time value",
SafeDumpJson(json));
+ }
+ ICEBERG_ASSIGN_OR_RAISE(auto micros,
+
TransformUtil::ParseTime(json.get<std::string>()));
+ return Literal::Time(micros);
+ }
+
+ case TypeId::kTimestamp: {
+ if (!json.is_string()) [[unlikely]] {
+ return JsonParseError("Cannot parse {} as a timestamp value",
SafeDumpJson(json));
+ }
+ ICEBERG_ASSIGN_OR_RAISE(auto micros,
+
TransformUtil::ParseTimestamp(json.get<std::string>()));
+ return Literal::Timestamp(micros);
+ }
+
+ case TypeId::kTimestampTz: {
+ if (!json.is_string()) [[unlikely]] {
+ return JsonParseError("Cannot parse {} as a timestamptz value",
+ SafeDumpJson(json));
+ }
+ ICEBERG_ASSIGN_OR_RAISE(
+ auto micros,
TransformUtil::ParseTimestampWithZone(json.get<std::string>()));
+ return Literal::TimestampTz(micros);
+ }
+
+ case TypeId::kUuid: {
+ if (!json.is_string()) [[unlikely]] {
+ return JsonParseError("Cannot parse {} as a uuid value",
SafeDumpJson(json));
+ }
+ ICEBERG_ASSIGN_OR_RAISE(auto uuid,
Uuid::FromString(json.get<std::string>()));
+ return Literal::UUID(uuid);
+ }
+
+ case TypeId::kBinary: {
+ if (!json.is_string()) [[unlikely]] {
+ return JsonParseError("Cannot parse {} as a binary value",
SafeDumpJson(json));
+ }
+ ICEBERG_ASSIGN_OR_RAISE(auto bytes,
+
StringUtils::HexStringToBytes(json.get<std::string>()));
+ return Literal::Binary(std::move(bytes));
+ }
+
+ case TypeId::kFixed: {
+ if (!json.is_string()) [[unlikely]] {
+ return JsonParseError("Cannot parse {} as a fixed value",
SafeDumpJson(json));
+ }
+ const auto& fixed_type = internal::checked_cast<const FixedType&>(*type);
+ const std::string& hex = json.get<std::string>();
+ if (hex.size() != static_cast<size_t>(fixed_type.length()) * 2)
[[unlikely]] {
+ return JsonParseError("Cannot parse fixed[{}]: expected {} hex chars,
got {}",
+ fixed_type.length(), fixed_type.length() * 2,
hex.size());
+ }
+ ICEBERG_ASSIGN_OR_RAISE(auto bytes, StringUtils::HexStringToBytes(hex));
+ return Literal::Fixed(std::move(bytes));
+ }
+
+ case TypeId::kDecimal: {
+ if (!json.is_string()) [[unlikely]] {
+ return JsonParseError("Cannot parse {} as a decimal value",
SafeDumpJson(json));
+ }
+ const auto& dec_type = internal::checked_cast<const DecimalType&>(*type);
+ int32_t parsed_precision = 0;
+ int32_t parsed_scale = 0;
+ ICEBERG_ASSIGN_OR_RAISE(
+ auto dec,
+ Decimal::FromString(json.get<std::string>(), &parsed_precision,
&parsed_scale));
+ if (parsed_precision > dec_type.precision() || parsed_scale !=
dec_type.scale())
+ [[unlikely]] {
+ return JsonParseError("Cannot parse {} as a {} value",
SafeDumpJson(json),
+ type->ToString());
+ }
+ return Literal::Decimal(dec.value(), dec_type.precision(),
dec_type.scale());
+ }
+
+ default:
+ return NotSupported("Unsupported type for literal JSON parsing: {}",
+ type->ToString());
+ }
}
Result<Literal> LiteralFromJson(const nlohmann::json& json) {
diff --git a/src/iceberg/expression/literal.cc
b/src/iceberg/expression/literal.cc
index 88bafd78..9b8060a1 100644
--- a/src/iceberg/expression/literal.cc
+++ b/src/iceberg/expression/literal.cc
@@ -23,11 +23,16 @@
#include <concepts>
#include <cstdint>
#include <string>
+#include <vector>
+#include "iceberg/type.h"
#include "iceberg/util/checked_cast.h"
#include "iceberg/util/conversions.h"
+#include "iceberg/util/decimal.h"
#include "iceberg/util/macros.h"
+#include "iceberg/util/string_util.h"
#include "iceberg/util/temporal_util.h"
+#include "iceberg/util/transform_util.h"
namespace iceberg {
@@ -193,12 +198,49 @@ Result<Literal> LiteralCaster::CastFromString(
ICEBERG_ASSIGN_OR_RAISE(auto uuid, Uuid::FromString(str_val));
return Literal::UUID(uuid);
}
- case TypeId::kDate:
- case TypeId::kTime:
- case TypeId::kTimestamp:
- case TypeId::kTimestampTz:
- return NotImplemented("Cast from String to {} is not implemented yet",
- target_type->ToString());
+ case TypeId::kDate: {
+ ICEBERG_ASSIGN_OR_RAISE(auto days, TransformUtil::ParseDay(str_val));
+ return Literal::Date(days);
+ }
+ case TypeId::kTime: {
+ ICEBERG_ASSIGN_OR_RAISE(auto micros, TransformUtil::ParseTime(str_val));
+ return Literal::Time(micros);
+ }
+ case TypeId::kTimestamp: {
+ ICEBERG_ASSIGN_OR_RAISE(auto micros,
TransformUtil::ParseTimestamp(str_val));
+ return Literal::Timestamp(micros);
+ }
+ case TypeId::kTimestampTz: {
+ ICEBERG_ASSIGN_OR_RAISE(auto micros,
+ TransformUtil::ParseTimestampWithZone(str_val));
+ return Literal::TimestampTz(micros);
+ }
+ case TypeId::kBinary: {
+ ICEBERG_ASSIGN_OR_RAISE(auto bytes,
StringUtils::HexStringToBytes(str_val));
+ return Literal::Binary(std::move(bytes));
+ }
+ case TypeId::kFixed: {
+ const auto& fixed_type = internal::checked_cast<const
FixedType&>(*target_type);
+ if (str_val.size() != static_cast<size_t>(fixed_type.length()) * 2) {
+ return InvalidArgument("Cannot cast string to {}: expected {} hex
chars, got {}",
+ target_type->ToString(), fixed_type.length() *
2,
+ str_val.size());
+ }
+ ICEBERG_ASSIGN_OR_RAISE(auto bytes,
StringUtils::HexStringToBytes(str_val));
+ return Literal::Fixed(std::move(bytes));
+ }
+ case TypeId::kDecimal: {
+ const auto& dec_type = internal::checked_cast<const
DecimalType&>(*target_type);
+ int32_t parsed_precision = 0;
+ int32_t parsed_scale = 0;
+ ICEBERG_ASSIGN_OR_RAISE(
+ auto dec, Decimal::FromString(str_val, &parsed_precision,
&parsed_scale));
+ if (parsed_precision > dec_type.precision() || parsed_scale !=
dec_type.scale()) {
+ return InvalidArgument("Cannot cast {} as a {} value", str_val,
+ target_type->ToString());
+ }
+ return Literal::Decimal(dec.value(), dec_type.precision(),
dec_type.scale());
+ }
default:
return NotSupported("Cast from String to {} is not supported",
target_type->ToString());
diff --git a/src/iceberg/meson.build b/src/iceberg/meson.build
index bfc502fd..81af8dc3 100644
--- a/src/iceberg/meson.build
+++ b/src/iceberg/meson.build
@@ -126,6 +126,7 @@ iceberg_sources = files(
'util/murmurhash3_internal.cc',
'util/property_util.cc',
'util/snapshot_util.cc',
+ 'util/string_util.cc',
'util/temporal_util.cc',
'util/timepoint.cc',
'util/transform_util.cc',
diff --git a/src/iceberg/test/expression_json_test.cc
b/src/iceberg/test/expression_json_test.cc
index 8a146b12..7b978ef7 100644
--- a/src/iceberg/test/expression_json_test.cc
+++ b/src/iceberg/test/expression_json_test.cc
@@ -18,6 +18,7 @@
*/
#include <memory>
+#include <optional>
#include <string>
#include <vector>
@@ -31,6 +32,7 @@
#include "iceberg/expression/literal.h"
#include "iceberg/expression/predicate.h"
#include "iceberg/schema.h"
+#include "iceberg/schema_field.h"
#include "iceberg/test/matchers.h"
#include "iceberg/type.h"
@@ -405,4 +407,129 @@ INSTANTIATE_TEST_SUITE_P(
return info.param.name;
});
+// --- LiteralFromJson(json, type) type-aware tests ---
+
+struct LiteralFromJsonTypedParam {
+ std::string name;
+ nlohmann::json json;
+ std::shared_ptr<Type> type;
+ TypeId expected_type_id;
+ std::optional<std::string> expected_str;
+};
+
+class LiteralFromJsonTypedTest
+ : public ::testing::TestWithParam<LiteralFromJsonTypedParam> {};
+
+TEST_P(LiteralFromJsonTypedTest, Parses) {
+ const auto& p = GetParam();
+ ICEBERG_UNWRAP_OR_FAIL(auto lit, LiteralFromJson(p.json, p.type.get()));
+ EXPECT_EQ(lit.type()->type_id(), p.expected_type_id);
+ if (p.expected_str) {
+ EXPECT_EQ(lit.ToString(), *p.expected_str);
+ }
+}
+
+INSTANTIATE_TEST_SUITE_P(
+ LiteralFromJsonTyped, LiteralFromJsonTypedTest,
+ ::testing::Values(
+ LiteralFromJsonTypedParam{"Boolean", nlohmann::json(true), boolean(),
+ TypeId::kBoolean, "true"},
+ LiteralFromJsonTypedParam{"Int", nlohmann::json(123), int32(),
TypeId::kInt,
+ "123"},
+ LiteralFromJsonTypedParam{"Long", nlohmann::json(9876543210LL),
int64(),
+ TypeId::kLong, "9876543210"},
+ LiteralFromJsonTypedParam{"Float", nlohmann::json(1.5), float32(),
TypeId::kFloat,
+ std::nullopt},
+ LiteralFromJsonTypedParam{"Double", nlohmann::json(3.14), float64(),
+ TypeId::kDouble, std::nullopt},
+ LiteralFromJsonTypedParam{"String", nlohmann::json("hello"), string(),
+ TypeId::kString, std::nullopt},
+ LiteralFromJsonTypedParam{"DateString", nlohmann::json("2024-01-15"),
date(),
+ TypeId::kDate, std::nullopt},
+ LiteralFromJsonTypedParam{"Uuid",
+
nlohmann::json("f79c3e09-677c-4bbd-a479-3f349cb785e7"),
+ uuid(), TypeId::kUuid, std::nullopt},
+ LiteralFromJsonTypedParam{"Binary", nlohmann::json("deadbeef"),
binary(),
+ TypeId::kBinary, std::nullopt},
+ LiteralFromJsonTypedParam{"Fixed", nlohmann::json("cafebabe"),
fixed(4),
+ TypeId::kFixed, std::nullopt},
+ LiteralFromJsonTypedParam{"DecimalMatchingScale",
nlohmann::json("123.4500"),
+ decimal(9, 4), TypeId::kDecimal, "123.4500"},
+ LiteralFromJsonTypedParam{"DecimalScaleZero", nlohmann::json("2"),
decimal(9, 0),
+ TypeId::kDecimal, "2"}),
+ [](const ::testing::TestParamInfo<LiteralFromJsonTypedParam>& info) {
+ return info.param.name;
+ });
+
+struct InvalidLiteralFromJsonTypedParam {
+ std::string name;
+ nlohmann::json json;
+ std::shared_ptr<Type> type;
+};
+
+class InvalidLiteralFromJsonTypedTest
+ : public ::testing::TestWithParam<InvalidLiteralFromJsonTypedParam> {};
+
+TEST_P(InvalidLiteralFromJsonTypedTest, ReturnsError) {
+ const auto& p = GetParam();
+ EXPECT_FALSE(LiteralFromJson(p.json, p.type.get()).has_value());
+}
+
+INSTANTIATE_TEST_SUITE_P(
+ LiteralFromJsonTyped, InvalidLiteralFromJsonTypedTest,
+ ::testing::Values(
+ InvalidLiteralFromJsonTypedParam{"BooleanTypeMismatch",
nlohmann::json(42),
+ boolean()},
+ InvalidLiteralFromJsonTypedParam{"DateTypeMismatch",
nlohmann::json(true),
+ date()},
+ InvalidLiteralFromJsonTypedParam{"UuidTypeMismatch",
nlohmann::json(42), uuid()},
+ InvalidLiteralFromJsonTypedParam{"BinaryInvalidHex",
nlohmann::json("xyz"),
+ binary()},
+ InvalidLiteralFromJsonTypedParam{"FixedLengthMismatch",
nlohmann::json("cafe12"),
+ fixed(4)},
+ InvalidLiteralFromJsonTypedParam{"DecimalScaleMismatch",
nlohmann::json("123.45"),
+ decimal(9, 4)},
+ InvalidLiteralFromJsonTypedParam{"DecimalNotString",
nlohmann::json(123.45),
+ decimal(9, 2)}),
+ [](const ::testing::TestParamInfo<InvalidLiteralFromJsonTypedParam>& info)
{
+ return info.param.name;
+ });
+
+struct SchemaAwarePredicateParam {
+ std::string name;
+ std::string field_name;
+ std::shared_ptr<Type> field_type;
+ nlohmann::json value;
+};
+
+class SchemaAwarePredicateRoundTripTest
+ : public ::testing::TestWithParam<SchemaAwarePredicateParam> {};
+
+TEST_P(SchemaAwarePredicateRoundTripTest, RoundTrip) {
+ const auto& p = GetParam();
+ auto schema = std::make_shared<Schema>(
+ std::vector<SchemaField>{SchemaField::MakeOptional(1, p.field_name,
p.field_type)});
+ nlohmann::json pred_json = {{"type", "eq"}, {"term", p.field_name},
{"value", p.value}};
+ ICEBERG_UNWRAP_OR_FAIL(auto expr, ExpressionFromJson(pred_json,
schema.get()));
+ ASSERT_NE(expr, nullptr);
+}
+
+INSTANTIATE_TEST_SUITE_P(
+ LiteralFromJsonTyped, SchemaAwarePredicateRoundTripTest,
+ ::testing::Values(
+ SchemaAwarePredicateParam{"Date", "event_date", date(), "2024-01-15"},
+ SchemaAwarePredicateParam{"Time", "event_time", time(), "14:30:00"},
+ SchemaAwarePredicateParam{"Timestamp", "created_at", timestamp(),
+ "2026-01-01T00:00:01.500"},
+ SchemaAwarePredicateParam{"TimestampTz", "updated_at", timestamp_tz(),
+ "2026-01-01T00:00:01.500+00:00"},
+ SchemaAwarePredicateParam{"Uuid", "trace_id", uuid(),
+ "f79c3e09-677c-4bbd-a479-3f349cb785e7"},
+ SchemaAwarePredicateParam{"Binary", "payload", binary(), "deadbeef"},
+ SchemaAwarePredicateParam{"Fixed", "hash", fixed(4), "cafebabe"},
+ SchemaAwarePredicateParam{"Decimal", "amount", decimal(9, 2),
"123.45"}),
+ [](const ::testing::TestParamInfo<SchemaAwarePredicateParam>& info) {
+ return info.param.name;
+ });
+
} // namespace iceberg
diff --git a/src/iceberg/test/literal_test.cc b/src/iceberg/test/literal_test.cc
index 01a7a7ce..97724aad 100644
--- a/src/iceberg/test/literal_test.cc
+++ b/src/iceberg/test/literal_test.cc
@@ -787,6 +787,37 @@ INSTANTIATE_TEST_SUITE_P(
.target_type = uuid(),
.expected_literal = Literal::UUID(
Uuid::FromString("123e4567-e89b-12d3-a456-426614174000").value())},
+ CastLiteralTestParam{.test_name = "StringToDate",
+ .source_literal = Literal::String("2024-01-16"),
+ .target_type = date(),
+ .expected_literal = Literal::Date(19738)},
+ CastLiteralTestParam{.test_name = "StringToTime",
+ .source_literal = Literal::String("14:30"),
+ .target_type = time(),
+ .expected_literal = Literal::Time(52200000000LL)},
+ CastLiteralTestParam{.test_name = "StringToTimestamp",
+ .source_literal =
Literal::String("2026-01-01T00:00:01.500"),
+ .target_type = timestamp(),
+ .expected_literal =
Literal::Timestamp(1767225601500000L)},
+ CastLiteralTestParam{
+ .test_name = "StringToTimestampTz",
+ .source_literal = Literal::String("2026-01-01T00:00:01.500+00:00"),
+ .target_type = timestamp_tz(),
+ .expected_literal = Literal::TimestampTz(1767225601500000L)},
+ CastLiteralTestParam{.test_name = "StringToBinary",
+ .source_literal = Literal::String("010203FF"),
+ .target_type = binary(),
+ .expected_literal =
Literal::Binary(std::vector<uint8_t>{
+ 0x01, 0x02, 0x03, 0xFF})},
+ CastLiteralTestParam{.test_name = "StringToFixed",
+ .source_literal = Literal::String("01020304"),
+ .target_type = fixed(4),
+ .expected_literal =
Literal::Fixed(std::vector<uint8_t>{
+ 0x01, 0x02, 0x03, 0x04})},
+ CastLiteralTestParam{.test_name = "StringToDecimal",
+ .source_literal = Literal::String("1234.56"),
+ .target_type = decimal(6, 2),
+ .expected_literal = Literal::Decimal(123456, 6,
2)},
// Same type cast test
CastLiteralTestParam{.test_name = "IntToInt",
.source_literal = Literal::Int(42),
diff --git a/src/iceberg/test/transform_util_test.cc
b/src/iceberg/test/transform_util_test.cc
index 76f6824b..54f36cd0 100644
--- a/src/iceberg/test/transform_util_test.cc
+++ b/src/iceberg/test/transform_util_test.cc
@@ -21,6 +21,8 @@
#include <gtest/gtest.h>
+#include "iceberg/test/matchers.h"
+
namespace iceberg {
TEST(TransformUtilTest, HumanYear) {
@@ -157,4 +159,212 @@ TEST(TransformUtilTest, Base64Encode) {
EXPECT_EQ("AA==", TransformUtil::Base64Encode({"\x00", 1}));
}
+struct ParseRoundTripParam {
+ std::string name;
+ std::string str;
+ int64_t value;
+ enum Kind { kDay, kTime, kTimestamp, kTimestampTz } kind;
+};
+
+class ParseRoundTripTest : public
::testing::TestWithParam<ParseRoundTripParam> {};
+
+TEST_P(ParseRoundTripTest, RoundTrip) {
+ const auto& param = GetParam();
+ switch (param.kind) {
+ case ParseRoundTripParam::kDay: {
+ EXPECT_EQ(TransformUtil::HumanDay(static_cast<int32_t>(param.value)),
param.str);
+ ICEBERG_UNWRAP_OR_FAIL(auto parsed, TransformUtil::ParseDay(param.str));
+ EXPECT_EQ(parsed, static_cast<int32_t>(param.value));
+ break;
+ }
+ case ParseRoundTripParam::kTime: {
+ EXPECT_EQ(TransformUtil::HumanTime(param.value), param.str);
+ ICEBERG_UNWRAP_OR_FAIL(auto parsed, TransformUtil::ParseTime(param.str));
+ EXPECT_EQ(parsed, param.value);
+ break;
+ }
+ case ParseRoundTripParam::kTimestamp: {
+ EXPECT_EQ(TransformUtil::HumanTimestamp(param.value), param.str);
+ ICEBERG_UNWRAP_OR_FAIL(auto parsed,
TransformUtil::ParseTimestamp(param.str));
+ EXPECT_EQ(parsed, param.value);
+ break;
+ }
+ case ParseRoundTripParam::kTimestampTz: {
+ EXPECT_EQ(TransformUtil::HumanTimestampWithZone(param.value), param.str);
+ ICEBERG_UNWRAP_OR_FAIL(auto parsed,
+ TransformUtil::ParseTimestampWithZone(param.str));
+ EXPECT_EQ(parsed, param.value);
+ break;
+ }
+ }
+}
+
+struct ParseOnlyParam {
+ std::string name;
+ std::string str;
+ int64_t value;
+ enum Kind { kDay, kTime, kTimestamp, kTimestampTz } kind;
+};
+
+class ParseOnlyTest : public ::testing::TestWithParam<ParseOnlyParam> {};
+
+TEST_P(ParseOnlyTest, ParsesCorrectly) {
+ const auto& param = GetParam();
+ switch (param.kind) {
+ case ParseOnlyParam::kDay: {
+ ICEBERG_UNWRAP_OR_FAIL(auto parsed, TransformUtil::ParseDay(param.str));
+ EXPECT_EQ(parsed, static_cast<int32_t>(param.value));
+ break;
+ }
+ case ParseOnlyParam::kTime: {
+ ICEBERG_UNWRAP_OR_FAIL(auto parsed, TransformUtil::ParseTime(param.str));
+ EXPECT_EQ(parsed, param.value);
+ break;
+ }
+ case ParseOnlyParam::kTimestamp: {
+ ICEBERG_UNWRAP_OR_FAIL(auto parsed,
TransformUtil::ParseTimestamp(param.str));
+ EXPECT_EQ(parsed, param.value);
+ break;
+ }
+ case ParseOnlyParam::kTimestampTz: {
+ ICEBERG_UNWRAP_OR_FAIL(auto parsed,
+ TransformUtil::ParseTimestampWithZone(param.str));
+ EXPECT_EQ(parsed, param.value);
+ break;
+ }
+ }
+}
+
+struct ParseTimeErrorParam {
+ std::string name;
+ std::string str;
+};
+
+class ParseTimeErrorTest : public
::testing::TestWithParam<ParseTimeErrorParam> {};
+
+TEST_P(ParseTimeErrorTest, ReturnsError) {
+ EXPECT_THAT(TransformUtil::ParseTime(GetParam().str),
+ IsError(ErrorKind::kInvalidArgument));
+}
+
+INSTANTIATE_TEST_SUITE_P(
+ TransformUtilTest, ParseRoundTripTest,
+ ::testing::Values(
+ // Day round-trips
+ ParseRoundTripParam{"DayEpoch", "1970-01-01", 0,
ParseRoundTripParam::kDay},
+ ParseRoundTripParam{"DayNext", "1970-01-02", 1,
ParseRoundTripParam::kDay},
+ ParseRoundTripParam{"DayBeforeEpoch", "1969-12-31", -1,
+ ParseRoundTripParam::kDay},
+ ParseRoundTripParam{"DayYear999", "0999-12-31", -354286,
+ ParseRoundTripParam::kDay},
+ ParseRoundTripParam{"DayNonLeap", "1971-01-01", 365,
ParseRoundTripParam::kDay},
+ ParseRoundTripParam{"DayY2K", "2000-01-01", 10957,
ParseRoundTripParam::kDay},
+ ParseRoundTripParam{"Day2026", "2026-01-01", 20454,
ParseRoundTripParam::kDay},
+ // Time round-trips
+ ParseRoundTripParam{"TimeMidnight", "00:00", 0,
ParseRoundTripParam::kTime},
+ ParseRoundTripParam{"TimeOneSec", "00:00:01", 1000000,
+ ParseRoundTripParam::kTime},
+ ParseRoundTripParam{"TimeMillis", "00:00:01.500", 1500000,
+ ParseRoundTripParam::kTime},
+ ParseRoundTripParam{"TimeOneMillis", "00:00:01.001", 1001000,
+ ParseRoundTripParam::kTime},
+ ParseRoundTripParam{"TimeMicros", "00:00:01.000001", 1000001,
+ ParseRoundTripParam::kTime},
+ ParseRoundTripParam{"TimeHourMinSec", "01:02:03", 3723000000,
+ ParseRoundTripParam::kTime},
+ ParseRoundTripParam{"TimeEndOfDay", "23:59:59", 86399000000,
+ ParseRoundTripParam::kTime},
+ // Timestamp round-trips
+ ParseRoundTripParam{"TimestampEpoch", "1970-01-01T00:00:00", 0,
+ ParseRoundTripParam::kTimestamp},
+ ParseRoundTripParam{"TimestampOneSec", "1970-01-01T00:00:01", 1000000,
+ ParseRoundTripParam::kTimestamp},
+ ParseRoundTripParam{"TimestampMillis", "2026-01-01T00:00:01.500",
+ 1767225601500000L,
ParseRoundTripParam::kTimestamp},
+ ParseRoundTripParam{"TimestampOneMillis", "2026-01-01T00:00:01.001",
+ 1767225601001000L,
ParseRoundTripParam::kTimestamp},
+ ParseRoundTripParam{"TimestampMicros", "2026-01-01T00:00:01.000001",
+ 1767225601000001L,
ParseRoundTripParam::kTimestamp},
+ // TimestampTz round-trips
+ ParseRoundTripParam{"TimestampTzEpoch", "1970-01-01T00:00:00+00:00", 0,
+ ParseRoundTripParam::kTimestampTz},
+ ParseRoundTripParam{"TimestampTzOneSec", "1970-01-01T00:00:01+00:00",
1000000,
+ ParseRoundTripParam::kTimestampTz},
+ ParseRoundTripParam{"TimestampTzMillis",
"2026-01-01T00:00:01.500+00:00",
+ 1767225601500000L,
ParseRoundTripParam::kTimestampTz},
+ ParseRoundTripParam{"TimestampTzOneMillis",
"2026-01-01T00:00:01.001+00:00",
+ 1767225601001000L,
ParseRoundTripParam::kTimestampTz},
+ ParseRoundTripParam{"TimestampTzMicros",
"2026-01-01T00:00:01.000001+00:00",
+ 1767225601000001L,
ParseRoundTripParam::kTimestampTz}),
+ [](const ::testing::TestParamInfo<ParseRoundTripParam>& info) {
+ return info.param.name;
+ });
+
+INSTANTIATE_TEST_SUITE_P(
+ TransformUtilTest, ParseOnlyTest,
+ ::testing::Values(
+ // TimestampTz with "Z" suffix
+ ParseOnlyParam{"TimestampTzSuffixZ_Epoch", "1970-01-01T00:00:00Z", 0,
+ ParseOnlyParam::kTimestampTz},
+ ParseOnlyParam{"TimestampTzSuffixZ_Millis", "2026-01-01T00:00:01.500Z",
+ 1767225601500000L, ParseOnlyParam::kTimestampTz},
+ // TimestampTz with "-00:00" suffix
+ ParseOnlyParam{"TimestampTzNegZero_Epoch",
"1970-01-01T00:00:00-00:00", 0,
+ ParseOnlyParam::kTimestampTz},
+ ParseOnlyParam{"TimestampTzNegZero_Millis",
"2026-01-01T00:00:01.500-00:00",
+ 1767225601500000L, ParseOnlyParam::kTimestampTz},
+ // Fractional micros truncates nanos
+ ParseOnlyParam{"TimeTruncatesNanos", "00:00:01.123456789", 1123456,
+ ParseOnlyParam::kTime},
+ // Fractional seconds (trimmed trailing zeros)
+ ParseOnlyParam{"1Digit", "00:00:01.5", 1500000, ParseOnlyParam::kTime},
+ ParseOnlyParam{"2Digits", "00:00:01.50", 1500000,
ParseOnlyParam::kTime},
+ ParseOnlyParam{"2DigitsNonZero", "00:00:01.12", 1120000,
ParseOnlyParam::kTime},
+ ParseOnlyParam{"4Digits", "00:00:01.0001", 1000100,
ParseOnlyParam::kTime},
+ // Timestamp without seconds
+ ParseOnlyParam{"TimestampNoSec_Zero", "1970-01-01T00:00", 0,
+ ParseOnlyParam::kTimestamp},
+ ParseOnlyParam{"TimestampNoSec_OneMin", "1970-01-01T00:01", 60000000,
+ ParseOnlyParam::kTimestamp},
+ // TimestampTz without seconds
+ ParseOnlyParam{"TimestampTzNoSec_Offset", "1970-01-01T00:00+00:00", 0,
+ ParseOnlyParam::kTimestampTz},
+ ParseOnlyParam{"TimestampTzNoSec_OneMin", "1970-01-01T00:01+00:00",
60000000,
+ ParseOnlyParam::kTimestampTz},
+ ParseOnlyParam{"TimestampTzNoSec_Z", "1970-01-01T00:00Z", 0,
+ ParseOnlyParam::kTimestampTz},
+ // Extended year with '+' prefix
+ ParseOnlyParam{"ExtendedYearPlusEpoch", "+1970-01-01", 0,
ParseOnlyParam::kDay},
+ ParseOnlyParam{"ExtendedYearPlus2026", "+2026-01-01", 20454,
+ ParseOnlyParam::kDay},
+ ParseOnlyParam{"ExtendedYearMinus2026", "-2026-01-01", -1459509,
+ ParseOnlyParam::kDay},
+ // Non-UTC timezone offsets
+ ParseOnlyParam{"TimestampTzPositiveOffset",
"1970-01-01T05:00:00+05:00", 0,
+ ParseOnlyParam::kTimestampTz},
+ ParseOnlyParam{"TimestampTzNegativeOffset",
"1970-01-01T00:00:00-05:00",
+ 18000000000, ParseOnlyParam::kTimestampTz},
+ ParseOnlyParam{"TimestampTzOffsetWithMillis",
"2026-01-01T05:30:01.500+05:30",
+ 1767225601500000L, ParseOnlyParam::kTimestampTz},
+ ParseOnlyParam{"TimestampTzNegOffsetToEpoch",
"1969-12-31T19:00:00-05:00", 0,
+ ParseOnlyParam::kTimestampTz},
+ ParseOnlyParam{"TimestampTzNoSecWithOffset", "1970-01-01T05:30+05:30",
0,
+ ParseOnlyParam::kTimestampTz}),
+ [](const ::testing::TestParamInfo<ParseOnlyParam>& info) { return
info.param.name; });
+
+INSTANTIATE_TEST_SUITE_P(
+ TransformUtilTest, ParseTimeErrorTest,
+ ::testing::Values(ParseTimeErrorParam{"EmptyString", ""},
+ ParseTimeErrorParam{"TooShort1Char", "1"},
+ ParseTimeErrorParam{"TooShort2Chars", "12"},
+ ParseTimeErrorParam{"TooShort4Chars", "12:3"},
+ ParseTimeErrorParam{"MissingColon", "1200:00"},
+ ParseTimeErrorParam{"OutofRangeHours", "24:00:00"},
+ ParseTimeErrorParam{"OutofRangeMinutes", "12:60:00"},
+ ParseTimeErrorParam{"OutofRangeSeconds", "12:30:61"},
+ ParseTimeErrorParam{"SpaceInsteadOfColon", "12 30"}),
+ [](const ::testing::TestParamInfo<ParseTimeErrorParam>& info) {
+ return info.param.name;
+ });
+
} // namespace iceberg
diff --git a/src/iceberg/util/string_util.cc b/src/iceberg/util/string_util.cc
new file mode 100644
index 00000000..0454a62b
--- /dev/null
+++ b/src/iceberg/util/string_util.cc
@@ -0,0 +1,46 @@
+/*
+ * 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.
+ */
+
+#include "iceberg/util/string_util.h"
+
+#include "iceberg/util/macros.h"
+
+namespace iceberg {
+
+Result<std::vector<uint8_t>> StringUtils::HexStringToBytes(std::string_view
hex) {
+ if (hex.size() % 2 != 0) [[unlikely]] {
+ return InvalidArgument("Hex string must have even length, got: {}",
hex.size());
+ }
+ std::vector<uint8_t> bytes;
+ bytes.reserve(hex.size() / 2);
+ auto nibble = [](char c) -> Result<uint8_t> {
+ if (c >= '0' && c <= '9') return static_cast<uint8_t>(c - '0');
+ if (c >= 'a' && c <= 'f') return static_cast<uint8_t>(c - 'a' + 10);
+ if (c >= 'A' && c <= 'F') return static_cast<uint8_t>(c - 'A' + 10);
+ return InvalidArgument("Invalid hex character: '{}'", c);
+ };
+ for (size_t i = 0; i < hex.size(); i += 2) {
+ ICEBERG_ASSIGN_OR_RAISE(auto hi, nibble(hex[i]));
+ ICEBERG_ASSIGN_OR_RAISE(auto lo, nibble(hex[i + 1]));
+ bytes.push_back(static_cast<uint8_t>((hi << 4) | lo));
+ }
+ return bytes;
+}
+
+} // namespace iceberg
diff --git a/src/iceberg/util/string_util.h b/src/iceberg/util/string_util.h
index fc202f0e..36dfba30 100644
--- a/src/iceberg/util/string_util.h
+++ b/src/iceberg/util/string_util.h
@@ -28,6 +28,7 @@
#include <type_traits>
#include <typeinfo>
#include <utility>
+#include <vector>
#include "iceberg/iceberg_export.h"
#include "iceberg/result.h"
@@ -78,6 +79,10 @@ class ICEBERG_EXPORT StringUtils {
T value = 0;
auto [ptr, ec] = std::from_chars(str.data(), str.data() + str.size(),
value);
if (ec == std::errc()) [[likely]] {
+ if (ptr != str.data() + str.size()) {
+ return InvalidArgument("Failed to parse {} from string '{}': trailing
characters",
+ typeid(T).name(), str);
+ }
return value;
}
if (ec == std::errc::invalid_argument) {
@@ -91,6 +96,10 @@ class ICEBERG_EXPORT StringUtils {
std::unreachable();
}
+ /// \brief Decode a hex string (upper or lower case) into bytes.
+ /// Returns an error if the string has odd length or contains invalid hex
characters.
+ static Result<std::vector<uint8_t>> HexStringToBytes(std::string_view hex);
+
template <typename T>
requires std::is_floating_point_v<T> && (!FromChars<T>)
static Result<T> ParseNumber(std::string_view str) {
diff --git a/src/iceberg/util/transform_util.cc
b/src/iceberg/util/transform_util.cc
index fe152343..a9221310 100644
--- a/src/iceberg/util/transform_util.cc
+++ b/src/iceberg/util/transform_util.cc
@@ -22,12 +22,50 @@
#include <array>
#include <chrono>
+#include "iceberg/util/macros.h"
+#include "iceberg/util/string_util.h"
+
namespace iceberg {
namespace {
constexpr auto kEpochDate = std::chrono::year{1970} / std::chrono::January / 1;
constexpr int64_t kMicrosPerMillis = 1'000;
constexpr int64_t kMicrosPerSecond = 1'000'000;
+constexpr int64_t kMicrosPerDay = 86'400'000'000LL;
+
+/// Parse a timezone offset of the form "+HH:mm" or "-HH:mm" and return the
+/// offset in microseconds (positive for east of UTC, negative for west).
+Result<int64_t> ParseTimezoneOffset(std::string_view offset) {
+ if (offset.size() != 6 || (offset[0] != '+' && offset[0] != '-') ||
offset[3] != ':') {
+ return InvalidArgument("Invalid timezone offset: '{}'", offset);
+ }
+ bool negative = offset[0] == '-';
+ ICEBERG_ASSIGN_OR_RAISE(auto hours,
+ StringUtils::ParseNumber<int64_t>(offset.substr(1,
2)));
+ ICEBERG_ASSIGN_OR_RAISE(auto minutes,
+ StringUtils::ParseNumber<int64_t>(offset.substr(4,
2)));
+ if (hours > 18 || minutes > 59) {
+ return InvalidArgument("Invalid timezone offset: '{}'", offset);
+ }
+ auto micros = hours * 3'600 * kMicrosPerSecond + minutes * 60 *
kMicrosPerSecond;
+ return negative ? -micros : micros;
+}
+
+/// Parse fractional seconds (after '.') and return micros.
+/// Digits beyond 6 are truncated (nanosecond precision).
+Result<int64_t> ParseFractionalMicros(std::string_view frac) {
+ if (frac.empty()) {
+ return InvalidArgument("Invalid fractional seconds: '{}'", frac);
+ }
+ // Truncate to microsecond precision (6 digits), matching Java
ISO_LOCAL_TIME behavior
+ if (frac.size() > 6) frac = frac.substr(0, 6);
+ ICEBERG_ASSIGN_OR_RAISE(auto val, StringUtils::ParseNumber<int32_t>(frac));
+ // Right-pad to 6 digits: "500" → 500000, "001" → 1000, "000001" → 1
+ for (size_t i = frac.size(); i < 6; ++i) {
+ val *= 10;
+ }
+ return static_cast<int64_t>(val);
+}
} // namespace
std::string TransformUtil::HumanYear(int32_t year_ordinal) {
@@ -92,6 +130,110 @@ std::string TransformUtil::HumanTimestampWithZone(int64_t
timestamp_micros) {
}
}
+Result<int32_t> TransformUtil::ParseDay(std::string_view str) {
+ // Expected format: "[+-]yyyy-MM-dd"
+ // Parse year, month, day manually, skipping leading '+' or '-' to find
first date dash
+ auto dash1 = str.find('-', (!str.empty() && (str[0] == '-' || str[0] ==
'+')) ? 1 : 0);
+ auto dash2 = str.find('-', dash1 + 1);
+ if (str.size() < 10 || dash1 == std::string_view::npos ||
+ dash2 == std::string_view::npos) [[unlikely]] {
+ return InvalidArgument("Invalid date string: '{}'", str);
+ }
+ auto year_str = str.substr(0, dash1);
+ // std::from_chars does not accept '+' prefix, strip it for positive
extended years
+ if (!year_str.empty() && year_str[0] == '+') {
+ year_str = year_str.substr(1);
+ }
+ ICEBERG_ASSIGN_OR_RAISE(auto year,
StringUtils::ParseNumber<int32_t>(year_str));
+ ICEBERG_ASSIGN_OR_RAISE(auto month, StringUtils::ParseNumber<int32_t>(
+ str.substr(dash1 + 1, dash2 - dash1
- 1)));
+ ICEBERG_ASSIGN_OR_RAISE(auto day,
+ StringUtils::ParseNumber<int32_t>(str.substr(dash2 +
1)));
+
+ auto ymd = std::chrono::year{year} /
std::chrono::month{static_cast<unsigned>(month)} /
+ std::chrono::day{static_cast<unsigned>(day)};
+ if (!ymd.ok()) [[unlikely]] {
+ return InvalidArgument("Invalid date: '{}'", str);
+ }
+
+ auto days = std::chrono::sys_days(ymd) - std::chrono::sys_days(kEpochDate);
+ return static_cast<int32_t>(days.count());
+}
+
+Result<int64_t> TransformUtil::ParseTime(std::string_view str) {
+ if (str.size() < 5 || str[2] != ':') [[unlikely]] {
+ return InvalidArgument("Invalid time string: '{}'", str);
+ }
+
+ ICEBERG_ASSIGN_OR_RAISE(auto hours,
+ StringUtils::ParseNumber<int64_t>(str.substr(0, 2)));
+ ICEBERG_ASSIGN_OR_RAISE(auto minutes,
+ StringUtils::ParseNumber<int64_t>(str.substr(3, 2)));
+ int64_t seconds = 0;
+
+ int64_t frac_micros = 0;
+ if (str.size() > 5) {
+ if (str[5] != ':' || str.size() < 8) [[unlikely]] {
+ return InvalidArgument("Invalid time string: '{}'", str);
+ }
+ ICEBERG_ASSIGN_OR_RAISE(seconds,
StringUtils::ParseNumber<int64_t>(str.substr(6, 2)));
+ if (str.size() > 8) {
+ if (str[8] != '.') [[unlikely]] {
+ return InvalidArgument("Invalid time string: '{}'", str);
+ }
+ ICEBERG_ASSIGN_OR_RAISE(frac_micros,
ParseFractionalMicros(str.substr(9)));
+ }
+ }
+
+ // check that hours, minutes, seconds are in valid ranges
+ if (hours < 0 || hours > 23 || minutes < 0 || minutes > 59 || seconds < 0 ||
+ seconds > 59) [[unlikely]] {
+ return InvalidArgument("Invalid time string: '{}'", str);
+ }
+
+ return hours * 3'600 * kMicrosPerSecond + minutes * 60 * kMicrosPerSecond +
+ seconds * kMicrosPerSecond + frac_micros;
+}
+
+Result<int64_t> TransformUtil::ParseTimestamp(std::string_view str) {
+ // Format: "yyyy-MM-ddTHH:mm:ss[.SSS[SSS]]"
+ auto t_pos = str.find('T');
+ if (t_pos == std::string_view::npos) [[unlikely]] {
+ return InvalidArgument("Invalid timestamp string (missing 'T'): '{}'",
str);
+ }
+
+ ICEBERG_ASSIGN_OR_RAISE(auto days, ParseDay(str.substr(0, t_pos)));
+ ICEBERG_ASSIGN_OR_RAISE(auto time_micros, ParseTime(str.substr(t_pos + 1)));
+
+ return static_cast<int64_t>(days) * kMicrosPerDay + time_micros;
+}
+
+Result<int64_t> TransformUtil::ParseTimestampWithZone(std::string_view str) {
+ if (str.empty()) [[unlikely]] {
+ return InvalidArgument("Invalid timestamptz string: '{}'", str);
+ }
+
+ int64_t offset_micros = 0;
+ std::string_view timestamp_part;
+
+ if (str.back() == 'Z') {
+ // "Z" suffix means UTC (offset = 0)
+ timestamp_part = str.substr(0, str.size() - 1);
+ } else if (str.size() >= 6 &&
+ (str[str.size() - 6] == '+' || str[str.size() - 6] == '-')) {
+ // Parse "+HH:mm" or "-HH:mm" offset suffix
+ ICEBERG_ASSIGN_OR_RAISE(offset_micros,
+ ParseTimezoneOffset(str.substr(str.size() - 6)));
+ timestamp_part = str.substr(0, str.size() - 6);
+ } else {
+ return InvalidArgument("Invalid timestamptz string (missing timezone
suffix): '{}'",
+ str);
+ }
+
+ ICEBERG_ASSIGN_OR_RAISE(auto local_micros, ParseTimestamp(timestamp_part));
+ return local_micros - offset_micros;
+}
+
std::string TransformUtil::Base64Encode(std::string_view str_to_encode) {
static constexpr std::string_view kBase64Chars =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
diff --git a/src/iceberg/util/transform_util.h
b/src/iceberg/util/transform_util.h
index 7482b0db..c23d08c8 100644
--- a/src/iceberg/util/transform_util.h
+++ b/src/iceberg/util/transform_util.h
@@ -22,6 +22,7 @@
#include <string>
#include "iceberg/iceberg_export.h"
+#include "iceberg/result.h"
namespace iceberg {
@@ -98,6 +99,44 @@ class ICEBERG_EXPORT TransformUtil {
/// \return a string representation of this timestamp.
static std::string HumanTimestampWithZone(int64_t timestamp_micros);
+ /// \brief Parses a date string in "[+-]yyyy-MM-dd" format into days since
epoch.
+ ///
+ /// Supports an optional '+' or '-' prefix for extended years beyond 9999.
+ ///
+ /// \param str The date string to parse.
+ /// \return The number of days since 1970-01-01, or an error.
+ static Result<int32_t> ParseDay(std::string_view str);
+
+ /// \brief Parses a time string into microseconds from midnight.
+ ///
+ /// Accepts ISO-8601 local time formats: "HH:mm", "HH:mm:ss", or
+ /// "HH:mm:ss.f" where the fractional part can be 1-9 digits.
+ /// Digits beyond 6 (microsecond precision) are truncated.
+ ///
+ /// \param str The time string to parse.
+ /// \return The number of microseconds from midnight, or an error.
+ static Result<int64_t> ParseTime(std::string_view str);
+
+ /// \brief Parses a timestamp string into microseconds since epoch.
+ ///
+ /// Accepts ISO-8601 local date-time formats: "yyyy-MM-ddTHH:mm",
+ /// "yyyy-MM-ddTHH:mm:ss", or "yyyy-MM-ddTHH:mm:ss.f" where the
+ /// fractional part can be 1-9 digits (truncated to microseconds).
+ ///
+ /// \param str The timestamp string to parse.
+ /// \return The number of microseconds since epoch, or an error.
+ static Result<int64_t> ParseTimestamp(std::string_view str);
+
+ /// \brief Parses a timestamp-with-zone string into microseconds since epoch
(UTC).
+ ///
+ /// Accepts the same formats as ParseTimestamp, with a timezone suffix:
+ /// "Z", "+HH:mm", or "-HH:mm". Non-UTC offsets are converted to UTC.
+ /// The seconds and fractional parts are optional (e.g.
"yyyy-MM-ddTHH:mm+00:00").
+ ///
+ /// \param str The timestamp string to parse.
+ /// \return The number of microseconds since epoch (UTC), or an error.
+ static Result<int64_t> ParseTimestampWithZone(std::string_view str);
+
/// \brief Base64 encode a string
static std::string Base64Encode(std::string_view str_to_encode);
};