Skip to content

Commit bd59c75

Browse files
committed
Validate physical SQL variable type contract
1 parent 1bffe46 commit bd59c75

2 files changed

Lines changed: 28 additions & 1 deletion

File tree

‎src/openstatspec/sql/wide.py‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,13 +124,25 @@ def validate_wide_dataset(*, database_url: str, dataset_id: str) -> dict[str, An
124124
if not variables:
125125
raise ValueError("A conforming dataset needs at least one source variable.")
126126
expected_columns = {"__case_ordinal", *(item["physical_name"] for item in variables)}
127-
actual_columns = set(rows[0]) if rows else {column.name for column in Table(dataset["data_table"], MetaData(), autoload_with=create_engine(database_url)).columns}
127+
reflected_table = Table(dataset["data_table"], MetaData(), autoload_with=create_engine(database_url))
128+
reflected_columns = {column.name: column for column in reflected_table.columns}
129+
actual_columns = set(reflected_columns)
128130
if actual_columns != expected_columns:
129131
raise ValueError("Data-table columns do not match the registered source variables.")
130132
if dataset["case_count"] != len(rows):
131133
raise ValueError("Registered case count does not match the data table.")
132134
if len({item["physical_name"] for item in variables}) != len(variables):
133135
raise ValueError("Registered physical variable names are not unique.")
136+
case_ordinal = reflected_columns["__case_ordinal"]
137+
if not isinstance(case_ordinal.type, BigInteger) or case_ordinal.nullable:
138+
raise ValueError("Reserved case ordinal must be a non-null BIGINT column.")
139+
for item in variables:
140+
column = reflected_columns[item["physical_name"]]
141+
if item["storage_kind"] == "numeric":
142+
if not isinstance(column.type, Float) or not column.nullable:
143+
raise ValueError(f"Numeric variable {item['source_name']!r} must be a nullable binary64 column.")
144+
elif not isinstance(column.type, Text) or column.nullable:
145+
raise ValueError(f"String variable {item['source_name']!r} must be a non-null text column.")
134146
if [row["__case_ordinal"] for row in rows] != list(range(1, len(rows) + 1)):
135147
raise ValueError("Case ordinals are not contiguous source order.")
136148
return {"dataset_id": dataset["dataset_id"], "valid": True, "case_count": len(rows), "variable_count": len(variables)}

‎tests/test_sav_sqlite.py‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import hashlib
2+
import pytest
23
import json
34
import sqlite3
45

@@ -56,3 +57,17 @@ def test_sav_round_trip_uses_one_wide_table_and_catalog(tmp_path) -> None:
5657
assert meta.missing_ranges == {"age": [{"lo": 34.0, "hi": 34.0}]}
5758
assert meta.notes == ["Fixture note"]
5859

60+
61+
62+
def test_validation_rejects_catalog_type_mismatch(tmp_path) -> None:
63+
source = tmp_path / "fixture.sav"
64+
database_path = tmp_path / "dataset.sqlite"
65+
database = f"sqlite:///{database_path}"
66+
pyreadstat.write_sav(pd.DataFrame({"answer": [1.0]}), source)
67+
openstatspec.import_sav(source, database_url=database, dataset_id="fixture")
68+
69+
connection = sqlite3.connect(database_path)
70+
connection.execute("update variable_catalog set storage_kind = 'string' where dataset_id = 'fixture'")
71+
connection.commit()
72+
with pytest.raises(ValueError, match="String variable"):
73+
openstatspec.validate(database_url=database, dataset_id="fixture")

0 commit comments

Comments
 (0)