Skip to content

Commit 200a421

Browse files
Merge pull request #31 from OpenStatSpec/fix/schema-extension-contracts
fix: distinguish Python schema extensions from official contracts
2 parents e772089 + 1a4dbdb commit 200a421

6 files changed

Lines changed: 146 additions & 21 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,21 @@
22

33
All notable changes to this reference implementation are documented here.
44

5+
## Unreleased
6+
7+
### Fixed
8+
9+
- Namespace new Python schema-change plans and SPSS output as
10+
`openstatspec-python-schema-change-plan-v0.1` and
11+
`openstatspec-python-schema-change-spss-v0.1`, retaining exported constant names.
12+
The former `openstatspec-transformation-plan-v0.3` remains accepted as a
13+
legacy Python extension with unchanged semantics, canonical JSON, and hashes;
14+
stored audits are not migrated. New compilation changes schema-plan hashes.
15+
- Clarify that specification `v0.5.0` has no official Plan 0.3 and that its
16+
syntax-only Frontend 0.3 over Plan 0.1/0.2 is not implemented here. Legacy
17+
acceptance and Python extension tests do not claim official conformance.
18+
See [compatibility and migration](docs/transformations.md#contract-ownership-and-legacy-compatibility).
19+
520
## 0.8.1 - 2026-09-10
621

722
### Fixed

‎docs/transformations.md‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,40 @@ This is a trust boundary. JSON plans must pass
3030
`transformation_plan_from_dict()` and live-schema validation before mutation;
3131
they are never treated as arbitrary SQL.
3232

33+
## Contract ownership and legacy compatibility
34+
35+
Published OpenStatSpec `v0.5.0` defines Transformation Plan 0.1/0.2, not
36+
Plan 0.3. Its optional SPSS Frontend 0.3 is a syntax-only expansion emitting
37+
Plan 0.1/0.2; this adapter does **not** implement that frontend yet.
38+
39+
Explicit `create_variable` / `delete_variable` operations (including SPSS
40+
`STRING` / `DELETE VARIABLES`) belong to a **Python extension**, not official
41+
OpenStatSpec conformance. New schema-changing compilations emit:
42+
43+
- Plan: `openstatspec-python-schema-change-plan-v0.1`
44+
- Frontend: `openstatspec-python-schema-change-spss-v0.1`
45+
46+
The exported names `TRANSFORMATION_PLAN_SCHEMA_CHANGE_CONTRACT` and
47+
`SPSS_FRONTEND_SCHEMA_CHANGE_CONTRACT` remain unchanged; their values now use
48+
these Python-owned identifiers. Plans without these explicit schema operations
49+
retain their existing Plan 0.1/0.2 selection and frontend identifier. Official
50+
Plan 0.1/0.2 still reject explicit create/delete operations.
51+
52+
The loader and executor continue to accept the old Python plan identifier
53+
`openstatspec-transformation-plan-v0.3` with its original operation semantics.
54+
This is legacy compatibility, **not** recognition of an official Plan 0.3.
55+
Loading, canonical serialization, hashing, and apply preserve the supplied
56+
identifier; existing canonical JSON, hashes, and stored audits are not rewritten.
57+
Historical `openstatspec-spss-syntax-frontend-v0.3` audit values described this
58+
Python extension and are not evidence of official Frontend 0.3 conformance.
59+
60+
No database migration is needed. Consumers should accept the new extension IDs
61+
before using new compiler output. Keep stored legacy plans and audits intact;
62+
recompiling schema-changing syntax now produces a new plan identity/hash even
63+
when its operations are identical. An intentional change of a saved plan's
64+
contract likewise creates a new artifact with a new hash, not an audit migration.
65+
Older adapter versions cannot load the new IDs.
66+
3367
## Install the audit schema
3468

3569
Install the compact audit relation once before the first apply:

‎src/openstatspec/frontends/spss/compiler.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@
1818

1919

2020
SPSS_FRONTEND_CONTRACT = "openstatspec-spss-syntax-frontend-v0.2"
21-
SPSS_FRONTEND_SCHEMA_CHANGE_CONTRACT = "openstatspec-spss-syntax-frontend-v0.3"
21+
# Python-owned schema extension, not the official syntax-only Frontend 0.3.
22+
SPSS_FRONTEND_SCHEMA_CHANGE_CONTRACT = "openstatspec-python-schema-change-spss-v0.1"
2223

2324

2425
@dataclass(frozen=True)

‎src/openstatspec/transform/plan.py‎

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,15 @@
1616

1717
TRANSFORMATION_PLAN_V1_CONTRACT = "openstatspec-transformation-plan-v0.1"
1818
TRANSFORMATION_PLAN_CONTRACT = "openstatspec-transformation-plan-v0.2"
19-
TRANSFORMATION_PLAN_SCHEMA_CHANGE_CONTRACT = "openstatspec-transformation-plan-v0.3"
20-
_TRANSFORMATION_PLAN_CONTRACTS = {TRANSFORMATION_PLAN_V1_CONTRACT, TRANSFORMATION_PLAN_CONTRACT, TRANSFORMATION_PLAN_SCHEMA_CHANGE_CONTRACT}
19+
TRANSFORMATION_PLAN_SCHEMA_CHANGE_CONTRACT = "openstatspec-python-schema-change-plan-v0.1"
20+
# Python extension compatibility only: never normalize legacy IDs or audit hashes.
21+
_SCHEMA_CHANGE_CONTRACTS = {
22+
TRANSFORMATION_PLAN_SCHEMA_CHANGE_CONTRACT,
23+
"openstatspec-transformation-plan-v0.3",
24+
}
25+
_TRANSFORMATION_PLAN_CONTRACTS = {
26+
TRANSFORMATION_PLAN_V1_CONTRACT, TRANSFORMATION_PLAN_CONTRACT,
27+
} | _SCHEMA_CHANGE_CONTRACTS
2128
_BINARY64 = re.compile(r"[0-9a-f]{16}")
2229

2330

@@ -589,7 +596,7 @@ def __post_init__(self) -> None:
589596
if not isinstance(self.contract, str) or self.contract not in _TRANSFORMATION_PLAN_CONTRACTS:
590597
_invalid("Plan contract is not a supported transformation-plan contract.")
591598
if (
592-
self.contract != TRANSFORMATION_PLAN_SCHEMA_CHANGE_CONTRACT
599+
self.contract not in _SCHEMA_CHANGE_CONTRACTS
593600
and any(
594601
isinstance(operation, (
595602
CreateVariableOperation, DeleteVariableOperation,
@@ -598,8 +605,9 @@ def __post_init__(self) -> None:
598605
)
599606
):
600607
_invalid(
601-
"Create/delete schema operations require "
602-
"openstatspec-transformation-plan-v0.3."
608+
"Create/delete schema operations require the Python extension "
609+
f"{TRANSFORMATION_PLAN_SCHEMA_CHANGE_CONTRACT} "
610+
"(legacy Python v0.3 plans are also accepted, not official conformance)."
603611
)
604612
if self.contract == TRANSFORMATION_PLAN_V1_CONTRACT and any(
605613
isinstance(operation, (
@@ -723,7 +731,7 @@ def _match(raw: Any) -> RecodeMatch:
723731
_invalid("Unknown recode match kind.")
724732

725733
def transformation_plan_from_dict(raw: Mapping[str, Any]) -> TransformationPlan:
726-
"""Strictly validate canonical v0.1, v0.2, or schema-change v0.3 plans."""
734+
"""Validate Plan 0.1/0.2 or Python schema-change plans, including legacy IDs."""
727735
if not isinstance(raw, Mapping):
728736
_invalid("Transformation plan must be an object.")
729737
_exact(raw, {"contract", "input_alias", "operations"}, "Transformation plan")

‎tests/test_inplace_transform.py‎

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -904,7 +904,7 @@ def test_public_apply_supports_non_dolt_without_building_undo(catalog) -> None:
904904
)) == (1, 1), "public SPSS apply must encode/hash the canonical plan once"
905905

906906

907-
def test_schema_commands_record_the_v03_frontend_contract(catalog) -> None:
907+
def test_schema_commands_record_python_extension_frontend_contract(catalog) -> None:
908908
url, path, dataset_id, _table_name = catalog
909909

910910
openstatspec.apply_spss_in_place(
@@ -919,10 +919,52 @@ def test_schema_commands_record_the_v03_frontend_contract(catalog) -> None:
919919
).fetchone()
920920
assert audit == (
921921
"spss_syntax",
922-
"openstatspec-spss-syntax-frontend-v0.3",
922+
"openstatspec-python-schema-change-spss-v0.1",
923923
)
924924

925925

926+
@pytest.mark.parametrize("contract", [
927+
"openstatspec-transformation-plan-v0.3", # Legacy acceptance, not conformance.
928+
"openstatspec-python-schema-change-plan-v0.1",
929+
])
930+
def test_schema_plan_apply_preserves_identity_and_audit(catalog, contract) -> None:
931+
url, path, dataset_id, table_name = catalog
932+
plan = openstatspec.TransformationPlan(
933+
(openstatspec.CreateVariableOperation("note", "string", 4),),
934+
contract=contract,
935+
)
936+
with sqlite3.connect(path) as connection:
937+
tables = connection.execute("SELECT name FROM sqlite_master ORDER BY name").fetchall()
938+
identity = connection.execute("SELECT * FROM dataset").fetchall()
939+
result = openstatspec.apply_transformation_plan_in_place(
940+
database_url=url, dataset_id=dataset_id, plan=plan.as_dict(), actor="test-agent",
941+
)
942+
assert (result["dataset_id"], result["physical_table_name"]) == (dataset_id, table_name)
943+
with sqlite3.connect(path) as connection:
944+
assert connection.execute(
945+
f'SELECT score, note FROM "{table_name}" ORDER BY __case_ordinal'
946+
).fetchall() == [(1.0, ""), (2.0, ""), (3.0, "")]
947+
audit = connection.execute("SELECT * FROM transformation_apply").fetchone()
948+
assert connection.execute(
949+
"SELECT canonical_plan_json, plan_hash, source_hash, frontend_contract "
950+
"FROM transformation_apply"
951+
).fetchone() == (plan.canonical_json(), plan.sha256(), plan.sha256(), None)
952+
# New compilation must not migrate an existing (including legacy) audit row.
953+
openstatspec.apply_spss_in_place(
954+
database_url=url, dataset_id=dataset_id,
955+
source_text="DELETE VARIABLES note.", actor="test-agent",
956+
)
957+
with sqlite3.connect(path) as connection:
958+
assert connection.execute("SELECT name FROM sqlite_master ORDER BY name").fetchall() == tables
959+
assert connection.execute("SELECT * FROM dataset").fetchall() == identity
960+
assert connection.execute(
961+
"SELECT * FROM transformation_apply WHERE apply_id = ?", (result["apply_id"],),
962+
).fetchone() == audit
963+
assert [row[1] for row in connection.execute(
964+
f'PRAGMA table_info("{table_name}")'
965+
)] == ["__case_ordinal", "score"]
966+
967+
926968
@pytest.mark.parametrize("as_mapping", [False, True])
927969
def test_public_generic_plan_apply_accepts_object_and_mapping(
928970
catalog, as_mapping,

‎tests/test_transform_frontend.py‎

Lines changed: 37 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -375,14 +375,16 @@ def test_source_normalization_hash_and_positions_are_stable() -> None:
375375
assert compilation.frontend_contract == "openstatspec-spss-syntax-frontend-v0.2"
376376

377377

378-
def test_schema_commands_use_the_v03_frontend_contract() -> None:
378+
def test_schema_commands_use_python_extension_contracts() -> None:
379379
compilation = compile_spss_syntax(
380380
"STRING note (A4).",
381381
_schema(VariableDefinition("q1", "numeric")),
382382
)
383383

384-
assert compilation.plan.contract == "openstatspec-transformation-plan-v0.3"
385-
assert compilation.frontend_contract == "openstatspec-spss-syntax-frontend-v0.3"
384+
assert compilation.plan.contract == "openstatspec-python-schema-change-plan-v0.1"
385+
assert compilation.frontend_contract == "openstatspec-python-schema-change-spss-v0.1"
386+
assert transform_module.TRANSFORMATION_PLAN_SCHEMA_CHANGE_CONTRACT == compilation.plan.contract
387+
assert transform_module.SPSS_FRONTEND_SCHEMA_CHANGE_CONTRACT == compilation.frontend_contract
386388

387389
def test_string_comparison_fails_closed_until_exact_collation_is_supported() -> None:
388390
error = _error(
@@ -584,26 +586,49 @@ def test_v02_plan_and_schema_reject_decimal_format_that_cannot_fit() -> None:
584586
)
585587

586588

587-
def test_schema_operations_require_v03_contract() -> None:
588-
operation = CreateVariableOperation("note", "string", 8)
589+
@pytest.mark.parametrize("contract", [
590+
"openstatspec-transformation-plan-v0.1",
591+
"openstatspec-transformation-plan-v0.2",
592+
])
593+
@pytest.mark.parametrize("operation", [
594+
CreateVariableOperation("note", "string", 8), DeleteVariableOperation("note"),
595+
])
596+
def test_official_plan_contracts_reject_schema_operations(contract, operation) -> None:
589597
with pytest.raises(TransformationFrontendError) as caught:
590-
TransformationPlan((operation,))
598+
transformation_plan_from_dict({
599+
"contract": contract, "input_alias": "parent",
600+
"operations": [operation.as_dict()],
601+
})
591602
assert caught.value.code == "invalid_transformation_plan"
592603

593-
plan = TransformationPlan(
594-
(operation,), contract="openstatspec-transformation-plan-v0.3",
604+
605+
def test_legacy_schema_plan_roundtrip_preserves_exact_hash() -> None:
606+
serialized = (
607+
'{"contract":"openstatspec-transformation-plan-v0.3","input_alias":"parent",'
608+
'"operations":[{"declared_string_width":4,"op":"create_variable",'
609+
'"storage_kind":"string","variable":"note"}]}'
595610
)
596-
assert plan.contract == "openstatspec-transformation-plan-v0.3"
611+
raw = json.loads(serialized)
612+
legacy = transformation_plan_from_dict(raw)
613+
assert canonical_plan_json(raw) == legacy.canonical_json() == serialized
614+
assert canonical_plan_hash(raw) == legacy.sha256() == (
615+
"3c1933c6df1ea664d506d3b7879af5141f912181884129d2861bf19e44f4e131"
616+
)
617+
new = transformation_plan_from_dict({
618+
**raw, "contract": "openstatspec-python-schema-change-plan-v0.1",
619+
})
620+
assert new.operations == legacy.operations
621+
assert new.sha256() != legacy.sha256()
597622

598623

599-
def test_spss_schema_commands_emit_v03_contract() -> None:
624+
def test_spss_schema_commands_emit_python_extension_contract() -> None:
600625
schema = _schema(VariableDefinition("q1", "numeric"))
601626
assert bind_spss_syntax(
602627
parse_spss_syntax("STRING note (A8)."), schema,
603-
).plan.contract == "openstatspec-transformation-plan-v0.3"
628+
).plan.contract == "openstatspec-python-schema-change-plan-v0.1"
604629
assert bind_spss_syntax(
605630
parse_spss_syntax("COMPUTE other = q1. DELETE VARIABLES q1."), schema,
606-
).plan.contract == "openstatspec-transformation-plan-v0.3"
631+
).plan.contract == "openstatspec-python-schema-change-plan-v0.1"
607632

608633

609634
def test_custom_nonempty_input_alias_is_canonical() -> None:

0 commit comments

Comments
 (0)