Skip to content

Commit 3c9cab7

Browse files
committed
Add TimestampNs Literal
1 parent 0dbeca3 commit 3c9cab7

7 files changed

Lines changed: 341 additions & 6 deletions

File tree

pyiceberg/expressions/literals.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,9 @@
4545
IntegerType,
4646
LongType,
4747
StringType,
48+
TimestampNanoType,
4849
TimestampType,
50+
TimestamptzNanoType,
4951
TimestamptzType,
5052
TimeType,
5153
UUIDType,
@@ -56,11 +58,17 @@
5658
datetime_to_micros,
5759
days_to_date,
5860
micros_to_days,
61+
micros_to_nanos,
5962
micros_to_timestamp,
63+
nanos_to_days,
64+
nanos_to_micros,
65+
nanos_to_timestamp,
6066
time_str_to_micros,
6167
time_to_micros,
6268
timestamp_to_micros,
69+
timestamp_to_nanos,
6370
timestamptz_to_micros,
71+
timestamptz_to_nanos,
6472
)
6573
from pyiceberg.utils.decimal import decimal_to_unscaled, unscaled_to_decimal
6674
from pyiceberg.utils.singleton import Singleton
@@ -347,6 +355,16 @@ def _(self, _: TimestampType) -> Literal[int]:
347355
def _(self, _: TimestamptzType) -> Literal[int]:
348356
return TimestampLiteral(self.value)
349357

358+
@to.register(TimestampNanoType)
359+
def _(self, type_var: TimestampNanoType) -> Literal[int]:
360+
# The value is assumed to be in microseconds, to match the TimestampType case above
361+
return TimestampLiteral(self.value).to(type_var)
362+
363+
@to.register(TimestamptzNanoType)
364+
def _(self, type_var: TimestamptzNanoType) -> Literal[int]:
365+
# The value is assumed to be in microseconds, to match the TimestamptzType case above
366+
return TimestampLiteral(self.value).to(type_var)
367+
350368
@to.register(DecimalType)
351369
def _(self, type_var: DecimalType) -> Literal[Decimal]:
352370
unscaled = Decimal(self.value)
@@ -495,6 +513,54 @@ def _(self, _: TimestamptzType) -> Literal[int]:
495513
def _(self, _: DateType) -> Literal[int]:
496514
return DateLiteral(micros_to_days(self.value))
497515

516+
@to.register(TimestampNanoType)
517+
def _(self, _: TimestampNanoType) -> Literal[int]:
518+
return TimestampNanoLiteral(micros_to_nanos(self.value))
519+
520+
@to.register(TimestamptzNanoType)
521+
def _(self, _: TimestamptzNanoType) -> Literal[int]:
522+
return TimestampNanoLiteral(micros_to_nanos(self.value))
523+
524+
525+
class TimestampNanoLiteral(Literal[int]):
526+
def __init__(self, value: int) -> None:
527+
super().__init__(value, int)
528+
529+
@model_serializer
530+
def ser_model(self) -> str:
531+
# Python datetime only goes down to microseconds, so the last three digits are appended
532+
return f"{nanos_to_timestamp(self.root).isoformat(timespec='microseconds')}{self.root % 1000:03d}"
533+
534+
def increment(self) -> Literal[int]:
535+
return TimestampNanoLiteral(self.value + 1)
536+
537+
def decrement(self) -> Literal[int]:
538+
return TimestampNanoLiteral(self.value - 1)
539+
540+
@singledispatchmethod
541+
def to(self, type_var: IcebergType) -> Literal: # type: ignore
542+
raise TypeError(f"Cannot convert TimestampNanoLiteral into {type_var}")
543+
544+
@to.register(TimestampNanoType)
545+
def _(self, _: TimestampNanoType) -> Literal[int]:
546+
return self
547+
548+
@to.register(TimestamptzNanoType)
549+
def _(self, _: TimestamptzNanoType) -> Literal[int]:
550+
return self
551+
552+
@to.register(TimestampType)
553+
def _(self, _: TimestampType) -> Literal[int]:
554+
return TimestampLiteral(nanos_to_micros(self.value))
555+
556+
@to.register(TimestamptzType)
557+
def _(self, _: TimestamptzType) -> Literal[int]:
558+
return TimestampLiteral(nanos_to_micros(self.value))
559+
560+
@to.register(DateType)
561+
def _(self, _: DateType) -> Literal[int]:
562+
return DateLiteral(nanos_to_days(self.value))
563+
498564

