Skip to content

Commit 600ac98

Browse files
committed
Harden persistent and bound catalog operations
1 parent a817c17 commit 600ac98

5 files changed

Lines changed: 173 additions & 15 deletions

File tree

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
"""Database URL invariants shared by persistent catalog operations."""
2+
3+
from sqlalchemy.engine import make_url
4+
5+
from ..core import UnsupportedOperationError
6+
7+
8+
def require_persistent_database_url(database_url: str) -> None:
9+
"""Reject SQLite URLs whose catalog disappears with a connection or engine."""
10+
parsed_url = make_url(database_url)
11+
if parsed_url.get_backend_name() != "sqlite":
12+
return
13+
database = parsed_url.database or ""
14+
mode = str(parsed_url.query.get("mode", "")).lower()
15+
if (
16+
database in {"", ":memory:"}
17+
or database.lower() == "file::memory:"
18+
or mode == "memory"
19+
):
20+
raise UnsupportedOperationError(
21+
"OpenStatSpec catalogs require a persistent SQLite database URL."
22+
)

‎src/openstatspec/sql/wide.py‎

Lines changed: 46 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
store_imported_dataset as store_normative_dataset,
3333
)
3434
from .catalog_verification import verify_catalog_relations
35+
from .database_urls import require_persistent_database_url
3536

3637
_IDENTIFIER = re.compile(r"[^a-zA-Z0-9_]+")
3738

