|
3 | 3 | import hashlib |
4 | 4 | import json |
5 | 5 | import sqlite3 |
| 6 | +from contextlib import contextmanager |
6 | 7 | from dataclasses import replace |
7 | 8 | from types import SimpleNamespace |
8 | 9 | from unittest.mock import patch |
9 | 10 |
|
10 | 11 | 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 |
12 | 14 | from sqlalchemy.exc import IntegrityError |
13 | 15 |
|
14 | 16 | import openstatspec |
@@ -89,6 +91,228 @@ def catalog(tmp_path): |
89 | 91 | return url, path, dataset_id, table_name |
90 | 92 |
|
91 | 93 |
|
| 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 | + |
92 | 316 | def _publish_score(catalog): |
93 | 317 | url, _path, dataset_id, _table_name = catalog |
94 | 318 | openstatspec.apply_spss_in_place( |
|
0 commit comments