Skip to content

Commit 27c0431

Browse files
committed
Implement SAV to SQLite wide-table adapter
1 parent a194263 commit 27c0431

9 files changed

Lines changed: 271 additions & 32 deletions

File tree

‎pyproject.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ readme = "README.md"
1010
requires-python = ">=3.11"
1111
license = {text = "Apache-2.0"}
1212
authors = [{name = "OpenStatSpec"}]
13-
dependencies = ["SQLAlchemy>=2.0"]
13+
dependencies = ["SQLAlchemy>=2.0", "pandas>=2.2", "pyreadstat>=1.3"]
1414

1515
[project.optional-dependencies]
1616
dev = ["pytest>=8"]

‎src/openstatspec/api.py‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,11 @@
1010

1111

1212
def capabilities() -> Mapping[str, Any]:
13-
return CapabilityDeclaration.empty().as_dict()
13+
return CapabilityDeclaration(
14+
specification="OpenStatSpec strict wide-table SPSS profile (initial)",
15+
formats={"SAV": {"import": True, "export": True}, "ZSAV": {"import": True, "export": False}},
16+
database_profiles={"sqlite": {"import": True, "export": True}},
17+
).as_dict()
1418

1519

1620
def inspect(source: str | Path, /, **options: Any) -> Mapping[str, Any]:

‎src/openstatspec/core/__init__.py‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,4 +26,7 @@ def empty(cls) -> "CapabilityDeclaration":
2626
return cls()
2727

2828
def as_dict(self) -> dict[str, Any]:
29-
return {"specification": self.specification, "formats": self.formats, "database_profiles": self.database_profiles, "operations": {"inspect": False, "import_sav": False, "export_sav": False, "validate": False}}
29+
return {
30+
"specification": self.specification, "formats": self.formats, "database_profiles": self.database_profiles,
31+
"operations": {"inspect": True, "import_sav": True, "export_sav": True, "validate": True},
32+
}

‎src/openstatspec/spss/__init__.py‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,16 @@
33
from pathlib import Path
44
from typing import Any
55

6-
from ..core import UnsupportedOperationError
6+
from .sav import export_sav_dataset, import_sav_dataset, inspect_sav
77

88

99
def inspect_source(source: str | Path, **options: Any) -> dict[str, Any]:
10-
raise UnsupportedOperationError("No SAV/ZSAV profile is implemented; source metadata cannot be guessed.")
10+
return inspect_sav(source)
1111

1212

1313
def import_dataset(source: str | Path, *, database_url: Any, dataset_id: str, **options: Any) -> dict[str, Any]:
14-
raise UnsupportedOperationError("No SAV/ZSAV-to-SQL profile is implemented; no partial import was attempted.")
14+
return import_sav_dataset(source=source, database_url=str(database_url), dataset_id=dataset_id)
1515

1616

1717
def export_dataset(*, database_url: Any, dataset_id: str, destination: str | Path, **options: Any) -> dict[str, Any]:
18-
raise UnsupportedOperationError("No SQL-to-SAV/ZSAV profile is implemented; no lossy export was attempted.")
18+
return export_sav_dataset(database_url=str(database_url), dataset_id=dataset_id, destination=destination)

