Skip to content

Commit b0b4b98

Browse files
committed
Preserve canonical schema change contracts
1 parent b52a0c6 commit b0b4b98

6 files changed

Lines changed: 134 additions & 8 deletions

File tree

src/openstatspec/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
__all__ = [
2828
"AssignOperation", "BooleanExpression", "ComparisonExpression",
2929
"ConditionalAssignOperation", "ExecuteOperation", "Operand",
30-
"ConditionalAssignOperation", "CreateVariableOperation", "DeleteVariableOperation",
30+
"PredicateExpression", "CreateVariableOperation", "DeleteVariableOperation",
3131
"CapabilityDeclaration", "DoltConformanceSource", "LossReport",
3232
"SpssFrontendCompilation", "TransformationError",
3333
"TransformationFrontendError",

src/openstatspec/frontends/spss/binding.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
PredicateExpression, RecodeMatch, RecodeOperation, RecodeResult, RecodeRule,
1414
ReplaceValueLabelsOperation, SetFormatOperation,
1515
SetMeasurementLevelOperation, SetVariableLabelOperation,
16+
TRANSFORMATION_PLAN_SCHEMA_CHANGE_CONTRACT,
1617
TRANSFORMATION_PLAN_V1_CONTRACT,
1718
TransformationPlan, TypedValue, ValueLabel,
1819
)
@@ -491,10 +492,15 @@ def bind_spss_syntax(
491492
v01_types = (
492493
RecodeOperation, SetVariableLabelOperation, ReplaceValueLabelsOperation,
493494
)
495+
schema_change_types = (CreateVariableOperation, DeleteVariableOperation)
494496
contract = (
495-
TRANSFORMATION_PLAN_V1_CONTRACT
496-
if all(isinstance(operation, v01_types) for operation in operations)
497-
else "openstatspec-transformation-plan-v0.2"
497+
TRANSFORMATION_PLAN_SCHEMA_CHANGE_CONTRACT
498+
if any(isinstance(operation, schema_change_types) for operation in operations)
499+
else (
500+
TRANSFORMATION_PLAN_V1_CONTRACT
501+
if all(isinstance(operation, v01_types) for operation in operations)
502+
else "openstatspec-transformation-plan-v0.2"
503+
)
498504
)
499505
plan = TransformationPlan(
500506
tuple(operations), contract=contract, input_alias=input_alias,

src/openstatspec/sql/inplace_transform.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -709,6 +709,28 @@ def _apply_plan_on_connection(
709709
_compact_variable_ordinals(
710710
connection, core=core, variables=variables,
711711
)
712+
canonical_physical = {"__case_ordinal"}
713+
for remaining_variable in variables:
714+
expected_physical = physical_name(
715+
str(remaining_variable["source_name"]), canonical_physical,
716+
)
717+
current_physical = str(remaining_variable["physical_name"])
718+
if current_physical == expected_physical:
719+
continue
720+
connection.exec_driver_sql(
721+
f"ALTER TABLE {qualified_table} RENAME COLUMN "
722+
f"{quote(current_physical)} TO {quote(expected_physical)}"
723+
)
724+
connection.execute(
725+
update(core.variable)
726+
.where(
727+
core.variable.c.variable_id
728+
== remaining_variable["variable_id"]
729+
)
730+
.values(physical_name=expected_physical)
731+
)
732+
remaining_variable["physical_name"] = expected_physical
733+
used_physical = canonical_physical
712734
relation = Table(
713735
table_name, MetaData(), schema=dataset.get("physical_table_schema"),
714736
autoload_with=connection,

src/openstatspec/transform/plan.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -588,6 +588,19 @@ class TransformationPlan:
588588
def __post_init__(self) -> None:
589589
if not isinstance(self.contract, str) or self.contract not in _TRANSFORMATION_PLAN_CONTRACTS:
590590
_invalid("Plan contract is not a supported transformation-plan contract.")
591+
if (
592+
self.contract != TRANSFORMATION_PLAN_SCHEMA_CHANGE_CONTRACT
593+
and any(
594+
isinstance(operation, (
595+
CreateVariableOperation, DeleteVariableOperation,
596+
))
597+
for operation in self.operations
598+
)
599+
):
600+
_invalid(
601+
"Create/delete schema operations require "
602+
"openstatspec-transformation-plan-v0.3."
603+
)
591604
if self.contract == TRANSFORMATION_PLAN_V1_CONTRACT and any(
592605
isinstance(operation, (
593606
AssignOperation, ConditionalAssignOperation, SetFormatOperation,
@@ -710,7 +723,7 @@ def _match(raw: Any) -> RecodeMatch:
710723
_invalid("Unknown recode match kind.")
711724

712725
def transformation_plan_from_dict(raw: Mapping[str, Any]) -> TransformationPlan:
713-
"""Strictly validate canonical v0.1 or additive v0.2 plan documents."""
726+
"""Strictly validate canonical v0.1, v0.2, or schema-change v0.3 plans."""
714727
if not isinstance(raw, Mapping):
715728
_invalid("Transformation plan must be an object.")
716729
_exact(raw, {"contract", "input_alias", "operations"}, "Transformation plan")

tests/test_inplace_transform.py

Lines changed: 65 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,67 @@ def test_string_declaration_creates_column_and_catalog_variable(catalog) -> None
189189
)["valid"] is True
190190

191191

192+
def test_delete_recanonicalizes_surviving_collision_columns(tmp_path) -> None:
193+
path = tmp_path / "collision-delete.sqlite"
194+
url = f"sqlite:///{path}"
195+
openstatspec.initialize_catalog(database_url=url)
196+
base = _variables()[0]
197+
variables = [
198+
{
199+
**base,
200+
"ordinal": ordinal,
201+
"source_name": source_name,
202+
"physical_name": physical,
203+
}
204+
for ordinal, (source_name, physical) in enumerate((
205+
("a-b", "a_b"), ("a_b", "a_b_2"), ("keep", "keep"),
206+
), start=1)
207+
]
208+
create_wide_dataset(
209+
database_url=url,
210+
dataset_id="collision_source",
211+
source_name="collision.sav",
212+
source_format="SAV",
213+
source_sha256="c" * 64,
214+
rows=[
215+
{"a_b": 1.0, "a_b_2": 2.0, "keep": 3.0},
216+
{"a_b": 4.0, "a_b_2": 5.0, "keep": 6.0},
217+
],
218+
variables=variables,
219+
)
220+
openstatspec.install_in_place_transformation_schema(database_url=url)
221+
connection = sqlite3.connect(path)
222+
dataset_id = connection.execute(
223+
"select dataset_id from dataset where dataset_name = 'collision_source'"
224+
).fetchone()[0]
225+
connection.close()
226+
plan = openstatspec.TransformationPlan(
227+
(openstatspec.DeleteVariableOperation("a-b"),),
228+
contract="openstatspec-transformation-plan-v0.3",
229+
)
230+
231+
openstatspec.apply_transformation_plan_in_place(
232+
database_url=url,
233+
dataset_id=dataset_id,
234+
plan=plan,
235+
actor="test-agent",
236+
)
237+
238+
connection = sqlite3.connect(path)
239+
assert connection.execute(
240+
"select source_name, physical_name, source_ordinal from variable "
241+
"where dataset_id = ? order by source_ordinal",
242+
(dataset_id,),
243+
).fetchall() == [("a_b", "a_b", 1), ("keep", "keep", 2)]
244+
assert connection.execute(
245+
"select a_b, keep from data_collision_source order by __case_ordinal"
246+
).fetchall() == [(2.0, 3.0), (5.0, 6.0)]
247+
connection.close()
248+
assert validate_wide_dataset(
249+
database_url=url, dataset_id=dataset_id,
250+
)["valid"] is True
251+
252+
192253
def test_delete_prunes_an_empty_multiple_response_set(catalog) -> None:
193254
url, path, dataset_id, _table_name = catalog
194255
connection = sqlite3.connect(path)
@@ -335,9 +396,10 @@ def test_generic_string_width_is_rejected_before_ddl(
335396
{"server_version": "3.35.0"},
336397
),
337398
)
338-
plan = openstatspec.TransformationPlan((
339-
openstatspec.CreateVariableOperation("note", "string", 4),
340-
))
399+
plan = openstatspec.TransformationPlan(
400+
(openstatspec.CreateVariableOperation("note", "string", 4),),
401+
contract="openstatspec-transformation-plan-v0.3",
402+
)
341403

342404
with pytest.raises(TargetCapabilityExceededError, match="permits 3"):
343405
openstatspec.apply_transformation_plan_in_place(

tests/test_transform_frontend.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
spss_source_hash,
1414
)
1515
from openstatspec.transform import (
16+
CreateVariableOperation,
1617
RecodeMatch,
1718
RecodeOperation,
1819
RecodeResult,
@@ -559,6 +560,28 @@ def test_v02_plan_and_schema_reject_decimal_format_that_cannot_fit() -> None:
559560
)
560561

561562

563+
def test_schema_operations_require_v03_contract() -> None:
564+
operation = CreateVariableOperation("note", "string", 8)
565+
with pytest.raises(TransformationFrontendError) as caught:
566+
TransformationPlan((operation,))
567+
assert caught.value.code == "invalid_transformation_plan"
568+
569+
plan = TransformationPlan(
570+
(operation,), contract="openstatspec-transformation-plan-v0.3",
571+
)
572+
assert plan.contract == "openstatspec-transformation-plan-v0.3"
573+
574+
575+
def test_spss_schema_commands_emit_v03_contract() -> None:
576+
schema = _schema(VariableDefinition("q1", "numeric"))
577+
assert bind_spss_syntax(
578+
parse_spss_syntax("STRING note (A8)."), schema,
579+
).plan.contract == "openstatspec-transformation-plan-v0.3"
580+
assert bind_spss_syntax(
581+
parse_spss_syntax("COMPUTE other = q1. DELETE VARIABLES q1."), schema,
582+
).plan.contract == "openstatspec-transformation-plan-v0.3"
583+
584+
562585
def test_custom_nonempty_input_alias_is_canonical() -> None:
563586
plan = bind_spss_syntax(
564587
parse_spss_syntax("VARIABLE LABELS q1 'One'."),

0 commit comments

Comments
 (0)