Skip to content

Commit e09ba6e

Browse files
committed
Address strict catalog review findings
1 parent f4588a5 commit e09ba6e

12 files changed

Lines changed: 157 additions & 16 deletions

‎CHANGELOG.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@
1010
use the normative UUID-keyed OpenStatSpec catalog exclusively.
1111
- Existing databases that contain only the former compatibility catalog must be
1212
remediated manually before export.
13+
- Imports now reject dataset names that collide with normative UUID identifiers,
14+
cleanup drops only a physical table actually created by the failing import,
15+
and non-preserved SPSS compatible variable names are reported as an explicit
1316
All notable changes to this reference implementation are documented here.
1417

1518
## 0.4.0 — 2026-07-31

‎src/openstatspec/api.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ def capability_matrix(database_url: str | None = None) -> Mapping[str, Any]:
7373
"multiple_response_sets": "supported",
7474
"variable_alignment": "supported",
7575
"variable_sets": "supported",
76-
"compatible_variable_names": "supported",
76+
"compatible_variable_names": "requires-explicit-loss-consent",
7777
"custom_attributes": {
7878
"scalar_values": "supported",
7979
"ordered_value_arrays": "supported",

‎src/openstatspec/spss/sav.py‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -494,6 +494,21 @@ def _engine_loss_report(metadata: dict[str, Any]) -> dict[str, dict[str, Any]]:
494494
"detail": "pyspssio could not inspect source variable sets; they cannot be preserved silently.",
495495
"details": {"engine_error": metadata.get("_var_sets_error", "unknown")},
496496
}
497+
compatible_names = {
498+
str(source_name): str(compatible_name)
499+
for source_name, compatible_name
500+
in dict(metadata.get("var_compat_names") or {}).items()
501+
if compatible_name
502+
and str(source_name).casefold() != str(compatible_name).casefold()
503+
}
504+
if compatible_names:
505+
events["compatible-variable-names-not-preserved"] = {
506+
"code": "compatible-variable-names-not-preserved",
507+
"detail": (
508+
"The normative catalog does not store SPSS compatible variable names."
509+
),
510+
"details": {"variable_names": sorted(compatible_names)},
511+
}
497512
if _is_non_utf8_encoding(metadata.get("encoding")):
498513
events["source-encoding-not-preserved"] = {
499514
"code": "source-encoding-not-preserved",

‎src/openstatspec/sql/wide.py‎

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111

1212
from sqlalchemy import (
1313
BigInteger, Column, Float, MetaData, Table, Text, create_engine, insert,
14-
inspect, select,
14+
inspect, or_, select,
1515
)
1616
from sqlalchemy.dialects import mysql, postgresql, sqlite
1717
from ..core import UnsupportedOperationError
@@ -509,16 +509,18 @@ def create_wide_dataset(
509509
normative_dataset_id, multiple_response_sets,
510510
)
511511
namespace_owned = False
512-
data_table_was_absent = False
512+
data_table_created = False
513513
try:
514514
with engine.begin() as setup:
515515
create_normative_catalog(setup, normative)
516516
namespace_owned = True
517517
with engine.begin() as connection:
518518
if connection.execute(
519-
select(normative.dataset.c.dataset_id).where(
520-
normative.dataset.c.dataset_name == dataset_id
521-
)
519+
select(normative.dataset.c.dataset_id).where(or_(
520+
normative.dataset.c.dataset_name == dataset_id,
521+
normative.dataset.c.dataset_id == dataset_id,
522+
normative.dataset.c.dataset_name == normative_dataset_id,
523+
))
522524
).first():
523525
raise ValueError(
524526
f"Dataset {dataset_id!r} already exists; imports never overwrite a dataset."
@@ -542,8 +544,8 @@ def create_wide_dataset(
542544
operation_kind="import", status="started",
543545
source_format=source_format,
544546
)
545-
data_table_was_absent = True
546547
data_table.create(connection)
548+
data_table_created = True
547549
store_normative_dataset(
548550
connection, normative, dataset_name=dataset_id,
549551
source_format=source_format, physical_table_name=data_table.name,
@@ -582,7 +584,7 @@ def create_wide_dataset(
582584
cleanup, normative, normative_dataset_id,
583585
)
584586
if (
585-
data_table_was_absent
587+
data_table_created
586588
and inspect(cleanup).has_table(data_table.name)
587589
):
588590
data_table.drop(cleanup, checkfirst=True)

‎tests/test_atomic_import.py‎

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,80 @@ def test_failed_row_insert_leaves_no_catalog_or_data_table(tmp_path) -> None:
3232
assert connection.execute("select count(*) from dataset").fetchone() == (0,)
3333
assert connection.execute("select count(*) from variable").fetchone() == (0,)
3434

35+
def test_failed_create_does_not_drop_a_concurrently_created_table(
36+
tmp_path, monkeypatch,
37+
) -> None:
38+
database_path = tmp_path / "race.sqlite"
39+
database = f"sqlite:///{database_path}"
40+
variables = [{
41+
"ordinal": 1, "source_name": "name", "physical_name": "name",
42+
"storage_kind": "string", "string_width": 8, "label": "",
43+
"format": "A8", "measure": "nominal", "alignment": "left",
44+
"display_width": 8, "value_labels": "{}", "missing_ranges": "[]",
45+
}]
46+
real_inspect = wide.inspect
47+
inspected_data_table = 0
48+
49+
def inspect_with_concurrent_table(bind):
50+
actual = real_inspect(bind)
51+
52+
class Inspector:
53+
def has_table(self, table_name):
54+
nonlocal inspected_data_table
55+
if table_name == "data_race":
56+
inspected_data_table += 1
57+
return inspected_data_table > 1
58+
return actual.has_table(table_name)
59+
60+
return Inspector()
61+
62+
real_create = wide.Table.create
63+
real_drop = wide.Table.drop
64+
dropped = []
65+
66+
def fail_data_table_create(table, bind, **kwargs):
67+
if table.name == "data_race":
68+
raise RuntimeError("concurrent create won")
69+
return real_create(table, bind, **kwargs)
70+
71+
def observe_drop(table, bind, **kwargs):
72+
if table.name == "data_race":
73+
dropped.append(table.name)
74+
return real_drop(table, bind, **kwargs)
75+
76+
monkeypatch.setattr(wide, "inspect", inspect_with_concurrent_table)
77+
monkeypatch.setattr(wide.Table, "create", fail_data_table_create)
78+
monkeypatch.setattr(wide.Table, "drop", observe_drop)
79+
80+
with pytest.raises(RuntimeError, match="concurrent create won"):
81+
create_wide_dataset(
82+
database_url=database, dataset_id="race", source_name="race.sav",
83+
source_format="SAV", rows=[{"name": "ok"}], variables=variables,
84+
)
85+
86+
assert inspected_data_table == 1
87+
assert dropped == []
88+
89+
90+
def test_dataset_name_cannot_equal_an_existing_normative_uuid(tmp_path) -> None:
91+
database = f"sqlite:///{tmp_path / 'namespace.sqlite'}"
92+
variables = [{
93+
"ordinal": 1, "source_name": "name", "physical_name": "name",
94+
"storage_kind": "string", "string_width": 8, "label": "",
95+
"format": "A8", "measure": "nominal", "alignment": "left",
96+
"display_width": 8, "value_labels": "{}", "missing_ranges": "[]",
97+
}]
98+
first = create_wide_dataset(
99+
database_url=database, dataset_id="first", source_name="first.sav",
100+
source_format="SAV", rows=[{"name": "first"}], variables=variables,
101+
)
102+
103+
with pytest.raises(ValueError, match="already exists"):
104+
create_wide_dataset(
105+
database_url=database, dataset_id=first["dataset_id"],
106+
source_name="second.sav", source_format="SAV",
107+
rows=[{"name": "second"}], variables=variables,
108+
)
35109

36110
def test_failed_preflight_persists_operation_without_creating_dataset(tmp_path) -> None:
37111
database_path = tmp_path / "preflight.sqlite"

‎tests/test_cli.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ def test_capability_matrix_is_public_and_cli_matches_engine_boundary(capsys) ->
6464
"multiple_response_sets": "supported",
6565
"variable_alignment": "supported",
6666
"variable_sets": "supported",
67-
"compatible_variable_names": "supported",
67+
"compatible_variable_names": "requires-explicit-loss-consent",
6868
"custom_attributes": {
6969
"scalar_values": "supported",
7070
"ordered_value_arrays": "supported",

‎tests/test_document_round_trip.py‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,10 @@ def test_document_and_long_variable_name_round_trip_to_zsav(tmp_path: Path) -> N
8181
write_document_lines(source, ["Combined dictionary fixture."], encoding="UTF-8")
8282

8383
openstatspec.import_sav(source, database_url=database, dataset_id="combined")
84-
openstatspec.export_sav(database_url=database, dataset_id="combined", destination=destination)
84+
openstatspec.export_sav(
85+
database_url=database, dataset_id="combined", destination=destination,
86+
allow_loss=["compatible-variable-names-not-preserved"],
87+
)
8588

8689
assert read_document_lines(destination, encoding="UTF-8") == ["Combined dictionary fixture."]
8790
assert pyspssio.read_sav(str(destination))[0][source_name].tolist() == [7.0]

‎tests/test_loss_reports.py‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from openstatspec.core import UnsupportedOperationError
99
from openstatspec.sql.wide import create_wide_dataset
1010
from openstatspec.spss import sav as sav_module
11+
from openstatspec.spss.raw_dictionary import write_compatible_names
1112

1213

1314
_REQUIRED_ENGINE_LOSS = []
@@ -118,3 +119,36 @@ def test_legacy_locale_must_emit_the_exact_source_encoding() -> None:
118119
sav_module._require_matching_legacy_encoding("WINDOWS-1252", "CP1252", True)
119120
with pytest.raises(UnsupportedOperationError, match="instead of required"):
120121
sav_module._require_matching_legacy_encoding("WINDOWS-1252", "UTF-8", True)
122+
123+
124+
def test_compatible_variable_names_are_explicit_imported_loss(tmp_path) -> None:
125+
source = tmp_path / "compatible-name.sav"
126+
destination = tmp_path / "compatible-name-out.sav"
127+
database = f"sqlite:///{tmp_path / 'compatible-name.sqlite'}"
128+
source_name = "long_variable_name"
129+
loss_code = "compatible-variable-names-not-preserved"
130+
131+
pyspssio.write_sav(str(source), pd.DataFrame({source_name: [1.0]}))
132+
write_compatible_names(source, {source_name: "ANSWER"}, encoding="UTF-8")
133+
134+
imported = openstatspec.import_sav(
135+
source, database_url=database, dataset_id="compatible-name",
136+
)
137+
assert {diagnostic.code for diagnostic in imported.diagnostics} == {loss_code}
138+
assert imported.diagnostics[0].details == {"variable_names": [source_name]}
139+
140+
with pytest.raises(UnsupportedOperationError, match=loss_code):
141+
openstatspec.export_sav(
142+
database_url=database, dataset_id="compatible-name",
143+
destination=destination,
144+
)
145+
146+
exported = openstatspec.export_sav(
147+
database_url=database, dataset_id="compatible-name",
148+
destination=destination, allow_loss=[loss_code],
149+
)
150+
assert {diagnostic.code for diagnostic in exported.diagnostics} == {loss_code}
151+
assert (
152+
pyspssio.read_metadata(str(destination))["var_compat_names"][source_name]
153+
!= "ANSWER"
154+
)

‎tests/test_official_conformance.py‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -235,16 +235,21 @@ def _assert_round_trip(
235235
imported = openstatspec.import_sav(
236236
source, database_url=database_url, dataset_id=dataset_id,
237237
)
238-
assert imported.diagnostics == ()
238+
assert {diagnostic.code for diagnostic in imported.diagnostics} <= {
239+
"compatible-variable-names-not-preserved",
240+
}
239241
assert openstatspec.validate(
240242
database_url=database_url, dataset_id=dataset_id,
241243
)["valid"] is True
242244
_assert_expected_catalog(database_url, dataset_id, fixture.get("expected_catalog", {}))
243245

244246
exported = openstatspec.export_sav(
245247
database_url=database_url, dataset_id=dataset_id, destination=destination,
248+
allow_loss=["compatible-variable-names-not-preserved"],
246249
)
247-
assert exported.diagnostics == ()
250+
assert {diagnostic.code for diagnostic in exported.diagnostics} <= {
251+
"compatible-variable-names-not-preserved",
252+
}
248253
assert compare_sav_semantics(source, destination) == {
249254
"equivalent": True,
250255
"differences": [],

‎tests/test_sav_sqlite.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212

1313
_REQUIRED_ENGINE_LOSS = []
1414

15-
_COMPAT_NAME_LOSS = _REQUIRED_ENGINE_LOSS
15+
_COMPAT_NAME_LOSS = [*_REQUIRED_ENGINE_LOSS, "compatible-variable-names-not-preserved"]
1616

1717
def test_pyspssio_round_trip_uses_one_wide_table_and_catalog(tmp_path) -> None:
1818
source = tmp_path / "tiny.sav"

0 commit comments

Comments
 (0)