Skip to content

Commit bfd281b

Browse files
committed
perf: reuse canonical import rows
1 parent 127017a commit bfd281b

2 files changed

Lines changed: 123 additions & 8 deletions

File tree

‎src/openstatspec/sql/wide.py‎

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -879,10 +879,8 @@ def create_wide_dataset(
879879
for item in variables
880880
),
881881
)
882-
materialized = [
883-
{"__case_ordinal": ordinal, **row}
884-
for ordinal, row in enumerate(source_rows, start=1)
885-
]
882+
for ordinal, row in enumerate(source_rows, start=1):
883+
row.setdefault("__case_ordinal", ordinal)
886884
docs_rows = document_rows(normative_dataset_id, documents)
887885
labels_rows = value_label_rows(normative_dataset_id, variables)
888886
missing_rows = missing_rule_rows(normative_dataset_id, variables)
@@ -940,7 +938,7 @@ def create_wide_dataset(
940938
connection, normative, dataset_name=dataset_id,
941939
source_format=source_format, physical_table_name=data_table.name,
942940
dataset_label=file_label, source_encoding=source_encoding,
943-
source_hash=source_sha256, source_case_count=len(materialized),
941+
source_hash=source_sha256, source_case_count=len(source_rows),
944942
imported_at=imported_at or None, variables=variables,
945943
documents=docs_rows, value_labels=labels_rows,
946944
missing_rules=missing_rows, attributes=attributes_rows,
@@ -963,9 +961,9 @@ def create_wide_dataset(
963961
},) if operation_details else ()),
964962
),
965963
)
966-
if materialized:
964+
if source_rows:
967965
for batch in _bounded_batches(
968-
materialized, variables, profile.max_statement_bytes,
966+
source_rows, variables, profile.max_statement_bytes,
969967
):
970968
connection.execute(insert(data_table), batch)
971969
finish_normative_operation(
@@ -1038,7 +1036,7 @@ def create_wide_dataset(
10381036
"dataset_id": normative_dataset_id,
10391037
"dataset_name": dataset_id,
10401038
"data_table": data_table.name,
1041-
"case_count": len(materialized),
1039+
"case_count": len(source_rows),
10421040
"operation_id": operation_id,
10431041
}
10441042

‎tests/test_atomic_import.py‎

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import sqlite3
22
from contextlib import contextmanager
33
from dataclasses import replace
4+
from decimal import Decimal
5+
import struct
46

57
import pytest
68

@@ -9,6 +11,121 @@
911
from openstatspec.sql.wide import create_wide_dataset
1012

1113

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+
12129
def test_invalid_string_row_leaves_no_dataset_or_data_table(tmp_path) -> None:
13130
database_path = tmp_path / "dataset.sqlite"
14131
database = f"sqlite:///{database_path}"

0 commit comments

Comments
 (0)