openstatspec 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- openstatspec/__init__.py +6 -0
- openstatspec/api.py +95 -0
- openstatspec/cli.py +42 -0
- openstatspec/core/__init__.py +33 -0
- openstatspec/core/results.py +47 -0
- openstatspec/spss/__init__.py +22 -0
- openstatspec/spss/dictionary.py +180 -0
- openstatspec/spss/raw_dictionary.py +393 -0
- openstatspec/spss/sav.py +569 -0
- openstatspec/sql/__init__.py +14 -0
- openstatspec/sql/capabilities.py +260 -0
- openstatspec/sql/normative.py +513 -0
- openstatspec/sql/profiles.py +217 -0
- openstatspec/sql/wide.py +1087 -0
- openstatspec-0.1.0.dist-info/METADATA +90 -0
- openstatspec-0.1.0.dist-info/RECORD +18 -0
- openstatspec-0.1.0.dist-info/WHEEL +4 -0
- openstatspec-0.1.0.dist-info/entry_points.txt +2 -0
openstatspec/sql/wide.py
ADDED
|
@@ -0,0 +1,1087 @@
|
|
|
1
|
+
"""SQLite reference SQL profile for the strict OpenStatSpec wide-table contract."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import math
|
|
5
|
+
import re
|
|
6
|
+
import sys
|
|
7
|
+
from datetime import UTC, datetime
|
|
8
|
+
from uuid import uuid4
|
|
9
|
+
from collections.abc import Iterable, Mapping
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from sqlalchemy import delete, BigInteger, Boolean, Column, Float, Integer, MetaData, String, Table, Text, create_engine, insert, inspect, select, text, update
|
|
13
|
+
from sqlalchemy.dialects import mysql, postgresql, sqlite
|
|
14
|
+
from ..core import UnsupportedOperationError
|
|
15
|
+
from .capabilities import effective_profile
|
|
16
|
+
from .profiles import preflight, validate_connection_url
|
|
17
|
+
from .normative import (
|
|
18
|
+
catalog as normative_catalog,
|
|
19
|
+
create as create_normative_catalog,
|
|
20
|
+
dataset_id_for_name as normative_dataset_id_for_name,
|
|
21
|
+
finish_operation as finish_normative_operation,
|
|
22
|
+
record_fidelity_events as record_normative_fidelity_events,
|
|
23
|
+
record_operation as record_normative_operation,
|
|
24
|
+
store_imported_dataset as store_normative_dataset,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
_IDENTIFIER = re.compile(r"[^a-zA-Z0-9_]+")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class CatalogPreflightError(UnsupportedOperationError):
|
|
31
|
+
"""A semantic SPSS catalog rule failed before an import or writer runs."""
|
|
32
|
+
|
|
33
|
+
def __init__(self, code: str, detail: str, *, details: Mapping[str, Any]) -> None:
|
|
34
|
+
super().__init__(f"OpenStatSpec catalog preflight failed [{code}]: {detail}")
|
|
35
|
+
self.code = code
|
|
36
|
+
self.details = {"reason": code, **details}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _catalog_error(code: str, detail: str, **details: Any) -> CatalogPreflightError:
|
|
40
|
+
return CatalogPreflightError(code, detail, details=details)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def binary64_type() -> Float:
|
|
44
|
+
"""Return the required IEEE-754 binary64 SQL type for every profile.
|
|
45
|
+
|
|
46
|
+
``Float()`` is not adequate as a portable declaration: SQLAlchemy compiles
|
|
47
|
+
it to ``FLOAT`` for MySQL, which is single precision there. The strict
|
|
48
|
+
profile therefore declares the physical type explicitly for every target.
|
|
49
|
+
"""
|
|
50
|
+
return (
|
|
51
|
+
Float(precision=53)
|
|
52
|
+
.with_variant(mysql.DOUBLE(asdecimal=False), "mysql")
|
|
53
|
+
.with_variant(mysql.DOUBLE(asdecimal=False), "mariadb")
|
|
54
|
+
.with_variant(postgresql.DOUBLE_PRECISION(), "postgresql")
|
|
55
|
+
.with_variant(sqlite.REAL(), "sqlite")
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def catalog(metadata: MetaData) -> tuple[Table, Table, Table, Table]:
|
|
60
|
+
datasets = Table(
|
|
61
|
+
"dataset_catalog", metadata,
|
|
62
|
+
Column("dataset_id", String(255), primary_key=True),
|
|
63
|
+
Column("data_table", String(255), nullable=False, unique=True),
|
|
64
|
+
Column("source_format", String(16), nullable=False),
|
|
65
|
+
Column("source_name", Text, nullable=False),
|
|
66
|
+
Column("source_table_name", Text),
|
|
67
|
+
Column("source_sha256", String(64), nullable=False),
|
|
68
|
+
Column("source_created_at", String(40)),
|
|
69
|
+
Column("source_modified_at", String(40)),
|
|
70
|
+
Column("imported_at", String(40), nullable=False),
|
|
71
|
+
Column("source_encoding", String(128)),
|
|
72
|
+
Column("case_count", BigInteger, nullable=False),
|
|
73
|
+
Column("file_label", Text, nullable=False, default=""),
|
|
74
|
+
Column("documents", Text, nullable=False, default="[]"),
|
|
75
|
+
Column("file_attributes", Text, nullable=False, default="{}"),
|
|
76
|
+
Column("case_weight_variable", String(255)),
|
|
77
|
+
Column("multiple_response_sets", Text, nullable=False, default="{}"),
|
|
78
|
+
)
|
|
79
|
+
variables = Table(
|
|
80
|
+
"variable_catalog", metadata,
|
|
81
|
+
Column("dataset_id", String(255), primary_key=True),
|
|
82
|
+
Column("ordinal", Integer, primary_key=True),
|
|
83
|
+
Column("source_name", String(255), nullable=False),
|
|
84
|
+
Column("physical_name", String(255), nullable=False),
|
|
85
|
+
Column("storage_kind", String(16), nullable=False),
|
|
86
|
+
Column("readstat_storage_type", String(32)),
|
|
87
|
+
Column("string_width", Integer),
|
|
88
|
+
Column("label", Text, nullable=False, default=""),
|
|
89
|
+
Column("format", String(64)),
|
|
90
|
+
# format remains the legacy print-format mirror; SPSS has a distinct write format.
|
|
91
|
+
Column("print_format", String(64)),
|
|
92
|
+
Column("write_format", String(64)),
|
|
93
|
+
Column("measure", String(32)),
|
|
94
|
+
Column("role", String(32)),
|
|
95
|
+
Column("alignment", String(32)),
|
|
96
|
+
Column("display_width", Integer),
|
|
97
|
+
Column("attributes", Text, nullable=False, default="{}"),
|
|
98
|
+
Column("compat_name", String(255)),
|
|
99
|
+
Column("value_labels", Text, nullable=False, default="{}"),
|
|
100
|
+
Column("missing_ranges", Text, nullable=False, default="[]"),
|
|
101
|
+
)
|
|
102
|
+
fidelity_events = Table(
|
|
103
|
+
"fidelity_event_catalog", metadata,
|
|
104
|
+
Column("operation_id", String(36), primary_key=True),
|
|
105
|
+
Column("ordinal", Integer, primary_key=True),
|
|
106
|
+
Column("dataset_id", String(255)),
|
|
107
|
+
Column("direction", String(16), nullable=False),
|
|
108
|
+
Column("severity", String(16), nullable=False),
|
|
109
|
+
Column("detail", Text, nullable=False),
|
|
110
|
+
Column("details", Text, nullable=False, default="{}"),
|
|
111
|
+
Column("code", String(128), nullable=False),
|
|
112
|
+
)
|
|
113
|
+
operations = Table(
|
|
114
|
+
"operation_catalog", metadata,
|
|
115
|
+
Column("operation_id", String(36), primary_key=True),
|
|
116
|
+
Column("direction", String(16), nullable=False),
|
|
117
|
+
Column("status", String(16), nullable=False),
|
|
118
|
+
Column("dataset_id", String(255)),
|
|
119
|
+
Column("source", Text),
|
|
120
|
+
Column("destination", Text),
|
|
121
|
+
Column("created_at", String(40), nullable=False),
|
|
122
|
+
Column("completed_at", String(40)),
|
|
123
|
+
Column("details", Text, nullable=False, default="{}"),
|
|
124
|
+
)
|
|
125
|
+
return datasets, variables, fidelity_events, operations
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _now() -> str:
|
|
129
|
+
return datetime.now(UTC).isoformat()
|
|
130
|
+
|
|
131
|
+
def _migrate_catalog_columns(
|
|
132
|
+
connection: Any, datasets: Table, variables: Table, multiple_response: Table,
|
|
133
|
+
) -> None:
|
|
134
|
+
"""Add pyspssio metadata columns to catalogs created by earlier adapters.
|
|
135
|
+
|
|
136
|
+
This additive migration is intentionally small and portable: no existing
|
|
137
|
+
source data or dictionary row is rewritten, while a later import can store
|
|
138
|
+
the newly observable metadata alongside it.
|
|
139
|
+
"""
|
|
140
|
+
inspector = inspect(connection)
|
|
141
|
+
additions = {
|
|
142
|
+
datasets.name: {
|
|
143
|
+
"file_attributes": "TEXT NOT NULL DEFAULT '{}'",
|
|
144
|
+
"case_weight_variable": "VARCHAR(255)",
|
|
145
|
+
},
|
|
146
|
+
variables.name: {
|
|
147
|
+
"role": "VARCHAR(32)",
|
|
148
|
+
"attributes": "TEXT NOT NULL DEFAULT '{}'",
|
|
149
|
+
"compat_name": "VARCHAR(255)",
|
|
150
|
+
"print_format": "VARCHAR(64)",
|
|
151
|
+
"write_format": "VARCHAR(64)",
|
|
152
|
+
},
|
|
153
|
+
multiple_response.name: {
|
|
154
|
+
"is_dichotomy": "BOOLEAN",
|
|
155
|
+
"use_category_labels": "BOOLEAN",
|
|
156
|
+
"use_first_var_label": "BOOLEAN",
|
|
157
|
+
"counted_value_type": "VARCHAR(16)",
|
|
158
|
+
"counted_numeric": "DOUBLE",
|
|
159
|
+
"counted_text": "TEXT",
|
|
160
|
+
},
|
|
161
|
+
}
|
|
162
|
+
preparer = connection.dialect.identifier_preparer
|
|
163
|
+
for table_name, columns in additions.items():
|
|
164
|
+
if not inspector.has_table(table_name):
|
|
165
|
+
continue
|
|
166
|
+
existing = {column["name"] for column in inspector.get_columns(table_name)}
|
|
167
|
+
for name, declaration in columns.items():
|
|
168
|
+
if name not in existing:
|
|
169
|
+
connection.execute(text(
|
|
170
|
+
f"ALTER TABLE {preparer.quote(table_name)} ADD COLUMN "
|
|
171
|
+
f"{preparer.quote(name)} {declaration}"
|
|
172
|
+
))
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _event_rows(
|
|
177
|
+
*, operation_id: str, dataset_id: str | None, direction: str,
|
|
178
|
+
fidelity_events: Iterable[Mapping[str, Any]],
|
|
179
|
+
) -> list[dict[str, Any]]:
|
|
180
|
+
"""Normalize the public compact diagnostic shape into durable catalog rows."""
|
|
181
|
+
rows: list[dict[str, Any]] = []
|
|
182
|
+
for ordinal, event in enumerate(fidelity_events, start=1):
|
|
183
|
+
detail = str(event["detail"])
|
|
184
|
+
details = event.get("details", {})
|
|
185
|
+
rows.append({
|
|
186
|
+
"operation_id": operation_id, "ordinal": ordinal, "dataset_id": dataset_id,
|
|
187
|
+
"direction": str(event.get("direction", direction)),
|
|
188
|
+
"severity": str(event.get("severity", "warning")),
|
|
189
|
+
"code": str(event["code"]), "detail": detail,
|
|
190
|
+
"details": json.dumps(details, default=str, sort_keys=True),
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
return rows
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _record_failed_preflight(
|
|
198
|
+
*, engine: Any, metadata: MetaData, datasets: Table, variable_catalog: Table,
|
|
199
|
+
multiple_response_catalog: Table, fidelity_event_catalog: Table,
|
|
200
|
+
operation_catalog: Table, normative: Any, operation_id: str, source_name: str,
|
|
201
|
+
source_format: str, variable_count: int, profile_name: str, error: Exception,
|
|
202
|
+
) -> None:
|
|
203
|
+
"""Persist a failed preflight without creating any source dataset state."""
|
|
204
|
+
with engine.begin() as connection:
|
|
205
|
+
metadata.create_all(connection, tables=[
|
|
206
|
+
datasets, variable_catalog, multiple_response_catalog,
|
|
207
|
+
fidelity_event_catalog, operation_catalog,
|
|
208
|
+
])
|
|
209
|
+
create_normative_catalog(connection, normative)
|
|
210
|
+
failed_at = datetime.now(UTC).replace(tzinfo=None)
|
|
211
|
+
record_normative_operation(
|
|
212
|
+
connection, normative, operation_id=operation_id,
|
|
213
|
+
operation_kind="import", status="failed", source_format=source_format,
|
|
214
|
+
started_at=failed_at, completed_at=failed_at,
|
|
215
|
+
)
|
|
216
|
+
connection.execute(insert(operation_catalog).values(
|
|
217
|
+
operation_id=operation_id, direction="import", status="failed", dataset_id=None,
|
|
218
|
+
source=source_name, created_at=_now(), completed_at=_now(),
|
|
219
|
+
details=json.dumps({"reason": "preflight", "variable_count": variable_count,
|
|
220
|
+
"capability": getattr(error, "details", {})}, sort_keys=True),
|
|
221
|
+
))
|
|
222
|
+
failed_events = ({
|
|
223
|
+
"code": getattr(error, "code", "target_capability_exceeded"),
|
|
224
|
+
"detail": str(error), "severity": "error", "source_item": source_name,
|
|
225
|
+
"details": {"variable_count": variable_count, "profile": profile_name,
|
|
226
|
+
**getattr(error, "details", {})},
|
|
227
|
+
},)
|
|
228
|
+
connection.execute(insert(fidelity_event_catalog), _event_rows(
|
|
229
|
+
operation_id=operation_id, dataset_id=None, direction="import",
|
|
230
|
+
fidelity_events=failed_events,
|
|
231
|
+
))
|
|
232
|
+
record_normative_fidelity_events(
|
|
233
|
+
connection, normative, operation_id=operation_id, dataset_id=None,
|
|
234
|
+
direction="import", events=failed_events,
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
def multiple_response_set_catalog(metadata: MetaData) -> Table:
|
|
238
|
+
return Table(
|
|
239
|
+
"multiple_response_set_catalog", metadata,
|
|
240
|
+
Column("dataset_id", String(255), primary_key=True),
|
|
241
|
+
Column("set_name", String(255), primary_key=True),
|
|
242
|
+
Column("member_ordinal", Integer, primary_key=True),
|
|
243
|
+
Column("kind", String(16)), Column("label", Text),
|
|
244
|
+
Column("is_dichotomy", Boolean),
|
|
245
|
+
Column("use_category_labels", Boolean),
|
|
246
|
+
Column("use_first_var_label", Boolean),
|
|
247
|
+
Column("counted_value", Text),
|
|
248
|
+
Column("counted_value_type", String(16)),
|
|
249
|
+
Column("counted_numeric", binary64_type()),
|
|
250
|
+
Column("counted_text", Text),
|
|
251
|
+
Column("variable_name", String(255)),
|
|
252
|
+
Column("definition", Text, nullable=False),
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def source_extension_catalog(metadata: MetaData) -> Table:
|
|
257
|
+
"""Namespaced raw source semantics retained even when export is fail-closed."""
|
|
258
|
+
return Table(
|
|
259
|
+
"source_extension_catalog", metadata,
|
|
260
|
+
Column("dataset_id", String(255), primary_key=True),
|
|
261
|
+
Column("extension_key", String(255), primary_key=True),
|
|
262
|
+
Column("payload", Text, nullable=False),
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def source_extension_rows(dataset_id: str, extensions: Mapping[str, Any]) -> list[dict[str, Any]]:
|
|
267
|
+
return [
|
|
268
|
+
{"dataset_id": dataset_id, "extension_key": str(key),
|
|
269
|
+
"payload": json.dumps(payload, default=str, ensure_ascii=False, sort_keys=True)}
|
|
270
|
+
for key, payload in sorted(extensions.items())
|
|
271
|
+
]
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def document_catalog(metadata: MetaData) -> Table:
|
|
275
|
+
"""Ordered file documents, normalized independently from the legacy JSON column."""
|
|
276
|
+
return Table(
|
|
277
|
+
"document_catalog", metadata,
|
|
278
|
+
Column("dataset_id", String(255), primary_key=True),
|
|
279
|
+
Column("ordinal", Integer, primary_key=True),
|
|
280
|
+
Column("text", Text, nullable=False),
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def value_label_catalog(metadata: MetaData) -> Table:
|
|
285
|
+
"""Typed, ordered value labels; JSON on variable_catalog remains a read fallback."""
|
|
286
|
+
return Table(
|
|
287
|
+
"value_label_catalog", metadata,
|
|
288
|
+
Column("dataset_id", String(255), primary_key=True),
|
|
289
|
+
Column("variable_ordinal", Integer, primary_key=True),
|
|
290
|
+
Column("ordinal", Integer, primary_key=True),
|
|
291
|
+
Column("value_type", String(16), nullable=False),
|
|
292
|
+
Column("numeric_value", binary64_type()),
|
|
293
|
+
Column("text_value", Text),
|
|
294
|
+
Column("label", Text, nullable=False),
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def missing_rule_catalog(metadata: MetaData) -> Table:
|
|
299
|
+
"""Typed inclusive SPSS user-missing intervals, including discrete values as lo == hi."""
|
|
300
|
+
return Table(
|
|
301
|
+
"missing_rule_catalog", metadata,
|
|
302
|
+
Column("dataset_id", String(255), primary_key=True),
|
|
303
|
+
Column("variable_ordinal", Integer, primary_key=True),
|
|
304
|
+
Column("ordinal", Integer, primary_key=True),
|
|
305
|
+
Column("kind", String(16), nullable=False),
|
|
306
|
+
Column("lower_type", String(16), nullable=False),
|
|
307
|
+
Column("lower_numeric", binary64_type()),
|
|
308
|
+
Column("lower_text", Text),
|
|
309
|
+
Column("upper_type", String(16), nullable=False),
|
|
310
|
+
Column("upper_numeric", binary64_type()),
|
|
311
|
+
Column("upper_text", Text),
|
|
312
|
+
Column("lower_inclusive", Boolean, nullable=False, default=True),
|
|
313
|
+
Column("upper_inclusive", Boolean, nullable=False, default=True),
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def attribute_catalog(metadata: MetaData) -> Table:
|
|
318
|
+
"""Ordered SPSS custom-attribute values for files and variables.
|
|
319
|
+
|
|
320
|
+
SPSS custom attributes are text-valued, but one attribute name can carry
|
|
321
|
+
an ordered array of values. ``scope`` is ``file`` for a file attribute
|
|
322
|
+
(with ``variable_ordinal == 0``) and ``variable`` for an attribute of one
|
|
323
|
+
source variable. This table is authoritative whenever it contains rows;
|
|
324
|
+
the JSON columns on older catalogs remain a migration fallback.
|
|
325
|
+
"""
|
|
326
|
+
return Table(
|
|
327
|
+
"attribute_catalog", metadata,
|
|
328
|
+
Column("dataset_id", String(255), primary_key=True),
|
|
329
|
+
Column("scope", String(16), primary_key=True),
|
|
330
|
+
Column("variable_ordinal", Integer, primary_key=True),
|
|
331
|
+
Column("attribute_ordinal", Integer, primary_key=True),
|
|
332
|
+
Column("value_ordinal", Integer, primary_key=True),
|
|
333
|
+
Column("attribute_name", String(255), nullable=False),
|
|
334
|
+
Column("attribute_value", Text, nullable=False),
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _attribute_values(value: Any) -> list[str]:
|
|
339
|
+
"""Normalize one attribute scalar or ordered array without coercing it."""
|
|
340
|
+
if isinstance(value, (list, tuple)):
|
|
341
|
+
return ["" if item is None else str(item) for item in value]
|
|
342
|
+
return ["" if value is None else str(value)]
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def attribute_rows(
|
|
346
|
+
dataset_id: str, variables: Iterable[Mapping[str, Any]], *,
|
|
347
|
+
file_attributes: Mapping[str, Any] | None = None,
|
|
348
|
+
variable_attributes: Mapping[str, Mapping[str, Any]] | None = None,
|
|
349
|
+
) -> list[dict[str, Any]]:
|
|
350
|
+
"""Encode ordered file and variable attributes into canonical rows."""
|
|
351
|
+
rows: list[dict[str, Any]] = []
|
|
352
|
+
|
|
353
|
+
def append(scope: str, variable_ordinal: int, attributes: Mapping[str, Any] | None) -> None:
|
|
354
|
+
if not attributes:
|
|
355
|
+
return
|
|
356
|
+
for attribute_ordinal, (name, value) in enumerate(attributes.items(), start=1):
|
|
357
|
+
for value_ordinal, text_value in enumerate(_attribute_values(value), start=1):
|
|
358
|
+
rows.append({
|
|
359
|
+
"dataset_id": dataset_id, "scope": scope,
|
|
360
|
+
"variable_ordinal": variable_ordinal,
|
|
361
|
+
"attribute_ordinal": attribute_ordinal,
|
|
362
|
+
"value_ordinal": value_ordinal,
|
|
363
|
+
"attribute_name": str(name), "attribute_value": text_value,
|
|
364
|
+
})
|
|
365
|
+
|
|
366
|
+
append("file", 0, file_attributes)
|
|
367
|
+
attributes_by_variable = variable_attributes or {}
|
|
368
|
+
for variable in variables:
|
|
369
|
+
append("variable", int(variable["ordinal"]),
|
|
370
|
+
attributes_by_variable.get(str(variable["source_name"])))
|
|
371
|
+
return rows
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def attributes_from_rows(
|
|
375
|
+
rows: Iterable[Mapping[str, Any]], *, variables: Iterable[Mapping[str, Any]],
|
|
376
|
+
) -> tuple[dict[str, Any], dict[str, dict[str, Any]]]:
|
|
377
|
+
"""Rebuild ordered attributes; rows, not legacy JSON, are authoritative."""
|
|
378
|
+
by_ordinal = {int(item["ordinal"]): str(item["source_name"]) for item in variables}
|
|
379
|
+
grouped: dict[tuple[str, int], dict[str, list[str]]] = {}
|
|
380
|
+
for row in rows:
|
|
381
|
+
target = grouped.setdefault((str(row["scope"]), int(row["variable_ordinal"])), {})
|
|
382
|
+
target.setdefault(str(row["attribute_name"]), []).append(str(row["attribute_value"]))
|
|
383
|
+
|
|
384
|
+
def collapse(values: Mapping[str, list[str]]) -> dict[str, Any]:
|
|
385
|
+
return {name: value[0] if len(value) == 1 else value for name, value in values.items()}
|
|
386
|
+
|
|
387
|
+
file_attributes = collapse(grouped.get(("file", 0), {}))
|
|
388
|
+
variable_attributes = {
|
|
389
|
+
by_ordinal[ordinal]: collapse(attributes)
|
|
390
|
+
for (scope, ordinal), attributes in grouped.items()
|
|
391
|
+
if scope == "variable" and ordinal in by_ordinal
|
|
392
|
+
}
|
|
393
|
+
return file_attributes, variable_attributes
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _typed_endpoint(value: Any) -> tuple[str, float | None, str | None]:
|
|
397
|
+
"""Encode typed values without losing SPSS LOWEST/HIGHEST sentinels.
|
|
398
|
+
|
|
399
|
+
ReadStat exposes a LOWEST range bound as NaN and HIGHEST as positive
|
|
400
|
+
infinity. Storing either in a SQL binary64 column is not portable, so the
|
|
401
|
+
catalog records an explicit endpoint type and reconstructs the ReadStat
|
|
402
|
+
writer input on export.
|
|
403
|
+
"""
|
|
404
|
+
if isinstance(value, float):
|
|
405
|
+
if math.isnan(value):
|
|
406
|
+
return "lowest", None, None
|
|
407
|
+
if math.isinf(value):
|
|
408
|
+
return ("highest" if value > 0 else "lowest"), None, None
|
|
409
|
+
if isinstance(value, bool):
|
|
410
|
+
return "text", None, str(value)
|
|
411
|
+
if isinstance(value, (int, float)):
|
|
412
|
+
return "numeric", float(value), None
|
|
413
|
+
return "text", None, "" if value is None else str(value)
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def document_rows(dataset_id: str, documents: str) -> list[dict[str, Any]]:
|
|
417
|
+
return [
|
|
418
|
+
{"dataset_id": dataset_id, "ordinal": ordinal, "text": str(text)}
|
|
419
|
+
for ordinal, text in enumerate(json.loads(documents or "[]"), start=1)
|
|
420
|
+
]
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def value_label_rows(dataset_id: str, variables: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
424
|
+
rows: list[dict[str, Any]] = []
|
|
425
|
+
for variable in variables:
|
|
426
|
+
labels = json.loads(variable.get("value_labels") or "{}")
|
|
427
|
+
for ordinal, (raw_value, label) in enumerate(labels.items(), start=1):
|
|
428
|
+
value: Any = float(raw_value) if variable["storage_kind"] == "numeric" else raw_value
|
|
429
|
+
value_type, numeric_value, text_value = _typed_endpoint(value)
|
|
430
|
+
rows.append({
|
|
431
|
+
"dataset_id": dataset_id, "variable_ordinal": variable["ordinal"], "ordinal": ordinal,
|
|
432
|
+
"value_type": value_type, "numeric_value": numeric_value, "text_value": text_value,
|
|
433
|
+
"label": str(label),
|
|
434
|
+
})
|
|
435
|
+
return rows
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def missing_rule_rows(dataset_id: str, variables: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
439
|
+
rows: list[dict[str, Any]] = []
|
|
440
|
+
for variable in variables:
|
|
441
|
+
rules = json.loads(variable.get("missing_ranges") or "[]")
|
|
442
|
+
for ordinal, raw_rule in enumerate(rules, start=1):
|
|
443
|
+
if isinstance(raw_rule, dict):
|
|
444
|
+
lower = raw_rule.get("lo")
|
|
445
|
+
upper = raw_rule.get("hi")
|
|
446
|
+
else:
|
|
447
|
+
lower = upper = raw_rule
|
|
448
|
+
lower_type, lower_numeric, lower_text = _typed_endpoint(lower)
|
|
449
|
+
upper_type, upper_numeric, upper_text = _typed_endpoint(upper)
|
|
450
|
+
kind = "discrete" if lower_type == upper_type and (lower_numeric == upper_numeric and lower_text == upper_text) else "range"
|
|
451
|
+
rows.append({
|
|
452
|
+
"dataset_id": dataset_id, "variable_ordinal": variable["ordinal"], "ordinal": ordinal,
|
|
453
|
+
"kind": kind,
|
|
454
|
+
"lower_type": lower_type, "lower_numeric": lower_numeric, "lower_text": lower_text,
|
|
455
|
+
"upper_type": upper_type, "upper_numeric": upper_numeric, "upper_text": upper_text,
|
|
456
|
+
"lower_inclusive": True, "upper_inclusive": True,
|
|
457
|
+
})
|
|
458
|
+
return rows
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def normalized_metadata_tables(metadata: MetaData) -> tuple[Table, Table, Table, Table]:
|
|
462
|
+
return (
|
|
463
|
+
document_catalog(metadata), value_label_catalog(metadata), missing_rule_catalog(metadata),
|
|
464
|
+
attribute_catalog(metadata),
|
|
465
|
+
)
|
|
466
|
+
|
|
467
|
+
def _mr_counted_value(definition: Mapping[str, Any]) -> tuple[str | None, float | None, str | None]:
|
|
468
|
+
value = definition.get("counted_value", definition.get("countedvalue"))
|
|
469
|
+
if value is None:
|
|
470
|
+
return None, None, None
|
|
471
|
+
if isinstance(value, bool):
|
|
472
|
+
return "text", None, str(value)
|
|
473
|
+
if isinstance(value, (int, float)):
|
|
474
|
+
return "numeric", float(value), None
|
|
475
|
+
return "text", None, str(value)
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def _multiple_response_definitions(definitions: str | Mapping[str, Any] | None) -> Mapping[str, Any]:
|
|
479
|
+
if definitions is None or definitions == "":
|
|
480
|
+
return {}
|
|
481
|
+
if isinstance(definitions, str):
|
|
482
|
+
try:
|
|
483
|
+
definitions = json.loads(definitions)
|
|
484
|
+
except json.JSONDecodeError as error:
|
|
485
|
+
raise _catalog_error(
|
|
486
|
+
"multiple-response-definition-invalid",
|
|
487
|
+
"Multiple-response set catalog is not valid JSON.",
|
|
488
|
+
error=str(error),
|
|
489
|
+
) from error
|
|
490
|
+
if not isinstance(definitions, Mapping):
|
|
491
|
+
raise _catalog_error(
|
|
492
|
+
"multiple-response-definition-invalid",
|
|
493
|
+
"Multiple-response set catalog must be an object keyed by set name.",
|
|
494
|
+
actual_type=type(definitions).__name__,
|
|
495
|
+
)
|
|
496
|
+
return definitions
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def _mr_is_dichotomy(definition: Mapping[str, Any]) -> bool:
|
|
500
|
+
value = definition.get("is_dichotomy")
|
|
501
|
+
if value is not None:
|
|
502
|
+
return bool(value)
|
|
503
|
+
return definition.get("set_type", definition.get("type")) in {"MD", "D", "E"} or (
|
|
504
|
+
definition.get("counted_value", definition.get("countedvalue")) is not None
|
|
505
|
+
)
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def validate_spss_catalog(
|
|
509
|
+
variables: Iterable[Mapping[str, Any]], *,
|
|
510
|
+
case_weight_variable: str | None = None,
|
|
511
|
+
multiple_response_sets: str | Mapping[str, Any] | None = None,
|
|
512
|
+
) -> None:
|
|
513
|
+
"""Validate SPSS dictionary cross-references before persisting or writing."""
|
|
514
|
+
variables_by_name: dict[str, Mapping[str, Any]] = {}
|
|
515
|
+
for variable in variables:
|
|
516
|
+
source_name = variable.get("source_name")
|
|
517
|
+
if isinstance(source_name, str):
|
|
518
|
+
variables_by_name[source_name] = variable
|
|
519
|
+
|
|
520
|
+
if case_weight_variable is not None:
|
|
521
|
+
if not isinstance(case_weight_variable, str) or not case_weight_variable:
|
|
522
|
+
raise _catalog_error(
|
|
523
|
+
"case-weight-variable-invalid",
|
|
524
|
+
"The case-weight reference must be a non-empty source variable name.",
|
|
525
|
+
case_weight_variable=case_weight_variable,
|
|
526
|
+
)
|
|
527
|
+
weight = variables_by_name.get(case_weight_variable)
|
|
528
|
+
if weight is None:
|
|
529
|
+
raise _catalog_error(
|
|
530
|
+
"case-weight-variable-not-found",
|
|
531
|
+
"The case-weight reference does not name a source variable.",
|
|
532
|
+
case_weight_variable=case_weight_variable,
|
|
533
|
+
)
|
|
534
|
+
if weight.get("storage_kind") != "numeric":
|
|
535
|
+
raise _catalog_error(
|
|
536
|
+
"case-weight-variable-not-numeric",
|
|
537
|
+
"The case-weight variable must use SPSS numeric storage.",
|
|
538
|
+
case_weight_variable=case_weight_variable,
|
|
539
|
+
storage_kind=weight.get("storage_kind"),
|
|
540
|
+
)
|
|
541
|
+
for set_name, definition in _multiple_response_definitions(multiple_response_sets).items():
|
|
542
|
+
if not isinstance(set_name, str) or not set_name:
|
|
543
|
+
raise _catalog_error(
|
|
544
|
+
"multiple-response-set-name-invalid",
|
|
545
|
+
"A multiple-response set needs a non-empty string name.",
|
|
546
|
+
set_name=set_name,
|
|
547
|
+
)
|
|
548
|
+
if not isinstance(definition, Mapping):
|
|
549
|
+
raise _catalog_error(
|
|
550
|
+
"multiple-response-definition-invalid",
|
|
551
|
+
"A multiple-response set definition must be an object.",
|
|
552
|
+
set_name=set_name,
|
|
553
|
+
actual_type=type(definition).__name__,
|
|
554
|
+
)
|
|
555
|
+
members = definition.get("variable_list", definition.get("variables", []))
|
|
556
|
+
if isinstance(members, str):
|
|
557
|
+
members = members.split()
|
|
558
|
+
if not isinstance(members, (list, tuple)) or not members:
|
|
559
|
+
raise _catalog_error(
|
|
560
|
+
"multiple-response-members-invalid",
|
|
561
|
+
"A multiple-response set needs at least one ordered member variable.",
|
|
562
|
+
set_name=set_name,
|
|
563
|
+
)
|
|
564
|
+
if any(not isinstance(member, str) or not member for member in members):
|
|
565
|
+
raise _catalog_error(
|
|
566
|
+
"multiple-response-member-invalid",
|
|
567
|
+
"Every multiple-response member must be a non-empty source variable name.",
|
|
568
|
+
set_name=set_name,
|
|
569
|
+
)
|
|
570
|
+
if len(set(members)) != len(members):
|
|
571
|
+
raise _catalog_error(
|
|
572
|
+
"multiple-response-member-duplicate",
|
|
573
|
+
"A multiple-response set cannot list one source variable twice.",
|
|
574
|
+
set_name=set_name,
|
|
575
|
+
)
|
|
576
|
+
member_variables: list[Mapping[str, Any]] = []
|
|
577
|
+
for member in members:
|
|
578
|
+
variable = variables_by_name.get(member)
|
|
579
|
+
if variable is None:
|
|
580
|
+
raise _catalog_error(
|
|
581
|
+
"multiple-response-member-not-found",
|
|
582
|
+
"A multiple-response member does not name a source variable.",
|
|
583
|
+
set_name=set_name,
|
|
584
|
+
member=member,
|
|
585
|
+
)
|
|
586
|
+
member_variables.append(variable)
|
|
587
|
+
storage_kinds = {str(variable.get("storage_kind")) for variable in member_variables}
|
|
588
|
+
if len(storage_kinds) != 1 or storage_kinds == {"None"}:
|
|
589
|
+
raise _catalog_error(
|
|
590
|
+
"multiple-response-member-type-mismatch",
|
|
591
|
+
"All multiple-response members must have one shared SPSS storage kind.",
|
|
592
|
+
set_name=set_name,
|
|
593
|
+
members=list(members),
|
|
594
|
+
storage_kinds=sorted(storage_kinds),
|
|
595
|
+
)
|
|
596
|
+
is_dichotomy = _mr_is_dichotomy(definition)
|
|
597
|
+
counted_value = definition.get("counted_value", definition.get("countedvalue"))
|
|
598
|
+
if not is_dichotomy:
|
|
599
|
+
if counted_value is not None:
|
|
600
|
+
raise _catalog_error(
|
|
601
|
+
"multiple-response-counted-value-not-permitted",
|
|
602
|
+
"An MC multiple-response set cannot define a dichotomy counted value.",
|
|
603
|
+
set_name=set_name,
|
|
604
|
+
counted_value=counted_value,
|
|
605
|
+
)
|
|
606
|
+
continue
|
|
607
|
+
if counted_value is None:
|
|
608
|
+
raise _catalog_error(
|
|
609
|
+
"multiple-response-counted-value-missing",
|
|
610
|
+
"An MD multiple-response set must define its counted value.",
|
|
611
|
+
set_name=set_name,
|
|
612
|
+
)
|
|
613
|
+
storage_kind = storage_kinds.pop()
|
|
614
|
+
if storage_kind == "numeric":
|
|
615
|
+
if isinstance(counted_value, bool) or not isinstance(counted_value, (int, float)) or not math.isfinite(float(counted_value)):
|
|
616
|
+
raise _catalog_error(
|
|
617
|
+
"multiple-response-counted-value-type-mismatch",
|
|
618
|
+
"An MD set of numeric variables requires a finite numeric counted value.",
|
|
619
|
+
set_name=set_name,
|
|
620
|
+
counted_value=counted_value,
|
|
621
|
+
storage_kind=storage_kind,
|
|
622
|
+
)
|
|
623
|
+
elif storage_kind == "string":
|
|
624
|
+
if not isinstance(counted_value, str):
|
|
625
|
+
raise _catalog_error(
|
|
626
|
+
"multiple-response-counted-value-type-mismatch",
|
|
627
|
+
"An MD set of string variables requires a string counted value.",
|
|
628
|
+
set_name=set_name,
|
|
629
|
+
counted_value=counted_value,
|
|
630
|
+
storage_kind=storage_kind,
|
|
631
|
+
)
|
|
632
|
+
encoded_length = len(counted_value.encode("utf-8"))
|
|
633
|
+
for member, variable in zip(members, member_variables):
|
|
634
|
+
width = variable.get("string_width")
|
|
635
|
+
if isinstance(width, int) and encoded_length > width:
|
|
636
|
+
raise _catalog_error(
|
|
637
|
+
"multiple-response-counted-value-too-wide",
|
|
638
|
+
"The MD counted value exceeds a member's declared SPSS string width.",
|
|
639
|
+
set_name=set_name,
|
|
640
|
+
member=member,
|
|
641
|
+
counted_value=counted_value,
|
|
642
|
+
counted_value_bytes=encoded_length,
|
|
643
|
+
string_width=width,
|
|
644
|
+
)
|
|
645
|
+
else:
|
|
646
|
+
raise _catalog_error(
|
|
647
|
+
"multiple-response-member-storage-invalid",
|
|
648
|
+
"A multiple-response member has unsupported SPSS storage kind.",
|
|
649
|
+
set_name=set_name,
|
|
650
|
+
storage_kind=storage_kind,
|
|
651
|
+
)
|
|
652
|
+
|
|
653
|
+
def multiple_response_set_rows(dataset_id: str, definitions: str) -> list[dict[str, Any]]:
|
|
654
|
+
sets = json.loads(definitions or "{}")
|
|
655
|
+
rows: list[dict[str, Any]] = []
|
|
656
|
+
for set_name, definition in sets.items():
|
|
657
|
+
if not isinstance(definition, Mapping):
|
|
658
|
+
continue
|
|
659
|
+
members = definition.get("variable_list", definition.get("variables", []))
|
|
660
|
+
if isinstance(members, str):
|
|
661
|
+
members = members.split()
|
|
662
|
+
is_dichotomy = definition.get("is_dichotomy")
|
|
663
|
+
if is_dichotomy is None:
|
|
664
|
+
is_dichotomy = definition.get("set_type", definition.get("type")) in {"MD", "D", "E"} or (
|
|
665
|
+
definition.get("counted_value", definition.get("countedvalue")) is not None
|
|
666
|
+
)
|
|
667
|
+
kind = "MD" if is_dichotomy else "MC"
|
|
668
|
+
value_type, numeric_value, text_value = _mr_counted_value(definition)
|
|
669
|
+
for ordinal, variable_name in enumerate(members or [None], start=1):
|
|
670
|
+
rows.append({
|
|
671
|
+
"dataset_id": dataset_id, "set_name": set_name, "member_ordinal": ordinal,
|
|
672
|
+
"kind": kind, "label": definition.get("label"),
|
|
673
|
+
"is_dichotomy": bool(is_dichotomy),
|
|
674
|
+
"use_category_labels": bool(definition.get("use_category_labels", False)),
|
|
675
|
+
"use_first_var_label": bool(definition.get("use_first_var_label", False)),
|
|
676
|
+
"counted_value": "" if value_type is None else str(
|
|
677
|
+
numeric_value if value_type == "numeric" else text_value
|
|
678
|
+
),
|
|
679
|
+
"counted_value_type": value_type,
|
|
680
|
+
"counted_numeric": numeric_value, "counted_text": text_value,
|
|
681
|
+
"variable_name": variable_name,
|
|
682
|
+
"definition": json.dumps(definition, default=str, ensure_ascii=False, sort_keys=True),
|
|
683
|
+
})
|
|
684
|
+
return rows
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
def multiple_response_sets_from_rows(rows: Iterable[Mapping[str, Any]]) -> dict[str, dict[str, Any]]:
|
|
688
|
+
"""Reconstruct pyspssio MR metadata from normalized catalog rows.
|
|
689
|
+
|
|
690
|
+
The new typed fields are authoritative. ``definition`` only supplies
|
|
691
|
+
backwards compatibility for catalogs written before those fields existed.
|
|
692
|
+
"""
|
|
693
|
+
result: dict[str, dict[str, Any]] = {}
|
|
694
|
+
for row in rows:
|
|
695
|
+
name = str(row["set_name"])
|
|
696
|
+
if name not in result:
|
|
697
|
+
try:
|
|
698
|
+
legacy = json.loads(row.get("definition") or "{}")
|
|
699
|
+
except (TypeError, json.JSONDecodeError):
|
|
700
|
+
legacy = {}
|
|
701
|
+
definition = dict(legacy) if isinstance(legacy, Mapping) else {}
|
|
702
|
+
definition["variable_list"] = []
|
|
703
|
+
result[name] = definition
|
|
704
|
+
definition = result[name]
|
|
705
|
+
if row.get("label") is not None:
|
|
706
|
+
definition["label"] = row["label"]
|
|
707
|
+
if row.get("is_dichotomy") is not None:
|
|
708
|
+
definition["is_dichotomy"] = bool(row["is_dichotomy"])
|
|
709
|
+
elif row.get("kind") is not None:
|
|
710
|
+
definition["is_dichotomy"] = str(row["kind"]).upper() == "MD"
|
|
711
|
+
if row.get("use_category_labels") is not None:
|
|
712
|
+
definition["use_category_labels"] = bool(row["use_category_labels"])
|
|
713
|
+
if row.get("use_first_var_label") is not None:
|
|
714
|
+
definition["use_first_var_label"] = bool(row["use_first_var_label"])
|
|
715
|
+
value_type = row.get("counted_value_type")
|
|
716
|
+
if value_type == "numeric":
|
|
717
|
+
definition["counted_value"] = row.get("counted_numeric")
|
|
718
|
+
elif value_type == "text":
|
|
719
|
+
definition["counted_value"] = row.get("counted_text")
|
|
720
|
+
elif row.get("counted_value") not in (None, ""):
|
|
721
|
+
definition["counted_value"] = row["counted_value"]
|
|
722
|
+
if row.get("variable_name") is not None:
|
|
723
|
+
definition["variable_list"].append(row["variable_name"])
|
|
724
|
+
return result
|
|
725
|
+
|
|
726
|
+
def physical_name(source_name: str, used: set[str]) -> str:
|
|
727
|
+
stem = _IDENTIFIER.sub("_", source_name).strip("_").lower() or "variable"
|
|
728
|
+
stem = stem[:54]
|
|
729
|
+
candidate, suffix = stem, 2
|
|
730
|
+
while candidate.lower() in used or candidate.startswith("__"):
|
|
731
|
+
candidate = f"{stem[:50]}_{suffix}"
|
|
732
|
+
suffix += 1
|
|
733
|
+
used.add(candidate.lower())
|
|
734
|
+
return candidate
|
|
735
|
+
|
|
736
|
+
|
|
737
|
+
def data_table_name(dataset_id: str) -> str:
|
|
738
|
+
stem = _IDENTIFIER.sub("_", dataset_id).strip("_").lower() or "dataset"
|
|
739
|
+
return f"data_{stem[:48]}"
|
|
740
|
+
|
|
741
|
+
|
|
742
|
+
def create_wide_dataset(
|
|
743
|
+
*, database_url: str, dataset_id: str, source_name: str, source_format: str,
|
|
744
|
+
rows: Iterable[Mapping[str, Any]], variables: list[dict[str, Any]], file_label: str = "",
|
|
745
|
+
source_encoding: str | None = None, documents: str = "[]",
|
|
746
|
+
file_attributes: str = "{}", case_weight_variable: str | None = None,
|
|
747
|
+
file_attribute_values: Mapping[str, Any] | None = None,
|
|
748
|
+
variable_attribute_values: Mapping[str, Mapping[str, Any]] | None = None,
|
|
749
|
+
source_table_name: str | None = None,
|
|
750
|
+
source_sha256: str = "",
|
|
751
|
+
source_created_at: str | None = None, source_modified_at: str | None = None,
|
|
752
|
+
imported_at: str = "",
|
|
753
|
+
multiple_response_sets: str = "{}",
|
|
754
|
+
source_extensions: Mapping[str, Any] | None = None,
|
|
755
|
+
fidelity_events: Iterable[Mapping[str, Any]] = (),
|
|
756
|
+
operation_details: Mapping[str, Any] | None = None,
|
|
757
|
+
) -> dict[str, Any]:
|
|
758
|
+
validate_connection_url(database_url)
|
|
759
|
+
profile, _active_connection = effective_profile(database_url)
|
|
760
|
+
engine = create_engine(database_url)
|
|
761
|
+
metadata = MetaData()
|
|
762
|
+
datasets, variable_catalog, fidelity_event_catalog, operation_catalog = catalog(metadata)
|
|
763
|
+
normative = normative_catalog(metadata)
|
|
764
|
+
multiple_response_catalog = multiple_response_set_catalog(metadata)
|
|
765
|
+
source_extensions_catalog = source_extension_catalog(metadata)
|
|
766
|
+
documents_catalog, value_labels_catalog, missing_rules_catalog, attributes_catalog = normalized_metadata_tables(metadata)
|
|
767
|
+
operation_id = str(uuid4())
|
|
768
|
+
fidelity_events = tuple(fidelity_events)
|
|
769
|
+
source_rows = list(rows)
|
|
770
|
+
try:
|
|
771
|
+
preflight(profile, variables, rows=source_rows)
|
|
772
|
+
validate_spss_catalog(
|
|
773
|
+
variables,
|
|
774
|
+
case_weight_variable=case_weight_variable,
|
|
775
|
+
multiple_response_sets=multiple_response_sets,
|
|
776
|
+
)
|
|
777
|
+
except Exception as error:
|
|
778
|
+
_record_failed_preflight(
|
|
779
|
+
engine=engine, metadata=metadata, datasets=datasets,
|
|
780
|
+
variable_catalog=variable_catalog, multiple_response_catalog=multiple_response_catalog,
|
|
781
|
+
fidelity_event_catalog=fidelity_event_catalog, operation_catalog=operation_catalog,
|
|
782
|
+
operation_id=operation_id, source_name=source_name,
|
|
783
|
+
source_format=source_format, variable_count=len(variables),
|
|
784
|
+
profile_name=profile.name, error=error, normative=normative,
|
|
785
|
+
)
|
|
786
|
+
raise
|
|
787
|
+
data_table = Table(
|
|
788
|
+
data_table_name(dataset_id), metadata,
|
|
789
|
+
Column("__case_ordinal", BigInteger, primary_key=True, nullable=False),
|
|
790
|
+
*(Column(item["physical_name"], binary64_type() if item["storage_kind"] == "numeric" else Text(),
|
|
791
|
+
nullable=item["storage_kind"] == "numeric") for item in variables),
|
|
792
|
+
)
|
|
793
|
+
with engine.begin() as connection:
|
|
794
|
+
metadata.create_all(connection, tables=[datasets, variable_catalog, multiple_response_catalog, source_extensions_catalog, documents_catalog, value_labels_catalog, missing_rules_catalog, attributes_catalog, fidelity_event_catalog, operation_catalog])
|
|
795
|
+
create_normative_catalog(connection, normative)
|
|
796
|
+
_migrate_catalog_columns(connection, datasets, variable_catalog, multiple_response_catalog)
|
|
797
|
+
record_normative_operation(
|
|
798
|
+
connection, normative, operation_id=operation_id,
|
|
799
|
+
operation_kind="import", status="started", source_format=source_format,
|
|
800
|
+
)
|
|
801
|
+
connection.execute(insert(operation_catalog).values(
|
|
802
|
+
operation_id=operation_id, direction="import", status="running", dataset_id=dataset_id,
|
|
803
|
+
source=source_name, created_at=_now(), details=json.dumps({"variable_count": len(variables), **dict(operation_details or {})}, sort_keys=True),
|
|
804
|
+
))
|
|
805
|
+
if connection.execute(select(datasets.c.dataset_id).where(datasets.c.dataset_id == dataset_id)).first():
|
|
806
|
+
raise ValueError(f"Dataset {dataset_id!r} already exists; imports never overwrite a dataset.")
|
|
807
|
+
if connection.execute(select(datasets.c.dataset_id).where(datasets.c.data_table == data_table.name)).first():
|
|
808
|
+
raise ValueError(f"Dataset ID {dataset_id!r} collides with an existing physical data-table name; import was not started.")
|
|
809
|
+
data_table.create(connection)
|
|
810
|
+
materialized = [
|
|
811
|
+
{"__case_ordinal": ordinal, **row}
|
|
812
|
+
for ordinal, row in enumerate(source_rows, start=1)
|
|
813
|
+
]
|
|
814
|
+
connection.execute(insert(datasets).values(
|
|
815
|
+
dataset_id=dataset_id, data_table=data_table.name, source_format=source_format,
|
|
816
|
+
source_name=source_name, source_encoding=source_encoding, case_count=len(materialized),
|
|
817
|
+
source_table_name=source_table_name,
|
|
818
|
+
source_sha256=source_sha256,
|
|
819
|
+
source_created_at=source_created_at, source_modified_at=source_modified_at,
|
|
820
|
+
imported_at=imported_at,
|
|
821
|
+
file_label=file_label, documents=documents,
|
|
822
|
+
file_attributes=file_attributes, case_weight_variable=case_weight_variable,
|
|
823
|
+
multiple_response_sets=multiple_response_sets,
|
|
824
|
+
))
|
|
825
|
+
connection.execute(insert(variable_catalog), [dict(dataset_id=dataset_id, **item) for item in variables])
|
|
826
|
+
docs_rows = document_rows(dataset_id, documents)
|
|
827
|
+
if docs_rows:
|
|
828
|
+
connection.execute(insert(documents_catalog), docs_rows)
|
|
829
|
+
labels_rows = value_label_rows(dataset_id, variables)
|
|
830
|
+
if labels_rows:
|
|
831
|
+
connection.execute(insert(value_labels_catalog), labels_rows)
|
|
832
|
+
missing_rows = missing_rule_rows(dataset_id, variables)
|
|
833
|
+
if missing_rows:
|
|
834
|
+
connection.execute(insert(missing_rules_catalog), missing_rows)
|
|
835
|
+
attributes_rows = attribute_rows(
|
|
836
|
+
dataset_id, variables,
|
|
837
|
+
file_attributes=file_attribute_values,
|
|
838
|
+
variable_attributes=variable_attribute_values,
|
|
839
|
+
)
|
|
840
|
+
if attributes_rows:
|
|
841
|
+
connection.execute(insert(attributes_catalog), attributes_rows)
|
|
842
|
+
mrset_rows = multiple_response_set_rows(dataset_id, multiple_response_sets)
|
|
843
|
+
if mrset_rows:
|
|
844
|
+
connection.execute(insert(multiple_response_catalog), mrset_rows)
|
|
845
|
+
extension_rows = source_extension_rows(dataset_id, source_extensions or {})
|
|
846
|
+
if extension_rows:
|
|
847
|
+
connection.execute(insert(source_extensions_catalog), extension_rows)
|
|
848
|
+
event_rows = _event_rows(
|
|
849
|
+
operation_id=operation_id, dataset_id=dataset_id, direction="import", fidelity_events=fidelity_events,
|
|
850
|
+
)
|
|
851
|
+
if event_rows:
|
|
852
|
+
connection.execute(insert(fidelity_event_catalog), event_rows)
|
|
853
|
+
normative_dataset_id = store_normative_dataset(
|
|
854
|
+
connection, normative, dataset_name=dataset_id,
|
|
855
|
+
source_format=source_format, physical_table_name=data_table.name,
|
|
856
|
+
dataset_label=file_label, source_encoding=source_encoding,
|
|
857
|
+
source_hash=source_sha256, source_case_count=len(materialized),
|
|
858
|
+
imported_at=imported_at or None, variables=variables,
|
|
859
|
+
documents=docs_rows, value_labels=labels_rows,
|
|
860
|
+
missing_rules=missing_rows, attributes=attributes_rows,
|
|
861
|
+
multiple_response_sets=mrset_rows,
|
|
862
|
+
source_extensions=source_extensions or {},
|
|
863
|
+
case_weight_variable=case_weight_variable,
|
|
864
|
+
)
|
|
865
|
+
record_normative_fidelity_events(
|
|
866
|
+
connection, normative, operation_id=operation_id,
|
|
867
|
+
dataset_id=normative_dataset_id, direction="import",
|
|
868
|
+
events=fidelity_events,
|
|
869
|
+
)
|
|
870
|
+
if materialized:
|
|
871
|
+
try:
|
|
872
|
+
connection.execute(insert(data_table), materialized)
|
|
873
|
+
except Exception:
|
|
874
|
+
data_table.drop(connection, checkfirst=True)
|
|
875
|
+
connection.execute(delete(multiple_response_catalog).where(multiple_response_catalog.c.dataset_id == dataset_id))
|
|
876
|
+
connection.execute(delete(source_extensions_catalog).where(source_extensions_catalog.c.dataset_id == dataset_id))
|
|
877
|
+
connection.execute(delete(documents_catalog).where(documents_catalog.c.dataset_id == dataset_id))
|
|
878
|
+
connection.execute(delete(value_labels_catalog).where(value_labels_catalog.c.dataset_id == dataset_id))
|
|
879
|
+
connection.execute(delete(missing_rules_catalog).where(missing_rules_catalog.c.dataset_id == dataset_id))
|
|
880
|
+
connection.execute(delete(attributes_catalog).where(attributes_catalog.c.dataset_id == dataset_id))
|
|
881
|
+
connection.execute(delete(fidelity_event_catalog).where(fidelity_event_catalog.c.dataset_id == dataset_id))
|
|
882
|
+
connection.execute(delete(variable_catalog).where(variable_catalog.c.dataset_id == dataset_id))
|
|
883
|
+
connection.execute(delete(datasets).where(datasets.c.dataset_id == dataset_id))
|
|
884
|
+
connection.commit()
|
|
885
|
+
raise
|
|
886
|
+
connection.execute(update(operation_catalog).where(operation_catalog.c.operation_id == operation_id).values(
|
|
887
|
+
status="succeeded", completed_at=_now(),
|
|
888
|
+
))
|
|
889
|
+
finish_normative_operation(
|
|
890
|
+
connection, normative, operation_id=operation_id, status="succeeded",
|
|
891
|
+
)
|
|
892
|
+
return {"dataset_id": dataset_id, "data_table": data_table.name, "case_count": len(materialized), "operation_id": operation_id}
|
|
893
|
+
|
|
894
|
+
|
|
895
|
+
def _endpoint_from_row(row: Mapping[str, Any], *, prefix: str) -> Any:
|
|
896
|
+
endpoint_type = row[f"{prefix}_type"]
|
|
897
|
+
if endpoint_type == "lowest":
|
|
898
|
+
return -sys.float_info.max
|
|
899
|
+
if endpoint_type == "highest":
|
|
900
|
+
return sys.float_info.max
|
|
901
|
+
return row[f"{prefix}_numeric"] if endpoint_type == "numeric" else row[f"{prefix}_text"]
|
|
902
|
+
|
|
903
|
+
|
|
904
|
+
def read_wide_dataset(*, database_url: str, dataset_id: str) -> tuple[dict[str, Any], list[dict[str, Any]], list[dict[str, Any]]]:
|
|
905
|
+
"""Read a strict dataset, preferring normalized metadata with JSON compatibility fallback."""
|
|
906
|
+
engine = create_engine(database_url)
|
|
907
|
+
metadata = MetaData()
|
|
908
|
+
datasets, variable_catalog, _, _ = catalog(metadata)
|
|
909
|
+
multiple_response_catalog = multiple_response_set_catalog(metadata)
|
|
910
|
+
source_extensions_catalog = source_extension_catalog(metadata)
|
|
911
|
+
documents_catalog, value_labels_catalog, missing_rules_catalog, attributes_catalog = normalized_metadata_tables(metadata)
|
|
912
|
+
with engine.begin() as connection:
|
|
913
|
+
metadata.create_all(connection, tables=[datasets, variable_catalog, multiple_response_catalog, source_extensions_catalog, documents_catalog, value_labels_catalog, missing_rules_catalog, attributes_catalog])
|
|
914
|
+
_migrate_catalog_columns(connection, datasets, variable_catalog, multiple_response_catalog)
|
|
915
|
+
dataset = dict(connection.execute(select(datasets).where(datasets.c.dataset_id == dataset_id)).mappings().one())
|
|
916
|
+
data_table = Table(dataset["data_table"], MetaData(), autoload_with=connection)
|
|
917
|
+
variables = [dict(item) for item in connection.execute(
|
|
918
|
+
select(variable_catalog).where(variable_catalog.c.dataset_id == dataset_id).order_by(variable_catalog.c.ordinal)
|
|
919
|
+
).mappings().all()]
|
|
920
|
+
rows = [dict(item) for item in connection.execute(
|
|
921
|
+
select(data_table).order_by(data_table.c.__case_ordinal)
|
|
922
|
+
).mappings().all()]
|
|
923
|
+
document_rows_result = connection.execute(
|
|
924
|
+
select(documents_catalog).where(documents_catalog.c.dataset_id == dataset_id)
|
|
925
|
+
.order_by(documents_catalog.c.ordinal)
|
|
926
|
+
).mappings().all()
|
|
927
|
+
label_rows_result = connection.execute(
|
|
928
|
+
select(value_labels_catalog).where(value_labels_catalog.c.dataset_id == dataset_id)
|
|
929
|
+
.order_by(value_labels_catalog.c.variable_ordinal, value_labels_catalog.c.ordinal)
|
|
930
|
+
).mappings().all()
|
|
931
|
+
missing_rows_result = connection.execute(
|
|
932
|
+
select(missing_rules_catalog).where(missing_rules_catalog.c.dataset_id == dataset_id)
|
|
933
|
+
.order_by(missing_rules_catalog.c.variable_ordinal, missing_rules_catalog.c.ordinal)
|
|
934
|
+
).mappings().all()
|
|
935
|
+
attribute_rows_result = connection.execute(
|
|
936
|
+
select(attributes_catalog).where(attributes_catalog.c.dataset_id == dataset_id)
|
|
937
|
+
.order_by(
|
|
938
|
+
attributes_catalog.c.scope, attributes_catalog.c.variable_ordinal,
|
|
939
|
+
attributes_catalog.c.attribute_ordinal, attributes_catalog.c.value_ordinal,
|
|
940
|
+
)
|
|
941
|
+
).mappings().all()
|
|
942
|
+
mrset_rows_result = connection.execute(
|
|
943
|
+
select(multiple_response_catalog).where(multiple_response_catalog.c.dataset_id == dataset_id)
|
|
944
|
+
.order_by(multiple_response_catalog.c.set_name, multiple_response_catalog.c.member_ordinal)
|
|
945
|
+
).mappings().all()
|
|
946
|
+
extension_rows_result = connection.execute(
|
|
947
|
+
select(source_extensions_catalog).where(source_extensions_catalog.c.dataset_id == dataset_id)
|
|
948
|
+
.order_by(source_extensions_catalog.c.extension_key)
|
|
949
|
+
).mappings().all()
|
|
950
|
+
|
|
951
|
+
if document_rows_result:
|
|
952
|
+
dataset["documents"] = json.dumps([item["text"] for item in document_rows_result], ensure_ascii=False)
|
|
953
|
+
if attribute_rows_result:
|
|
954
|
+
file_attributes, variable_attributes = attributes_from_rows(
|
|
955
|
+
attribute_rows_result, variables=variables,
|
|
956
|
+
)
|
|
957
|
+
dataset["file_attributes"] = json.dumps(file_attributes, ensure_ascii=False)
|
|
958
|
+
for variable in variables:
|
|
959
|
+
variable["attributes"] = json.dumps(
|
|
960
|
+
variable_attributes.get(variable["source_name"], {}), ensure_ascii=False,
|
|
961
|
+
)
|
|
962
|
+
if mrset_rows_result:
|
|
963
|
+
dataset["multiple_response_sets"] = json.dumps(
|
|
964
|
+
multiple_response_sets_from_rows(mrset_rows_result), ensure_ascii=False, default=str,
|
|
965
|
+
)
|
|
966
|
+
if extension_rows_result:
|
|
967
|
+
dataset["source_extensions"] = {
|
|
968
|
+
item["extension_key"]: json.loads(item["payload"])
|
|
969
|
+
for item in extension_rows_result
|
|
970
|
+
}
|
|
971
|
+
variables_by_ordinal = {item["ordinal"]: item for item in variables}
|
|
972
|
+
labels_by_variable: dict[int, dict[Any, str]] = {}
|
|
973
|
+
for item in label_rows_result:
|
|
974
|
+
labels_by_variable.setdefault(item["variable_ordinal"], {})[
|
|
975
|
+
item["numeric_value"] if item["value_type"] == "numeric" else item["text_value"]
|
|
976
|
+
] = item["label"]
|
|
977
|
+
for ordinal, labels in labels_by_variable.items():
|
|
978
|
+
variables_by_ordinal[ordinal]["value_labels"] = json.dumps(labels, ensure_ascii=False)
|
|
979
|
+
rules_by_variable: dict[int, list[dict[str, Any]]] = {}
|
|
980
|
+
for item in missing_rows_result:
|
|
981
|
+
lower = _endpoint_from_row(item, prefix="lower")
|
|
982
|
+
upper = _endpoint_from_row(item, prefix="upper")
|
|
983
|
+
rules_by_variable.setdefault(item["variable_ordinal"], []).append({"lo": lower, "hi": upper})
|
|
984
|
+
for ordinal, rules in rules_by_variable.items():
|
|
985
|
+
variables_by_ordinal[ordinal]["missing_ranges"] = json.dumps(rules, ensure_ascii=False)
|
|
986
|
+
return dataset, variables, rows
|
|
987
|
+
|
|
988
|
+
|
|
989
|
+
def read_fidelity_events(*, database_url: str, dataset_id: str) -> tuple[dict[str, Any], ...]:
|
|
990
|
+
"""Read import-time fidelity diagnostics for a catalogued dataset."""
|
|
991
|
+
engine = create_engine(database_url)
|
|
992
|
+
metadata = MetaData()
|
|
993
|
+
_, _, fidelity_event_catalog, _ = catalog(metadata)
|
|
994
|
+
with engine.connect() as connection:
|
|
995
|
+
fidelity_event_catalog.create(connection, checkfirst=True)
|
|
996
|
+
events = connection.execute(
|
|
997
|
+
select(fidelity_event_catalog)
|
|
998
|
+
.where(fidelity_event_catalog.c.dataset_id == dataset_id)
|
|
999
|
+
.order_by(fidelity_event_catalog.c.code)
|
|
1000
|
+
).mappings().all()
|
|
1001
|
+
return tuple({
|
|
1002
|
+
"code": item["code"], "detail": item["detail"],
|
|
1003
|
+
"details": json.loads(item["details"] or "{}"),
|
|
1004
|
+
} for item in events)
|
|
1005
|
+
|
|
1006
|
+
|
|
1007
|
+
|
|
1008
|
+
def record_export_operation(
|
|
1009
|
+
*, database_url: str, dataset_id: str, destination: str,
|
|
1010
|
+
allowed_fidelity_events: Iterable[Mapping[str, Any]],
|
|
1011
|
+
operation_details: Mapping[str, Any] | None = None,
|
|
1012
|
+
) -> str:
|
|
1013
|
+
"""Persist a completed export and the fidelity loss explicitly accepted by its caller."""
|
|
1014
|
+
engine = create_engine(database_url)
|
|
1015
|
+
metadata = MetaData()
|
|
1016
|
+
datasets, variables, fidelity_events, operations = catalog(metadata)
|
|
1017
|
+
normative = normative_catalog(metadata)
|
|
1018
|
+
multiple_response = multiple_response_set_catalog(metadata)
|
|
1019
|
+
operation_id = str(uuid4())
|
|
1020
|
+
events = tuple(allowed_fidelity_events)
|
|
1021
|
+
with engine.begin() as connection:
|
|
1022
|
+
metadata.create_all(connection, tables=[datasets, variables, multiple_response, fidelity_events, operations])
|
|
1023
|
+
create_normative_catalog(connection, normative)
|
|
1024
|
+
normative_dataset_id = normative_dataset_id_for_name(connection, normative, dataset_id)
|
|
1025
|
+
completed_at = datetime.now(UTC).replace(tzinfo=None)
|
|
1026
|
+
record_normative_operation(
|
|
1027
|
+
connection, normative, operation_id=operation_id,
|
|
1028
|
+
operation_kind="export", status="succeeded", source_format=None,
|
|
1029
|
+
started_at=completed_at, completed_at=completed_at,
|
|
1030
|
+
)
|
|
1031
|
+
connection.execute(insert(operations).values(
|
|
1032
|
+
operation_id=operation_id, direction="export", status="succeeded", dataset_id=dataset_id,
|
|
1033
|
+
destination=destination, created_at=_now(), completed_at=_now(),
|
|
1034
|
+
details=json.dumps({"allow_loss": [event["code"] for event in events], **dict(operation_details or {})}, sort_keys=True),
|
|
1035
|
+
))
|
|
1036
|
+
rows = _event_rows(
|
|
1037
|
+
operation_id=operation_id, dataset_id=dataset_id, direction="export",
|
|
1038
|
+
fidelity_events=({**event, "severity": event.get("severity", "warning"),
|
|
1039
|
+
"details": {**event.get("details", {}), "accepted_by_user": True}}
|
|
1040
|
+
for event in events),
|
|
1041
|
+
)
|
|
1042
|
+
if rows:
|
|
1043
|
+
connection.execute(insert(fidelity_events), rows)
|
|
1044
|
+
record_normative_fidelity_events(
|
|
1045
|
+
connection, normative, operation_id=operation_id,
|
|
1046
|
+
dataset_id=normative_dataset_id, direction="export",
|
|
1047
|
+
events=({**event, "severity": event.get("severity", "warning"),
|
|
1048
|
+
"details": {**event.get("details", {}), "accepted_by_user": True}}
|
|
1049
|
+
for event in events),
|
|
1050
|
+
)
|
|
1051
|
+
return operation_id
|
|
1052
|
+
|
|
1053
|
+
def validate_wide_dataset(*, database_url: str, dataset_id: str) -> dict[str, Any]:
|
|
1054
|
+
dataset, variables, rows = read_wide_dataset(database_url=database_url, dataset_id=dataset_id)
|
|
1055
|
+
profile = validate_connection_url(database_url)
|
|
1056
|
+
preflight(profile, variables)
|
|
1057
|
+
validate_spss_catalog(
|
|
1058
|
+
variables,
|
|
1059
|
+
case_weight_variable=dataset.get("case_weight_variable"),
|
|
1060
|
+
multiple_response_sets=dataset.get("multiple_response_sets"),
|
|
1061
|
+
)
|
|
1062
|
+
if not variables:
|
|
1063
|
+
raise ValueError("A conforming dataset needs at least one source variable.")
|
|
1064
|
+
expected_columns = {"__case_ordinal", *(item["physical_name"] for item in variables)}
|
|
1065
|
+
reflected_table = Table(dataset["data_table"], MetaData(), autoload_with=create_engine(database_url))
|
|
1066
|
+
reflected_columns = {column.name: column for column in reflected_table.columns}
|
|
1067
|
+
actual_columns = set(reflected_columns)
|
|
1068
|
+
if actual_columns != expected_columns:
|
|
1069
|
+
raise ValueError("Data-table columns do not match the registered source variables.")
|
|
1070
|
+
if dataset["case_count"] != len(rows):
|
|
1071
|
+
raise ValueError("Registered case count does not match the data table.")
|
|
1072
|
+
if len({item["physical_name"] for item in variables}) != len(variables):
|
|
1073
|
+
raise ValueError("Registered physical variable names are not unique.")
|
|
1074
|
+
case_ordinal = reflected_columns["__case_ordinal"]
|
|
1075
|
+
if not isinstance(case_ordinal.type, BigInteger) or case_ordinal.nullable:
|
|
1076
|
+
raise ValueError("Reserved case ordinal must be a non-null BIGINT column.")
|
|
1077
|
+
for item in variables:
|
|
1078
|
+
column = reflected_columns[item["physical_name"]]
|
|
1079
|
+
if item["storage_kind"] == "numeric":
|
|
1080
|
+
if not isinstance(column.type, Float) or not column.nullable:
|
|
1081
|
+
raise ValueError(f"Numeric variable {item['source_name']!r} must be a nullable binary64 column.")
|
|
1082
|
+
elif not isinstance(column.type, Text) or column.nullable:
|
|
1083
|
+
raise ValueError(f"String variable {item['source_name']!r} must be a non-null text column.")
|
|
1084
|
+
if [row["__case_ordinal"] for row in rows] != list(range(1, len(rows) + 1)):
|
|
1085
|
+
raise ValueError("Case ordinals are not contiguous source order.")
|
|
1086
|
+
return {"dataset_id": dataset["dataset_id"], "valid": True, "case_count": len(rows), "variable_count": len(variables)}
|
|
1087
|
+
|