Skip to content

Commit 0303873

Browse files
committed
Implement in-place SPSS transformations
1 parent 6aba054 commit 0303873

15 files changed

Lines changed: 2697 additions & 6 deletions

‎CHANGELOG.md‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,21 @@ All notable changes to this reference implementation are documented here.
44

55
## Unreleased
66

7+
### Added
8+
9+
- Added a bounded SPSS-like `RECODE`, `VARIABLE LABELS`, and `VALUE LABELS`
10+
parser with stable diagnostics and conformance fixtures.
11+
- Added canonical Transformation Plan serialization, RFC 8785 hashes, schema
12+
binding, deterministic typed operations, and an agent-facing CLI/API.
13+
- Added product-neutral in-place execution with same dataset/table identity,
14+
direct data and metadata mutation, and compact operation audit. Dolt adds
15+
expected branch/HEAD and clean-working-set checks.
16+
717
### Changed
818

19+
- Version history, diff, rollback, restoration, and commit remain database
20+
responsibilities; the transformer creates no derived/copy/snapshot/recovery
21+
layer and performs no `DOLT_COMMIT`.
922
- SQL server support now distinguishes conservative family claims from exact
1023
CI evidence: PostgreSQL 17.x/18.x at 17.10/18.4, MySQL 8.4.x/9.7.x at
1124
8.4.11/9.7.2, and MariaDB 11.4.x/11.8.x/12.3.x at

‎README.md‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,46 @@ See [the SQL transformation workflow](docs/sql-transformation-workflow.md) for
6161
Python and CLI examples, migration behavior, hashing, atomicity, and the exact
6262
implemented capability boundary.
6363

64+
## SPSS-like transformation frontend
65+
66+
The transformation frontend accepts `RECODE`, `VARIABLE LABELS`, and
67+
`VALUE LABELS`, lowers them to a canonical OpenStatSpec Transformation Plan,
68+
and mutates the same logical dataset and physical wide table. SQLite,
69+
PostgreSQL, MySQL, MariaDB, and Dolt connections are allowed. It creates no
70+
derived dataset, copied table, snapshot, or OpenStatSpec rollback/version layer.
71+
72+
Install the compact operation-audit relation separately, then apply syntax:
73+
74+
```python
75+
from openstatspec import (
76+
apply_spss_in_place,
77+
install_in_place_transformation_schema,
78+
)
79+
80+
database_url = "postgresql+psycopg://user:password@host/database"
81+
install_in_place_transformation_schema(database_url=database_url)
82+
result = apply_spss_in_place(
83+
database_url=database_url,
84+
dataset_id="...",
85+
actor="agent@example.org",
86+
source_text="""
87+
RECODE age (18 THRU 34 = 1) (35 THRU 64 = 2) INTO age_group.
88+
VARIABLE LABELS age_group 'Age group'.
89+
VALUE LABELS age_group 1 '18-34' 2 '35-64'.
90+
""",
91+
)
92+
```
93+
94+
Existing-target recodes and metadata mutations use direct DML. SQLite and
95+
PostgreSQL may also add a target column in the same native transaction. MySQL,
96+
MariaDB, and Dolt reject such schema-changing plans before mutation because
97+
their implicit-commit DDL could otherwise leave a partial apply. On Dolt, the
98+
caller additionally supplies expected branch and HEAD identities, and the
99+
working set must be clean. The transformer never calls `DOLT_COMMIT`.
100+
101+
See [in-place transformations](docs/in-place-transformation.md) for the full
102+
execution boundary and CLI form.
103+
64104
## Current support status
65105

66106
The adapter requires `openstatspec-pyspssio==0.5.1.post2` as its sole SPSS