499565
class DecimalLiteral(Literal[Decimal]):
500566
def __init__(self, value: Decimal) -> None:
@@ -615,6 +681,14 @@ def _(self, _: TimestampType) -> Literal[int]:
615681
def _(self, _: TimestamptzType) -> Literal[int]:
616682
return TimestampLiteral(timestamptz_to_micros(self.value))
617683

684+
@to.register(TimestampNanoType)
685+
def _(self, _: TimestampNanoType) -> Literal[int]:
686+
return TimestampNanoLiteral(timestamp_to_nanos(self.value))
687+
688+
@to.register(TimestamptzNanoType)
689+
def _(self, _: TimestamptzNanoType) -> Literal[int]:
690+
return TimestampNanoLiteral(timestamptz_to_nanos(self.value))
691+
618692
@to.register(UUIDType)
619693
def _(self, _: UUIDType) -> Literal[bytes]:
620694
return UUIDLiteral(UUID(self.value).bytes)

pyiceberg/transforms.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@
6565
Literal,
6666
LongLiteral,
6767
TimestampLiteral,
68+
TimestampNanoLiteral,
6869
literal,
6970
)
7071
from pyiceberg.typedef import IcebergRootModel, L
@@ -1049,7 +1050,7 @@ def _truncate_number(
10491050
) -> UnboundPredicate | None:
10501051
boundary = pred.literal
10511052

1052-
if not isinstance(boundary, (LongLiteral, DecimalLiteral, DateLiteral, TimestampLiteral)):
1053+
if not isinstance(boundary, (LongLiteral, DecimalLiteral, DateLiteral, TimestampLiteral, TimestampNanoLiteral)):
10531054
raise ValueError(f"Expected a numeric literal, got: {type(boundary)}")
10541055

10551056
if isinstance(pred, BoundLessThan):
@@ -1071,7 +1072,7 @@ def _truncate_number_strict(
10711072
) -> UnboundPredicate | None:
10721073
boundary = pred.literal
10731074

1074-
if not isinstance(boundary, (LongLiteral, DecimalLiteral, DateLiteral, TimestampLiteral)):
1075+
if not isinstance(boundary, (LongLiteral, DecimalLiteral, DateLiteral, TimestampLiteral, TimestampNanoLiteral)):
10751076
raise ValueError(f"Expected a numeric literal, got: {type(boundary)}")
10761077

10771078
if isinstance(pred, BoundLessThan):

pyiceberg/utils/datetime.py

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@
2626
timedelta,
2727
)
2828

