Skip to content

Commit 0cff81a

Browse files
Add in-place SPSS schema transformations
Add in-place SPSS schema transformations
2 parents 37a9622 + 2c12ba8 commit 0cff81a

9 files changed

Lines changed: 322 additions & 66 deletions

File tree

‎src/openstatspec/frontends/spss/binding.py‎

Lines changed: 39 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,41 @@ def _result_type(
216216
return result.value.type
217217

218218

219+
def _validate_recode_string_width(
220+
result: RecodeResult,
221+
source: VariableDefinition,
222+
target: VariableDefinition,
223+
span: SourceSpan,
224+
) -> None:
225+
if target.storage_kind != "string" or target.declared_string_width is None:
226+
return
227+
if result.kind == "literal":
228+
assert result.value is not None
229+
if result.value.type != "string":
230+
return
231+
assert isinstance(result.value.value, str)
232+
if len(result.value.value.encode("utf-8")) > target.declared_string_width:
233+
raise frontend_error(
234+
"string_width_exceeded",
235+
"A RECODE string literal exceeds the target's declared string width.",
236+
span=span,
237+
variable=target.name,
238+
declared_string_width=target.declared_string_width,
239+
)
240+
return
241+
if result.kind == "copy" and source.storage_kind == "string":
242+
source_width = source.declared_string_width
243+
if source_width is None or source_width > target.declared_string_width:
244+
raise frontend_error(
245+
"string_width_exceeded",
246+
"A RECODE COPY result can exceed the target's declared string width.",
247+
span=span,
248+
source=source.name,
249+
variable=target.name,
250+
declared_string_width=target.declared_string_width,
251+
)
252+
253+
219254
def _bind_recode(
220255
command: RecodeCommandSyntax, variables: list[VariableDefinition],
221256
) -> tuple[list[RecodeOperation], list[SourceSpan]]:
@@ -252,13 +287,17 @@ def _bind_recode(
252287
else_result: RecodeResult | None = None
253288
for clause in command.clauses:
254289
result = _result(clause.result, source)
290+
if target_mode == "replace":
291+
_validate_recode_string_width(result, source, source, clause.result.span)
255292
if clause.match.kind == "else":
256293
else_result = result
257294
continue
258295
rules.append(RecodeRule(_match(clause.match, source), result))
259296
unmatched = else_result or RecodeResult(
260297
"system_missing" if target_mode == "create" else "copy"
261298
)
299+
if target_mode == "replace" and else_result is None:
300+
_validate_recode_string_width(unmatched, source, source, command.span)
262301
result_types = {
263302
_result_type(result, source)
264303
for result in [*(rule.result for rule in rules), unmatched]
@@ -339,13 +378,6 @@ def bind_spss_syntax(
339378
index, variable = _resolve(
340379
variables, variable_token.text, variable_token.span,
341380
)
342-
if len(variables) == 1:
343-
raise frontend_error(
344-
"cannot_delete_last_variable",
345-
"DELETE VARIABLES cannot remove the final dataset variable.",
346-
span=variable_token.span,
347-
variable=variable.name,
348-
)
349381
operations.append(DeleteVariableOperation(variable.name))
350382
spans.append(command.span)
351383
del variables[index]

‎src/openstatspec/frontends/spss/syntax.py‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -563,10 +563,24 @@ def execute(self, start: Token) -> ExecuteCommandSyntax:
563563
end = self.expects("period", "Expected '.' after EXECUTE.")
564564
return ExecuteCommandSyntax(_joined_span(start.span, end.span))
565565

566+
@staticmethod
567+
def reject_to_range(variables: tuple[Token, ...], command: str) -> None:
568+
range_token = next(
569+
(variable for variable in variables if variable.text.casefold() == "to"),
570+
None,
571+
)
572+
if range_token is not None:
573+
raise frontend_error(
574+
"unsupported_spss_feature",
575+
f"{command} variable ranges using TO are not supported.",
576+
span=range_token.span,
577+
)
578+
566579
def string(self, start: Token) -> StringCommandSyntax:
567580
variables = self.variable_list(
568581
stop_kinds=frozenset({"left_paren", "period", "eof"}),
569582
)
583+
self.reject_to_range(variables, "STRING")
570584
self.expects("left_paren", "Expected '(' before a STRING width.")
571585
width_token = self.expects(
572586
"identifier", "STRING requires a width such as A20.",
@@ -595,6 +609,7 @@ def string(self, start: Token) -> StringCommandSyntax:
595609
def delete_variables(self, start: Token) -> DeleteVariablesCommandSyntax:
596610
self.expects_keyword("VARIABLES")
597611
variables = self.variable_list(stop_kinds=frozenset({"period", "eof"}))
612+
self.reject_to_range(variables, "DELETE VARIABLES")
598613
end = self.expects("period", "Expected '.' after DELETE VARIABLES.")
599614
return DeleteVariablesCommandSyntax(
600615
variables, _joined_span(start.span, end.span),

‎src/openstatspec/sql/inplace_transform.py‎

Lines changed: 28 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -389,22 +389,30 @@ def _delete_variable_metadata(
389389
))
390390

391391

392-
def _compact_variable_ordinals(
392+
def _assert_postgresql_column_slot_available(
393393
connection: Any,
394394
*,
395-
core: Any,
396-
variables: list[dict[str, Any]],
395+
relation: Table,
396+
target_profile: SqlProfile,
397397
) -> None:
398-
"""Keep normative variable source order contiguous after deletion."""
399-
for source_ordinal, variable in enumerate(variables, start=1):
400-
if int(variable["source_ordinal"]) == source_ordinal:
401-
continue
402-
connection.execute(
403-
update(core.variable)
404-
.where(core.variable.c.variable_id == variable["variable_id"])
405-
.values(source_ordinal=source_ordinal)
398+
"""Reject PostgreSQL creates when dropped columns exhaust attribute slots."""
399+
if connection.dialect.name != "postgresql":
400+
return
401+
qualified_relation = connection.dialect.identifier_preparer.format_table(relation)
402+
attribute_slots = connection.execute(text(
403+
"SELECT COUNT(*) FROM pg_attribute "
404+
"WHERE attrelid = to_regclass(:relation_name) AND attnum > 0"
405+
), {"relation_name": qualified_relation}).scalar_one()
406+
if not isinstance(attribute_slots, int) or attribute_slots < 1:
407+
raise TransformationError(
408+
"physical_table_missing",
409+
"The target dataset's physical wide table does not exist.",
410+
)
411+
if attribute_slots >= target_profile.max_source_variables + 1:
412+
raise TransformationError(
413+
"source_variable_limit",
414+
"PostgreSQL physical column slots are exhausted for this dataset table.",
406415
)
407-
variable["source_ordinal"] = source_ordinal
408416

409417

410418
def _failure_boundary(_name: str) -> None:
@@ -555,6 +563,9 @@ def _apply_plan_on_connection(
555563
)
556564
by_name = {str(row["source_name"]).casefold(): row for row in variables}
557565
used_physical = {str(row["physical_name"]).casefold() for row in variables}
566+
next_source_ordinal = max(
567+
(int(row["source_ordinal"]) for row in variables), default=0,
568+
) + 1
558569
quote = connection.dialect.identifier_preparer.quote
559570
qualified_table = connection.dialect.identifier_preparer.format_table(relation)
560571
numeric_type = (
@@ -603,6 +614,9 @@ def _apply_plan_on_connection(
603614
)
604615
)
605616
if creates_target:
617+
_assert_postgresql_column_slot_available(
618+
connection, relation=relation, target_profile=target_profile,
619+
)
606620
target_name = (
607621
operation.variable
608622
if isinstance(operation, CreateVariableOperation)
@@ -631,7 +645,7 @@ def _apply_plan_on_connection(
631645
created_target = {
632646
"variable_id": str(uuid4()),
633647
"dataset_id": dataset_id,
634-
"source_ordinal": len(variables) + 1,
648+
"source_ordinal": next_source_ordinal,
635649
"source_name": target_name,
636650
"physical_name": target_physical,
637651
"storage_kind": storage_kind,
@@ -648,6 +662,7 @@ def _apply_plan_on_connection(
648662
)
649663
connection.execute(insert(core.variable).values(**created_target))
650664
variables.append(created_target)
665+
next_source_ordinal += 1
651666
by_name[str(created_target["source_name"]).casefold()] = created_target
652667
_failure_boundary("catalog")
653668
if isinstance(operation, CreateVariableOperation):
@@ -725,36 +740,10 @@ def _apply_plan_on_connection(
725740
)
726741
_delete_variable_metadata(connection, core=core, variable=variable)
727742
by_name.pop(operation.variable.casefold(), None)
728-
used_physical.discard(str(variable["physical_name"]).casefold())
729743
variables = [
730744
row for row in variables
731745
if row["variable_id"] != variable["variable_id"]
732746
]
733-
_compact_variable_ordinals(
734-
connection, core=core, variables=variables,
735-
)
736-
canonical_physical = {"__case_ordinal"}
737-
for remaining_variable in variables:
738-
expected_physical = physical_name(
739-
str(remaining_variable["source_name"]), canonical_physical,
740-
)
741-
current_physical = str(remaining_variable["physical_name"])
742-
if current_physical == expected_physical:
743-
continue
744-
connection.exec_driver_sql(
745-
f"ALTER TABLE {qualified_table} RENAME COLUMN "
746-
f"{quote(current_physical)} TO {quote(expected_physical)}"
747-
)
748-
connection.execute(
749-
update(core.variable)
750-
.where(
751-
core.variable.c.variable_id
752-
== remaining_variable["variable_id"]
753-
)
754-
.values(physical_name=expected_physical)
755-
)
756-
remaining_variable["physical_name"] = expected_physical
757-
used_physical = canonical_physical
758747
relation = Table(
759748
table_name, MetaData(), schema=dataset.get("physical_table_schema"),
760749
autoload_with=connection,

‎src/openstatspec/sql/profiles.py‎

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@ def preflight(
124124
variables_or_count: int | Iterable[Mapping[str, Any]],
125125
*,
126126
rows: Iterable[Mapping[str, Any]] | None = None,
127+
require_canonical_mapping: bool = True,
127128
) -> None:
128129
"""Validate strict target capabilities before any source dataset is created."""
129130
variables = None if isinstance(variables_or_count, int) else list(variables_or_count)
@@ -143,6 +144,7 @@ def preflight(
143144

144145
used = {"__case_ordinal"}
145146
source_names: set[str] = set()
147+
physical_names = {"__case_ordinal"}
146148
for expected_ordinal, variable in enumerate(variables, start=1):
147149
source_name = variable.get("source_name")
148150
if not isinstance(source_name, str) or not source_name or source_name in source_names:
@@ -151,18 +153,36 @@ def preflight(
151153
source_name=source_name,
152154
)
153155
source_names.add(source_name)
154-
expected_name = _physical_name(source_name, used)
155156
actual_name = variable.get("physical_name")
156-
if variable.get("ordinal") != expected_ordinal or actual_name != expected_name:
157+
if not isinstance(actual_name, str) or not actual_name:
157158
raise _exceeded(
158159
"physical_identifier_mapping_invalid",
159-
f"{source_name!r} must map deterministically to {expected_name!r} in source order.",
160-
source_name=source_name, expected_physical_name=expected_name,
161-
actual_physical_name=actual_name,
160+
f"{source_name!r} has no physical variable identifier.",
161+
source_name=source_name, actual_physical_name=actual_name,
162+
)
163+
if actual_name.casefold() in physical_names:
164+
raise _exceeded(
165+
"physical_identifier_collision",
166+
"physical variable identifiers must be unique.",
167+
source_name=source_name, physical_name=actual_name,
168+
)
169+
physical_names.add(actual_name.casefold())
170+
if require_canonical_mapping:
171+
expected_name = _physical_name(source_name, used)
172+
if variable.get("ordinal") != expected_ordinal or actual_name != expected_name:
173+
raise _exceeded(
174+
"physical_identifier_mapping_invalid",
175+
f"{source_name!r} must map deterministically to {expected_name!r} in source order.",
176+
source_name=source_name, expected_physical_name=expected_name,
177+
actual_physical_name=actual_name,
178+
)
179+
preflight_identifier(
180+
profile, expected_name, role="physical variable identifier",
181+
)
182+
else:
183+
preflight_identifier(
184+
profile, actual_name, role="physical variable identifier",
162185
)
163-
preflight_identifier(
164-
profile, expected_name, role="physical variable identifier",
165-
)
166186
if variable.get("storage_kind") == "string":
167187
declared_width = variable.get("string_width")
168188
if declared_width is not None and (

‎src/openstatspec/sql/wide.py‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1239,7 +1239,9 @@ def read_wide_dataset(
12391239
)
12401240
for row in response_sets
12411241
}, ensure_ascii=False)
1242-
preflight(profile, variables, rows=rows)
1242+
preflight(
1243+
profile, variables, rows=rows, require_canonical_mapping=False,
1244+
)
12431245
return dataset, variables, rows
12441246

12451247

@@ -1705,7 +1707,9 @@ def validate_wide_dataset(
17051707
database_url=database_url, dataset_id=dataset_id, profile=profile,
17061708
dolt_conformance_source=dolt_conformance_source,
17071709
)
1708-
preflight(profile, variables, rows=rows)
1710+
preflight(
1711+
profile, variables, rows=rows, require_canonical_mapping=False,
1712+
)
17091713
validate_spss_catalog(
17101714
variables,
17111715
case_weight_variable=dataset.get("case_weight_variable"),

‎src/openstatspec/transform/validation.py‎

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,38 @@ def _result_type(
100100
return result.value.type
101101

102102

103+
def _validate_recode_string_width(
104+
result: RecodeResult,
105+
source: VariableDefinition,
106+
target: VariableDefinition,
107+
) -> None:
108+
if target.storage_kind != "string" or target.declared_string_width is None:
109+
return
110+
if result.kind == "literal":
111+
assert result.value is not None
112+
if result.value.type != "string":
113+
return
114+
assert isinstance(result.value.value, str)
115+
if len(result.value.value.encode("utf-8")) > target.declared_string_width:
116+
raise frontend_error(
117+
"string_width_exceeded",
118+
"A RECODE string literal exceeds the target's declared string width.",
119+
variable=target.name,
120+
declared_string_width=target.declared_string_width,
121+
)
122+
return
123+
if result.kind == "copy" and source.storage_kind == "string":
124+
source_width = source.declared_string_width
125+
if source_width is None or source_width > target.declared_string_width:
126+
raise frontend_error(
127+
"string_width_exceeded",
128+
"A RECODE COPY result can exceed the target's declared string width.",
129+
source=source.name,
130+
variable=target.name,
131+
declared_string_width=target.declared_string_width,
132+
)
133+
134+
103135
def _bind_recode(
104136
operation: RecodeOperation, variables: list[VariableDefinition]
105137
) -> None:
@@ -122,6 +154,10 @@ def _bind_recode(
122154
)
123155
for rule in operation.rules:
124156
_validate_match(rule.match, source)
157+
if operation.target_mode == "replace":
158+
_validate_recode_string_width(rule.result, source, source)
159+
if operation.target_mode == "replace":
160+
_validate_recode_string_width(operation.unmatched, source, source)
125161
result_types = {
126162
_result_type(result, source)
127163
for result in [
@@ -282,12 +318,16 @@ def bind_transformation_plan(
282318
if not isinstance(schema, VariableSchema):
283319
raise TypeError("schema must be a VariableSchema.")
284320
variables = list(schema.variables)
285-
for operation in plan.operations:
321+
for operation_index, operation in enumerate(plan.operations):
322+
later_create = any(
323+
isinstance(later_operation, CreateVariableOperation)
324+
for later_operation in plan.operations[operation_index + 1:]
325+
)
286326
if isinstance(operation, CreateVariableOperation):
287327
_bind_create(operation, variables)
288328
continue
289329
if isinstance(operation, DeleteVariableOperation):
290-
_bind_delete(operation, variables)
330+
_bind_delete(operation, variables, allow_empty=later_create)
291331
continue
292332
if isinstance(operation, RecodeOperation):
293333
_bind_recode(operation, variables)
@@ -367,9 +407,11 @@ def _bind_create(
367407
def _bind_delete(
368408
operation: DeleteVariableOperation,
369409
variables: list[VariableDefinition],
410+
*,
411+
allow_empty: bool,
370412
) -> None:
371413
index, variable = _resolve(variables, operation.variable)
372-
if len(variables) == 1:
414+
if len(variables) == 1 and not allow_empty:
373415
raise frontend_error(
374416
"cannot_delete_last_variable",
375417
"A dataset must retain at least one variable.",

0 commit comments

Comments
 (0)