‎src/openstatspec/spss/sav.py‎

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
"""Initial unencrypted SAV/ZSAV adapter using pyreadstat."""
2+
3+
import json
4+
import math
5+
from pathlib import Path
6+
from typing import Any
7+
8+
import pyreadstat
9+
10+
from ..core import LossReport, UnsupportedOperationError
11+
from ..sql.wide import create_wide_dataset, physical_name, read_wide_dataset
12+
13+
14+
def _variables(meta: Any, names: list[str]) -> list[dict[str, Any]]:
15+
used = {"__case_ordinal"}
16+
labels = dict(getattr(meta, "column_names_to_labels", {}) or {})
17+
formats = dict(getattr(meta, "original_variable_types", {}) or {})
18+
measures = dict(getattr(meta, "variable_measure", {}) or {})
19+
displays = dict(getattr(meta, "variable_display_width", {}) or {})
20+
value_labels = dict(getattr(meta, "variable_value_labels", {}) or {})
21+
missing_ranges = dict(getattr(meta, "missing_ranges", {}) or {})
22+
widths = dict(getattr(meta, "variable_storage_width", {}) or {})
23+
result = []
24+
for ordinal, source_name in enumerate(names, start=1):
25+
kind = "string" if str(formats.get(source_name, "")).upper().startswith("A") else "numeric"
26+
result.append({
27+
"ordinal": ordinal, "source_name": source_name,
28+
"physical_name": physical_name(source_name, used), "storage_kind": kind,
29+
"string_width": widths.get(source_name) if kind == "string" else None,
30+
"label": labels.get(source_name, "") or "", "format": formats.get(source_name),
31+
"measure": measures.get(source_name), "display_width": displays.get(source_name),
32+
"value_labels": json.dumps(value_labels.get(source_name, {}), sort_keys=True),
33+
"missing_ranges": json.dumps(missing_ranges.get(source_name, []), default=str),
34+
})
35+
return result
36+
37+
38+
def _rows(frame: Any, variables: list[dict[str, Any]]) -> list[dict[str, Any]]:
39+
result = []
40+
for input_row in frame.to_dict(orient="records"):
41+
output_row = {}
42+
for item in variables:
43+
value = input_row[item["source_name"]]
44+
if item["storage_kind"] == "numeric" and isinstance(value, float) and math.isnan(value):
45+
value = None
46+
elif item["storage_kind"] == "string" and value is None:
47+
value = ""
48+
output_row[item["physical_name"]] = value
49+
result.append(output_row)
50+
return result
51+
52+
53+
def inspect_sav(source: str | Path) -> dict[str, Any]:
54+
source_path = Path(source)
55+
_require_source(source_path)
56+
_, meta = pyreadstat.read_sav(source_path, metadataonly=True, user_missing=True)
57+
return {
58+
"source_format": source_path.suffix[1:].upper(), "source_name": source_path.name,
59+
"variable_count": len(meta.column_names), "variables": _variables(meta, list(meta.column_names)),
60+
}
61+
62+
63+
def import_sav_dataset(*, source: str | Path, database_url: str, dataset_id: str) -> dict[str, Any]:
64+
source_path = Path(source)
65+
_require_source(source_path)
66+
frame, meta = pyreadstat.read_sav(source_path, user_missing=True, disable_datetime_conversion=True)
67+
variables = _variables(meta, list(frame.columns))
68+
result = create_wide_dataset(
69+
database_url=database_url, dataset_id=dataset_id, source_name=source_path.name,
70+
source_format=source_path.suffix[1:].upper(), rows=_rows(frame, variables),
71+
variables=variables, file_label=getattr(meta, "file_label", "") or "",
72+
)
73+
return {**result, "loss_report": LossReport().events}
74+
75+
76+
def export_sav_dataset(*, database_url: str, dataset_id: str, destination: str | Path) -> dict[str, Any]:
77+
destination_path = Path(destination)
78+
if destination_path.suffix.lower() != ".sav":
79+
raise UnsupportedOperationError("This initial profile exports SAV only; ZSAV output is not yet supported.")
80+
dataset, variables, rows = read_wide_dataset(database_url=database_url, dataset_id=dataset_id)
81+
import pandas as pd
82+
frame = pd.DataFrame(
83+
[{item["source_name"]: row[item["physical_name"]] for item in variables} for row in rows],
84+
columns=[item["source_name"] for item in variables],
85+
)
86+
labels = {item["source_name"]: item["label"] for item in variables if item["label"]}
87+
formats = {item["source_name"]: item["format"] for item in variables if item["format"]}
88+
measures = {item["source_name"]: item["measure"] for item in variables if item["measure"]}
89+
displays = {item["source_name"]: item["display_width"] for item in variables if item["display_width"]}
90+
value_labels = {
91+
item["source_name"]: {(float(key) if item["storage_kind"] == "numeric" else key): value for key, value in json.loads(item["value_labels"]).items()}
92+
for item in variables if item["value_labels"] != "{}"
93+
}
94+
pyreadstat.write_sav(
95+
frame, destination_path, file_label=dataset["file_label"], column_labels=labels,
96+
variable_format=formats, variable_measure=measures,
97+
variable_display_width=displays, variable_value_labels=value_labels,
98+
)
99+
return {
100+
"dataset_id": dataset_id, "destination": str(destination_path),
101+
"loss_report": ({"code": "missing-ranges-not-exported", "detail": "The initial writer does not preserve user-missing ranges."},),
102+
}
103+
104+
105+
def _require_source(source_path: Path) -> None:
106+
if source_path.suffix.lower() not in {".sav", ".zsav"}:
107+
raise UnsupportedOperationError("Only unencrypted SAV and ZSAV sources are supported.")
108+

