Skip to content

Commit f5b655e

Browse files
authored
fix: keep SQLite loader and source timestamps as text and add a waffle shop E2E (#1154)
* fix: keep SQLite loader and source timestamps as ISO text and add a waffle shop E2E SQLite's CAST to a date or time type keeps only the leading number, so loader rows and declared TIMESTAMP/DATE source columns were stored and read as 2026. Loader rows are now written uncast with their types declared on the staging table, Python datetimes are written as canonical UTC text, and date/time source, contract and retype casts keep the stored text. Tables copied from untyped columns no longer gain a BLOB type that later reads as a type change. Adds a waffle shop build, test and rebuild E2E on SQLite whose row counts match a DuckDB run, plus focused loader and adapter tests. * docs: regenerate skill reference pages
1 parent 0edf933 commit f5b655e

12 files changed

Lines changed: 438 additions & 26 deletions

File tree

‎src/sqlbuild/.agents/skills/sqlbuild/references/docs/concepts/adapters/sqlite.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,9 @@ SQLite stores values by type affinity, so SQLBuild keeps column types explicitly
7575
its declared type; a computed column gets the type SQLBuild infers at compile time.
7676
- Timestamps and dates are ISO-8601 text. `CAST(x AS TIMESTAMP)` and `CAST(x AS DATE)` do not
7777
produce timestamps in SQLite (they keep only the leading number), so write timestamps as text
78-
literals or use SQLite date functions.
78+
literals or use SQLite date functions. SQLBuild never applies such a cast itself: source,
79+
contract and type-change casts to a date or time type keep the stored text, and source loaders
80+
write Python `datetime` values as UTC text in the form `YYYY-MM-DD HH:MM:SS[.ffffff]`.
7981
- Cursor windows, watermarks and window deletes compare timestamp cursors as UTC text in the
8082
form `YYYY-MM-DD HH:MM:SS[.ffffff]`, so values written with a `T` separator, fractional seconds
8183
or a UTC offset fall in the right window. A value with an offset belongs to the window of its UTC

‎src/sqlbuild/adapters/sqlite/_helpers/tables.py‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
r"(\"(?:[^\"]|\"\")+\"|[^\s(]+)\s+AS\s+(.+)$",
1616
re.IGNORECASE | re.DOTALL,
1717
)
18+
_UNTYPED_VIEW_COLUMN_TYPE: str = "BLOB"
1819
_DECIMAL_TYPE: re.Pattern[str] = re.compile(EXACT_DECIMAL_TYPE_PATTERN, re.IGNORECASE)
1920

2021

