Skip to content

Commit 43522c3

Browse files
committed
Preserve source extensions and normalize MR metadata
1 parent bffdb6c commit 43522c3

4 files changed

Lines changed: 250 additions & 15 deletions

File tree

‎src/openstatspec/spss/sav.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,10 @@ def import_sav_dataset(*, source: str | Path, database_url: str, dataset_id: str
189189
file_attributes=_json(metadata.get("file_attributes") or {}),
190190
case_weight_variable=metadata.get("case_weight_var") or None,
191191
multiple_response_sets=_json(metadata.get("mrsets") or {}),
192+
source_extensions=(
193+
{"spss.variable_sets": metadata["_var_sets"]}
194+
if metadata.get("_var_sets") else {}
195+
),
192196
fidelity_events=tuple(loss_report.values()),
193197
)
194198
return {**result, "loss_report": tuple(loss_report.values())}

‎src/openstatspec/sql/wide.py‎

Lines changed: 144 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,9 @@ def catalog(metadata: MetaData) -> tuple[Table, Table, Table, Table]:
101101
def _now() -> str:
102102
return datetime.now(UTC).isoformat()
103103

104-
def _migrate_catalog_columns(connection: Any, datasets: Table, variables: Table) -> None:
104+
def _migrate_catalog_columns(
105+
connection: Any, datasets: Table, variables: Table, multiple_response: Table,
106+
) -> None:
105107
"""Add pyspssio metadata columns to catalogs created by earlier adapters.
106108
107109
This additive migration is intentionally small and portable: no existing
@@ -119,6 +121,14 @@ def _migrate_catalog_columns(connection: Any, datasets: Table, variables: Table)
119121
"attributes": "TEXT NOT NULL DEFAULT '{}'",
120122
"compat_name": "VARCHAR(255)",
121123
},
124+
multiple_response.name: {
125+
"is_dichotomy": "BOOLEAN",
126+
"use_category_labels": "BOOLEAN",
127+
"use_first_var_label": "BOOLEAN",
128+
"counted_value_type": "VARCHAR(16)",
129+
"counted_numeric": "DOUBLE",
130+
"counted_text": "TEXT",
131+
},
122132
}
123133
preparer = connection.dialect.identifier_preparer
124134
for table_name, columns in additions.items():
@@ -188,11 +198,36 @@ def multiple_response_set_catalog(metadata: MetaData) -> Table:
188198
Column("set_name", String(255), primary_key=True),
189199
Column("member_ordinal", Integer, primary_key=True),
190200
Column("kind", String(16)), Column("label", Text),
191-
Column("counted_value", Text), Column("variable_name", String(255)),
201+
Column("is_dichotomy", Boolean),
202+
Column("use_category_labels", Boolean),
203+
Column("use_first_var_label", Boolean),
204+
Column("counted_value", Text),
205+
Column("counted_value_type", String(16)),
206+
Column("counted_numeric", binary64_type()),
207+
Column("counted_text", Text),
208+
Column("variable_name", String(255)),
192209
Column("definition", Text, nullable=False),
193210
)
194211

195212

213+
def source_extension_catalog(metadata: MetaData) -> Table:
214+
"""Namespaced raw source semantics retained even when export is fail-closed."""
215+
return Table(
216+
"source_extension_catalog", metadata,
217+
Column("dataset_id", String(255), primary_key=True),
218+
Column("extension_key", String(255), primary_key=True),
219+
Column("payload", Text, nullable=False),
220+
)
221+
222+
223+
def source_extension_rows(dataset_id: str, extensions: Mapping[str, Any]) -> list[dict[str, Any]]:
224+
return [
225+
{"dataset_id": dataset_id, "extension_key": str(key),
226+
"payload": json.dumps(payload, default=str, ensure_ascii=False, sort_keys=True)}
227+
for key, payload in sorted(extensions.items())
228+
]
229+
230+
196231
def document_catalog(metadata: MetaData) -> Table:
197232
"""Ordered file documents, normalized independently from the legacy JSON column."""
198233
return Table(
@@ -304,23 +339,90 @@ def missing_rule_rows(dataset_id: str, variables: list[dict[str, Any]]) -> list[
304339
def normalized_metadata_tables(metadata: MetaData) -> tuple[Table, Table, Table]:
305340
return document_catalog(metadata), value_label_catalog(metadata), missing_rule_catalog(metadata)
306341

342+
def _mr_counted_value(definition: Mapping[str, Any]) -> tuple[str | None, float | None, str | None]:
343+
value = definition.get("counted_value", definition.get("countedvalue"))
344+
if value is None:
345+
return None, None, None
346+
if isinstance(value, bool):
347+
return "text", None, str(value)
348+
if isinstance(value, (int, float)):
349+
return "numeric", float(value), None
350+
return "text", None, str(value)
351+
352+
307353
def multiple_response_set_rows(dataset_id: str, definitions: str) -> list[dict[str, Any]]:
308-
sets = json.loads(definitions)
309-
rows = []
354+
sets = json.loads(definitions or "{}")
355+
rows: list[dict[str, Any]] = []
310356
for set_name, definition in sets.items():
357+
if not isinstance(definition, Mapping):
358+
continue
311359
members = definition.get("variable_list", definition.get("variables", []))
312360
if isinstance(members, str):
313361
members = members.split()
362+
is_dichotomy = definition.get("is_dichotomy")
363+
if is_dichotomy is None:
364+
is_dichotomy = definition.get("set_type", definition.get("type")) in {"MD", "D", "E"} or (
365+
definition.get("counted_value", definition.get("countedvalue")) is not None
366+
)
367+
kind = "MD" if is_dichotomy else "MC"
368+
value_type, numeric_value, text_value = _mr_counted_value(definition)
314369
for ordinal, variable_name in enumerate(members or [None], start=1):
315370
rows.append({
316371
"dataset_id": dataset_id, "set_name": set_name, "member_ordinal": ordinal,
317-
"kind": definition.get("set_type", definition.get("type")),
318-
"label": definition.get("label"),
319-
"counted_value": str(definition.get("counted_value", definition.get("countedvalue", ""))),
320-
"variable_name": variable_name, "definition": json.dumps(definition, default=str, sort_keys=True),
372+
"kind": kind, "label": definition.get("label"),
373+
"is_dichotomy": bool(is_dichotomy),
374+
"use_category_labels": bool(definition.get("use_category_labels", False)),
375+
"use_first_var_label": bool(definition.get("use_first_var_label", False)),
376+
"counted_value": "" if value_type is None else str(
377+
numeric_value if value_type == "numeric" else text_value
378+
),
379+
"counted_value_type": value_type,
380+
"counted_numeric": numeric_value, "counted_text": text_value,
381+
"variable_name": variable_name,
382+
"definition": json.dumps(definition, default=str, ensure_ascii=False, sort_keys=True),
321383
})
322384
return rows
323385

386+
387+
def multiple_response_sets_from_rows(rows: Iterable[Mapping[str, Any]]) -> dict[str, dict[str, Any]]:
388+
"""Reconstruct pyspssio MR metadata from normalized catalog rows.
389+
390+
The new typed fields are authoritative. ``definition`` only supplies
391+
backwards compatibility for catalogs written before those fields existed.
392+
"""
393+
result: dict[str, dict[str, Any]] = {}
394+
for row in rows:
395+
name = str(row["set_name"])
396+
if name not in result:
397+
try:
398+
legacy = json.loads(row.get("definition") or "{}")
399+
except (TypeError, json.JSONDecodeError):
400+
legacy = {}
401+
definition = dict(legacy) if isinstance(legacy, Mapping) else {}
402+
definition["variable_list"] = []
403+
result[name] = definition
404+
definition = result[name]
405+
if row.get("label") is not None:
406+
definition["label"] = row["label"]
407+
if row.get("is_dichotomy") is not None:
408+
definition["is_dichotomy"] = bool(row["is_dichotomy"])
409+
elif row.get("kind") is not None:
410+
definition["is_dichotomy"] = str(row["kind"]).upper() == "MD"
411+
if row.get("use_category_labels") is not None:
412+
definition["use_category_labels"] = bool(row["use_category_labels"])
413+
if row.get("use_first_var_label") is not None:
414+
definition["use_first_var_label"] = bool(row["use_first_var_label"])
415+
value_type = row.get("counted_value_type")
416+
if value_type == "numeric":
417+
definition["counted_value"] = row.get("counted_numeric")
418+
elif value_type == "text":
419+
definition["counted_value"] = row.get("counted_text")
420+
elif row.get("counted_value") not in (None, ""):
421+
definition["counted_value"] = row["counted_value"]
422+
if row.get("variable_name") is not None:
423+
definition["variable_list"].append(row["variable_name"])
424+
return result
425+
324426
def physical_name(source_name: str, used: set[str]) -> str:
325427
stem = _IDENTIFIER.sub("_", source_name).strip("_").lower() or "variable"
326428
stem = stem[:54]
@@ -347,13 +449,15 @@ def create_wide_dataset(
347449
source_created_at: str | None = None, source_modified_at: str | None = None,
348450
imported_at: str = "",
349451
multiple_response_sets: str = "{}",
452+
source_extensions: Mapping[str, Any] | None = None,
350453
fidelity_events: Iterable[Mapping[str, Any]] = (),
351454
) -> dict[str, Any]:
352455
profile = validate_connection_url(database_url)
353456
engine = create_engine(database_url)
354457
metadata = MetaData()
355458
datasets, variable_catalog, fidelity_event_catalog, operation_catalog = catalog(metadata)
356459
multiple_response_catalog = multiple_response_set_catalog(metadata)
460+
source_extensions_catalog = source_extension_catalog(metadata)
357461
documents_catalog, value_labels_catalog, missing_rules_catalog = normalized_metadata_tables(metadata)
358462
operation_id = str(uuid4())
359463
try:
@@ -374,8 +478,8 @@ def create_wide_dataset(
374478
nullable=item["storage_kind"] == "numeric") for item in variables),
375479
)
376480
with engine.begin() as connection:
377-
metadata.create_all(connection, tables=[datasets, variable_catalog, multiple_response_catalog, documents_catalog, value_labels_catalog, missing_rules_catalog, fidelity_event_catalog, operation_catalog])
378-
_migrate_catalog_columns(connection, datasets, variable_catalog)
481+
metadata.create_all(connection, tables=[datasets, variable_catalog, multiple_response_catalog, source_extensions_catalog, documents_catalog, value_labels_catalog, missing_rules_catalog, fidelity_event_catalog, operation_catalog])
482+
_migrate_catalog_columns(connection, datasets, variable_catalog, multiple_response_catalog)
379483
connection.execute(insert(operation_catalog).values(
380484
operation_id=operation_id, direction="import", status="running", dataset_id=dataset_id,
381485
source=source_name, created_at=_now(), details=json.dumps({"variable_count": len(variables)}, sort_keys=True),
@@ -410,6 +514,9 @@ def create_wide_dataset(
410514
mrset_rows = multiple_response_set_rows(dataset_id, multiple_response_sets)
411515
if mrset_rows:
412516
connection.execute(insert(multiple_response_catalog), mrset_rows)
517+
extension_rows = source_extension_rows(dataset_id, source_extensions or {})
518+
if extension_rows:
519+
connection.execute(insert(source_extensions_catalog), extension_rows)
413520
event_rows = _event_rows(
414521
operation_id=operation_id, dataset_id=dataset_id, direction="import", fidelity_events=fidelity_events,
415522
)
@@ -421,6 +528,7 @@ def create_wide_dataset(
421528
except Exception:
422529
data_table.drop(connection, checkfirst=True)
423530
connection.execute(delete(multiple_response_catalog).where(multiple_response_catalog.c.dataset_id == dataset_id))
531+
connection.execute(delete(source_extensions_catalog).where(source_extensions_catalog.c.dataset_id == dataset_id))
424532
connection.execute(delete(documents_catalog).where(documents_catalog.c.dataset_id == dataset_id))
425533
connection.execute(delete(value_labels_catalog).where(value_labels_catalog.c.dataset_id == dataset_id))
426534
connection.execute(delete(missing_rules_catalog).where(missing_rules_catalog.c.dataset_id == dataset_id))
@@ -449,10 +557,12 @@ def read_wide_dataset(*, database_url: str, dataset_id: str) -> tuple[dict[str,
449557
engine = create_engine(database_url)
450558
metadata = MetaData()
451559
datasets, variable_catalog, _, _ = catalog(metadata)
560+
multiple_response_catalog = multiple_response_set_catalog(metadata)
561+
source_extensions_catalog = source_extension_catalog(metadata)
452562
documents_catalog, value_labels_catalog, missing_rules_catalog = normalized_metadata_tables(metadata)
453563
with engine.begin() as connection:
454-
metadata.create_all(connection, tables=[datasets, variable_catalog, documents_catalog, value_labels_catalog, missing_rules_catalog])
455-
_migrate_catalog_columns(connection, datasets, variable_catalog)
564+
metadata.create_all(connection, tables=[datasets, variable_catalog, multiple_response_catalog, source_extensions_catalog, documents_catalog, value_labels_catalog, missing_rules_catalog])
565+
_migrate_catalog_columns(connection, datasets, variable_catalog, multiple_response_catalog)
456566
dataset = dict(connection.execute(select(datasets).where(datasets.c.dataset_id == dataset_id)).mappings().one())
457567
data_table = Table(dataset["data_table"], MetaData(), autoload_with=connection)
458568
variables = [dict(item) for item in connection.execute(
@@ -473,9 +583,26 @@ def read_wide_dataset(*, database_url: str, dataset_id: str) -> tuple[dict[str,
473583
select(missing_rules_catalog).where(missing_rules_catalog.c.dataset_id == dataset_id)
474584
.order_by(missing_rules_catalog.c.variable_ordinal, missing_rules_catalog.c.ordinal)
475585
).mappings().all()
586+
mrset_rows_result = connection.execute(
587+
select(multiple_response_catalog).where(multiple_response_catalog.c.dataset_id == dataset_id)
588+
.order_by(multiple_response_catalog.c.set_name, multiple_response_catalog.c.member_ordinal)
589+
).mappings().all()
590+
extension_rows_result = connection.execute(
591+
select(source_extensions_catalog).where(source_extensions_catalog.c.dataset_id == dataset_id)
592+
.order_by(source_extensions_catalog.c.extension_key)
593+
).mappings().all()
476594

477595
if document_rows_result:
478596
dataset["documents"] = json.dumps([item["text"] for item in document_rows_result], ensure_ascii=False)
597+
if mrset_rows_result:
598+
dataset["multiple_response_sets"] = json.dumps(
599+
multiple_response_sets_from_rows(mrset_rows_result), ensure_ascii=False, default=str,
600+
)
601+
if extension_rows_result:
602+
dataset["source_extensions"] = {
603+
item["extension_key"]: json.loads(item["payload"])
604+
for item in extension_rows_result
605+
}
479606
variables_by_ordinal = {item["ordinal"]: item for item in variables}
480607
labels_by_variable: dict[int, dict[Any, str]] = {}
481608
for item in label_rows_result:
@@ -494,7 +621,7 @@ def read_wide_dataset(*, database_url: str, dataset_id: str) -> tuple[dict[str,
494621
return dataset, variables, rows
495622

496623

497-
def read_fidelity_events(*, database_url: str, dataset_id: str) -> tuple[dict[str, str], ...]:
624+
def read_fidelity_events(*, database_url: str, dataset_id: str) -> tuple[dict[str, Any], ...]:
498625
"""Read import-time fidelity diagnostics for a catalogued dataset."""
499626
engine = create_engine(database_url)
500627
metadata = MetaData()
@@ -506,7 +633,10 @@ def read_fidelity_events(*, database_url: str, dataset_id: str) -> tuple[dict[st
506633
.where(fidelity_event_catalog.c.dataset_id == dataset_id)
507634
.order_by(fidelity_event_catalog.c.code)
508635
).mappings().all()
509-
return tuple({"code": item["code"], "detail": item["detail"]} for item in events)
636+
return tuple({
637+
"code": item["code"], "detail": item["detail"],
638+
"details": json.loads(item["details"] or "{}"),
639+
} for item in events)
510640

511641

512642

0 commit comments

Comments
 (0)