‎docs/in-place-transformation.md‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
# In-place SPSS-like transformation
2+
3+
`openstatspec.apply_spss_in_place` is the public execution path for the
4+
SPSS-like frontend. It accepts `RECODE`, `VARIABLE LABELS`, and `VALUE LABELS`,
5+
binds them to the existing core dataset, and applies the canonical plan to that
6+
same SQL wide table and metadata catalog.
7+
8+
The caller supplies a supported SQL URL, the existing normative `dataset_id`,
9+
and a non-empty actor identity. For Dolt, the caller also supplies the expected
10+
active branch and current `HEAD` hash.
11+
12+
Install the compact audit relation once with
13+
`openstatspec.install_in_place_transformation_schema(database_url=...)` before
14+
the first apply. Apply never creates schema-management objects itself.
15+
16+
The adapter uses the engine's ordinary transaction behavior. On Dolt it first
17+
checks branch and HEAD and requires `dolt_status` to be empty. Existing-target
18+
recodes are one direct `UPDATE`. SQLite and PostgreSQL can add a new numeric
19+
`INTO` target to the same table. MySQL, MariaDB, and Dolt require that target
20+
column and variable metadata to exist before apply because their DDL can commit
21+
independently of the following data and metadata changes. Label commands
22+
update/replace the same dataset's normative and compatibility metadata rows.
23+
24+
One compact `transformation_apply` row records operation identity, canonical
25+
plan/source hashes, actor, status, timestamps, and the observed Dolt branch and
26+
HEAD. It contains no row values and points to no copied table.
27+
28+
The adapter does not create `derived_dataset` rows, persistent output tables,
29+
full-table copies, staging datasets, snapshots, rollback tables, retirement
30+
records, or a recovery/version catalog. It does not call `DOLT_COMMIT`, change
31+
branches, merge, reset, or tag. After success, the caller reviews `dolt diff`
32+
and independently decides whether to commit or restore the working set.
33+
34+
The local SQLite tests exercise the public mutation path and assert that
35+
dataset/table counts and identities do not change. Live PostgreSQL/MySQL/MariaDB and
36+
exact-version Dolt service evidence remains required before release execution
37+
claims for those engines.
38+
39+
```text
40+
openstatspec apply-spss --database-url mysql+pymysql://user:password@host/database --dataset-id ... --actor agent@example.org --expected-branch feature/recode --expected-head ... --syntax-file transform.sps
41+
```

‎src/openstatspec/__init__.py‎

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,30 @@
11
"""Public API for the OpenStatSpec Python reference implementation."""
22

33
from .api import (
4-
capabilities, capability_matrix, derive_sql_dataset, execute_sql_transformation,
5-
export_sav, get_dataset, import_sav, inspect, list_datasets,
4+
apply_spss_in_place, capabilities, capability_matrix, derive_sql_dataset,
5+
execute_sql_transformation,
6+
export_sav, get_dataset, import_sav, inspect,
7+
install_in_place_transformation_schema, list_datasets,
68
register_sql_transformation, reconcile_derived_removals,
79
reconcile_sql_transformation_runs,
810
remove_derived_physical_relation, retire_derived, validate, validate_derived,
911
)
1012
from .core import CapabilityDeclaration, LossReport, UnsupportedOperationError
1113
from .sql.workflow import TransformationError
14+
from .transform import (
15+
SpssFrontendCompilation, TransformationFrontendError,
16+
VariableDefinition, VariableSchema, compile_spss_syntax,
17+
)
1218

