Skip to content

Commit 1f4b89b

Browse files
committed
test: require dataset-scoped transformation label reads
1 parent c6cf9e5 commit 1f4b89b

1 file changed

Lines changed: 225 additions & 1 deletion

File tree

‎tests/test_inplace_transform.py‎

Lines changed: 225 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,14 @@
33
import hashlib
44
import json
55
import sqlite3
6+
from contextlib import contextmanager
67
from dataclasses import replace
78
from types import SimpleNamespace
89
from unittest.mock import patch
910

1011
import pytest
11-
from sqlalchemy import create_engine, inspect, text
12+
from sqlalchemy import create_engine, event, inspect, text
13+
from sqlalchemy.engine import Dialect, Engine
1214
from sqlalchemy.exc import IntegrityError
1315

1416
import openstatspec
@@ -89,6 +91,228 @@ def catalog(tmp_path):
8991
return url, path, dataset_id, table_name
9092

9193

94+
@pytest.fixture
95+
def label_catalog(catalog):
96+
url, path, dataset_id, _table_name = catalog
97+
openstatspec.apply_spss_in_place(
98+
database_url=url, dataset_id=dataset_id, actor="test-setup",
99+
source_text=(
100+
"COMPUTE other = score. STRING note (A4). "
101+
"FORMATS other (F8.0). VARIABLE LEVEL other (SCALE). "
102+
"VALUE LABELS score 2 'Two' 1 'One'. VALUE LABELS note 'x' 'Text'."
103+
),
104+
)
105+
create_wide_dataset(
106+
database_url=url, dataset_id="sibling", source_name="sibling.sav",
107+
source_format="SAV", source_sha256="e" * 64,
108+
rows=[{"score": n, "bulk": n} for n in (1.0, 2.0, 3.0)],
109+
variables=[_variables()[0], {
110+
**_variables()[0], "ordinal": 2, "source_name": "bulk", "physical_name": "bulk",
111+
"value_labels": '{"9": "Unrelated"}',
112+
}],
113+
)
114+
with sqlite3.connect(path) as connection:
115+
shared_set = connection.execute(
116+
"SELECT value_label_set_id FROM variable_value_label_set "
117+
"JOIN variable USING (variable_id) WHERE dataset_id = ? "
118+
"AND source_name = 'score'", (dataset_id,),
119+
).fetchone()[0]
120+
sibling_id, _bulk_variable, bulk_set = connection.execute(
121+
"SELECT dataset_id, variable_id, value_label_set_id FROM variable "
122+
"JOIN variable_value_label_set USING (variable_id) WHERE dataset_id != ?",
123+
(dataset_id,),
124+
).fetchone()
125+
sibling_variable = connection.execute(
126+
"SELECT variable_id FROM variable WHERE dataset_id = ? AND source_name = 'score'",
127+
(sibling_id,),
128+
).fetchone()[0]
129+
other_id = connection.execute(
130+
"SELECT variable_id FROM variable WHERE dataset_id = ? AND source_name = 'other'",
131+
(dataset_id,),
132+
).fetchone()[0]
133+
connection.execute(
134+
"UPDATE value_label_set SET dataset_id = ? WHERE value_label_set_id = ?",
135+
(sibling_id, shared_set),
136+
)
137+
connection.executemany(
138+
"INSERT INTO variable_value_label_set VALUES (?, ?)",
139+
[(other_id, shared_set), (sibling_variable, shared_set)],
140+
)
141+
return catalog, shared_set, bulk_set
142+
143+
144+
@contextmanager
145+
def _count_sqlite_reads():
146+
# Count only rows actually fetched by the consumer, not SELECT rowcount or
147+
# an event-side drain. Connection-setup reads without a SQL event are excluded.
148+
counts = {"statements": 0, "rows": 0, "labels": 0}
149+
150+
class Cursor(sqlite3.Cursor):
151+
counted = False
152+
153+
def record(self, rows):
154+
if self.counted:
155+
counts["rows"] += len(rows)
156+
columns = {column[0] for column in self.description or ()}
157+
if {"variable_id", "code_kind", "numeric_code", "string_code", "label"} <= columns:
158+
counts["labels"] += len(rows)
159+
return rows
160+
161+
def fetchone(self):
162+
row = super().fetchone()
163+
self.record([] if row is None else [row])
164+
return row
165+
166+
def fetchmany(self, size=None):
167+
return self.record(super().fetchmany() if size is None else super().fetchmany(size))
168+
169+
def fetchall(self):
170+
return self.record(super().fetchall())
171+
172+
class Connection(sqlite3.Connection):
173+
def cursor(self, *args, **kwargs):
174+
return super().cursor(*args, **{**kwargs, "factory": Cursor})
175+
176+
def connect(dialect, _record, _args, kwargs):
177+
if dialect.name == "sqlite":
178+
kwargs["factory"] = Connection
179+
180+
def before(_connection, cursor, _statement, _parameters, _context, _many):
181+
counts["statements"] += 1
182+
cursor.counted = True
183+
184+
event.listen(Dialect, "do_connect", connect)
185+
event.listen(Engine, "before_cursor_execute", before)
186+
try:
187+
yield counts
188+
finally:
189+
event.remove(Engine, "before_cursor_execute", before)
190+
event.remove(Dialect, "do_connect", connect)
191+
192+
193+
def _label_surface(catalog, surface, *, unknown=False):
194+
url, _path, dataset_id, _table_name = catalog
195+
if surface == "loader":
196+
engine = create_engine(url)
197+
try:
198+
with engine.connect() as connection:
199+
return inplace_transform.load_transformation_schema(connection, dataset_id)
200+
finally:
201+
engine.dispose()
202+
syntax = (
203+
"VARIABLE LABELS missing 'Must fail'." if unknown else
204+
"RECODE score (1 = 7). VARIABLE LABELS score 'Changed'."
205+
)
206+
if surface == "spss":
207+
return openstatspec.apply_spss_in_place(
208+
database_url=url, dataset_id=dataset_id, actor="test-agent", source_text=syntax,
209+
)
210+
plan = (openstatspec.TransformationPlan((
211+
openstatspec.SetVariableLabelOperation("missing", "Must fail"),
212+
)) if unknown else _plan(syntax))
213+
return openstatspec.apply_transformation_plan_in_place(
214+
database_url=url, dataset_id=dataset_id, actor="test-agent", plan=plan,
215+
)
216+
217+
218+
@pytest.mark.parametrize("surface", ["loader", "canonical", "spss"])
219+
def test_transformation_label_reads_are_dataset_scoped(label_catalog, surface):
220+
catalog, _shared_set, bulk_set = label_catalog
221+
url, path, dataset_id, table_name = catalog
222+
numeric_labels = tuple(
223+
openstatspec.ValueLabel(openstatspec.TypedValue.binary64(code), label)
224+
for code, label in [(2, "Two"), (1, "One")]
225+
)
226+
expected = openstatspec.VariableSchema((
227+
openstatspec.VariableDefinition(
228+
"score", "numeric", variable_label="Score", value_labels=numeric_labels,
229+
format_family="F", format_width=8, format_decimals=0, measurement_level="scale",
230+
),
231+
openstatspec.VariableDefinition(
232+
"other", "numeric", value_labels=numeric_labels,
233+
format_family="F", format_width=8, format_decimals=0, measurement_level="scale",
234+
),
235+
openstatspec.VariableDefinition(
236+
"note", "string", declared_string_width=4,
237+
value_labels=(openstatspec.ValueLabel(openstatspec.TypedValue.string("x"), "Text"),),
238+
),
239+
))
240+
measurements = []
241+
validations = []
242+
for size in (0, 3, 40):
243+
with sqlite3.connect(path) as connection:
244+
connection.execute("DELETE FROM value_label WHERE value_label_set_id = ?", (bulk_set,))
245+
connection.executemany(
246+
"INSERT INTO value_label (value_label_id, value_label_set_id, ordinal, code_kind, numeric_code, label) "
247+
"VALUES (?, ?, ?, 'numeric', ?, ?)",
248+
[(f"bulk-{n}", bulk_set, n + 1, n, f"Unrelated {n}") for n in range(size)],
249+
)
250+
with _count_sqlite_reads() as counts:
251+
result = _label_surface(catalog, surface)
252+
measurements.append(dict(counts))
253+
if surface == "loader":
254+
assert result == expected
255+
else:
256+
assert (result["dataset_id"], result["physical_table_name"]) == (dataset_id, table_name)
257+
assert _label_surface(catalog, "loader") == replace(
258+
expected, variables=(replace(expected.variables[0], variable_label="Changed"), *expected.variables[1:]),
259+
)
260+
with sqlite3.connect(path) as connection:
261+
assert connection.execute(
262+
f'SELECT __case_ordinal, score, other, note FROM "{table_name}" ORDER BY __case_ordinal'
263+
).fetchall() == [(1, 7.0, 1.0, ""), (2, 2.0, 2.0, ""), (3, 3.0, 3.0, "")]
264+
assert connection.execute(
265+
"SELECT status, actor, source_kind, plan_hash, source_hash FROM transformation_apply "
266+
"WHERE apply_id = ?", (result["apply_id"],),
267+
).fetchone() == (
268+
"succeeded", "test-agent", result["source_kind"], result["plan_hash"], result["source_hash"],
269+
)
270+
validations.append(openstatspec.validate(database_url=url, dataset_id=dataset_id))
271+
assert validations[0]["valid"] is True
272+
assert validations == [validations[0]] * 3
273+
print(surface, measurements)
274+
assert [m["labels"] for m in measurements] == [10 if surface == "spss" else 5] * 3
275+
assert measurements == [measurements[0]] * 3
276+
if surface == "loader":
277+
assert measurements[0] == {"statements": 3, "rows": 9, "labels": 5}
278+
279+
280+
@pytest.mark.parametrize("surface", ["loader", "canonical", "spss"])
281+
@pytest.mark.parametrize("target_linked", [False, True], ids=["unrelated", "target-linked"])
282+
def test_transformation_malformed_label_isolation(label_catalog, surface, target_linked):
283+
catalog, shared_set, bulk_set = label_catalog
284+
_url, path, _dataset_id, _table_name = catalog
285+
# Capture the ordinary binding diagnostic before introducing the numeric NULL.
286+
baseline = None
287+
if surface == "loader":
288+
baseline = _label_surface(catalog, surface)
289+
else:
290+
with pytest.raises(openstatspec.TransformationFrontendError) as caught:
291+
_label_surface(catalog, surface, unknown=True)
292+
baseline = caught.value
293+
with sqlite3.connect(path) as connection:
294+
connection.execute(
295+
"UPDATE value_label SET numeric_code = NULL WHERE value_label_set_id = ? AND ordinal = 1",
296+
(shared_set if target_linked else bulk_set,),
297+
)
298+
before = tuple(connection.iterdump())
299+
try:
300+
if target_linked:
301+
with pytest.raises(TypeError) as caught:
302+
_label_surface(catalog, surface, unknown=True)
303+
assert str(caught.value) == "float() argument must be a string or a real number, not 'NoneType'"
304+
elif surface == "loader":
305+
assert _label_surface(catalog, surface) == baseline
306+
else:
307+
with pytest.raises(openstatspec.TransformationFrontendError) as caught:
308+
_label_surface(catalog, surface, unknown=True)
309+
assert str(caught.value) == str(baseline)
310+
assert vars(caught.value) == vars(baseline)
311+
finally:
312+
with sqlite3.connect(path) as connection:
313+
assert tuple(connection.iterdump()) == before
314+
315+
92316
def _publish_score(catalog):
93317
url, _path, dataset_id, _table_name = catalog
94318
openstatspec.apply_spss_in_place(

0 commit comments

Comments
 (0)