29+
from pyiceberg.types import LongType
30+
2931
EPOCH_DATE = date.fromisoformat("1970-01-01")
3032
EPOCH_TIMESTAMP = datetime.fromisoformat("1970-01-01T00:00:00.000000")
3133
ISO_TIMESTAMP = re.compile(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(.\d{1,6})?")
@@ -35,6 +37,18 @@
3537
ISO_TIMESTAMPTZ_NANO = re.compile(r"(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(.\d{1,6})?(\d{1,3})?([-+]\d{2}:\d{2})")
3638

3739

40+
def _check_nanos_range(nanos: int, source: object) -> int:
41+
"""Reject nanosecond timestamps that do not fit in a 64-bit integer.
42+
43+
Python integers are unbounded, so the overflow has to be checked explicitly. Java gets this for
44+
free: both DateTimeUtil.microsToNanos and DateTimeUtil.nanosFromTimestamp are exact long
45+
operations that throw an ArithmeticException instead of wrapping around.
46+
"""
47+
if not LongType.min <= nanos <= LongType.max:
48+
raise OverflowError(f"Timestamp cannot be converted to nanoseconds, out of range: {source}")
49+
return nanos
50+
51+
3852
def micros_to_days(timestamp: int) -> int:
3953
"""Convert a timestamp in microseconds to a date in days."""
4054
return timedelta(microseconds=timestamp).days
@@ -105,8 +119,8 @@ def time_to_nanos(t: time) -> int:
105119
return ((((t.hour * 60 + t.minute) * 60) + t.second) * 1_000_000 + t.microsecond) * 1_000
106120

107121

108-
def datetime_to_nanos(dt: datetime) -> int:
109-
"""Convert a datetime to nanoseconds from 1970-01-01T00:00:00.000000000."""
122+
def _unchecked_datetime_to_nanos(dt: datetime) -> int:
123+
"""Convert a datetime to nanoseconds without a range check, so a caller can add the sub-microsecond digits first."""
110124
# python datetime and time doesn't have nanoseconds support yet
111125
# https://github.com/python/cpython/issues/59648
112126
if dt.tzinfo:
@@ -116,6 +130,11 @@ def datetime_to_nanos(dt: datetime) -> int:
116130
return ((delta.days * 86400 + delta.seconds) * 1_000_000 + delta.microseconds) * 1_000
117131

118132

133+
def datetime_to_nanos(dt: datetime) -> int:
134+
"""Convert a datetime to nanoseconds from 1970-01-01T00:00:00.000000000."""
135+
return _check_nanos_range(_unchecked_datetime_to_nanos(dt), dt)
136+
137+
119138
def timestamp_to_nanos(timestamp_str: str) -> int:
120139
"""Convert an ISO-9601 formatted timestamp without zone to nanoseconds from 1970-01-01T00:00:00.000000000."""
121140
if match := ISO_TIMESTAMP_NANO.fullmatch(timestamp_str):
@@ -126,7 +145,8 @@ def timestamp_to_nanos(timestamp_str: str) -> int:
126145
ns_str = (match.group(3) or "0").ljust(3, "0")
127146
ms_str = match.group(2) if match.group(2) else ""
128147
timestamp_str_without_ns_str = match.group(1) + ms_str
129-
return datetime_to_nanos(datetime.fromisoformat(timestamp_str_without_ns_str)) + int(ns_str)
148+
nanos = _unchecked_datetime_to_nanos(datetime.fromisoformat(timestamp_str_without_ns_str)) + int(ns_str)
149+
return _check_nanos_range(nanos, timestamp_str)
130150
if ISO_TIMESTAMPTZ_NANO.fullmatch(timestamp_str):
131151
# When we can match a timestamp without a zone, we can give a more specific error
132152
raise ValueError(f"Zone offset provided, but not expected: {timestamp_str}")
@@ -143,7 +163,8 @@ def timestamptz_to_nanos(timestamptz_str: str) -> int:
143163
ns_str = (match.group(3) or "0").ljust(3, "0")
144164
ms_str = match.group(2) if match.group(2) else ""
145165
timestamptz_str_without_ns_str = match.group(1) + ms_str + match.group(4)
146-
return datetime_to_nanos(datetime.fromisoformat(timestamptz_str_without_ns_str)) + int(ns_str)
166+
nanos = _unchecked_datetime_to_nanos(datetime.fromisoformat(timestamptz_str_without_ns_str)) + int(ns_str)
167+
return _check_nanos_range(nanos, timestamptz_str)
147168
if ISO_TIMESTAMP_NANO.fullmatch(timestamptz_str):
148169
# When we can match a timestamp without a zone, we can give a more specific error
149170
raise ValueError(f"Missing zone offset: {timestamptz_str} (must be ISO-8601)")
@@ -283,3 +304,8 @@ def nanos_to_hours(nanos: int) -> int:
283304
def nanos_to_micros(nanos: int) -> int:
284305
"""Convert a nanoseconds timestamp to microsecond timestamp by dropping precision."""
285306
return nanos // 1000
307+
308+
309+
def micros_to_nanos(micros: int) -> int:
310+
"""Convert a microseconds timestamp to a nanosecond timestamp."""
311+
return _check_nanos_range(micros * 1000, micros)

tests/expressions/test_expressions.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,8 @@
7777
NestedField,
7878
StringType,
7979
StructType,
80+
TimestampNanoType,
81+
TimestamptzNanoType,
8082
)
8183

8284

@@ -110,6 +112,20 @@ def test_invert_not_nan_bind() -> None:
110112
assert ~NotNaN(Reference("a")).bind(schema) == IsNaN(Reference("a")).bind(schema)
111113

112114

115+
def test_bind_timestamp_nano() -> None:
116+
schema = Schema(NestedField(2, "a", TimestampNanoType(), required=False), schema_id=1)
117+
118+
assert GreaterThan("a", "2017-08-18T14:21:01.919234567").bind(schema).literal.value == 1503066061919234567
119+
# A long is read as microseconds, matching the plain timestamp case
120+
assert EqualTo("a", 1503066061919234).bind(schema).literal.value == 1503066061919234000
121+
122+
123+
def test_bind_timestamptz_nano() -> None:
124+
schema = Schema(NestedField(2, "a", TimestamptzNanoType(), required=False), schema_id=1)
125+
126+
assert GreaterThan("a", "2017-08-18T14:21:01.919234567+00:00").bind(schema).literal.value == 1503066061919234567
127+
128+
113129
def test_bind_expr_does_not_exists() -> None:
114130
schema = Schema(NestedField(2, "a", IntegerType()), schema_id=1)
115131
with pytest.raises(ValueError) as exc_info:

0 commit comments

Comments
 (0)