‎src/openstatspec/sql/__init__.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22

33
from typing import Any
44

5-
from ..core import UnsupportedOperationError
5+
from .wide import validate_wide_dataset
66

77

88
def validate_dataset(*, database_url: Any, dataset_id: str, **options: Any) -> dict[str, Any]:
9-
raise UnsupportedOperationError("No OpenStatSpec SQL profile is implemented; validation cannot guess a schema.")
9+
return validate_wide_dataset(database_url=str(database_url), dataset_id=dataset_id)

‎src/openstatspec/sql/wide.py‎

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
"""SQLite reference SQL profile for the strict OpenStatSpec wide-table contract."""
2+
3+
import re
4+
from collections.abc import Iterable, Mapping
5+
from typing import Any
6+
7+
from sqlalchemy import BigInteger, Column, Float, Integer, MetaData, String, Table, Text, create_engine, insert, select
8+
9+
_IDENTIFIER = re.compile(r"[^a-zA-Z0-9_]+")
10+
11+
12+
def catalog(metadata: MetaData) -> tuple[Table, Table]:
13+
datasets = Table(
14+
"dataset_catalog", metadata,
15+
Column("dataset_id", String(255), primary_key=True),
16+
Column("data_table", String(255), nullable=False, unique=True),
17+
Column("source_format", String(16), nullable=False),
18+
Column("source_name", Text, nullable=False),
19+
Column("case_count", BigInteger, nullable=False),
20+
Column("file_label", Text, nullable=False, default=""),
21+
)
22+
variables = Table(
23+
"variable_catalog", metadata,
24+
Column("dataset_id", String(255), primary_key=True),
25+
Column("ordinal", Integer, primary_key=True),
26+
Column("source_name", String(255), nullable=False),
27+
Column("physical_name", String(255), nullable=False),
28+
Column("storage_kind", String(16), nullable=False),
29+
Column("string_width", Integer),
30+
Column("label", Text, nullable=False, default=""),
31+
Column("format", String(64)),
32+
Column("measure", String(32)),
33+
Column("display_width", Integer),
34+
Column("value_labels", Text, nullable=False, default="{}"),
35+
Column("missing_ranges", Text, nullable=False, default="[]"),
36+
)
37+
return datasets, variables
38+
39+
40+
def physical_name(source_name: str, used: set[str]) -> str:
41+
stem = _IDENTIFIER.sub("_", source_name).strip("_").lower() or "variable"
42+
stem = stem[:54]
43+
candidate, suffix = stem, 2
44+
while candidate.lower() in used or candidate.startswith("__"):
45+
candidate = f"{stem[:50]}_{suffix}"
46+
suffix += 1
47+
used.add(candidate.lower())
48+
return candidate
49+
50+
51+
def data_table_name(dataset_id: str) -> str:
52+
stem = _IDENTIFIER.sub("_", dataset_id).strip("_").lower() or "dataset"
53+
return f"data_{stem[:48]}"
54+
55+
56+
def create_wide_dataset(
57+
*, database_url: str, dataset_id: str, source_name: str, source_format: str,
58+
rows: Iterable[Mapping[str, Any]], variables: list[dict[str, Any]], file_label: str = "",
59+
) -> dict[str, Any]:
60+
engine = create_engine(database_url)
61+
metadata = MetaData()
62+
datasets, variable_catalog = catalog(metadata)
63+
data_table = Table(
64+
data_table_name(dataset_id), metadata,
65+
Column("__case_ordinal", BigInteger, primary_key=True, nullable=False),
66+
*(Column(item["physical_name"], Float() if item["storage_kind"] == "numeric" else Text(),
67+
nullable=item["storage_kind"] == "numeric") for item in variables),
68+
)
69+
with engine.begin() as connection:
70+
metadata.create_all(connection, tables=[datasets, variable_catalog])
71+
if connection.execute(select(datasets.c.dataset_id).where(datasets.c.dataset_id == dataset_id)).first():
72+
raise ValueError(f"Dataset {dataset_id!r} already exists; imports never overwrite a dataset.")
73+
data_table.create(connection)
74+
materialized = [{"__case_ordinal": ordinal, **row} for ordinal, row in enumerate(rows, start=1)]
75+
connection.execute(insert(datasets).values(
76+
dataset_id=dataset_id, data_table=data_table.name, source_format=source_format,
77+
source_name=source_name, case_count=len(materialized), file_label=file_label,
78+
))
79+
connection.execute(insert(variable_catalog), [dict(dataset_id=dataset_id, **item) for item in variables])
80+
if materialized:
81+
connection.execute(insert(data_table), materialized)
82+
return {"dataset_id": dataset_id, "data_table": data_table.name, "case_count": len(materialized)}
83+
84+
85+
def read_wide_dataset(*, database_url: str, dataset_id: str) -> tuple[dict[str, Any], list[dict[str, Any]], list[dict[str, Any]]]:
86+
engine = create_engine(database_url)
87+
metadata = MetaData()
88+
datasets, variable_catalog = catalog(metadata)
89+
with engine.connect() as connection:
90+
dataset = connection.execute(select(datasets).where(datasets.c.dataset_id == dataset_id)).mappings().one()
91+
data_table = Table(dataset["data_table"], MetaData(), autoload_with=connection)
92+
variables = connection.execute(
93+
select(variable_catalog).where(variable_catalog.c.dataset_id == dataset_id).order_by(variable_catalog.c.ordinal)
94+
).mappings().all()
95+
rows = connection.execute(select(data_table).order_by(data_table.c.__case_ordinal)).mappings().all()
96+
return dict(dataset), [dict(item) for item in variables], [dict(item) for item in rows]
97+
98+
99+
def validate_wide_dataset(*, database_url: str, dataset_id: str) -> dict[str, Any]:
100+
dataset, variables, rows = read_wide_dataset(database_url=database_url, dataset_id=dataset_id)
101+
if not variables:
102+
raise ValueError("A conforming dataset needs at least one source variable.")
103+
if [row["__case_ordinal"] for row in rows] != list(range(1, len(rows) + 1)):
104+
raise ValueError("Case ordinals are not contiguous source order.")
105+
return {"dataset_id": dataset["dataset_id"], "valid": True, "case_count": len(rows), "variable_count": len(variables)}
106+