@@ -669,12 +670,20 @@ def initialize_wide_catalog(
669670
) -> dict[str, Any]:
670671
"""Initialize or verify the singular normative OpenStatSpec catalog."""
671672
validate_connection_url(database_url)
672-
profile, _active = effective_profile(
673+
require_persistent_database_url(database_url)
674+
profile, active = effective_profile(
673675
database_url, dolt_conformance_source=dolt_conformance_source,
674676
)
675677
engine = create_engine(database_url)
676678
normative = normative_catalog(MetaData())
677-
with engine.begin() as connection:
679+
audit_relations = {
680+
normative.fidelity_event.name,
681+
normative.operation.name,
682+
}
683+
with _bound_catalog_transaction(
684+
engine=engine, profile_name=profile.name, active=active,
685+
audit_relations=audit_relations, phase="catalog initialization",
686+
) as connection:
678687
inspector = inspect(connection)
679688
views = set(inspector.get_view_names())
680689
if views and not inspector.has_table(normative.catalog_identity.name):
@@ -755,8 +764,9 @@ def create_wide_dataset(
755764
dolt_conformance_source: Any | None = None,
756765
) -> dict[str, Any]:
757766
del source_table_name, source_created_at, source_modified_at
767+
require_persistent_database_url(database_url)
758768
validate_connection_url(database_url)
759-
profile, _active_connection = (
769+
profile, active_connection = (
760770
effective_profile(database_url)
761771
if dolt_conformance_source is None
762772
else effective_profile(
@@ -765,7 +775,14 @@ def create_wide_dataset(
765775
)
766776
engine = create_engine(database_url)
767777
normative = normative_catalog(MetaData())
768-
with engine.begin() as catalog_connection:
778+
audit_relations = {
779+
normative.fidelity_event.name,
780+
normative.operation.name,
781+
}
782+
with _bound_catalog_transaction(
783+
engine=engine, profile_name=profile.name, active=active_connection,
784+
audit_relations=audit_relations, phase="catalog initialization",
785+
) as catalog_connection:
769786
catalog_existed = inspect(catalog_connection).has_table(
770787
normative.catalog_identity.name
771788
)
@@ -827,7 +844,10 @@ def create_wide_dataset(
827844
with engine.begin() as setup:
828845
_verify_normative_catalog(setup, normative)
829846
namespace_owned = True
830-
with engine.begin() as connection:
847+
with _bound_catalog_transaction(
848+
engine=engine, profile_name=profile.name, active=active_connection,
849+
audit_relations=audit_relations, phase="import",
850+
) as connection:
831851
if connection.execute(
832852
select(normative.dataset.c.dataset_id).where(or_(
833853
normative.dataset.c.dataset_name == dataset_id,
@@ -1333,7 +1353,7 @@ def record_export_operation(
13331353
dolt_conformance_source: Any | None = None,
13341354
) -> str:
13351355
"""Persist a completed export only in the normative audit catalog."""
1336-
del destination, operation_details
1356+
operation_details = dict(operation_details or {})
13371357
engine = create_engine(database_url)
13381358
effective_profile(
13391359
database_url, dolt_conformance_source=dolt_conformance_source,
@@ -1354,14 +1374,26 @@ def record_export_operation(
13541374
record_normative_fidelity_events(
13551375
connection, normative, operation_id=operation_id,
13561376
dataset_id=str(dataset["dataset_id"]), direction="export",
1357-
events=({
1358-
**event,
1359-
"severity": event.get("severity", "warning"),
1360-
"details": {
1361-
**event.get("details", {}),
1362-
"accepted_by_user": True,
1363-
},
1364-
} for event in events),
1377+
events=(
1378+
*(
1379+
{
1380+
**event,
1381+
"severity": event.get("severity", "warning"),
1382+
"details": {
1383+
**event.get("details", {}),
1384+
"accepted_by_user": True,
1385+
},
1386+
}
1387+
for event in events
1388+
),
1389+
*(({
1390+
"code": "operation-engine-identity",
1391+
"detail": "Export engine identity recorded for audit.",
1392+
"severity": "info",
1393+
"source_item": destination,
1394+
"details": operation_details,
1395+
},) if operation_details else ()),
1396+
),
13651397
)
13661398
return operation_id
13671399

‎tests/test_atomic_import.py‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,9 @@ def test_dolt_nonfinite_preflight_creates_no_dataset_or_physical_table(
284284
database_path = tmp_path / "dolt-nonfinite.sqlite"
285285
database = f"sqlite:///{database_path}"
286286
monkeypatch.setattr(wide, "effective_profile", lambda _url: (DOLT, {}))
287+
monkeypatch.setattr(
288+
wide, "_capture_dolt_state", lambda *_args, **_kwargs: None,
289+
)
287290
variables = [{
288291
"ordinal": 1, "source_name": "value", "physical_name": "value",
289292
"storage_kind": "numeric", "string_width": None, "label": "",
@@ -320,6 +323,9 @@ def test_empty_namespace_dolt_width_failure_initializes_identity_and_one_audit(
320323
database_path = tmp_path / "dolt-preflight.sqlite"
321324
database = f"sqlite:///{database_path}"
322325
monkeypatch.setattr(wide, "effective_profile", lambda _url: (DOLT, {}))
326+
monkeypatch.setattr(
327+
wide, "_capture_dolt_state", lambda *_args, **_kwargs: None,
328+
)
323329

324330
with pytest.raises(Exception, match="Target capability exceeded"):
325331
create_wide_dataset(
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
"""Regression tests for persistent URLs and operation-bound audit writes."""
2+
3+
import json
4+
import sqlite3
5+
from contextlib import contextmanager
6+
7+
import pytest
8+
9+
from openstatspec.core import UnsupportedOperationError
10+
from openstatspec.sql import wide
11+
12+
13+
def _variables():
14+
return [{
15+
"ordinal": 1,
16+
"source_name": "name",
17+
"physical_name": "name",
18+
"storage_kind": "string",
19+
"string_width": 8,
20+
"label": "",
21+
"format": "A8",
22+
"measure": "nominal",
23+
"alignment": "left",
24+
"display_width": 8,
25+
"value_labels": "{}",
26+
"missing_ranges": "[]",
27+
}]
28+
29+
30+
def _create(database_url, dataset_id="sample"):
31+
return wide.create_wide_dataset(
32+
database_url=database_url,
33+
dataset_id=dataset_id,
34+
source_name=f"{dataset_id}.sav",
35+
source_format="SAV",
36+
rows=[{"name": "ok"}],
37+
variables=_variables(),
38+
)
39+
40+
41+
@pytest.mark.parametrize(
42+
"database_url",
43+
[
44+
"sqlite://",
45+
"sqlite:///:memory:",
46+
"sqlite:///file:shared-memory?mode=memory&uri=true",
47+
],
48+
)
49+
def test_import_rejects_ephemeral_sqlite_catalogs(database_url):
50+
with pytest.raises(UnsupportedOperationError, match="persistent SQLite"):
51+
_create(database_url)
52+
53+
54+
def test_import_routes_catalog_and_dataset_mutations_through_binding_guard(
55+
tmp_path, monkeypatch,
56+
):
57+
database_url = f"sqlite:///{tmp_path / 'bound.sqlite'}"
58+
real_bound_transaction = wide._bound_catalog_transaction
59+
phases = []
60+
61+
@contextmanager
62+
def observe(**kwargs):
63+
phases.append(kwargs["phase"])
64+
with real_bound_transaction(**kwargs) as connection:
65+
yield connection
66+
67+
monkeypatch.setattr(wide, "_bound_catalog_transaction", observe)
68+
_create(database_url)
69+
70+
assert phases == ["catalog initialization", "import"]
71+
72+
73+
def test_export_engine_identity_is_persisted_as_non_loss_audit_metadata(tmp_path):
74+
path = tmp_path / "export-audit.sqlite"
75+
database_url = f"sqlite:///{path}"
76+
_create(database_url)
77+
engine_identity = {"name": "pyspssio", "commit": "export-commit"}
78+
79+
operation_id = wide.record_export_operation(
80+
database_url=database_url,
81+
dataset_id="sample",
82+
destination="out.sav",
83+
allowed_fidelity_events=(),
84+
operation_details={"engine": engine_identity},
85+
)
86+
87+
connection = sqlite3.connect(path)
88+
row = connection.execute(
89+
"select severity, event_code, source_item, detail_json "
90+
"from fidelity_event where operation_id = ?",
91+
(operation_id,),
92+
).fetchone()
93+
connection.close()
94+
assert row[:3] == ("info", "operation-engine-identity", "out.sav")
95+
assert json.loads(row[3])["engine"] == engine_identity
96+
assert wide.read_fidelity_events(
97+
database_url=database_url, dataset_id="sample",
98+
) == ()

‎tests/test_loss_reports.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ def test_loss_allowed_export_persists_accepted_diagnostics(tmp_path) -> None:
5555
result = openstatspec.export_sav(database_url=database, dataset_id="accepted", destination=destination, allow_loss=_REQUIRED_ENGINE_LOSS)
5656

5757
connection = sqlite3.connect(database_path)
58-
rows = connection.execute("select direction, severity, event_code, detail_json from fidelity_event where operation_id = ? order by event_code", (result["operation_id"],)).fetchall()
58+
rows = connection.execute("select direction, severity, event_code, detail_json from fidelity_event where operation_id = ? and severity != 'info' order by event_code", (result["operation_id"],)).fetchall()
5959
assert [(row[0], row[1], row[2]) for row in rows] == [("export", "warning", code) for code in _REQUIRED_ENGINE_LOSS]
6060
assert all('"accepted_by_user": true' in row[3] for row in rows)
6161

0 commit comments

Comments
 (0)