Skip to content

Commit 7fce8fa

Browse files
committed
Preserve strict wide invariants after schema edits
1 parent 07852a5 commit 7fce8fa

2 files changed

Lines changed: 96 additions & 60 deletions

File tree

‎src/openstatspec/sql/inplace_transform.py‎

Lines changed: 77 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -355,6 +355,25 @@ def _delete_variable_metadata(
355355
core.variable.c.variable_id == variable_id
356356
))
357357

358+
359+
def _compact_variable_ordinals(
360+
connection: Any,
361+
*,
362+
core: Any,
363+
variables: list[dict[str, Any]],
364+
) -> None:
365+
"""Keep normative variable source order contiguous after deletion."""
366+
for source_ordinal, variable in enumerate(variables, start=1):
367+
if int(variable["source_ordinal"]) == source_ordinal:
368+
continue
369+
connection.execute(
370+
update(core.variable)
371+
.where(core.variable.c.variable_id == variable["variable_id"])
372+
.values(source_ordinal=source_ordinal)
373+
)
374+
variable["source_ordinal"] = source_ordinal
375+
376+
358377
def _failure_boundary(_name: str) -> None:
359378
"""Synthetic-test hook for schema/data/catalog/audit failure boundaries."""
360379

@@ -477,8 +496,6 @@ def _apply_plan_on_connection(
477496
)
478497
by_name = {str(row["source_name"]).casefold(): row for row in variables}
479498
used_physical = {str(row["physical_name"]).casefold() for row in variables}
480-
next_ordinal = max(int(row["source_ordinal"]) for row in variables) + 1
481-
target_rows_by_operation: dict[int, dict[str, Any]] = {}
482499
quote = connection.dialect.identifier_preparer.quote
483500
qualified_table = connection.dialect.identifier_preparer.format_table(relation)
484501
numeric_type = (
@@ -515,59 +532,61 @@ def _apply_plan_on_connection(
515532
"The Dolt working set changed after locking the dataset.",
516533
)
517534

518-
# All non-transactional DDL precedes every data/catalog mutation. This makes
519-
# Compensation is journal-bounded to target columns and catalog identities
520-
# created by this apply, without resetting unrelated database state.
521-
for operation in create_operations:
522-
target_name = (
523-
operation.variable
524-
if isinstance(operation, CreateVariableOperation)
525-
else operation.target
526-
)
527-
target_physical = physical_name(target_name, used_physical)
528-
storage_kind = (
529-
operation.storage_kind
530-
if isinstance(operation, CreateVariableOperation)
531-
else "numeric"
532-
)
533-
string_width = (
534-
operation.declared_string_width
535-
if isinstance(operation, CreateVariableOperation)
536-
else None
537-
)
538-
column_type = numeric_type if storage_kind == "numeric" else f"VARCHAR({string_width})"
539-
connection.exec_driver_sql(
540-
f"ALTER TABLE {qualified_table} ADD COLUMN "
541-
f"{quote(target_physical)} {column_type} NULL"
542-
)
543-
if mutation_journal is not None:
544-
mutation_journal["added_columns"].append(target_physical)
545-
target_row = {
546-
"variable_id": str(uuid4()),
547-
"dataset_id": dataset_id,
548-
"source_ordinal": next_ordinal,
549-
"source_name": target_name,
550-
"physical_name": target_physical,
551-
"storage_kind": storage_kind,
552-
"declared_string_width": string_width,
553-
"variable_label": None,
554-
}
555-
next_ordinal += 1
556-
target_rows_by_operation[id(operation)] = target_row
557-
if mutation_journal is not None:
558-
mutation_journal["target_rows"].append(dict(target_row))
559-
if create_operations:
560-
_failure_boundary("schema")
561-
562-
if create_operations:
563-
relation = Table(
564-
table_name, MetaData(), schema=dataset.get("physical_table_schema"),
565-
autoload_with=connection,
566-
)
567-
535+
# Schema changes are allowed only on profiles whose DDL participates in
536+
# this apply transaction, so execute them in canonical operation order.
537+
# That preserves deterministic physical naming across delete/recreate flows.
568538
for operation in plan.operations:
569-
created_target = target_rows_by_operation.get(id(operation))
570-
if created_target is not None:
539+
creates_target = (
540+
isinstance(operation, CreateVariableOperation)
541+
or (
542+
isinstance(operation, (RecodeOperation, AssignOperation))
543+
and operation.target_mode == "create"
544+
)
545+
)
546+
if creates_target:
547+
target_name = (
548+
operation.variable
549+
if isinstance(operation, CreateVariableOperation)
550+
else operation.target
551+
)
552+
target_physical = physical_name(target_name, used_physical)
553+
storage_kind = (
554+
operation.storage_kind
555+
if isinstance(operation, CreateVariableOperation)
556+
else "numeric"
557+
)
558+
string_width = (
559+
operation.declared_string_width
560+
if isinstance(operation, CreateVariableOperation)
561+
else None
562+
)
563+
column_type = numeric_type if storage_kind == "numeric" else "TEXT"
564+
null_clause = (
565+
"NULL" if storage_kind == "numeric"
566+
else "NOT NULL DEFAULT ''"
567+
)
568+
connection.exec_driver_sql(
569+
f"ALTER TABLE {qualified_table} ADD COLUMN "
570+
f"{quote(target_physical)} {column_type} {null_clause}"
571+
)
572+
created_target = {
573+
"variable_id": str(uuid4()),
574+
"dataset_id": dataset_id,
575+
"source_ordinal": len(variables) + 1,
576+
"source_name": target_name,
577+
"physical_name": target_physical,
578+
"storage_kind": storage_kind,
579+
"declared_string_width": string_width,
580+
"variable_label": None,
581+
}
582+
if mutation_journal is not None:
583+
mutation_journal["added_columns"].append(target_physical)
584+
mutation_journal["target_rows"].append(dict(created_target))
585+
_failure_boundary("schema")
586+
relation = Table(
587+
table_name, MetaData(), schema=dataset.get("physical_table_schema"),
588+
autoload_with=connection,
589+
)
571590
connection.execute(insert(core.variable).values(**created_target))
572591
variables.append(created_target)
573592
by_name[str(created_target["source_name"]).casefold()] = created_target
@@ -647,10 +666,14 @@ def _apply_plan_on_connection(
647666
)
648667
_delete_variable_metadata(connection, core=core, variable=variable)
649668
by_name.pop(operation.variable.casefold(), None)
669+
used_physical.discard(str(variable["physical_name"]).casefold())
650670
variables = [
651671
row for row in variables
652672
if row["variable_id"] != variable["variable_id"]
653673
]
674+
_compact_variable_ordinals(
675+
connection, core=core, variables=variables,
676+
)
654677
relation = Table(
655678
table_name, MetaData(), schema=dataset.get("physical_table_schema"),
656679
autoload_with=connection,

‎tests/test_inplace_transform.py‎

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
InPlacePlanSubmission,
1313
_apply_plan_on_connection,
1414
)
15-
from openstatspec.sql.wide import create_wide_dataset
15+
from openstatspec.sql.wide import create_wide_dataset, validate_wide_dataset
1616

1717

1818
def _variables() -> list[dict[str, object]]:
@@ -172,8 +172,16 @@ def test_string_declaration_creates_column_and_catalog_variable(catalog) -> None
172172
).fetchall() == [("score", "numeric", None), ("note", "string", 8)]
173173
assert connection.execute(
174174
f'SELECT note FROM "{table_name}" ORDER BY __case_ordinal'
175-
).fetchall() == [(None,), (None,), (None,)]
175+
).fetchall() == [("",), ("",), ("",)]
176+
note_column = next(
177+
row for row in connection.execute(f'PRAGMA table_info("{table_name}")')
178+
if row[1] == "note"
179+
)
180+
assert note_column[2:5] == ("TEXT", 1, "''")
176181
connection.close()
182+
assert validate_wide_dataset(
183+
database_url=url, dataset_id=dataset_id,
184+
)["valid"] is True
177185

