Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
f4588a5
Remove legacy compatibility catalogs
TonisOrmisson Aug 4, 2026
e09ba6e
Address strict catalog review findings
TonisOrmisson Aug 4, 2026
95b0506
Fix changelog entry
TonisOrmisson Aug 4, 2026
8d55573
Merge remote-tracking branch 'origin/main' into agent/fix-strict-sav-…
TonisOrmisson Aug 4, 2026
9a39125
Normalize database decimal values for strict imports
TonisOrmisson Aug 4, 2026
57dda83
Normalize numeric values at the database read boundary
TonisOrmisson Aug 4, 2026
bd3cb70
Allow documented compatible-name loss in service tests
TonisOrmisson Aug 4, 2026
a817c17
Address strict catalog review findings
TonisOrmisson Aug 4, 2026
600ac98
Harden persistent and bound catalog operations
TonisOrmisson Aug 4, 2026
7c878c5
Bind export audits and fail closed on catalog init
TonisOrmisson Aug 4, 2026
aefd0c8
Enforce strict catalog verification on reads
TonisOrmisson Aug 4, 2026
1bea277
Keep Dolt checks and export events transaction-bound
TonisOrmisson Aug 4, 2026
7ab2394
Separate export lifecycle diagnostics from loss consent
TonisOrmisson Aug 4, 2026
59692c2
Verify Dolt diffs and workflow triggers
TonisOrmisson Aug 4, 2026
f726e06
Honor removed derived relation history
TonisOrmisson Aug 4, 2026
e09dc4e
Add basic variable transformations
TonisOrmisson Aug 4, 2026
f9caa5f
Bind failed import audits to Dolt state
TonisOrmisson Aug 4, 2026
07852a5
Fix sequential schema operation execution
TonisOrmisson Aug 4, 2026
7fce8fa
Preserve strict wide invariants after schema edits
TonisOrmisson Aug 4, 2026
beaf6ae
Prune empty response sets after variable deletion
TonisOrmisson Aug 4, 2026
b52a0c6
Bind cleanup and preflight schema edits
TonisOrmisson Aug 4, 2026
b0b4b98
Preserve canonical schema change contracts
TonisOrmisson Aug 4, 2026
ecebe50
Reject foreign views and prune variable sets
TonisOrmisson Aug 4, 2026
dd7b75c
Handle transient schema create targets
TonisOrmisson Aug 4, 2026
be1ed43
Restore transform star exports
TonisOrmisson Aug 4, 2026
d871658
Version schema-changing SPSS frontend submissions
TonisOrmisson Aug 4, 2026
d4f2ec0
Verify catalog before failed import audits
TonisOrmisson Aug 4, 2026
3e55e51
Reject Dolt audit relation schema diffs
TonisOrmisson Aug 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 14 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,18 @@ All notable changes to this reference implementation are documented here.
## Unreleased

Planned adapter release: `0.5.0`, after lifecycle integration and final specification conformance.
### Fixed

- Removed the temporary `*_catalog` compatibility schema and made SAV/ZSAV
import, validation, export, fidelity reporting, and in-place metadata edits
use the normative UUID-keyed OpenStatSpec catalog exclusively.
- Existing databases that contain former compatibility relations must be
remediated manually before further operations.
- Imports now reject dataset names that collide with normative UUID identifiers,
cleanup drops only a physical table actually created by the failing import,
and non-preserved SPSS compatible variable names are reported as an explicit
loss requiring export consent.


### Added

Expand All @@ -24,8 +36,8 @@ Planned adapter release: `0.5.0`, after lifecycle integration and final specific

- Bumped the canonical transformation-plan and SPSS frontend contracts to
`v0.2`; canonical JSON and hashes include every sequential operation.
- In-place apply now records variable label, value labels, `F` print/write
format, and measurement level in both normative and compatibility catalogs.
- In-place apply records variable label, value labels, `F` print/write
format, and measurement level only in the normative catalog.
- Dolt still requires an exact branch, exact HEAD, and clean working set;
successful apply leaves an inspectable diff and never calls `DOLT_COMMIT`.
- Dolt declaration validation is implemented by the Python adapter. The
Expand Down
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,10 @@ For each supported import, one source dataset becomes one dedicated wide SQL
table. Cases are rows and source variables are physical SQL columns. The
singular UUID-keyed tables from the specification (`dataset`, `variable`,
`operation`, `fidelity_event`, and related metadata tables) are the public
catalog contract. Historical `*_catalog` tables are an internal compatibility
layer for the current exporter and are not the standard database interface.
The adapter does not reshape data, create EAV
or long-form tables, or harmonize studies or waves.
catalog contract and the only catalog representation created or read by the
adapter. Databases created with the former `*_catalog` compatibility schema
must be remediated to the normative schema before use. The adapter does not
reshape data, create EAV or long-form tables, or harmonize studies or waves.

