Skip to content

Commit 7f1d410

Browse files
m1n0claude
andauthored
fix: zero-pad years below 1000 in SQL date and datetime literals (#2797)
C strftime("%Y") does not zero-pad years < 1000 on glibc, so a date like 0200-12-17 rendered as DATE '200-12-17' — an INVALID_TYPED_LITERAL for Spark/Databricks that broke failed-row-keys inserts into the diagnostics warehouse. Switch to isoformat(), which always emits a 4-digit year, in the base SqlDialect.literal_date, the SQLServer literal_date override, the Trino literal_datetime overrides, and Soda Cloud payload date serialization. SCS-1320 Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent eac1eab commit 7f1d410

8 files changed

Lines changed: 58 additions & 7 deletions

File tree

soda-core/src/soda_core/common/soda_cloud.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1516,7 +1516,8 @@ def to_jsonnable(o: Any, remove_null_values_in_dicts: bool = True) -> object:
15161516
if isinstance(o, datetime):
15171517
return convert_datetime_to_str(o)
15181518
if isinstance(o, date):
1519-
return o.strftime("%Y-%m-%d")
1519+
# Not strftime("%Y-%m-%d"): C strftime does not zero-pad years < 1000 on glibc.
1520+
return o.isoformat()
15201521
if isinstance(o, time):
15211522
return o.strftime("%H:%M:%S")
15221523
if isinstance(o, timedelta):

soda-core/src/soda_core/common/sql_dialect.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -362,8 +362,9 @@ def literal_list(self, l: list):
362362
return "(" + (",".join([self.literal(e) for e in l])) + ")"
363363

364364
def literal_date(self, date: date):
365-
date_string = date.strftime("%Y-%m-%d")
366-
return f"DATE '{date_string}'"
365+
# Not strftime("%Y-%m-%d"): C strftime does not zero-pad years < 1000 on glibc,
366+
# producing literals like DATE '200-12-17' that Spark/Databricks reject.
367+
return f"DATE '{date.isoformat()}'"
367368

368369
def literal_datetime(self, datetime: datetime):
369370
return f"'{datetime.isoformat()}'"

soda-databricks/tests/unit/test_databricks_dialect.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from datetime import date
2+
13
import pytest
24
from soda_core.common.metadata_types import SodaDataTypeName
35
from soda_core.common.sql_dialect import FROM, RANDOM, SELECT, STAR, SamplerType
@@ -56,6 +58,12 @@ def test_random():
5658
assert sql == "SELECT RAND()\nFROM `a`;"
5759

5860

61+
def test_literal_date_pads_year_to_four_digits():
62+
"""Spark SQL rejects DATE literals without a 4-digit year as INVALID_TYPED_LITERAL,
63+
and C strftime("%Y") drops the leading zeros for years < 1000 on glibc."""
64+
assert DatabricksSqlDialect().literal(date(200, 12, 17)) == "DATE '0200-12-17'"
65+
66+
5967
class TestTimestampReverseMapping:
6068
"""Pin the cross-source DWH dispatch invariant for Databricks.
6169

soda-sqlserver/src/soda_sqlserver/common/data_sources/sqlserver_data_source.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -120,8 +120,7 @@ def _build_limit_line(self, select_elements: list) -> Optional[str]:
120120

121121
def literal_date(self, date: date):
122122
"""Technically dates can be passed directly as strings, but this is more explicit."""
123-
date_string = date.strftime("%Y-%m-%d")
124-
return f"CAST('{date_string}' AS DATE)"
123+
return f"CAST('{date.isoformat()}' AS DATE)"
125124

126125
def literal_datetime(self, datetime: datetime):
127126
return f"'{datetime.isoformat(timespec='milliseconds')}'"

soda-sqlserver/tests/unit/test_sqlserver_dialect.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from datetime import date
2+
13
from soda_core.common.metadata_types import SqlDataType
24
from soda_core.common.sql_ast import COUNT, CREATE_TABLE_COLUMN, STAR
35
from soda_core.common.sql_dialect import FROM, RANDOM, SELECT
@@ -10,6 +12,12 @@ def test_random():
1012
assert sql == "SELECT ABS(CAST(CHECKSUM(NEWID()) AS FLOAT)) / 2147483648.0\nFROM [a];"
1113

1214

15+
def test_literal_date_pads_year_to_four_digits():
16+
"""C strftime("%Y") drops leading zeros for years < 1000 on glibc; the CAST
17+
string must always carry a 4-digit, zero-padded year."""
18+
assert SqlServerSqlDialect().literal(date(200, 12, 17)) == "CAST('0200-12-17' AS DATE)"
19+
20+
1321
def test_count_renders_as_count_big():
1422
# T-SQL COUNT returns INT and overflows above 2.1B rows; the dialect must emit COUNT_BIG.
1523
assert SqlServerSqlDialect().build_expression_sql(COUNT(STAR())) == "COUNT_BIG(*)"

soda-tests/tests/unit/test_sql_generation.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,26 @@ def test_sql_ast_insert_into_with_datetimes():
204204
)
205205

206206

207+
def test_sql_ast_insert_into_pads_date_years_below_1000():
208+
"""C strftime("%Y") drops leading zeros for years < 1000 on glibc, which used to
209+
render DATE '200-12-17' — an INVALID_TYPED_LITERAL for Spark/Databricks. Date
210+
literals must always carry a 4-digit, zero-padded year."""
211+
sql_dialect: SqlDialect = SqlDialect()
212+
213+
assert sql_dialect.literal(date(200, 12, 17)) == "DATE '0200-12-17'"
214+
215+
my_insert_into_statement = sql_dialect.build_insert_into_sql(
216+
INSERT_INTO(
217+
fully_qualified_table_name='"customers"',
218+
columns=[COLUMN("id"), COLUMN("birth_date")],
219+
values=[VALUES_ROW([LITERAL(1), LITERAL(date(200, 12, 17))])],
220+
)
221+
)
222+
assert my_insert_into_statement == (
223+
'INSERT INTO "customers" ("id", "birth_date") VALUES\n' "(1, DATE '0200-12-17');"
224+
)
225+
226+
207227
def test_sql_ast_insert_into_via_select():
208228
sql_dialect: SqlDialect = SqlDialect()
209229
my_insert_into_statement = sql_dialect.build_insert_into_via_select_sql(

soda-trino/src/soda_trino/common/data_sources/trino_data_source.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -279,10 +279,11 @@ def data_type_has_parameter_datetime_precision(self, data_type_name) -> bool:
279279
]
280280

281281
def literal_datetime(self, datetime: datetime):
282-
return f"TIMESTAMP '{datetime.strftime('%Y-%m-%d %H:%M:%S.%f')}'"
282+
return f"TIMESTAMP '{datetime.isoformat(sep=' ', timespec='microseconds')}'"
283283

284284
def literal_datetime_with_tz(self, datetime: datetime):
285-
return f"TIMESTAMP '{datetime.strftime('%Y-%m-%d %H:%M:%S.%f%z')}'"
285+
# isoformat appends the +HH:MM offset for tz-aware values.
286+
return f"TIMESTAMP '{datetime.isoformat(sep=' ', timespec='microseconds')}'"
286287

287288
def literal_time(self, time: time):
288289
formatted_time = time.strftime("%H:%M:%S.%f")

soda-trino/tests/unit/test_trino_dialect.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from datetime import datetime, timezone
2+
13
from soda_core.common.sql_dialect import FROM, RANDOM, SELECT
24
from soda_trino.common.data_sources.trino_data_source import TrinoSqlDialect
35

@@ -6,3 +8,14 @@ def test_random():
68
sql_dialect: TrinoSqlDialect = TrinoSqlDialect()
79
sql = sql_dialect.build_select_sql([SELECT(RANDOM()), FROM("a")])
810
assert sql == 'SELECT RANDOM()\nFROM "a"'
11+
12+
13+
def test_literal_datetime_pads_year_to_four_digits():
14+
"""C strftime("%Y") drops leading zeros for years < 1000 on glibc, producing
15+
TIMESTAMP literals Trino cannot parse; the year must always be 4 digits."""
16+
sql_dialect: TrinoSqlDialect = TrinoSqlDialect()
17+
assert sql_dialect.literal(datetime(200, 12, 17, 1, 2, 3, 456)) == "TIMESTAMP '0200-12-17 01:02:03.000456'"
18+
assert (
19+
sql_dialect.literal(datetime(200, 12, 17, 1, 2, 3, 456, tzinfo=timezone.utc))
20+
== "TIMESTAMP '0200-12-17 01:02:03.000456+00:00'"
21+
)

0 commit comments

Comments
 (0)