@@ -49,7 +50,7 @@ def create_table_typed(
4950
column_sql: str = ", ".join(
5051
_column_definition(
5152
name=str(row[1]),
52-
declared=str(row[2] or "") or inferred_types.get(str(row[1]).lower(), ""),
53+
declared=_probed_type(row[2]) or inferred_types.get(str(row[1]).lower(), ""),
5354
column_types=column_types,
5455
)
5556
for row in rows
@@ -83,6 +84,13 @@ def decimal_scale(target_type: str) -> int | None:
8384
return int(match.group(2) or 0)
8485

8586

87+
def _probed_type(declared: object) -> str:
88+
"""A view reports an untyped source column as BLOB; keep such columns untyped."""
89+
90+
text: str = str(declared or "")
91+
return "" if text.upper() == _UNTYPED_VIEW_COLUMN_TYPE else text
92+
93+
8694
def _column_definition(*, name: str, declared: str, column_types: Mapping[str, str]) -> str:
8795
override: str | None = column_types.get(name.lower())
8896
column_type: str = storage_type(override) if override is not None else declared

‎src/sqlbuild/adapters/sqlite/classes/sqlite_adapter.py‎

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,7 @@
143143
MISSING_RELATION_ERROR_FRAGMENT,
144144
SCHEMA_REGISTRY_TABLE,
145145
SQLITE_TABLE_TYPE,
146+
TEMPORAL_TYPE_PATTERN,
146147
UNSUPPORTED_FEATURE_CODE,
147148
VERSION_TOO_OLD_CODE,
148149
)
@@ -156,6 +157,7 @@
156157
_INFERRED_COLUMN_TYPES: ContextVar[Mapping[str, str]] = ContextVar(
157158
"sqlite_inferred_column_types", default=MappingProxyType({})
158159
)
160+
_TEMPORAL_TYPE: re.Pattern[str] = re.compile(TEMPORAL_TYPE_PATTERN, re.IGNORECASE)
159161
_TRUE_SEED_VALUES: frozenset[str] = frozenset({"true", "t", "yes", "y", "1"})
160162
_FALSE_SEED_VALUES: frozenset[str] = frozenset({"false", "f", "no", "n", "0"})
161163

@@ -2237,7 +2239,9 @@ def render_loader_value_literal(
22372239
return "1" if value else "0"
22382240
if isinstance(value, int | float | Decimal):
22392241
return str(value)
2240-
if isinstance(value, datetime | date):
2242+
if isinstance(value, datetime):
2243+
return _quote_sql_string(canonical_timestamp_text(value.isoformat()))
2244+
if isinstance(value, date):
22412245
return _quote_sql_string(value.isoformat())
22422246
return _quote_sql_string(str(value))
22432247

@@ -2249,13 +2253,12 @@ def render_loader_rows_select(
22492253
column_sql_types: dict[str, str],
22502254
inferred_types: dict[str, LoaderLogicalType],
22512255
) -> str:
2252-
"""Render rows as one multi-row VALUES; SQLite names VALUES columns column1, column2."""
2256+
"""Render uncast rows as one VALUES; staging declares the types and affinity converts."""
22532257

2258+
del column_sql_types
22542259
if not rows:
22552260
projections: str = ", ".join(
2256-
f"CAST(NULL AS {column_sql_types.get(column_name, 'TEXT')}) AS "
2257-
f"{self.render_identifier(column_name)}"
2258-
for column_name in column_names
2261+
f"NULL AS {self.render_identifier(column_name)}" for column_name in column_names
22592262
)
22602263
return f"SELECT {projections} WHERE 1 = 0"
22612264
value_rows: list[str] = []
@@ -2270,21 +2273,18 @@ def render_loader_rows_select(
22702273
value_rows.append(f"({literals})")
22712274
values_sql: str = ", ".join(value_rows)
22722275
select_sql: str = ", ".join(
2273-
(
2274-
f"column{index} AS {self.render_identifier(column_name)}"
2275-
if column_name not in column_sql_types
2276-
else f"CAST(column{index} AS {column_sql_types[column_name]}) "
2277-
f"AS {self.render_identifier(column_name)}"
2278-
)
2276+
f"column{index} AS {self.render_identifier(column_name)}"
22792277
for index, column_name in enumerate(column_names, start=1)
22802278
)
22812279
return f"SELECT {select_sql} FROM (VALUES {values_sql}) AS __loader_rows"
22822280

22832281
def render_source_expression_cast(
22842282
self, *, expression: str, target_type: str, alias: str
22852283
) -> str:
2286-
"""Cast, rendering exact decimals as canonical fixed-scale text."""
2284+
"""Cast, keeping date and time text and rendering exact decimals as fixed-scale text."""
22872285

2286+
if _TEMPORAL_TYPE.match(target_type) is not None:
2287+
return f"{expression} AS {alias}"
22882288
scale: int | None = decimal_scale(target_type)
22892289
if scale is None:
22902290
return f"CAST({expression} AS {target_type}) AS {alias}"

‎src/sqlbuild/adapters/sqlite/constants.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
NUMBER_DECIMAL_TYPE_PATTERN: str = r"^\s*(?:DECIMAL|NUMERIC)\b"
3131
NUMBER_DECIMAL_REPORTED_TYPE: str = "NUMERIC"
3232
BOOLEAN_TYPE_MARKER: str = "BOOL"
33+
TEMPORAL_TYPE_PATTERN: str = r"^\s*(?:DATE|TIME)"
3334
INSERT_KEYWORD: str = "INSERT"
3435
SQLITE_VIEW_TYPE: str = "view"
3536
SQLITE_TABLE_TYPE: str = "table"

‎src/sqlbuild/executor/load/_helpers/rows.py‎

Lines changed: 26 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -136,29 +136,45 @@ def build_rows_sql(
136136
)
137137
if not resolved_column_names:
138138
raise ExecutorInputError("Source loader returned no rows and source declares no columns")
139-
declared_types: dict[str, str] = _declared_types(columns=columns)
140139
resolved_inferred_types: dict[str, LoaderLogicalType] = inferred_types or _infer_column_types(
141140
rows=rows
142141
)
143-
column_sql_types: dict[str, str] = {
142+
return adapter.render_loader_rows_select(
143+
rows=rows,
144+
column_names=resolved_column_names,
145+
column_sql_types=loader_column_sql_types(
146+
adapter=adapter,
147+
columns=columns,
148+
column_names=resolved_column_names,
149+
inferred_types=resolved_inferred_types,
150+
),
151+
inferred_types=resolved_inferred_types,
152+
)
153+
154+
155+
def loader_column_sql_types(
156+
*,
157+
adapter: BaseAdapter,
158+
columns: tuple[SourceColumnEntry, ...],
159+
column_names: tuple[str, ...],
160+
inferred_types: dict[str, LoaderLogicalType],
161+
) -> dict[str, str]:
162+
"""Return each loader column's declared or inferred warehouse type, when known."""
163+
164+
declared_types: dict[str, str] = _declared_types(columns=columns)
165+
return {
144166
column_name: sql_type
145-
for column_name in resolved_column_names
167+
for column_name in column_names
146168
if (
147169
sql_type := _column_sql_type(
148170
adapter=adapter,
149171
column_name=column_name,
150172
declared_types=declared_types,
151-
inferred_types=resolved_inferred_types,
173+
inferred_types=inferred_types,
152174
)
153175
)
154176
is not None
155177
}
156-
return adapter.render_loader_rows_select(
157-
rows=rows,
158-
column_names=resolved_column_names,
159-
column_sql_types=column_sql_types,
160-
inferred_types=resolved_inferred_types,
161-
)
162178

163179

164180
def _declared_types(*, columns: tuple[SourceColumnEntry, ...]) -> dict[str, str]:

‎src/sqlbuild/executor/load/_helpers/staging.py‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from sqlbuild.executor.load._helpers.rows import (
1515
build_rows_sql,
1616
iter_loader_row_batches,
17+
loader_column_sql_types,
1718
update_loader_rows_schema,
1819
)
1920
from sqlbuild.executor.load.models import LoaderRowsSchema
@@ -83,6 +84,12 @@ def write_loader_rows_to_staging(
8384
connection=connection,
8485
destination=staging,
8586
sql=sql,
87+
config=_staging_config(
88+
adapter=adapter,
89+
source_entry=source_entry,
90+
column_names=column_names,
91+
inferred_types=inferred_types,
92+
),
8693
statement_recorder=statement_recorder,
8794
)
8895
staging_created = True
@@ -99,11 +106,36 @@ def write_loader_rows_to_staging(
99106
connection=connection,
100107
destination=staging,
101108
sql=sql,
109+
config=_staging_config(
110+
adapter=adapter,
111+
source_entry=source_entry,
112+
column_names=column_names,
113+
inferred_types=inferred_types,
114+
),
102115
statement_recorder=statement_recorder,
103116
)
104117
return rows_loaded
105118

106119

120+
def _staging_config(
121+
*,
122+
adapter: BaseAdapter,
123+
source_entry: SourceEntry,
124+
column_names: tuple[str, ...],
125+
inferred_types: dict[str, LoaderLogicalType],
126+
) -> dict[str, Any]:
127+
"""Hand loader column types to adapters whose CTAS cannot keep them."""
128+
129+
return {
130+
"inferred_column_types": loader_column_sql_types(
131+
adapter=adapter,
132+
columns=source_entry.columns,
133+
column_names=column_names,
134+
inferred_types=inferred_types,
135+
)
136+
}
137+
138+
107139
def resolve_loader_relations(
108140
*, adapter: BaseAdapter, source_entry: SourceEntry, destination_name: str
109141
) -> tuple[str, str]:

‎tests/e2e/src/sqlbuild/cli/commands/main/sqlite/_test_types.py‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,3 +85,21 @@ class SqliteCursorWindowE2ETestCase:
8585
events_csv: str
8686
commands: tuple[tuple[str, ...], ...]
8787
expected_order_ids: tuple[tuple[object, ...], ...]
88+
89+
90+
@dataclass(frozen=True)
91+
class SqliteWaffleShopE2ETestCase:
92+
description: str
93+
commands: tuple[tuple[str, ...], ...]
94+
expected_fragments: tuple[str, ...]
95+
expected_row_counts: dict[str, int]
96+
expected_return_code: int = 0
97+
98+
99+
@dataclass(frozen=True)
100+
class SqliteLoaderE2ETestCase:
101+
description: str
102+
builds: int
103+
relations: tuple[str, ...]
104+
expected_rows: tuple[tuple[object, ...], ...]
105+
expected_declared_types: tuple[tuple[object, ...], ...]

0 commit comments

Comments
 (0)