Unsupported source features, SQL targets, or export paths fail explicitly.
There is no silent truncation, type conversion, metadata loss, or partial
Expand Down
4 changes: 2 additions & 2 deletions docs/release-readiness.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,8 @@ The gate must prove that:
- the exact bounded `COMPUTE`/`IF` program compiles to all seven ordered
operations without dropping `FORMATS`, `VARIABLE LEVEL`, or `EXECUTE`;
- boolean data results match the equivalent expression and the target's label,
0/1 value labels, `F1.0` print/write format, and nominal level exist in both
normative and compatibility catalogs;
0/1 value labels, `F1.0` print/write format, and nominal level exist in the
normative catalog;
- injected schema, data, catalog, and audit failures leave no partial apply;
- compensation tracks only newly created targets and never drops or rewrites a
pre-existing target;
Expand Down
10 changes: 5 additions & 5 deletions docs/transformations.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,15 +141,15 @@ schema/table identity. It creates no derived dataset, output table, full-table
copy, staging table, snapshot, rollback artifact, or recovery/history layer.
Assignments and recodes use ordered `UPDATE` statements; later operations see
earlier results. Label, value-label, format, and measurement-level operations
update both the normative and compatibility catalogs.
update the normative catalog.

A numeric create target is supported atomically on SQLite and PostgreSQL.
MySQL, MariaDB, and Dolt reject `target_mode=create` before mutation. On those
profiles a separate versioned stage must first provision the nullable numeric
physical column and both catalog representations; the transformation executor
then sees a pre-existing target and performs no schema DDL.
The public operation reports success only after physical data, both metadata
representations, and the compact audit row are mutually complete.
physical column and normative variable row; the transformation executor then
sees a pre-existing target and performs no schema DDL.
The public operation reports success only after physical data, normative
metadata, and the compact audit row are mutually complete.

