Skip to content

Commit c6cf9e5

Browse files
Merge pull request #22 from OpenStatSpec/perf/query-planning
perf: make transformation validation and hashing linear
2 parents 6656652 + ebe4637 commit c6cf9e5

4 files changed

Lines changed: 214 additions & 55 deletions

File tree

‎src/openstatspec/sql/inplace_transform.py‎

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

33
from __future__ import annotations
44

5+
import hashlib
6+
57
from collections.abc import Callable, Mapping
68
from dataclasses import dataclass
79
from datetime import UTC, datetime
@@ -471,6 +473,7 @@ def _apply_plan_on_connection(
471473
dolt_branch: str | None,
472474
dolt_head: str | None,
473475
mutation_journal: dict[str, Any] | None = None,
476+
encoded_plan: tuple[bytes, str] | None = None,
474477
) -> dict[str, Any]:
475478
before_identity = _target_identity_state(connection, dataset_id, lock_dataset=True)
476479
if before_identity[3] != 1:
@@ -792,6 +795,10 @@ def _apply_plan_on_connection(
792795
started = _now()
793796
if mutation_journal is not None:
794797
mutation_journal["apply_id"] = apply_id
798+
if encoded_plan is None:
799+
plan_bytes = plan.canonical_bytes()
800+
encoded_plan = plan_bytes, hashlib.sha256(plan_bytes).hexdigest()
801+
plan_bytes, plan_hash = encoded_plan
795802
connection.execute(insert(audit).values(
796803
apply_id=apply_id,
797804
contract_id=APPLY_CONTRACT,
@@ -802,8 +809,8 @@ def _apply_plan_on_connection(
802809
source_kind=submission.source_kind,
803810
source_hash=submission.source_hash,
804811
frontend_contract=submission.frontend_contract,
805-
plan_hash=plan.sha256(),
806-
canonical_plan_json=plan.canonical_json(),
812+
plan_hash=plan_hash,
813+
canonical_plan_json=plan_bytes.decode("utf-8"),
807814
actor=actor,
808815
status="succeeded",
809816
dolt_branch=dolt_branch,
@@ -837,7 +844,7 @@ def _apply_plan_on_connection(
837844
"source_kind": submission.source_kind,
838845
"source_hash": submission.source_hash,
839846
"frontend_contract": submission.frontend_contract,
840-
"plan_hash": plan.sha256(),
847+
"plan_hash": plan_hash,
841848
"dolt_branch": dolt_branch,
842849
"dolt_head_before": dolt_head,
843850
"dolt_head_after": dolt_head,
@@ -978,6 +985,7 @@ def _run_in_place_submission(
978985
dataset_id: str,
979986
actor: str,
980987
prepare: Callable[[Any, str], InPlacePlanSubmission],
988+
encoded_plan: tuple[bytes, str] | None = None,
981989
expected_branch: str | None = None,
982990
expected_head: str | None = None,
983991
dolt_conformance_source: DoltConformanceSource | None = None,
@@ -1062,6 +1070,7 @@ def enable_sqlite_foreign_keys(dbapi_connection, _connection_record):
10621070
dolt_branch=branch,
10631071
dolt_head=head,
10641072
mutation_journal=journal,
1073+
encoded_plan=encoded_plan,
10651074
)
10661075
if profile.name == "dolt":
10671076
after_branch, after_head, dirty_after = _dolt_state(connection)
@@ -1116,7 +1125,8 @@ def apply_transformation_plan_in_place(
11161125
if isinstance(plan, TransformationPlan)
11171126
else transformation_plan_from_dict(plan)
11181127
)
1119-
plan_hash = normalized.sha256()
1128+
plan_bytes = normalized.canonical_bytes()
1129+
plan_hash = hashlib.sha256(plan_bytes).hexdigest()
11201130
submission = InPlacePlanSubmission(
11211131
plan=normalized,
11221132
source_kind="canonical_plan",
@@ -1127,6 +1137,7 @@ def apply_transformation_plan_in_place(
11271137
dataset_id=dataset_id,
11281138
actor=actor,
11291139
prepare=lambda _connection, _dataset_id: submission,
1140+
encoded_plan=(plan_bytes, plan_hash),
11301141
expected_branch=expected_branch,
11311142
expected_head=expected_head,
11321143
dolt_conformance_source=dolt_conformance_source,

‎src/openstatspec/transform/validation.py‎

Lines changed: 32 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -40,20 +40,16 @@ def _expected_type(storage_kind: StorageKind) -> ValueType:
4040

4141

4242
def _resolve(
43-
variables: list[VariableDefinition], name: str
44-
) -> tuple[int, VariableDefinition]:
45-
matches = [
46-
(index, variable)
47-
for index, variable in enumerate(variables)
48-
if variable.name.casefold() == name.casefold()
49-
]
50-
if len(matches) != 1:
43+
variables: dict[str, VariableDefinition], name: str
44+
) -> tuple[str, VariableDefinition]:
45+
key = name.casefold()
46+
if key not in variables:
5147
raise frontend_error(
5248
"unknown_variable",
5349
f"Variable {name!r} is not present in the current schema.",
5450
variable=name,
5551
)
56-
return matches[0]
52+
return key, variables[key]
5753

5854

5955
def _validate_match(match: RecodeMatch, source: VariableDefinition) -> None:
@@ -133,7 +129,7 @@ def _validate_recode_string_width(
133129

134130

135131
def _bind_recode(
136-
operation: RecodeOperation, variables: list[VariableDefinition]
132+
operation: RecodeOperation, variables: dict[str, VariableDefinition]
137133
) -> None:
138134
_, source = _resolve(variables, operation.source)
139135
if operation.target_mode == "create":
@@ -143,10 +139,8 @@ def _bind_recode(
143139
f"Target name {operation.target!r} is reserved.",
144140
target=operation.target,
145141
)
146-
if any(
147-
variable.name.casefold() == operation.target.casefold()
148-
for variable in variables
149-
):
142+
target_key = operation.target.casefold()
143+
if target_key in variables:
150144
raise frontend_error(
151145
"target_already_exists",
152146
f"Target name {operation.target!r} already exists.",
@@ -183,16 +177,14 @@ def _bind_recode(
183177
variable=source.name,
184178
)
185179
if operation.target_mode == "create":
186-
variables.append(
187-
VariableDefinition(
188-
operation.target,
189-
"numeric" if output_type == "binary64" else "string",
190-
)
180+
variables[target_key] = VariableDefinition(
181+
operation.target,
182+
"numeric" if output_type == "binary64" else "string",
191183
)
192184

193185

194186
def _operand_type(
195-
operand: Operand, variables: list[VariableDefinition]
187+
operand: Operand, variables: dict[str, VariableDefinition]
196188
) -> ValueType:
197189
if operand.kind == "literal":
198190
assert operand.value is not None
@@ -204,7 +196,7 @@ def _operand_type(
204196

205197
def _validate_predicate(
206198
predicate: ComparisonExpression | BooleanExpression,
207-
variables: list[VariableDefinition],
199+
variables: dict[str, VariableDefinition],
208200
) -> None:
209201
if isinstance(predicate, BooleanExpression):
210202
for item in predicate.operands:
@@ -236,7 +228,7 @@ def _validate_predicate(
236228

237229

238230
def _bind_assign(
239-
operation: AssignOperation, variables: list[VariableDefinition]
231+
operation: AssignOperation, variables: dict[str, VariableDefinition]
240232
) -> None:
241233
output_type = _operand_type(operation.value, variables)
242234
if output_type == "string":
@@ -253,19 +245,17 @@ def _bind_assign(
253245
f"Target name {operation.target!r} is reserved.",
254246
target=operation.target,
255247
)
256-
if any(
257-
variable.name.casefold() == operation.target.casefold()
258-
for variable in variables
259-
):
248+
target_key = operation.target.casefold()
249+
if target_key in variables:
260250
raise frontend_error(
261251
"target_already_exists",
262252
f"Target name {operation.target!r} already exists.",
263253
target=operation.target,
264254
)
265-
variables.append(VariableDefinition(
255+
variables[target_key] = VariableDefinition(
266256
operation.target,
267257
"numeric" if output_type == "binary64" else "string",
268-
))
258+
)
269259
return
270260
_, target = _resolve(variables, operation.target)
271261
if target.storage_kind == "string":
@@ -284,7 +274,7 @@ def _bind_assign(
284274

285275
def _bind_conditional_assign(
286276
operation: ConditionalAssignOperation,
287-
variables: list[VariableDefinition],
277+
variables: dict[str, VariableDefinition],
288278
) -> None:
289279
_validate_predicate(operation.condition, variables)
290280
_, target = _resolve(variables, operation.target)
@@ -328,17 +318,17 @@ def bind_transformation_plan(
328318
raise TypeError("plan must be a TransformationPlan.")
329319
if not isinstance(schema, VariableSchema):
330320
raise TypeError("schema must be a VariableSchema.")
331-
variables = list(schema.variables)
321+
variables = {variable.name.casefold(): variable for variable in schema.variables}
322+
last_create = max(
323+
(index for index, operation in enumerate(plan.operations) if _creates_variable(operation)),
324+
default=-1,
325+
)
332326
for operation_index, operation in enumerate(plan.operations):
333-
later_create = any(
334-
_creates_variable(later_operation)
335-
for later_operation in plan.operations[operation_index + 1:]
336-
)
337327
if isinstance(operation, CreateVariableOperation):
338328
_bind_create(operation, variables)
339329
continue
340330
if isinstance(operation, DeleteVariableOperation):
341-
_bind_delete(operation, variables, allow_empty=later_create)
331+
_bind_delete(operation, variables, allow_empty=operation_index < last_create)
342332
continue
343333
if isinstance(operation, RecodeOperation):
344334
_bind_recode(operation, variables)
@@ -397,27 +387,28 @@ def bind_transformation_plan(
397387
if isinstance(operation, ExecuteOperation):
398388
continue
399389
raise AssertionError(f"Unknown plan operation: {type(operation)!r}")
400-
return BoundTransformation(plan, VariableSchema(tuple(variables)))
390+
return BoundTransformation(plan, VariableSchema(tuple(variables.values())))
401391
def _bind_create(
402392
operation: CreateVariableOperation,
403-
variables: list[VariableDefinition],
393+
variables: dict[str, VariableDefinition],
404394
) -> None:
405-
if any(variable.name.casefold() == operation.variable.casefold() for variable in variables):
395+
key = operation.variable.casefold()
396+
if key in variables:
406397
raise frontend_error(
407398
"target_already_exists",
408399
f"Target name {operation.variable!r} already exists.",
409400
target=operation.variable,
410401
)
411-
variables.append(VariableDefinition(
402+
variables[key] = VariableDefinition(
412403
operation.variable,
413404
operation.storage_kind,
414405
declared_string_width=operation.declared_string_width,
415-
))
406+
)
416407

417408

418409
def _bind_delete(
419410
operation: DeleteVariableOperation,
420-
variables: list[VariableDefinition],
411+
variables: dict[str, VariableDefinition],
421412
*,
422413
allow_empty: bool,
423414
) -> None:

‎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(

0 commit comments

Comments
 (0)