Skip to content

Commit 1cdcc18

Browse files
committed
test: bound transformation validation and plan encoding work
1 parent 6656652 commit 1cdcc18

2 files changed

Lines changed: 167 additions & 10 deletions

File tree

‎tests/test_inplace_transform.py‎

Lines changed: 58 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
from __future__ import annotations
22

3+
import hashlib
34
import json
45
import sqlite3
56
from dataclasses import replace
67
from types import SimpleNamespace
8+
from unittest.mock import patch
79

810
import pytest
911
from sqlalchemy import create_engine, inspect, text
@@ -632,12 +634,25 @@ def test_temporary_target_type_is_not_taken_from_same_name_recreation(catalog) -
632634
def test_public_apply_supports_non_dolt_without_building_undo(catalog) -> None:
633635
url, path, dataset_id, table_name = catalog
634636
plan = _plan("RECODE score (1 = 0).")
635-
result = openstatspec.apply_spss_in_place(
636-
database_url=url,
637-
dataset_id=dataset_id,
638-
source_text="RECODE score (1 = 0).",
639-
actor="test-agent",
637+
expected_json = (
638+
'{"contract":"openstatspec-transformation-plan-v0.1","input_alias":"parent",'
639+
'"operations":[{"op":"recode","rules":[{"match":{"kind":"values","values":'
640+
'[{"bits":"3ff0000000000000","type":"binary64"}]},"result":{"kind":"literal",'
641+
'"value":{"bits":"0000000000000000","type":"binary64"}}}],"source":"score",'
642+
'"target":"score","target_mode":"replace","unmatched":{"kind":"copy"}}]}'
640643
)
644+
expected_hash = "c1a0025028816c7b424312612af4e0a95d00da6b1bcff25e9b742e4be25dbb42"
645+
source_hash = "7e86162055308158fa2cf1781eae7d997c07bb870fc66becdabdfdd00580dc7b"
646+
with patch.object(
647+
openstatspec.TransformationPlan, "canonical_bytes", autospec=True,
648+
side_effect=openstatspec.TransformationPlan.canonical_bytes,
649+
) as encodes, patch("hashlib.sha256", wraps=hashlib.sha256) as hashes:
650+
result = openstatspec.apply_spss_in_place(
651+
database_url=url,
652+
dataset_id=dataset_id,
653+
source_text="RECODE score (1 = 0).\r\n",
654+
actor="test-agent",
655+
)
641656
assert result["dolt_branch"] is None
642657
assert result["dolt_commit_performed"] is False
643658
assert result["plan_hash"] == plan.sha256()
@@ -651,6 +666,18 @@ def test_public_apply_supports_non_dolt_without_building_undo(catalog) -> None:
651666
"spss_syntax",
652667
"openstatspec-spss-syntax-frontend-v0.2",
653668
)
669+
assert (result["dataset_id"], result["physical_table_name"]) == (dataset_id, table_name)
670+
assert (result["plan_hash"], result["source_hash"]) == (expected_hash, source_hash)
671+
with sqlite3.connect(path) as connection:
672+
audit_json, audit_hash, audit_source = connection.execute(
673+
"SELECT canonical_plan_json, plan_hash, source_hash FROM transformation_apply"
674+
).fetchone()
675+
assert (audit_json.encode("utf-8"), audit_hash, audit_source) == (
676+
expected_json.encode("utf-8"), expected_hash, source_hash,
677+
)
678+
assert (encodes.call_count, sum(
679+
call.args == (expected_json.encode("utf-8"),) for call in hashes.call_args_list
680+
)) == (1, 1), "public SPSS apply must encode/hash the canonical plan once"
654681

655682