Before Dolt mutation, the executor verifies the expected branch and `HEAD` and
requires clean `dolt_status`. Success changes the same working set without
Expand Down
5 changes: 3 additions & 2 deletions src/openstatspec/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@
from .sql.workflow import TransformationError
from .transform import (
AssignOperation, BooleanExpression, ComparisonExpression,
ConditionalAssignOperation, ExecuteOperation, Operand, PredicateExpression,
ConditionalAssignOperation, CreateVariableOperation, DeleteVariableOperation,
ExecuteOperation, Operand, PredicateExpression,
RecodeMatch, RecodeOperation, RecodeResult, RecodeRule,
ReplaceValueLabelsOperation, SetFormatOperation,
SetMeasurementLevelOperation, SetVariableLabelOperation,
Expand All @@ -26,7 +27,7 @@
__all__ = [
"AssignOperation", "BooleanExpression", "ComparisonExpression",
"ConditionalAssignOperation", "ExecuteOperation", "Operand",
"PredicateExpression",
"PredicateExpression", "CreateVariableOperation", "DeleteVariableOperation",
"CapabilityDeclaration", "DoltConformanceSource", "LossReport",
"SpssFrontendCompilation", "TransformationError",
"TransformationFrontendError",
Expand Down
2 changes: 1 addition & 1 deletion src/openstatspec/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ def capability_matrix(
"multiple_response_sets": "supported",
"variable_alignment": "supported",
"variable_sets": "supported",
"compatible_variable_names": "supported",
"compatible_variable_names": "requires-explicit-loss-consent",
"custom_attributes": {
"scalar_values": "supported",
"ordered_value_arrays": "supported",
Expand Down
10 changes: 7 additions & 3 deletions src/openstatspec/frontends/spss/__init__.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
"""SPSS syntax frontend for canonical OpenStatSpec transformation plans."""

from .binding import bind_spss_syntax
from .compiler import SpssFrontendCompilation, compile_spss_syntax
from .compiler import (
SPSS_FRONTEND_CONTRACT,
SPSS_FRONTEND_SCHEMA_CHANGE_CONTRACT,
SpssFrontendCompilation,
compile_spss_syntax,
)
from .syntax import (
SpssSyntaxProgram,
normalize_spss_source,
Expand All @@ -11,10 +16,9 @@
)


SPSS_FRONTEND_CONTRACT = "openstatspec-spss-syntax-frontend-v0.2"

__all__ = [
"SPSS_FRONTEND_CONTRACT",
"SPSS_FRONTEND_SCHEMA_CHANGE_CONTRACT",
"SpssFrontendCompilation",
"SpssSyntaxProgram",
"bind_spss_syntax",
Expand Down
66 changes: 58 additions & 8 deletions src/openstatspec/frontends/spss/binding.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,12 @@
from ...transform.errors import SourceSpan, frontend_error
from ...transform.plan import (
AssignOperation, BooleanExpression, ComparisonExpression,
ConditionalAssignOperation, ExecuteOperation, Operand, PlanOperation,
ConditionalAssignOperation, CreateVariableOperation, DeleteVariableOperation,
ExecuteOperation, Operand, PlanOperation,
PredicateExpression, RecodeMatch, RecodeOperation, RecodeResult, RecodeRule,
ReplaceValueLabelsOperation, SetFormatOperation,
SetMeasurementLevelOperation, SetVariableLabelOperation,
TRANSFORMATION_PLAN_SCHEMA_CHANGE_CONTRACT,
TRANSFORMATION_PLAN_V1_CONTRACT,
TransformationPlan, TypedValue, ValueLabel,
)
Expand All @@ -21,10 +23,11 @@
from ...transform.validation import bind_transformation_plan
from .syntax import (
BooleanSyntax, ComparisonSyntax, ComputeCommandSyntax, ExecuteCommandSyntax,
FormatsCommandSyntax, IfCommandSyntax, OperandSyntax, PredicateSyntax,
DeleteVariablesCommandSyntax, FormatsCommandSyntax, IfCommandSyntax,
OperandSyntax, PredicateSyntax,
RecodeCommandSyntax, RecodeMatchSyntax, RecodeResultSyntax,
SpssSyntaxProgram, SyntaxLiteral, ValueLabelsCommandSyntax,
VariableLabelsCommandSyntax, VariableLevelCommandSyntax,
StringCommandSyntax, VariableLabelsCommandSyntax, VariableLevelCommandSyntax,
)


Expand Down Expand Up @@ -290,8 +293,6 @@ def _bind_recode(
# replace intentionally preserves the existing variable metadata. A later
# VALUE LABELS command replaces value labels explicitly.
return operations, spans


def bind_spss_syntax(
program: SpssSyntaxProgram, schema: VariableSchema, *, input_alias: str = "parent",
) -> BoundTransformation:
Expand All @@ -305,6 +306,50 @@ def bind_spss_syntax(
operations: list[PlanOperation] = []
spans: list[SourceSpan] = []
for command in program.commands:
if isinstance(command, StringCommandSyntax):
for variable_token in command.variables:
if variable_token.text.startswith("__"):
raise frontend_error(
"reserved_target_name",
f"Target name {variable_token.text!r} is reserved.",
span=variable_token.span,
target=variable_token.text,
)
if any(
variable.name.casefold() == variable_token.text.casefold()
for variable in variables
):
raise frontend_error(
"target_already_exists",
f"Target name {variable_token.text!r} already exists.",
span=variable_token.span,
target=variable_token.text,
)
operations.append(CreateVariableOperation(
variable_token.text, "string", command.width,
))
spans.append(command.span)
variables.append(VariableDefinition(
variable_token.text, "string",
declared_string_width=command.width,
))
continue
if isinstance(command, DeleteVariablesCommandSyntax):
for variable_token in command.variables:
index, variable = _resolve(
variables, variable_token.text, variable_token.span,
)
if len(variables) == 1:
raise frontend_error(
"cannot_delete_last_variable",
"DELETE VARIABLES cannot remove the final dataset variable.",
span=variable_token.span,
variable=variable.name,
)
operations.append(DeleteVariableOperation(variable.name))
spans.append(command.span)
del variables[index]
continue
if isinstance(command, RecodeCommandSyntax):
recodes, recode_spans = _bind_recode(command, variables)
operations.extend(recodes)
Expand Down Expand Up @@ -447,10 +492,15 @@ def bind_spss_syntax(
v01_types = (
RecodeOperation, SetVariableLabelOperation, ReplaceValueLabelsOperation,
)
schema_change_types = (CreateVariableOperation, DeleteVariableOperation)
contract = (
TRANSFORMATION_PLAN_V1_CONTRACT
if all(isinstance(operation, v01_types) for operation in operations)
else "openstatspec-transformation-plan-v0.2"
TRANSFORMATION_PLAN_SCHEMA_CHANGE_CONTRACT
if any(isinstance(operation, schema_change_types) for operation in operations)
Comment thread
TonisOrmisson marked this conversation as resolved.
else (
TRANSFORMATION_PLAN_V1_CONTRACT
if all(isinstance(operation, v01_types) for operation in operations)
else "openstatspec-transformation-plan-v0.2"
)
)
plan = TransformationPlan(
tuple(operations), contract=contract, input_alias=input_alias,
Expand Down
15 changes: 14 additions & 1 deletion src/openstatspec/frontends/spss/compiler.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@

from dataclasses import dataclass

from ...transform.plan import TransformationPlan
from ...transform.plan import (
TRANSFORMATION_PLAN_SCHEMA_CHANGE_CONTRACT,
TransformationPlan,
)
from ...transform.schema import BoundTransformation, VariableSchema
from .binding import bind_spss_syntax
from .syntax import (
Expand All @@ -14,6 +17,10 @@
)


SPSS_FRONTEND_CONTRACT = "openstatspec-spss-syntax-frontend-v0.2"
SPSS_FRONTEND_SCHEMA_CHANGE_CONTRACT = "openstatspec-spss-syntax-frontend-v0.3"


@dataclass(frozen=True)
class SpssFrontendCompilation:
"""One source artifact and its fully bound canonical plan."""
Expand All @@ -30,6 +37,12 @@ def plan(self) -> TransformationPlan:
def plan_hash(self) -> str:
return self.plan.sha256()

@property
def frontend_contract(self) -> str:
if self.plan.contract == TRANSFORMATION_PLAN_SCHEMA_CHANGE_CONTRACT:
return SPSS_FRONTEND_SCHEMA_CHANGE_CONTRACT
return SPSS_FRONTEND_CONTRACT


def compile_spss_syntax(
source: str,
Expand Down
3 changes: 1 addition & 2 deletions src/openstatspec/frontends/spss/execution.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@
_run_in_place_submission,
load_transformation_schema,
)
from . import SPSS_FRONTEND_CONTRACT
from .compiler import compile_spss_syntax


Expand All @@ -37,7 +36,7 @@ def prepare(connection: Any, live_dataset_id: str) -> InPlacePlanSubmission:
plan=compilation.plan,
source_kind="spss_syntax",
source_hash=compilation.source_hash,
frontend_contract=SPSS_FRONTEND_CONTRACT,
frontend_contract=compilation.frontend_contract,
)

return _run_in_place_submission(
Expand Down
55 changes: 55 additions & 0 deletions src/openstatspec/frontends/spss/syntax.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,19 @@ class ExecuteCommandSyntax:
span: SourceSpan


@dataclass(frozen=True)
class StringCommandSyntax:
variables: tuple[Token, ...]
width: int
span: SourceSpan


@dataclass(frozen=True)
class DeleteVariablesCommandSyntax:
variables: tuple[Token, ...]
span: SourceSpan



@dataclass(frozen=True)
class VariableLabelSyntax:
Expand Down Expand Up @@ -175,6 +188,7 @@ class ValueLabelsCommandSyntax:
RecodeCommandSyntax | ComputeCommandSyntax | IfCommandSyntax
| VariableLabelsCommandSyntax | ValueLabelsCommandSyntax
| FormatsCommandSyntax | VariableLevelCommandSyntax | ExecuteCommandSyntax
| StringCommandSyntax | DeleteVariablesCommandSyntax
)


Expand Down Expand Up @@ -549,6 +563,43 @@ def execute(self, start: Token) -> ExecuteCommandSyntax:
end = self.expects("period", "Expected '.' after EXECUTE.")
return ExecuteCommandSyntax(_joined_span(start.span, end.span))

def string(self, start: Token) -> StringCommandSyntax:
variables = self.variable_list(
stop_kinds=frozenset({"left_paren", "period", "eof"}),
)
self.expects("left_paren", "Expected '(' before a STRING width.")
width_token = self.expects(
"identifier", "STRING requires a width such as A20.",
)
match = re.fullmatch(r"A([0-9]+)", width_token.text, re.IGNORECASE)
if match is None:
raise frontend_error(
"spss_syntax_error",
"STRING supports only character widths such as A20.",
span=width_token.span,
)
width = int(match.group(1))
if not 1 <= width <= 32767:
raise frontend_error(
"invalid_string_width",
"STRING width must be between 1 and 32767.",
span=width_token.span,
width=width,
)
self.expects("right_paren", "Expected ')' after a STRING width.")
end = self.expects("period", "Expected '.' after STRING.")
return StringCommandSyntax(
variables, width, _joined_span(start.span, end.span),
)

def delete_variables(self, start: Token) -> DeleteVariablesCommandSyntax:
self.expects_keyword("VARIABLES")
variables = self.variable_list(stop_kinds=frozenset({"period", "eof"}))
end = self.expects("period", "Expected '.' after DELETE VARIABLES.")
return DeleteVariablesCommandSyntax(
variables, _joined_span(start.span, end.span),
)


def recode_result(self) -> RecodeResultSyntax:
if (token := self.accepts_keyword("SYSMIS")) is not None:
Expand Down Expand Up @@ -692,6 +743,10 @@ def parse(self) -> SpssSyntaxProgram:
commands.append(self.formats(start))
elif command == "execute":
commands.append(self.execute(start))
elif command == "string":
commands.append(self.string(start))
elif command == "delete":
commands.append(self.delete_variables(start))
elif command == "variable":
if self.current.kind == "identifier" and self.current.text.casefold() == "level":
commands.append(self.variable_level(start))
Expand Down
Loading