Skip to content

Commit 8b40b32

Browse files
committed
Merge remote-tracking branch 'origin/main' into agent/spss-vls-compatible-names
2 parents 8291abb + 034ce47 commit 8b40b32

14 files changed

Lines changed: 4330 additions & 16 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ jobs:
2020
uses: actions/checkout@v7
2121
with: &specification-checkout
2222
repository: OpenStatSpec/specification
23-
ref: 6b9d1fc38f2f083c0ac5cf1c64874a6d07b95045
23+
ref: 7edcad38470fffebfe2306f22fdecb6892f8eece
2424
path: openstatspec-specification
2525
- name: Checkout required SPSS engine
2626
uses: actions/checkout@v7

‎.github/workflows/release.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ jobs:
3939
uses: actions/checkout@v7
4040
with:
4141
repository: OpenStatSpec/specification
42-
ref: 6b9d1fc38f2f083c0ac5cf1c64874a6d07b95045
42+
ref: 7edcad38470fffebfe2306f22fdecb6892f8eece
4343
path: openstatspec-specification
4444
- name: Checkout required SPSS engine
4545
uses: actions/checkout@v7

‎README.md‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,20 @@ openstatspec import responses.sav --database-url postgresql+psycopg://... --data
4141
openstatspec export --database-url postgresql+psycopg://... --dataset-id responses-2026 --output responses-roundtrip.sav
4242
```
4343

44+
## Optional database-first SQL workflow
45+
46+
Imported datasets remain immutable source records. The optional SQL
47+
transformation profile can register versioned, parameterized SQLite SELECT queries,
48+
materialize results, record lineage and weights, and expose
49+
derived datasets through a public catalog API. It uses a separate profile
50+
catalog and never presents SQL output as an imported source dataset.
51+
Workflow operations support SQLite only in this milestone and fail closed on
52+
PostgreSQL/MySQL/MariaDB; core import/export database support is unchanged.
53+
54+
See [the SQL transformation workflow](docs/sql-transformation-workflow.md) for
55+
Python and CLI examples, migration behavior, hashing, atomicity, and the exact
56+
implemented capability boundary.
57+
4458
## 0.1.0 support status
4559

4660
The adapter requires `openstatspec-pyspssio==0.5.1.post2` as its sole SPSS

‎docs/architecture.md‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,19 @@ the database dataset and writes a supported SPSS file.
1212

1313
No layer may silently substitute EAV, long-form views, JSON, pivots, automatic
1414
harmonization, truncation, or partial import for the standard's strict mapping.
15+
16+
17+
## Optional SQL transformation profile
18+
19+
`openstatspec.sql.workflow` layers a separate, independently identified
20+
catalog beside the core contract. It owns immutable transformation
21+
definitions/versions, driver-bound runs, input snapshots, derived relations,
22+
variables, lineage, weights, safe events, and append-only disposition events.
23+
Its executable backend is SQLite only: foreign keys, transactional staging,
24+
scope-aware AST validation, and the SQLite authorizer form one tested security
25+
and atomicity boundary. Other core database profiles do not imply workflow
26+
support.
27+
It may read validated core or published derived datasets but never writes a SQL
28+
result into the source-faithful core `dataset` relation. Read-only discovery
29+
is provided by `openstatspec.sql.catalog_api`; historical `*_catalog`
30+
relations remain private compatibility state.
Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
# SQL transformation workflow
2+
3+
OpenStatSpec's optional SQL workflow keeps imported source datasets immutable.
4+
A SQL result is recorded in the separate
5+
`openstatspec-sql-transformation-workflow-v0.1` catalog and is never inserted
6+
into the source-faithful core `dataset` relation.
7+
8+
This milestone implements one workflow backend: SQLite. PostgreSQL,
9+
MySQL, and MariaDB remain supported by the core import/export catalog, but SQL
10+
workflow registration, execution, derived-catalog access, and cleanup fail
11+
closed with `dialect_not_supported` on those engines.
12+
13+
## Public API
14+
15+
First obtain the UUID of a core dataset. The import API's caller-supplied name
16+
is not a public catalog UUID.
17+
18+
```python
19+
from openstatspec import list_datasets
20+
21+
catalog = list_datasets(
22+
database_url="sqlite:///survey.sqlite",
23+
kind="core",
24+
)
25+
parent_id = catalog["datasets"][0]["dataset_id"]
26+
```
27+
28+
Register and execute a materialized transformation:
29+
30+
```python
31+
from openstatspec import derive_sql_dataset
32+
33+
derived = derive_sql_dataset(
34+
database_url="sqlite:///survey.sqlite",
35+
parent_dataset_id=parent_id,
36+
transformation_name="eligible_responses",
37+
query_sql="""
38+
SELECT respondent_id, weight, answer
39+
FROM parent
40+
WHERE age >= :minimum_age
41+
ORDER BY respondent_id ASC NULLS LAST
42+
""",
43+
parameters={"minimum_age": 18},
44+
columns=[
45+
{
46+
"name": "respondent_id",
47+
"storage_kind": "numeric",
48+
"source": "respondent_id",
49+
"lineage_kind": "identity",
50+
},
51+
{
52+
"name": "weight",
53+
"storage_kind": "numeric",
54+
"source": "weight",
55+
"lineage_kind": "identity",
56+
},
57+
{
58+
"name": "answer",
59+
"storage_kind": "numeric",
60+
"source": "answer",
61+
"lineage_kind": "identity",
62+
},
63+
],
64+
weight_variable="weight",
65+
row_semantics="filter",
66+
metadata_policy="declared",
67+
)
68+
```
69+
70+
The same operation can be split into
71+
`register_sql_transformation(...)` and
72+
`execute_sql_transformation(...)`. Re-registering the same stable name and
73+
unchanged definition returns the existing immutable version. Changing that
74+
definition publishes the next version number. Re-executing one version with
75+
identical parameters and input snapshot returns the existing derived dataset
76+
only after rechecking its disposition, physical-table presence, immutable
77+
triggers, and content hash. Retired or removed results fail with
78+
`derived_unavailable`; drifted results fail with `derived_corrupt`.
79+
The same centralized integrity check runs before `validate_derived(...)` and
80+
before a published derived table can become another transformation's parent;
81+
large-table snapshot hashing remains streaming and is reused within that call.
82+
83+
Use `get_dataset(..., kind="derived")` for variables, weight, lineage, and
84+
run audit, and `validate_derived(...)` to recheck its physical relation,
85+
contiguous row ordinals, run/version binding, and content snapshot hash.
86+
87+
A published materialized dataset may be used as the next run's
88+
`parent_kind="derived"`. The logical input relation inside SQL is always
89+
`parent`.
90+
91+
## SQL boundary
92+
93+
The implementation parses SQLite SQL with sqlglot.
94+
It accepts one outer SELECT over only `parent`. It rejects DDL, DML,
95+
comments, multiple statements, undeclared relations, positional parameters,
96+
volatile functions, and functions outside its deterministic allowlist.
97+
Scope-aware resolution prevents nested CTEs from hiding undeclared relations,
98+
and `parent` cannot be shadowed or database-qualified. The parent CTE is merged
99+
into an existing leading `WITH` through the AST. SQLite's database authorizer
100+
independently permits reads only from the exact resolved physical parent.
101+
Each outer projection is AST-checked against its declared lineage: computed
102+
and aggregate contributors must exactly cover referenced columns, identity
103+
must be an unchanged direct parent projection, and constants may reference no
104+
columns. The AST also determines the required lineage kind: aggregate-function
105+
expressions are `aggregate`, transformed non-aggregate references are
106+
`computed`, reference-free literals are `constant`, and exact passthroughs are
107+
`identity`. `grouping` and `ordering` roles are accepted only when the matching
108+
source is actually grouped or ordered. `COUNT(*)` and `COUNT(1)` fail with
109+
`aggregate_lineage_unrepresentable` because this profile's lineage model has
110+
no relation-level contributor; column aggregates such as `COUNT(score)` are
111+
supported with that column declared as a contributor.
112+
113+
The AST also determines row semantics. Plain single-parent SELECTs are
114+
`one_to_one`, WHERE-only shapes are `filter`, joins are `join`, and aggregate,
115+
GROUP BY, or DISTINCT shapes are `aggregate`. CTE, scalar-subquery, window,
116+
limit/offset, and mixed join/aggregate shapes are classified `other` and stay
117+
fail-closed unless explicitly declared as such. An omitted declaration is
118+
filled from this classification; an explicit mismatch is rejected.
119+
120+
Every outer SELECT must have an ORDER BY made of declared output columns. The
121+
SQL must explicitly declare `NULLS FIRST` or `NULLS LAST`; the executor stores
122+
the AST-equivalent uppercase direction, NULL placement, and fixed collation.
123+
Before publication it asks the database to prove that the evaluated order tuple
124+
has no NULLs and no ties. `__row_ordinal` is then contiguous from one in that
125+
total order.
126+
127+
Named parameters are bound through the database driver. Parameter and input-set
128+
hashes use the profile's RFC 8785 parameter-set-v1 and input-set-v1 envelopes.
129+
All published outputs are materialized; view mode fails closed until immutable
130+
view inputs can be enforced. Version 0.1 of this adapter supports JSON scalar
131+
parameters and the exact `supported-profile` server constraint, which means
132+
SQLite `>=3.35.0,<4.0.0`. Registration and execution both check the live
133+
`sqlite_version()` and reject any other constraint string or runtime version.
134+
Version 0.1 supports JSON scalar
135+
parameters in the RFC 8785 safe domain and a single parent input. Fractional
136+
numbers are rejected until the adapter implements a typed `binary64` parameter;
137+
booleans, strings, null, and safe-range integers are supported. See
138+
`capability_matrix()` for the exact
139+
machine-readable boundary.
140+
141+
## Hashes, atomicity, and audit
142+
143+
Input and output relations use the profile's
144+
`openstatspec-relation-snapshot-v1` SHA-256 envelope. It includes the schema
145+
hash and rows ordered by the verified reserved ordinal, with integer ordinals,
146+
IEEE-754 binary64 bit strings, exact strings, and explicit NULL values.
147+
148+
A run and its input/parameter envelopes are committed as `started` before SQL
149+
execution. SQLite foreign keys are enabled on every workflow connection. A
150+
materialized run writes to a reserved staging table, validates it, then renames
151+
and publishes the physical output and derived catalog rows in one transaction.
152+
The renamed table receives INSERT, UPDATE, and DELETE rejection triggers;
153+
catalog tables likewise have normalized, definition-validated append-only and
154+
one-way run-transition triggers.
155+
A failed run remains in `transformation_run` with a redacted, code/phase-only
156+
safe
157+
`transformation_event`, but has no `derived_dataset` row or profile-owned
158+
physical output. `reconcile_sql_transformation_runs(...)` marks interrupted
159+
`started` runs failed and removes only reserved `__oss_stage_` relations.
160+
161+
Retirement and physical removal never delete catalog history.
162+
`retire_derived(...)` appends a `retired` event.
163+
`remove_derived_physical_relation(...)` appends
164+
`physical_removal_requested` before DROP and `physical_removed` only after
165+
the relation is confirmed absent. `reconcile_derived_removals(...)` resumes
166+
every request without a terminal event after a crash. Removal is rejected while
167+
a later derived run depends on the relation.
168+
169+
## Existing 0.1.0 catalogs
170+
171+
The workflow catalog is an additive optional-profile migration. On first
172+
registration the adapter:
173+
174+
1. verifies the existing core `catalog_identity`;
175+
2. refuses conflicting unowned workflow relation names;
176+
3. creates the 13 workflow relations and their independent identity; and
177+
4. leaves every core row, imported physical table, and historical private
178+
`*_catalog` compatibility table unchanged.
179+
180+
SQLite performs this in the existing database file. The adapter validates the
181+
exact profile identity, columns, primary keys, unique constraints, foreign
182+
keys, checks, and normalized reflected dialect types before use. UUID-sized
183+
strings, unbounded text, integer, bigint, boolean, and timestamp declarations
184+
therefore cannot be silently substituted for one another. A mismatched or drifted schema
185+
fails closed rather than attempting an implicit rewrite. Profile schema version
186+
2 adds enforced publication, lineage, XOR, same-dataset constraints, and exact
187+
trigger definitions.
188+
189+
## CLI
190+
191+
```text
192+
openstatspec catalog-list --database-url sqlite:///survey.sqlite --kind core
193+
194+
openstatspec derive \
195+
--database-url sqlite:///survey.sqlite \
196+
--parent-dataset-id 00000000-0000-0000-0000-000000000000 \
197+
--name eligible_responses \
198+
--sql "SELECT respondent_id FROM parent ORDER BY respondent_id ASC NULLS LAST" \
199+
--columns-json '[{"name":"respondent_id","storage_kind":"numeric","source":"respondent_id"}]'
200+
201+
openstatspec validate-derived \
202+
--database-url sqlite:///survey.sqlite \
203+
--derived-dataset-id 00000000-0000-0000-0000-000000000000
204+
```

‎pyproject.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ readme = "README.md"
1010
requires-python = ">=3.11"
1111
license = {text = "Apache-2.0"}
1212
authors = [{name = "OpenStatSpec"}]
13-
dependencies = ["SQLAlchemy>=2.0", "pandas>=2.2", "openstatspec-pyspssio==0.5.1.post2"]
13+
dependencies = ["SQLAlchemy>=2.0", "pandas>=2.2", "openstatspec-pyspssio==0.5.1.post2", "sqlglot>=30.14", "rfc8785>=0.1.4"]
1414

1515
[project.optional-dependencies]
1616
dev = ["pytest>=8"]

‎src/openstatspec/__init__.py‎

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,22 @@
11
"""Public API for the OpenStatSpec Python reference implementation."""
22

3-
from .api import capabilities, capability_matrix, export_sav, import_sav, inspect, validate
3+
from .api import (
4+
capabilities, capability_matrix, derive_sql_dataset, execute_sql_transformation,
5+
export_sav, get_dataset, import_sav, inspect, list_datasets,
6+
register_sql_transformation, reconcile_derived_removals,
7+
reconcile_sql_transformation_runs,
8+
remove_derived_physical_relation, retire_derived, validate, validate_derived,
9+
)
410
from .core import CapabilityDeclaration, LossReport, UnsupportedOperationError
11+
from .sql.workflow import TransformationError
512

6-
__all__ = ["CapabilityDeclaration", "LossReport", "UnsupportedOperationError", "capabilities", "capability_matrix", "export_sav", "import_sav", "inspect", "validate"]
13+
__all__ = [
14+
"CapabilityDeclaration", "LossReport", "TransformationError",
15+
"UnsupportedOperationError", "capabilities", "capability_matrix",
16+
"derive_sql_dataset", "execute_sql_transformation", "export_sav",
17+
"get_dataset", "import_sav", "inspect", "list_datasets",
18+
"register_sql_transformation", "reconcile_derived_removals",
19+
"reconcile_sql_transformation_runs",
20+
"remove_derived_physical_relation", "retire_derived", "validate",
21+
"validate_derived",
22+
]

‎src/openstatspec/api.py‎

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,18 @@
88
from .spss import export_dataset, import_dataset, inspect_source
99
from .spss.sav import engine_identity
1010
from .sql import declared_profiles, validate_dataset
11+
from .sql.catalog_api import catalog_dataset as _catalog_dataset, catalog_datasets as _catalog_datasets
12+
from .sql.workflow import (
13+
derive_dataset as _derive_dataset,
14+
execute_transformation as _execute_transformation,
15+
reconcile_started_runs as _reconcile_started_runs,
16+
register_transformation as _register_transformation,
17+
validate_derived_dataset as _validate_derived_dataset,
18+
transformation_capabilities,
19+
reconcile_physical_removals as _reconcile_physical_removals,
20+
remove_derived_relation as _remove_derived_relation,
21+
retire_derived_dataset as _retire_derived_dataset,
22+
)
1123
from .sql.capabilities import (
1224
SPECIFICATION_COMMIT, SPECIFICATION_RELEASE, active_connection, catalog_binding,
1325
)
@@ -70,6 +82,9 @@ def capability_matrix(database_url: str | None = None) -> Mapping[str, Any]:
7082
"active_connection": active_connection(database_url) if database_url else None,
7183
"catalog_binding": catalog_binding(database_url) if database_url else None,
7284
"sql_profiles": declared_profiles(database_url),
85+
"optional_profiles": {
86+
"sql_transformation_workflow": transformation_capabilities(database_url),
87+
},
7388
}
7489
return declaration
7590

@@ -93,3 +108,51 @@ def export_sav(*, database_url: Any, dataset_id: str, destination: str | Path, *
93108

94109
def validate(*, database_url: Any, dataset_id: str, **options: Any) -> Mapping[str, Any]:
95110
return result(validate_dataset(database_url=database_url, dataset_id=dataset_id, **options))
111+
112+
113+
def list_datasets(*, database_url: Any, kind: str | None = None) -> Mapping[str, Any]:
114+
return result(_catalog_datasets(database_url=str(database_url), kind=kind))
115+
116+
117+
def get_dataset(*, database_url: Any, dataset_id: str, kind: str) -> Mapping[str, Any]:
118+
return result(_catalog_dataset(database_url=str(database_url), dataset_id=dataset_id, kind=kind))
119+
120+
121+
def register_sql_transformation(*, database_url: Any, **options: Any) -> Mapping[str, Any]:
122+
return result(_register_transformation(database_url=str(database_url), **options))
123+
124+
125+
def execute_sql_transformation(*, database_url: Any, **options: Any) -> Mapping[str, Any]:
126+
return result(_execute_transformation(database_url=str(database_url), **options))
127+
128+
129+
def derive_sql_dataset(*, database_url: Any, **options: Any) -> Mapping[str, Any]:
130+
return result(_derive_dataset(database_url=str(database_url), **options))
131+
132+
133+
def validate_derived(*, database_url: Any, derived_dataset_id: str) -> Mapping[str, Any]:
134+
return result(_validate_derived_dataset(
135+
database_url=str(database_url), derived_dataset_id=derived_dataset_id,
136+
))
137+
138+
139+
def retire_derived(*, database_url: Any, **options: Any) -> Mapping[str, Any]:
140+
return result(_retire_derived_dataset(database_url=str(database_url), **options))
141+
142+
143+
def remove_derived_physical_relation(
144+
*, database_url: Any, **options: Any,
145+
) -> Mapping[str, Any]:
146+
return result(_remove_derived_relation(database_url=str(database_url), **options))
147+
148+
149+
def reconcile_derived_removals(
150+
*, database_url: Any, **options: Any,
151+
) -> Mapping[str, Any]:
152+
return result(_reconcile_physical_removals(database_url=str(database_url), **options))
153+
154+
155+
def reconcile_sql_transformation_runs(
156+
*, database_url: Any, **options: Any,
157+
) -> Mapping[str, Any]:
158+
return result(_reconcile_started_runs(database_url=str(database_url), **options))

0 commit comments

Comments
 (0)