1319
__all__ = [
14-
"CapabilityDeclaration", "LossReport", "TransformationError",
20+
"CapabilityDeclaration", "LossReport", "SpssFrontendCompilation",
21+
"TransformationError", "TransformationFrontendError",
22+
"VariableDefinition", "VariableSchema",
1523
"UnsupportedOperationError", "capabilities", "capability_matrix",
16-
"derive_sql_dataset", "execute_sql_transformation", "export_sav",
17-
"get_dataset", "import_sav", "inspect", "list_datasets",
24+
"apply_spss_in_place", "compile_spss_syntax", "derive_sql_dataset",
25+
"execute_sql_transformation", "export_sav",
26+
"get_dataset", "import_sav", "inspect",
27+
"install_in_place_transformation_schema", "list_datasets",
1828
"register_sql_transformation", "reconcile_derived_removals",
1929
"reconcile_sql_transformation_runs",
2030
"remove_derived_physical_relation", "retire_derived", "validate",

‎src/openstatspec/api.py‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,11 @@
2020
remove_derived_relation as _remove_derived_relation,
2121
retire_derived_dataset as _retire_derived_dataset,
2222
)
23+
from .sql.inplace_transform import (
24+
apply_spss_in_place as _apply_spss_in_place,
25+
in_place_transformation_capabilities,
26+
install_in_place_transformation_schema as _install_in_place_schema,
27+
)
2328
from .sql.capabilities import (
2429
SPECIFICATION_COMMIT, SPECIFICATION_RELEASE, active_connection, catalog_binding,
2530
)
@@ -84,6 +89,9 @@ def capability_matrix(database_url: str | None = None) -> Mapping[str, Any]:
8489
"sql_profiles": declared_profiles(database_url),
8590
"optional_profiles": {
8691
"sql_transformation_workflow": transformation_capabilities(database_url),
92+
"spss_in_place_transformation": (
93+
in_place_transformation_capabilities()
94+
),
8795
},
8896
}
8997
return declaration
@@ -130,6 +138,27 @@ def derive_sql_dataset(*, database_url: Any, **options: Any) -> Mapping[str, Any
130138
return result(_derive_dataset(database_url=str(database_url), **options))
131139

132140

141+
def apply_spss_in_place(
142+
*, database_url: Any, dataset_id: str, source_text: str,
143+
actor: str, expected_branch: str | None = None,
144+
expected_head: str | None = None,
145+
) -> Mapping[str, Any]:
146+
"""Apply supported SPSS-like syntax to the same SQL dataset/table."""
147+
return result(_apply_spss_in_place(
148+
database_url=str(database_url),
149+
dataset_id=dataset_id,
150+
source_text=source_text,
151+
actor=actor,
152+
expected_branch=expected_branch,
153+
expected_head=expected_head,
154+
))
155+
156+
157+
def install_in_place_transformation_schema(*, database_url: Any) -> None:
158+
"""Install the compact apply-audit relation before the first apply."""
159+
_install_in_place_schema(database_url=str(database_url))
160+
161+
133162
def validate_derived(*, database_url: Any, derived_dataset_id: str) -> Mapping[str, Any]:
134163
return result(_validate_derived_dataset(
135164
database_url=str(database_url), derived_dataset_id=derived_dataset_id,

‎src/openstatspec/cli.py‎

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@
22
import argparse
33
import json
44
from collections.abc import Sequence
5+
from pathlib import Path
56

67
from .api import (
7-
capability_matrix, derive_sql_dataset, execute_sql_transformation,
8+
apply_spss_in_place, capability_matrix, derive_sql_dataset,
9+
execute_sql_transformation,
810
export_sav, get_dataset, import_sav, inspect, list_datasets,
911
register_sql_transformation, validate, validate_derived,
1012
)
@@ -73,6 +75,19 @@ def main(argv: Sequence[str] | None = None) -> int:
7375
derive.add_argument("--dataset-name")
7476
derive.add_argument("--weight-variable")
7577

78+
apply_spss = commands.add_parser(
79+
"apply-spss",
80+
help="apply supported SPSS syntax in-place on a controlled Dolt branch",
81+
)
82+
apply_spss.add_argument("--database-url", required=True)
83+
apply_spss.add_argument("--dataset-id", required=True)
84+
apply_spss.add_argument("--actor", required=True)
85+
apply_spss.add_argument("--expected-branch")
86+
apply_spss.add_argument("--expected-head")
87+
syntax_source = apply_spss.add_mutually_exclusive_group(required=True)
88+
syntax_source.add_argument("--syntax")
89+
syntax_source.add_argument("--syntax-file")
90+
7691
derived_validator = commands.add_parser("validate-derived", help="validate a derived dataset")
7792
derived_validator.add_argument("--database-url", required=True)
7893
derived_validator.add_argument("--derived-dataset-id", required=True)
@@ -122,6 +137,20 @@ def main(argv: Sequence[str] | None = None) -> int:
122137
transformation_name=args.name, dataset_name=args.dataset_name,
123138
weight_variable=args.weight_variable,
124139
)
140+
elif args.command == "apply-spss":
141+
source_text = (
142+
args.syntax
143+
if args.syntax is not None
144+
else Path(args.syntax_file).read_text(encoding="utf-8")
145+
)
146+
output = apply_spss_in_place(
147+
database_url=args.database_url,
148+
dataset_id=args.dataset_id,
149+
source_text=source_text,
150+
actor=args.actor,
151+
expected_branch=args.expected_branch,
152+
expected_head=args.expected_head,
153+
)
125154
else:
126155
output = validate_derived(
127156
database_url=args.database_url, derived_dataset_id=args.derived_dataset_id,

0 commit comments

Comments
 (0)