|
1 | 1 | import sqlite3 |
2 | 2 | from contextlib import contextmanager |
3 | 3 | from dataclasses import replace |
| 4 | +from decimal import Decimal |
| 5 | +import struct |
4 | 6 |
|
5 | 7 | import pytest |
6 | 8 |
|
|
9 | 11 | from openstatspec.sql.wide import create_wide_dataset |
10 | 12 |
|
11 | 13 |
|
| 14 | +def _reuse_variables() -> list[dict[str, object]]: |
| 15 | + return [ |
| 16 | + { |
| 17 | + "ordinal": 1, "source_name": "score", "physical_name": "score", |
| 18 | + "storage_kind": "numeric", "string_width": None, "label": "Score", |
| 19 | + "format": "F8.2", "measure": "scale", "alignment": "right", |
| 20 | + "display_width": 8, "value_labels": "{}", "missing_ranges": "[]", |
| 21 | + }, |
| 22 | + { |
| 23 | + "ordinal": 2, "source_name": "label", "physical_name": "label", |
| 24 | + "storage_kind": "string", "string_width": 16, "label": "Label", |
| 25 | + "format": "A16", "measure": "nominal", "alignment": "left", |
| 26 | + "display_width": 16, "value_labels": "{}", "missing_ranges": "[]", |
| 27 | + }, |
| 28 | + ] |
| 29 | + |
| 30 | + |
| 31 | +def test_import_reuses_canonical_rows_after_preflight(tmp_path, monkeypatch) -> None: |
| 32 | + database_path = tmp_path / "reuse.sqlite" |
| 33 | + database = f"sqlite:///{database_path}" |
| 34 | + input_rows = [ |
| 35 | + {"score": Decimal("1.25"), "label": " Ä "}, |
| 36 | + {"score": None, "label": ""}, |
| 37 | + {"score": 0.1, "label": "trailing "}, |
| 38 | + ] |
| 39 | + original_rows = [dict(row) for row in input_rows] |
| 40 | + canonical_rows = None |
| 41 | + batches = [] |
| 42 | + real_canonicalize = wide._canonicalize_database_numeric_rows |
| 43 | + real_batches = wide._bounded_batches |
| 44 | + |
| 45 | + def capture_canonicalize(rows, variables): |
| 46 | + nonlocal canonical_rows |
| 47 | + canonical_rows = real_canonicalize(rows, variables) |
| 48 | + return canonical_rows |
| 49 | + |
| 50 | + def capture_batches(rows, variables, maximum_statement_bytes): |
| 51 | + assert rows is canonical_rows |
| 52 | + assert all(actual is expected for actual, expected in zip(rows, canonical_rows, strict=True)) |
| 53 | + for batch in real_batches(rows, variables, maximum_statement_bytes): |
| 54 | + batches.append(batch) |
| 55 | + yield batch |
| 56 | + |
| 57 | + monkeypatch.setattr( |
| 58 | + wide, "_canonicalize_database_numeric_rows", capture_canonicalize, |
| 59 | + ) |
| 60 | + monkeypatch.setattr(wide, "_bounded_batches", capture_batches) |
| 61 | + monkeypatch.setattr( |
| 62 | + wide, "effective_profile", |
| 63 | + lambda _url: (replace(SQLITE, max_statement_bytes=80), {}), |
| 64 | + ) |
| 65 | + |
| 66 | + result = create_wide_dataset( |
| 67 | + database_url=database, dataset_id="reuse", source_name="fixture.sav", |
| 68 | + source_format="SAV", rows=(row for row in input_rows), |
| 69 | + variables=_reuse_variables(), |
| 70 | + ) |
| 71 | + |
| 72 | + assert input_rows == original_rows |
| 73 | + assert result["case_count"] == len(input_rows) |
| 74 | + assert len(batches) == len(input_rows) |
| 75 | + connection = sqlite3.connect(database_path) |
| 76 | + try: |
| 77 | + rows = connection.execute( |
| 78 | + "select __case_ordinal, score, label from data_reuse " |
| 79 | + "order by __case_ordinal" |
| 80 | + ).fetchall() |
| 81 | + finally: |
| 82 | + connection.close() |
| 83 | + assert [row[0] for row in rows] == [1, 2, 3] |
| 84 | + assert rows[0][1] == 1.25 and rows[0][2] == " Ä " |
| 85 | + assert rows[1][1] is None and rows[1][2] == "" |
| 86 | + assert struct.pack(">d", rows[2][1]) == struct.pack(">d", 0.1) |
| 87 | + assert rows[2][2] == "trailing " |
| 88 | + |
| 89 | + |
| 90 | +@pytest.mark.parametrize( |
| 91 | + ("rows", "expected"), |
| 92 | + [ |
| 93 | + ( |
| 94 | + [ |
| 95 | + {"score": 0.1, "label": "ten", "__case_ordinal": 10}, |
| 96 | + {"score": 2.0, "label": "two", "__case_ordinal": 2}, |
| 97 | + ], |
| 98 | + [(2, 2.0, "two"), (10, 0.1, "ten")], |
| 99 | + ), |
| 100 | + ([], []), |
| 101 | + ], |
| 102 | +) |
| 103 | +def test_import_preserves_supplied_ordinals_and_empty_input( |
| 104 | + tmp_path, rows, expected, |
| 105 | +) -> None: |
| 106 | + database_path = tmp_path / "ordinals.sqlite" |
| 107 | + result = create_wide_dataset( |
| 108 | + database_url=f"sqlite:///{database_path}", dataset_id="ordinals", |
| 109 | + source_name="fixture.sav", source_format="SAV", rows=rows, |
| 110 | + variables=_reuse_variables(), |
| 111 | + ) |
| 112 | + |
| 113 | + connection = sqlite3.connect(database_path) |
| 114 | + try: |
| 115 | + actual = connection.execute( |
| 116 | + "select __case_ordinal, score, label from data_ordinals " |
| 117 | + "order by __case_ordinal" |
| 118 | + ).fetchall() |
| 119 | + case_count = connection.execute( |
| 120 | + "select source_case_count from dataset where dataset_id = ?", |
| 121 | + (result["dataset_id"],), |
| 122 | + ).fetchone()[0] |
| 123 | + finally: |
| 124 | + connection.close() |
| 125 | + assert actual == expected |
| 126 | + assert case_count == len(rows) |
| 127 | + |
| 128 | + |
12 | 129 | def test_invalid_string_row_leaves_no_dataset_or_data_table(tmp_path) -> None: |
13 | 130 | database_path = tmp_path / "dataset.sqlite" |
14 | 131 | database = f"sqlite:///{database_path}" |
|
0 commit comments