656683
def test_schema_commands_record_the_v03_frontend_contract(catalog) -> None:
@@ -679,12 +706,24 @@ def test_public_generic_plan_apply_accepts_object_and_mapping(
679706
url, path, dataset_id, table_name = catalog
680707
plan = _plan("RECODE score (1 = 7).")
681708
supplied = plan.as_dict() if as_mapping else plan
682-
result = openstatspec.apply_transformation_plan_in_place(
683-
database_url=url,
684-
dataset_id=dataset_id,
685-
plan=supplied,
686-
actor="test-agent",
709+
expected_json = (
710+
'{"contract":"openstatspec-transformation-plan-v0.1","input_alias":"parent",'
711+
'"operations":[{"op":"recode","rules":[{"match":{"kind":"values","values":'
712+
'[{"bits":"3ff0000000000000","type":"binary64"}]},"result":{"kind":"literal",'
713+
'"value":{"bits":"401c000000000000","type":"binary64"}}}],"source":"score",'
714+
'"target":"score","target_mode":"replace","unmatched":{"kind":"copy"}}]}'
687715
)
716+
expected_hash = "089de9933b97157e9cb8b595d6f00afc11044907ca69e4ab747f6d0aac0bf60f"
717+
with patch.object(
718+
openstatspec.TransformationPlan, "canonical_bytes", autospec=True,
719+
side_effect=openstatspec.TransformationPlan.canonical_bytes,
720+
) as encodes, patch("hashlib.sha256", wraps=hashlib.sha256) as hashes:
721+
result = openstatspec.apply_transformation_plan_in_place(
722+
database_url=url,
723+
dataset_id=dataset_id,
724+
plan=supplied,
725+
actor="test-agent",
726+
)
688727
assert result["dataset_id"] == dataset_id
689728
assert result["physical_table_name"] == table_name
690729
assert result["source_kind"] == "canonical_plan"
@@ -702,6 +741,15 @@ def test_public_generic_plan_apply_accepts_object_and_mapping(
702741
None,
703742
plan.sha256(),
704743
)
744+
audit_json, audit_hash = connection.execute(
745+
"SELECT canonical_plan_json, plan_hash FROM transformation_apply"
746+
).fetchone()
747+
assert (audit_json.encode("utf-8"), audit_hash, result["plan_hash"]) == (
748+
expected_json.encode("utf-8"), expected_hash, expected_hash,
749+
)
750+
assert (encodes.call_count, sum(
751+
call.args == (expected_json.encode("utf-8"),) for call in hashes.call_args_list
752+
)) == (1, 1), "public canonical apply must encode/hash the plan once"
705753

706754

707755
def test_generic_string_width_is_rejected_before_ddl(

‎tests/test_transform_frontend.py‎

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,10 @@
1414
spss_source_hash,
1515
)
1616
from openstatspec.transform import (
17+
AssignOperation,
1718
CreateVariableOperation,
19+
ExecuteOperation,
20+
Operand,
1821
DeleteVariableOperation,
1922
RecodeMatch,
2023
RecodeOperation,
@@ -680,6 +683,112 @@ def test_recode_copy_preserves_source_declared_width() -> None:
680683
assert bound.output_schema == schema
681684

682685

686+
@pytest.mark.parametrize("workload", ["execute", "labels"])
687+
def test_generic_binding_10k_has_linear_bookkeeping(workload) -> None:
688+
size = 10_000
689+
budget = 10 * (size + size)
690+
probes = 0
691+
692+
class Name(str):
693+
def casefold(self):
694+
nonlocal probes
695+
probes += 1
696+
assert probes <= budget, f"variable-name casefold probes: {probes} > {budget}"
697+
return super().casefold()
698+
699+
class Operations(tuple):
700+
def __getitem__(self, key):
701+
nonlocal probes
702+
if isinstance(key, slice):
703+
probes += len(range(*key.indices(len(self))))
704+
assert probes <= budget, f"operation suffix entries: {probes} > {budget}"
705+
return super().__getitem__(key)
706+
707+
schema = _schema(*(VariableDefinition(Name(f"V{i}"), "numeric") for i in range(size)))
708+
operations = (
709+
Operations((ExecuteOperation(),) * size) if workload == "execute"
710+
else tuple(SetVariableLabelOperation(f"v{i}", "L") for i in range(size))
711+
)
712+
plan = TransformationPlan(operations)
713+
probes = 0 # Count only the public bind, not input construction.
714+
bound = bind_transformation_plan(plan, schema)
715+
assert bound.plan == plan
716+
assert bound.output_schema == _schema(*(
717+
VariableDefinition(f"V{i}", "numeric", variable_label="L" if workload == "labels" else None)
718+
for i in range(size)
719+
))
720+
721+
722+
@pytest.mark.parametrize("create", [
723+
CreateVariableOperation("replacement", "numeric"),
724+
AssignOperation("replacement", "create", Operand("literal", value=TypedValue.binary64(1))),
725+
], ids=["explicit", "assign"])
726+
def test_generic_distant_create_preserves_first_error_and_empty_schema_exception(create) -> None:
727+
schema = _schema(VariableDefinition("Only", "numeric"))
728+
prefix = (DeleteVariableOperation("oNLY"),) + (ExecuteOperation(),) * 100
729+
plan = TransformationPlan(prefix + (create,), contract="openstatspec-transformation-plan-v0.3")
730+
assert bind_transformation_plan(plan, schema).output_schema == _schema(
731+
VariableDefinition("replacement", "numeric"),
732+
)
733+
plan = TransformationPlan(
734+
prefix + (create, SetVariableLabelOperation("missing", "L"), create),
735+
contract=plan.contract,
736+
)
737+
with pytest.raises(TransformationFrontendError) as caught:
738+
bind_transformation_plan(plan, schema)
739+
assert caught.value.as_dict() == {
740+
"code": "unknown_variable",
741+
"detail": "Variable 'missing' is not present in the current schema.",
742+
"details": {"variable": "missing"},
743+
}
744+
745+
746+
@pytest.mark.parametrize("later", [(), (RecodeOperation(
747+
source="missing", target="replacement", target_mode="create",
748+
rules=(RecodeRule(RecodeMatch("values", (TypedValue.binary64(1),)),
749+
RecodeResult("literal", TypedValue.binary64(0))),),
750+
unmatched=RecodeResult("copy"),
751+
),)], ids=["no-create", "recode-create"])
752+
def test_generic_last_delete_precedes_later_lookup_error(later) -> None:
753+
plan = TransformationPlan(
754+
(DeleteVariableOperation("oNLY"), SetVariableLabelOperation("missing", "L"))
755+
+ (ExecuteOperation(),) * 100 + later,
756+
contract="openstatspec-transformation-plan-v0.3",
757+
)
758+
with pytest.raises(TransformationFrontendError) as caught:
759+
bind_transformation_plan(plan, _schema(VariableDefinition("Only", "numeric")))
760+
assert caught.value.as_dict() == {
761+
"code": "cannot_delete_last_variable",
762+
"detail": "A dataset must retain at least one variable.",
763+
"details": {"variable": "Only"},
764+
}
765+
766+
767+
def test_generic_delete_recreate_appends_with_new_spelling_and_metadata() -> None:
768+
plan = TransformationPlan((
769+
DeleteVariableOperation("oNLY"),
770+
CreateVariableOperation("ONLY", "string", 3),
771+
SetVariableLabelOperation("only", "New"),
772+
), contract="openstatspec-transformation-plan-v0.3")
773+
bound = bind_transformation_plan(plan, _schema(
774+
VariableDefinition("Only", "numeric", variable_label="Old"),
775+
VariableDefinition("Keep", "numeric"),
776+
))
777+
assert bound.output_schema == _schema(
778+
VariableDefinition("Keep", "numeric"),
779+
VariableDefinition("ONLY", "string", variable_label="New", declared_string_width=3),
780+
)
781+
782+
783+
def test_literal_unicode_canonical_plan_vector() -> None:
784+
plan = TransformationPlan((SetVariableLabelOperation("Only", "Täpselt"),), input_alias="survey")
785+
assert plan.canonical_bytes() == (
786+
'{"contract":"openstatspec-transformation-plan-v0.2","input_alias":"survey",'
787+
'"operations":[{"label":"Täpselt","op":"set_variable_label","variable":"Only"}]}'
788+
).encode("utf-8")
789+
assert plan.sha256() == "2ab3bf3c6dd2c21ce0fed076d43c49d93b6cc38e3e38529a18b7563286af9e03"
790+
791+
683792
def test_generic_plan_binding_validates_sequential_schema_state() -> None:
684793
plan = TransformationPlan((
685794
RecodeOperation(

0 commit comments

Comments
 (0)