Skip to content

Commit 2ab215e

Browse files
committed
Keep bind processing for non-plain import cells
Exact integer cells pass preflight but SQLite cannot bind integers beyond int64, so only rows proven float/None/str bypass SQLAlchemy. Strings that cannot be UTF-8 encoded fall back to the row-major checks.
1 parent 78a93e0 commit 2ab215e

3 files changed

Lines changed: 44 additions & 9 deletions

File tree

‎src/openstatspec/sql/profiles.py‎

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -128,8 +128,12 @@ def preflight(
128128
*,
129129
rows: Iterable[Mapping[str, Any]] | None = None,
130130
require_canonical_mapping: bool = True,
131-
) -> None:
132-
"""Validate strict target capabilities before any source dataset is created."""
131+
) -> bool:
132+
"""Validate strict target capabilities before any source dataset is created.
133+
134+
Returns True only when every cell was proven to be float/None (numeric) or
135+
str, so callers may bypass per-value bind processing.
136+
"""
133137
variables = None if isinstance(variables_or_count, int) else list(variables_or_count)
134138
variable_count = variables_or_count if isinstance(variables_or_count, int) else len(variables)
135139
if variable_count > profile.max_source_variables:
@@ -143,7 +147,7 @@ def preflight(
143147
max_physical=profile.max_source_variables + 1,
144148
)
145149
if variables is None:
146-
return
150+
return False
147151

148152
used = {"__case_ordinal"}
149153
source_names: set[str] = set()
@@ -216,7 +220,7 @@ def preflight(
216220
)
217221
rows = list(rows or ())
218222
if not rows or _rows_within_limits(profile, variables, rows):
219-
return
223+
return True
220224
# Slow path only reports the first failing row in row-major order.
221225
for row_ordinal, row in enumerate(rows, start=1):
222226
row_bytes = 8
@@ -317,6 +321,7 @@ def preflight(
317321
row_ordinal=row_ordinal, statement_bytes=statement_bytes,
318322
maximum=profile.max_statement_bytes,
319323
)
324+
return False
320325

321326

322327
def _rows_within_limits(
@@ -346,7 +351,10 @@ def _rows_within_limits(
346351
continue
347352
if set(map(type, column)) != {str}:
348353
return False
349-
longest = max(map(len, map(str.encode, column)))
354+
try:
355+
longest = max(map(len, map(str.encode, column)))
356+
except UnicodeEncodeError:
357+
return False
350358
if longest > profile.max_text_value_bytes:
351359
return False
352360
row_bytes += 20 if profile.name in MYSQL_WIRE_PROFILES else longest

‎src/openstatspec/sql/wide.py‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -871,7 +871,7 @@ def create_wide_dataset(
871871
fidelity_events = tuple(fidelity_events)
872872
source_rows = _canonicalize_database_numeric_rows(rows, variables)
873873
try:
874-
preflight(profile, variables, rows=source_rows)
874+
plain_rows = preflight(profile, variables, rows=source_rows)
875875
validate_spss_catalog(
876876
variables,
877877
case_weight_variable=case_weight_variable,
@@ -982,14 +982,17 @@ def create_wide_dataset(
982982
),
983983
)
984984
if source_rows:
985-
# Preflight proved cells are binary64/None or str, so the only
986-
# bind processor (SQLite to_float) is a no-op; skip the per-row
987-
# SQLAlchemy parameter pass and hand rows to the driver.
985+
# Plain rows hold only float/None and str, so the only bind
986+
# processor (SQLite to_float) is a no-op: hand them straight to
987+
# the driver. Anything else (e.g. exact ints) keeps SQLAlchemy.
988988
compiled = insert(data_table).compile(dialect=connection.dialect)
989989
keys = compiled.positiontup
990990
for batch in _bounded_batches(
991991
source_rows, variables, profile.max_statement_bytes,
992992
):
993+
if not plain_rows:
994+
connection.execute(insert(data_table), batch)
995+
continue
993996
connection.exec_driver_sql(compiled.string, (
994997
[tuple(map(row.__getitem__, keys)) for row in batch]
995998
if keys else batch

‎tests/test_sql_profiles.py‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -771,3 +771,27 @@ def test_column_preflight_fast_path_agrees_with_row_checks():
771771
preflight(MYSQL, variables, rows=[*plain, bad])
772772
too_long = [{"q": 1.0, "s": "x" * (MYSQL.max_text_value_bytes + 1)}]
773773
assert not _rows_within_limits(MYSQL, variables, too_long)
774+
assert not _rows_within_limits(MYSQL, variables, [{"q": 1.0, "s": "\ud800"}])
775+
# Exact ints are valid but not plain, so callers keep bind processing.
776+
assert preflight(MYSQL, variables, rows=[*plain, {"q": 2**63, "s": ""}]) is False
777+
with pytest.raises(UnsupportedOperationError, match="binary64"):
778+
preflight(MYSQL, variables, rows=[*plain, {"q": 2**53 + 1, "s": ""}])
779+
780+
781+
def test_import_stores_exact_integer_cells_as_binary64(tmp_path):
782+
from openstatspec.sql.wide import create_wide_dataset
783+
import sqlite3
784+
785+
database = f"sqlite:///{tmp_path / 'ints.sqlite'}"
786+
variables = [{
787+
"source_name": "q", "physical_name": "q", "ordinal": 1,
788+
"storage_kind": "numeric", "format": "F20.0",
789+
}]
790+
create_wide_dataset(
791+
database_url=database, dataset_id="ints", source_name="ints.sav",
792+
source_format="SAV", rows=[{"q": 2**63}, {"q": None}], variables=variables,
793+
)
794+
stored = sqlite3.connect(tmp_path / "ints.sqlite").execute(
795+
"select q from data_ints order by __case_ordinal"
796+
).fetchall()
797+
assert stored == [(float(2**63),), (None,)]

0 commit comments

Comments
 (0)