Skip to content

Commit 2cd0dae

Browse files
authored
Merge branch 'main' into feat/recall-core
2 parents 4070f4e + f5b655e commit 2cd0dae

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)