178186

179187
def test_delete_then_recreate_same_name_resolves_operations_in_order(catalog) -> None:
@@ -191,17 +199,22 @@ def test_delete_then_recreate_same_name_resolves_operations_in_order(catalog) ->
191199

192200
connection = sqlite3.connect(path)
193201
variables = connection.execute(
194-
"select source_name, physical_name from variable where dataset_id = ? "
195-
"order by source_ordinal",
202+
"select source_name, physical_name, source_ordinal from variable "
203+
"where dataset_id = ? order by source_ordinal",
196204
(dataset_id,),
197205
).fetchall()
198-
assert [row[0] for row in variables] == ["other", "score"]
199-
physical = dict(variables)
206+
assert [(row[0], row[2]) for row in variables] == [
207+
("other", 1), ("score", 2),
208+
]
209+
physical = {row[0]: row[1] for row in variables}
200210
assert connection.execute(
201211
f'SELECT "{physical["other"]}", "{physical["score"]}" '
202212
f'FROM "{table_name}" ORDER BY __case_ordinal'
203213
).fetchall() == [(1.0, 1.0), (2.0, 9.0), (3.0, 3.0)]
204214
connection.close()
215+
assert validate_wide_dataset(
216+
database_url=url, dataset_id=dataset_id,
217+
)["valid"] is True
205218

206219

207220
def test_public_apply_supports_non_dolt_without_building_undo(catalog) -> None:

0 commit comments

Comments
 (0)