Skip to content

Commit 07852a5

Browse files
committed
Fix sequential schema operation execution
1 parent f9caa5f commit 07852a5

2 files changed

Lines changed: 117 additions & 10 deletions

File tree

‎src/openstatspec/sql/inplace_transform.py‎

Lines changed: 29 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -400,6 +400,7 @@ def _apply_plan_on_connection(
400400
actor: str,
401401
database_profile: str,
402402
allow_schema_change: bool,
403+
allow_delete_variable: bool,
403404
dolt_branch: str | None,
404405
dolt_head: str | None,
405406
mutation_journal: dict[str, Any] | None = None,
@@ -450,6 +451,14 @@ def _apply_plan_on_connection(
450451
"schema_change_not_atomic",
451452
"This database profile has no coherent new-target strategy.",
452453
)
454+
if any(
455+
isinstance(operation, DeleteVariableOperation)
456+
for operation in plan.operations
457+
) and not allow_delete_variable:
458+
raise TransformationError(
459+
"delete_variable_not_supported",
460+
"This SQLite runtime does not support ALTER TABLE DROP COLUMN.",
461+
)
453462
unsupported_targets = [
454463
operation.target for operation in create_operations
455464
if not isinstance(operation, CreateVariableOperation)
@@ -469,7 +478,7 @@ def _apply_plan_on_connection(
469478
by_name = {str(row["source_name"]).casefold(): row for row in variables}
470479
used_physical = {str(row["physical_name"]).casefold() for row in variables}
471480
next_ordinal = max(int(row["source_ordinal"]) for row in variables) + 1
472-
target_rows: list[dict[str, Any]] = []
481+
target_rows_by_operation: dict[int, dict[str, Any]] = {}
473482
quote = connection.dialect.identifier_preparer.quote
474483
qualified_table = connection.dialect.identifier_preparer.format_table(relation)
475484
numeric_type = (
@@ -544,24 +553,27 @@ def _apply_plan_on_connection(
544553
"variable_label": None,
545554
}
546555
next_ordinal += 1
547-
target_rows.append(target_row)
556+
target_rows_by_operation[id(operation)] = target_row
548557
if mutation_journal is not None:
549558
mutation_journal["target_rows"].append(dict(target_row))
550559
if create_operations:
551560
_failure_boundary("schema")
552561

553-
for target_row in target_rows:
554-
connection.execute(insert(core.variable).values(**target_row))
555-
variables.append(target_row)
556-
by_name[str(target_row["source_name"]).casefold()] = target_row
557-
if target_rows:
558-
_failure_boundary("catalog")
562+
if create_operations:
559563
relation = Table(
560564
table_name, MetaData(), schema=dataset.get("physical_table_schema"),
561565
autoload_with=connection,
562566
)
563567

564568
for operation in plan.operations:
569+
created_target = target_rows_by_operation.get(id(operation))
570+
if created_target is not None:
571+
connection.execute(insert(core.variable).values(**created_target))
572+
variables.append(created_target)
573+
by_name[str(created_target["source_name"]).casefold()] = created_target
574+
_failure_boundary("catalog")
575+
if isinstance(operation, CreateVariableOperation):
576+
continue
565577
if isinstance(operation, RecodeOperation):
566578
source_variable = by_name[operation.source.casefold()]
567579
target_variable = by_name[operation.target.casefold()]
@@ -856,9 +868,16 @@ def _run_in_place_submission(
856868
raise TransformationError(
857869
"actor_required", "A non-empty actor identity is mandatory.",
858870
)
859-
profile, _active = effective_profile(
871+
profile, active = effective_profile(
860872
database_url, dolt_conformance_source=dolt_conformance_source,
861873
)
874+
allow_delete_variable = True
875+
if profile.name == "sqlite":
876+
sqlite_version_parts = tuple(
877+
int(part) for part in str(active["server_version"]).split(".")
878+
)
879+
sqlite_version = (*sqlite_version_parts, 0, 0)[:3]
880+
allow_delete_variable = sqlite_version >= (3, 35, 0)
862881
engine = create_engine(database_url)
863882
journal: dict[str, Any] = {}
864883
branch: str | None = None
@@ -905,6 +924,7 @@ def _run_in_place_submission(
905924
actor=actor,
906925
database_profile=profile.name,
907926
allow_schema_change=profile.name in {"sqlite", "postgresql"},
927+
allow_delete_variable=allow_delete_variable,
908928
dolt_branch=branch,
909929
dolt_head=head,
910930
mutation_journal=journal,

‎tests/test_inplace_transform.py‎

Lines changed: 88 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ def test_plan_applies_to_same_dataset_and_physical_table_without_copy(
103103
actor="test-agent",
104104
database_profile="sqlite",
105105
allow_schema_change=True,
106+
allow_delete_variable=True,
106107
dolt_branch=None,
107108
dolt_head=None,
108109
)
@@ -153,6 +154,56 @@ def test_plan_applies_to_same_dataset_and_physical_table_without_copy(
153154
)
154155

155156

157+
def test_string_declaration_creates_column_and_catalog_variable(catalog) -> None:
158+
url, path, dataset_id, table_name = catalog
159+
160+
openstatspec.apply_spss_in_place(
161+
database_url=url,
162+
dataset_id=dataset_id,
163+
source_text="STRING note (A8).",
164+
actor="test-agent",
165+
)
166+
167+
connection = sqlite3.connect(path)
168+
assert connection.execute(
169+
"select source_name, storage_kind, declared_string_width from variable "
170+
"where dataset_id = ? order by source_ordinal",
171+
(dataset_id,),
172+
).fetchall() == [("score", "numeric", None), ("note", "string", 8)]
173+
assert connection.execute(
174+
f'SELECT note FROM "{table_name}" ORDER BY __case_ordinal'
175+
).fetchall() == [(None,), (None,), (None,)]
176+
connection.close()
177+
178+
179+
def test_delete_then_recreate_same_name_resolves_operations_in_order(catalog) -> None:
180+
url, path, dataset_id, table_name = catalog
181+
182+
openstatspec.apply_spss_in_place(
183+
database_url=url,
184+
dataset_id=dataset_id,
185+
source_text=(
186+
"COMPUTE other = score. DELETE VARIABLES score. "
187+
"RECODE other (2 = 9) (ELSE = COPY) INTO score."
188+
),
189+
actor="test-agent",
190+
)
191+
192+
connection = sqlite3.connect(path)
193+
variables = connection.execute(
194+
"select source_name, physical_name from variable where dataset_id = ? "
195+
"order by source_ordinal",
196+
(dataset_id,),
197+
).fetchall()
198+
assert [row[0] for row in variables] == ["other", "score"]
199+
physical = dict(variables)
200+
assert connection.execute(
201+
f'SELECT "{physical["other"]}", "{physical["score"]}" '
202+
f'FROM "{table_name}" ORDER BY __case_ordinal'
203+
).fetchall() == [(1.0, 1.0), (2.0, 9.0), (3.0, 3.0)]
204+
connection.close()
205+
206+
156207
def test_public_apply_supports_non_dolt_without_building_undo(catalog) -> None:
157208
url, path, dataset_id, table_name = catalog
158209
plan = _plan("RECODE score (1 = 0).")
@@ -364,6 +415,38 @@ def test_missing_audit_schema_fails_before_mutation(catalog) -> None:
364415
).fetchall() == [(1.0,), (2.0,), (3.0,)]
365416

366417

418+
def test_delete_is_rejected_when_drop_column_is_unavailable(
419+
catalog, monkeypatch,
420+
) -> None:
421+
url, path, dataset_id, table_name = catalog
422+
monkeypatch.setattr(
423+
inplace_transform,
424+
"effective_profile",
425+
lambda _url, **_kwargs: (
426+
SimpleNamespace(name="sqlite"),
427+
{"server_version": "3.34.0"},
428+
),
429+
)
430+
431+
with pytest.raises(openstatspec.TransformationError) as caught:
432+
openstatspec.apply_spss_in_place(
433+
database_url=url,
434+
dataset_id=dataset_id,
435+
source_text="COMPUTE other = score. DELETE VARIABLES score.",
436+
actor="test-agent",
437+
)
438+
439+
assert caught.value.code == "delete_variable_not_supported"
440+
connection = sqlite3.connect(path)
441+
assert connection.execute(
442+
f'SELECT score FROM "{table_name}" ORDER BY __case_ordinal'
443+
).fetchall() == [(1.0,), (2.0,), (3.0,)]
444+
assert [
445+
row[1] for row in connection.execute(f'PRAGMA table_info("{table_name}")')
446+
] == ["__case_ordinal", "score"]
447+
connection.close()
448+
449+
367450
def test_nontransactional_ddl_profile_rejects_create_before_mutation(
368451
catalog,
369452
) -> None:
@@ -381,6 +464,7 @@ def test_nontransactional_ddl_profile_rejects_create_before_mutation(
381464
actor="test-agent",
382465
database_profile="mysql",
383466
allow_schema_change=False,
467+
allow_delete_variable=True,
384468
dolt_branch=None,
385469
dolt_head=None,
386470
)
@@ -692,7 +776,10 @@ def test_transactional_ddl_rollback_skips_unlocked_compensation(
692776
monkeypatch.setattr(
693777
inplace_transform,
694778
"effective_profile",
695-
lambda _url, **_kwargs: (SimpleNamespace(name=profile_name), {}),
779+
lambda _url, **_kwargs: (
780+
SimpleNamespace(name=profile_name),
781+
{"server_version": "3.35.0"},
782+
),
696783
)
697784

698785
def fail_after_schema_change(

0 commit comments

Comments
 (0)