‎tests/test_public_api.py‎

Lines changed: 0 additions & 23 deletions
This file was deleted.

‎tests/test_sav_sqlite.py‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import sqlite3
2+
3+
import openstatspec
4+
import pandas as pd
5+
import pyreadstat
6+
7+
8+
def test_sav_round_trip_uses_one_wide_table_and_catalog(tmp_path) -> None:
9+
source = tmp_path / "tiny.sav"
10+
database_path = tmp_path / "dataset.sqlite"
11+
database = f"sqlite:///{database_path}"
12+
exported = tmp_path / "roundtrip.sav"
13+
pyreadstat.write_sav(
14+
pd.DataFrame({"age": [34.0, None], "name": ["Ada", ""]}),
15+
source,
16+
file_label="Tiny fixture",
17+
column_labels={"age": "Age", "name": "Name"},
18+
variable_value_labels={"age": {34.0: "thirty-four"}},
19+
variable_measure={"age": "scale", "name": "nominal"},
20+
variable_format={"age": "F8.0", "name": "A12"},
21+
)
22+
23+
imported = openstatspec.import_sav(source, database_url=database, dataset_id="tiny")
24+
assert imported["case_count"] == 2
25+
assert imported["data_table"] == "data_tiny"
26+
assert openstatspec.validate(database_url=database, dataset_id="tiny")["valid"] is True
27+
28+
connection = sqlite3.connect(database_path)
29+
table_names = [row[0] for row in connection.execute("select name from sqlite_master where type = \"table\" order by name")]
30+
assert table_names == ["data_tiny", "dataset_catalog", "variable_catalog"]
31+
assert connection.execute("select __case_ordinal, age, name from data_tiny order by __case_ordinal").fetchall() == [
32+
(1, 34.0, "Ada"), (2, None, "")
33+
]
34+
35+
openstatspec.export_sav(database_url=database, dataset_id="tiny", destination=exported)
36+
frame, meta = pyreadstat.read_sav(exported, user_missing=True)
37+
assert frame["age"].iloc[0] == 34.0
38+
assert pd.isna(frame["age"].iloc[1])
39+
assert frame["name"].tolist() == ["Ada", ""]
40+
assert meta.column_labels == ["Age", "Name"]
41+

0 commit comments

Comments
 (0)