|
11 | 11 |
|
12 | 12 | from sqlalchemy import delete, BigInteger, Boolean, Column, Float, Integer, MetaData, String, Table, Text, create_engine, insert, inspect, select, text, update |
13 | 13 | from sqlalchemy.dialects import mysql, postgresql, sqlite |
| 14 | +from ..core import UnsupportedOperationError |
14 | 15 | from .profiles import preflight, validate_connection_url |
15 | 16 |
|
16 | 17 | _IDENTIFIER = re.compile(r"[^a-zA-Z0-9_]+") |
17 | 18 |
|
18 | 19 |
|
| 20 | +class CatalogPreflightError(UnsupportedOperationError): |
| 21 | + """A semantic SPSS catalog rule failed before an import or writer runs.""" |
| 22 | + |
| 23 | + def __init__(self, code: str, detail: str, *, details: Mapping[str, Any]) -> None: |
| 24 | + super().__init__(f"OpenStatSpec catalog preflight failed [{code}]: {detail}") |
| 25 | + self.code = code |
| 26 | + self.details = {"reason": code, **details} |
| 27 | + |
| 28 | + |
| 29 | +def _catalog_error(code: str, detail: str, **details: Any) -> CatalogPreflightError: |
| 30 | + return CatalogPreflightError(code, detail, details=details) |
| 31 | + |
| 32 | + |
19 | 33 | def binary64_type() -> Float: |
20 | 34 | """Return the required IEEE-754 binary64 SQL type for every profile. |
21 | 35 |
|
@@ -185,7 +199,7 @@ def _record_failed_preflight( |
185 | 199 | )) |
186 | 200 | connection.execute(insert(fidelity_event_catalog), _event_rows( |
187 | 201 | operation_id=operation_id, dataset_id=None, direction="import", fidelity_events=({ |
188 | | - "code": "target-capability-exceeded", "detail": str(error), "severity": "error", |
| 202 | + "code": getattr(error, "code", "target-capability-exceeded"), "detail": str(error), "severity": "error", |
189 | 203 | "details": {"variable_count": variable_count, "profile": profile_name, |
190 | 204 | **getattr(error, "details", {})}, |
191 | 205 | },), |
@@ -350,6 +364,189 @@ def _mr_counted_value(definition: Mapping[str, Any]) -> tuple[str | None, float |
350 | 364 | return "text", None, str(value) |
351 | 365 |
|
352 | 366 |
|
| 367 | +def _multiple_response_definitions(definitions: str | Mapping[str, Any] | None) -> Mapping[str, Any]: |
| 368 | + if definitions is None or definitions == "": |
| 369 | + return {} |
| 370 | + if isinstance(definitions, str): |
| 371 | + try: |
| 372 | + definitions = json.loads(definitions) |
| 373 | + except json.JSONDecodeError as error: |
| 374 | + raise _catalog_error( |
| 375 | + "multiple-response-definition-invalid", |
| 376 | + "Multiple-response set catalog is not valid JSON.", |
| 377 | + error=str(error), |
| 378 | + ) from error |
| 379 | + if not isinstance(definitions, Mapping): |
| 380 | + raise _catalog_error( |
| 381 | + "multiple-response-definition-invalid", |
| 382 | + "Multiple-response set catalog must be an object keyed by set name.", |
| 383 | + actual_type=type(definitions).__name__, |
| 384 | + ) |
| 385 | + return definitions |
| 386 | + |
| 387 | + |
| 388 | +def _mr_is_dichotomy(definition: Mapping[str, Any]) -> bool: |
| 389 | + value = definition.get("is_dichotomy") |
| 390 | + if value is not None: |
| 391 | + return bool(value) |
| 392 | + return definition.get("set_type", definition.get("type")) in {"MD", "D", "E"} or ( |
| 393 | + definition.get("counted_value", definition.get("countedvalue")) is not None |
| 394 | + ) |
| 395 | + |
| 396 | + |
| 397 | +def validate_spss_catalog( |
| 398 | + variables: Iterable[Mapping[str, Any]], *, |
| 399 | + case_weight_variable: str | None = None, |
| 400 | + multiple_response_sets: str | Mapping[str, Any] | None = None, |
| 401 | +) -> None: |
| 402 | + """Validate SPSS dictionary cross-references before persisting or writing.""" |
| 403 | + variables_by_name: dict[str, Mapping[str, Any]] = {} |
| 404 | + for variable in variables: |
| 405 | + source_name = variable.get("source_name") |
| 406 | + if isinstance(source_name, str): |
| 407 | + variables_by_name[source_name] = variable |
| 408 | + |
| 409 | + if case_weight_variable is not None: |
| 410 | + if not isinstance(case_weight_variable, str) or not case_weight_variable: |
| 411 | + raise _catalog_error( |
| 412 | + "case-weight-variable-invalid", |
| 413 | + "The case-weight reference must be a non-empty source variable name.", |
| 414 | + case_weight_variable=case_weight_variable, |
| 415 | + ) |
| 416 | + weight = variables_by_name.get(case_weight_variable) |
| 417 | + if weight is None: |
| 418 | + raise _catalog_error( |
| 419 | + "case-weight-variable-not-found", |
| 420 | + "The case-weight reference does not name a source variable.", |
| 421 | + case_weight_variable=case_weight_variable, |
| 422 | + ) |
| 423 | + if weight.get("storage_kind") != "numeric": |
| 424 | + raise _catalog_error( |
| 425 | + "case-weight-variable-not-numeric", |
| 426 | + "The case-weight variable must use SPSS numeric storage.", |
| 427 | + case_weight_variable=case_weight_variable, |
| 428 | + storage_kind=weight.get("storage_kind"), |
| 429 | + ) |
| 430 | + if str(weight.get("measure") or "").lower() != "scale": |
| 431 | + raise _catalog_error( |
| 432 | + "case-weight-variable-not-scale", |
| 433 | + "The case-weight variable must have SPSS scale measurement level.", |
| 434 | + case_weight_variable=case_weight_variable, |
| 435 | + measurement_level=weight.get("measure"), |
| 436 | + ) |
| 437 | + |
| 438 | + for set_name, definition in _multiple_response_definitions(multiple_response_sets).items(): |
| 439 | + if not isinstance(set_name, str) or not set_name: |
| 440 | + raise _catalog_error( |
| 441 | + "multiple-response-set-name-invalid", |
| 442 | + "A multiple-response set needs a non-empty string name.", |
| 443 | + set_name=set_name, |
| 444 | + ) |
| 445 | + if not isinstance(definition, Mapping): |
| 446 | + raise _catalog_error( |
| 447 | + "multiple-response-definition-invalid", |
| 448 | + "A multiple-response set definition must be an object.", |
| 449 | + set_name=set_name, |
| 450 | + actual_type=type(definition).__name__, |
| 451 | + ) |
| 452 | + members = definition.get("variable_list", definition.get("variables", [])) |
| 453 | + if isinstance(members, str): |
| 454 | + members = members.split() |
| 455 | + if not isinstance(members, (list, tuple)) or not members: |
| 456 | + raise _catalog_error( |
| 457 | + "multiple-response-members-invalid", |
| 458 | + "A multiple-response set needs at least one ordered member variable.", |
| 459 | + set_name=set_name, |
| 460 | + ) |
| 461 | + if any(not isinstance(member, str) or not member for member in members): |
| 462 | + raise _catalog_error( |
| 463 | + "multiple-response-member-invalid", |
| 464 | + "Every multiple-response member must be a non-empty source variable name.", |
| 465 | + set_name=set_name, |
| 466 | + ) |
| 467 | + if len(set(members)) != len(members): |
| 468 | + raise _catalog_error( |
| 469 | + "multiple-response-member-duplicate", |
| 470 | + "A multiple-response set cannot list one source variable twice.", |
| 471 | + set_name=set_name, |
| 472 | + ) |
| 473 | + member_variables: list[Mapping[str, Any]] = [] |
| 474 | + for member in members: |
| 475 | + variable = variables_by_name.get(member) |
| 476 | + if variable is None: |
| 477 | + raise _catalog_error( |
| 478 | + "multiple-response-member-not-found", |
| 479 | + "A multiple-response member does not name a source variable.", |
| 480 | + set_name=set_name, |
| 481 | + member=member, |
| 482 | + ) |
| 483 | + member_variables.append(variable) |
| 484 | + storage_kinds = {str(variable.get("storage_kind")) for variable in member_variables} |
| 485 | + if len(storage_kinds) != 1 or storage_kinds == {"None"}: |
| 486 | + raise _catalog_error( |
| 487 | + "multiple-response-member-type-mismatch", |
| 488 | + "All multiple-response members must have one shared SPSS storage kind.", |
| 489 | + set_name=set_name, |
| 490 | + members=list(members), |
| 491 | + storage_kinds=sorted(storage_kinds), |
| 492 | + ) |
| 493 | + is_dichotomy = _mr_is_dichotomy(definition) |
| 494 | + counted_value = definition.get("counted_value", definition.get("countedvalue")) |
| 495 | + if not is_dichotomy: |
| 496 | + if counted_value is not None: |
| 497 | + raise _catalog_error( |
| 498 | + "multiple-response-counted-value-not-permitted", |
| 499 | + "An MC multiple-response set cannot define a dichotomy counted value.", |
| 500 | + set_name=set_name, |
| 501 | + counted_value=counted_value, |
| 502 | + ) |
| 503 | + continue |
| 504 | + if counted_value is None: |
| 505 | + raise _catalog_error( |
| 506 | + "multiple-response-counted-value-missing", |
| 507 | + "An MD multiple-response set must define its counted value.", |
| 508 | + set_name=set_name, |
| 509 | + ) |
| 510 | + storage_kind = storage_kinds.pop() |
| 511 | + if storage_kind == "numeric": |
| 512 | + if isinstance(counted_value, bool) or not isinstance(counted_value, (int, float)) or not math.isfinite(float(counted_value)): |
| 513 | + raise _catalog_error( |
| 514 | + "multiple-response-counted-value-type-mismatch", |
| 515 | + "An MD set of numeric variables requires a finite numeric counted value.", |
| 516 | + set_name=set_name, |
| 517 | + counted_value=counted_value, |
| 518 | + storage_kind=storage_kind, |
| 519 | + ) |
| 520 | + elif storage_kind == "string": |
| 521 | + if not isinstance(counted_value, str): |
| 522 | + raise _catalog_error( |
| 523 | + "multiple-response-counted-value-type-mismatch", |
| 524 | + "An MD set of string variables requires a string counted value.", |
| 525 | + set_name=set_name, |
| 526 | + counted_value=counted_value, |
| 527 | + storage_kind=storage_kind, |
| 528 | + ) |
| 529 | + encoded_length = len(counted_value.encode("utf-8")) |
| 530 | + for member, variable in zip(members, member_variables): |
| 531 | + width = variable.get("string_width") |
| 532 | + if isinstance(width, int) and encoded_length > width: |
| 533 | + raise _catalog_error( |
| 534 | + "multiple-response-counted-value-too-wide", |
| 535 | + "The MD counted value exceeds a member's declared SPSS string width.", |
| 536 | + set_name=set_name, |
| 537 | + member=member, |
| 538 | + counted_value=counted_value, |
| 539 | + counted_value_bytes=encoded_length, |
| 540 | + string_width=width, |
| 541 | + ) |
| 542 | + else: |
| 543 | + raise _catalog_error( |
| 544 | + "multiple-response-member-storage-invalid", |
| 545 | + "A multiple-response member has unsupported SPSS storage kind.", |
| 546 | + set_name=set_name, |
| 547 | + storage_kind=storage_kind, |
| 548 | + ) |
| 549 | + |
353 | 550 | def multiple_response_set_rows(dataset_id: str, definitions: str) -> list[dict[str, Any]]: |
354 | 551 | sets = json.loads(definitions or "{}") |
355 | 552 | rows: list[dict[str, Any]] = [] |
@@ -462,6 +659,11 @@ def create_wide_dataset( |
462 | 659 | operation_id = str(uuid4()) |
463 | 660 | try: |
464 | 661 | preflight(profile, variables) |
| 662 | + validate_spss_catalog( |
| 663 | + variables, |
| 664 | + case_weight_variable=case_weight_variable, |
| 665 | + multiple_response_sets=multiple_response_sets, |
| 666 | + ) |
465 | 667 | except Exception as error: |
466 | 668 | _record_failed_preflight( |
467 | 669 | engine=engine, metadata=metadata, datasets=datasets, |
@@ -672,6 +874,11 @@ def validate_wide_dataset(*, database_url: str, dataset_id: str) -> dict[str, An |
672 | 874 | dataset, variables, rows = read_wide_dataset(database_url=database_url, dataset_id=dataset_id) |
673 | 875 | profile = validate_connection_url(database_url) |
674 | 876 | preflight(profile, variables) |
| 877 | + validate_spss_catalog( |
| 878 | + variables, |
| 879 | + case_weight_variable=dataset.get("case_weight_variable"), |
| 880 | + multiple_response_sets=dataset.get("multiple_response_sets"), |
| 881 | + ) |
675 | 882 | if not variables: |
676 | 883 | raise ValueError("A conforming dataset needs at least one source variable.") |
677 | 884 | expected_columns = {"__case_ordinal", *(item["physical_name"] for item in variables)} |
|
0 commit comments