|
| 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 | +``` |
0 commit comments