# ADR-0038: The DSL → SQL teaching echo ## Status Proposed (design agreed with the user 2026-05-27; pending `/runda` + implementation). **Realises ADR-0030 §10** ("The DSL → SQL teaching bridge") — the Phase-5 echo that **ADR-0035 §12 forward-referenced** as "a separate ADR." Builds on **ADR-0037** (the execution-time mode side-channel that gates it) and **ADR-0035 Amendment 2** (the standard- first dialect + `ALTER COLUMN` gap-fill that makes its DDL echoes runnable). ## Context ADR-0030 §10 specified a teaching bridge: when a **DSL-form** command runs **in advanced mode**, its output includes the equivalent SQL, so a learner who knows the simple-mode form reads off how to spell it in SQL. The §10 contract: fires only for DSL-entered commands in advanced mode (a command already typed as SQL is not echoed; simple mode stays uncluttered); renders as a de-emphasised `OutputLine` beneath the `[ok]` summary (styled-runs, ADR-0028); app-level commands have no SQL form and are not echoed; the reverse SQL → DSL echo is out of scope (§13 OOS-5). ### The firing reality — this is a DDL + `show data` feature A consequence of ADR-0033 Amendment 3 sharpens the scope and must be stated plainly, because it is easy to over-estimate coverage: **in advanced mode the overlapping data commands route SQL-first.** A user who types `insert into T values (…)` / `update T set …` / `delete from T where …` in advanced mode produces `Command::SqlInsert` / `SqlUpdate` / `SqlDelete` — they **already typed SQL**, so §10 explicitly does not echo them. The echo is therefore meaningful only where the **DSL spelling differs from the SQL spelling** — i.e. the DSL-*only* surface: - **DDL DSL spellings** with no winning SQL competitor — `create table … with pk`, `add`/`drop`/`rename`/`change column`, `add`/`drop constraint`, `add`/`drop index`, `add`/`drop relationship`. - **`show data`** (DSL-only; SQL uses `SELECT`). - The **`--all-rows` fall-throughs** — `delete … --all-rows` falls back to the DSL `Delete` (the SQL `DELETE` has no slot for the flag), and `update … --all-rows` likewise falls back to the DSL `Update` per **ADR-0033 Amendment 4** (which reclassifies Amendment 3's non-fall- back of `update … --all-rows` as a bug and reverses it). Plain / `where`-filtered `insert`/`update`/`delete` stay SQL-first (`Sql*`). Everything a learner types that is *already SQL* needs no echo by construction. So the catalogue below is dominated by DDL. ### Dependencies - **ADR-0037** delivers the `SubmissionMode` to the worker, so a command knows at execution time whether it ran under `Advanced` / `AdvancedOneShot` (echo) or `Simple` (silent). - **ADR-0035 Amendment 2** adds the `ALTER COLUMN SET/DROP NOT NULL`, `SET/DROP DEFAULT`, and the ISO `SET DATA TYPE` canonical form, so the constraint-modification echoes are runnable advanced-mode SQL, and fixes the dialect stance the echoes emit in. ## Decision ### 1. The copy-paste contract — every echo is runnable advanced-mode SQL The defining invariant, stronger than "looks like SQL": **each echoed line is exactly what the user could paste into advanced mode and run — to the same effect, except where a Category-3 *caveat* line (§6) flags a divergence the SQL surface cannot express** (today the sole such case is `change column … --dont-convert`). This is testable as a **round-trip**: parse the echo through the advanced-mode walker and assert it yields a command with the same effect as the one that produced it (a caveated line still parses and runs — it just runs differently, which the caveat states). A planned one-shot "copy the echo" UX affordance depends on this contract, so it is a hard requirement, not a nicety. Multi-statement echoes (§6 category 2) hold the contract per line. ### 2. Type vocabulary — the playground's own keywords The echo emits the playground's `Type::keyword()` spelling (`serial`, `shortid`, `decimal`, `bool`, `date`, `datetime`, …), **not** engine storage types. This is sound because the advanced-mode type slot (`Type::from_sql_name`) accepts those ten keywords, so the echo round- trips (§1), and because the curated vocabulary is what the learner knows from the DSL and what carries the teaching information (`serial`'s auto- increment intent, `shortid`'s identity flavour) that `INTEGER`/`TEXT` would erase. The **statement shape** follows the standard-first dialect (ADR-0035 Amendment 2); the **type slots** carry playground keywords. ### 3. Firing rule The echo fires **iff** the executed command is a **DSL-form** command — *not* a `Sql*` variant and *not* a `Command::App(_)` — **and** its `SubmissionMode` (ADR-0037) is `Advanced` or `AdvancedOneShot` **and** the command is an interactive submission (**not** a `replay`ed line). Silent in `Simple`; silent for SQL-entered commands (they are `Sql*`); silent for app commands (no SQL form, §10); silent during `replay` (per-line echoes would bury the replay summary — ADR-0037 §3). ### 4. Where it is built and rendered The echo is built at the **runtime's `ExecuteDsl` handler** (build correction, ADR-0037 §3 + Implementation notes): the db.rs worker receives *decomposed* calls, not the `Command`, so it cannot render `Command → SQL`. The runtime is the one place where the `Command`, the threaded `EffectiveMode`, and the worker's **result** (resolved auto- names, generated `shortid`s, conversion counts) all converge — so it builds the echo there, still **execution-time aware** (it consumes the results). The **App renders** it as one or more **de-emphasised** `OutputLine`s beneath the `[ok]` summary, using the ADR-0028 styled-runs mechanism (a dimmed `Executing SQL:` prefix; the SQL in a code-ish run). One statement per line (§6 category 2). ### 5. `Value → SQL-literal` rendering DML echoes substitute the **actual literal values** (not `?` placeholders — decision B1), so the line is runnable. A per-type renderer produces SQL literals that **round-trip** (§1): | Type | Literal form | |---|---| | `text` / `shortid` | single-quoted, `'` doubled (`'O''Hara'`) | | `int` / `serial` | bare integer | | `real` / `decimal` | bare numeric (decimal as authored) | | `bool` | `true` / `false` | | `date` / `datetime` | quoted ISO-8601 (`'2026-05-27'`) | | `NULL` | `NULL` | **`blob` has no literal:** neither the DSL nor advanced SQL has a blob- literal syntax (the playground deliberately does not provide one), so a DSL command **cannot carry a blob value** for the echo to render — this is moot, not a gap. **Auto-generated columns are omitted** from an `INSERT` echo: `do_insert` filters out PK `serial` (rowid alias), non-PK `serial` (MAX+1), and `shortid` columns the user did not list, and advanced-mode SQL auto-fills the same omissions (`requirements.md` X4). So the echo omits them too — matching both the executed statement and the advanced-mode form a learner would type. ### 6. The three-category framework Everything that happens "beyond the literal SQL line" sorts into exactly three categories; naming them makes the rule testable: - **Category 1 — engine-implementation-hiding. Never surfaced.** The table rebuild that backs `ALTER COLUMN …` / `ADD CONSTRAINT` / `RENAME TO` on this engine; the rowid alias behind a PK `serial`; the `MAX(col)+1` behind a non-PK `serial`. These are how *this* engine honours a standard operation — not the lesson (ADR-0030 §7, ADR-0035 §9). The headline echo shows the clean statement; the means stays invisible. (Non-PK `serial` auto-fill is **here, not category 3**: auto-increment is an ordinary database feature; only the engine's *means* of faking it on a non-PK column is hidden.) - **Category 2 — decomposable into advanced-mode SQL. Shown as the runnable multi-line sequence.** One statement per line; the lines *are* the explanation, no prose. See `drop column --cascade` and `add relationship --create-fk` below. - **Category 3 — a playground type-system behaviour the SQL line cannot express (no function, no clause exists to write it). Surfaced as a de-emphasised prose line.** The note is one of two kinds: *illuminating* — the headline echo *is* equivalent and the note merely reveals a value-add the SQL does not show; or *caveat* — the headline is the **nearest** SQL but **not** equivalent because a playground flag's semantics have no SQL form (these caveats are the §1 contract's only exceptions). Members: - **`shortid` generation** *(illuminating)* — no `shortid()` function exists, so the generation cannot be written in SQL: "generated unique shortid(s) for ``". - **type-conversion transform** *(illuminating)* — `change column type` when cells are actually transformed; the playground auto-converts where standard SQL would need a cast clause it deliberately omits (the Postgres `USING`, ADR-0035 §12): "converted `` from `` to `` [N values with loss]". - **`change column … --dont-convert`** *(caveat)* — the headline `ALTER TABLE T ALTER COLUMN c SET DATA TYPE ` converts, but `--dont-convert` left the stored values as-is, so the line is not equivalent: "`--dont-convert` kept the stored values as-is; standard SQL always converts, so running the line above would transform them instead." All are built from the worker's existing `client_side.*` result notes (ADR-0017 §6 / ADR-0018 §9), not recomputed. ### 7. The catalogue DSL-form commands that surface in advanced mode, with their echo. The SQL uses the standard-first dialect (ADR-0035 Amendment 2) and playground type keywords (§2). `` denotes a worker-resolved name (auto- generated or positional → resolved at execution, "B1"). **Bucket A — single runnable statement.** | DSL command | Echoed SQL | Cat-3 expansion | |---|---|---| | `create table T with pk` | `CREATE TABLE T (id serial PRIMARY KEY)` | — | | `create table T with pk a(int),b(int)` | `CREATE TABLE T (a int, b int, PRIMARY KEY (a, b))` | — | | `add column to T: c () [not null] [unique] [default v] [check e]` | `ALTER TABLE T ADD COLUMN c [NOT NULL] [UNIQUE] [DEFAULT v] [CHECK (e)]` | shortid (if `` = shortid) | | `drop column from T: c` *(no covering index)* | `ALTER TABLE T DROP COLUMN c` | — | | `rename column in T: old to new` | `ALTER TABLE T RENAME COLUMN old TO new` | — | | `change column in T: c ()` | `ALTER TABLE T ALTER COLUMN c SET DATA TYPE ` | conversion *(illum.)* if transformed (incl. → shortid); `--dont-convert` → *caveat* (§6) | | `add constraint not null to T.c` | `ALTER TABLE T ALTER COLUMN c SET NOT NULL` | — | | `add constraint default to T.c` | `ALTER TABLE T ALTER COLUMN c SET DEFAULT ` | — | | `add constraint unique to T.c` | `ALTER TABLE T ADD UNIQUE (c)` | — | | `add constraint check to T.c` | `ALTER TABLE T ADD CHECK (e)` | — | | `drop constraint not null from T.c` | `ALTER TABLE T ALTER COLUMN c DROP NOT NULL` | — | | `drop constraint default from T.c` | `ALTER TABLE T ALTER COLUMN c DROP DEFAULT` | — | | `add index [as N] on T (cols)` | `CREATE INDEX ON T (cols)` | — | | `show data T [where …] [limit n]` | `SELECT * FROM T [WHERE …] [ORDER BY LIMIT n]` | — | | `delete from T --all-rows` | `DELETE FROM T` | — | | `update T set … --all-rows` | `UPDATE T SET …` | — | **Bucket B — resolved-name and multi-line (one statement per line).** | DSL command | Echoed SQL | Notes | |---|---|---| | `drop index on T(cols)` *(positional)* | `DROP INDEX ` | name resolved at execution | | `add 1:n relationship [as N] from P.pc to C.cc [on delete X] [on update Y]` | `ALTER TABLE C ADD CONSTRAINT FOREIGN KEY (cc) REFERENCES P (pc) [ON DELETE X] [ON UPDATE Y]` | name resolved | | `drop relationship (from P.pc to C.cc \| N)` | `ALTER TABLE C DROP CONSTRAINT ` | name resolved | | `add 1:n relationship … --create-fk` *(child col created)* | `ALTER TABLE C ADD COLUMN cc ` ⏎ `ALTER TABLE C ADD CONSTRAINT FOREIGN KEY (cc) REFERENCES P (pc) …` | category 2: two runnable lines (one if the column already existed) | | `drop column T.c --cascade` *(drops covering indexes)* | `DROP INDEX ` ⏎ … ⏎ `ALTER TABLE T DROP COLUMN c` | category 2: index names resolved; plain `DROP COLUMN` would refuse an indexed column | **Bucket C — no echo.** | DSL command | Reason | |---|---| | `show table T` | structure display; no SQL spelling in the surface | | `explain …` | `EXPLAIN` of advanced SQL is OOS-2 (ADR-0030 §13) | | `replay ` | app-lifecycle; no SQL form | | `drop constraint unique/check from T.c` *(column-level)* | residual gap (ADR-0035 Amendment 2): anonymous column constraint, no portable name | | `Command::App(_)` (all app commands) | §10 — no SQL form | | `Sql*` variants, `select`/`with` | already SQL-entered — not echoed by construction | | `insert …`, `update …` / `update … where …`, `delete …` / `delete … where …` | route SQL-first in advanced mode (`Sql*`) — already SQL, nothing to echo (the two `--all-rows` forms fall back to DSL and *do* echo — Bucket A) | ### 8. Coverage and phasing The renderer is one mechanism, so the natural unit is "Bucket A + B at once." A reasonable phasing if a smaller first slice is wanted: - **Phase 1** — Bucket A single-statement DDL + `show data` (the bulk of the teaching value; no resolved-name or multi-line machinery). - **Phase 2** — Bucket B (worker-resolved names; multi-line sequences). - **Phase 3** — the category-3 prose expansion. (Coverage/phasing is a sequencing choice for the user; the catalogue itself is fixed.) **Build-order dependencies (verified against the current grammar).** Two groups of echoes emit SQL the advanced surface does **not yet parse**, so their round-trip tests (§1) are gated on their prerequisites landing first: - The **constraint-modification** rows and the `change column` row — `ALTER COLUMN … SET DATA TYPE / SET NOT NULL / DROP NOT NULL / SET DEFAULT / DROP DEFAULT` — require **ADR-0035 Amendment 2**. Today `alter table T alter column c …` accepts only `type ` (probed: `set data type` / `set not null` / `set default` all error with "expected `type`"). So Amendment 2 must build **before** these rows' round-trip tests. (`change column` could echo the `TYPE` synonym in the interim, but the canonical/emitted form is `SET DATA TYPE` per Amendment 2.) - The **`update … --all-rows`** row requires **ADR-0033 Amendment 4** (today it is `SqlUpdate`, not the DSL `Update` the echo needs). All other Bucket A/B rows round-trip against the surface as it stands today (probe-confirmed: `create table T (id serial primary key)`, compound PK, `add unique (c)`, `add check (…)`, `create index … on …`, `select * from …` all parse in advanced mode). ### 9. Out of scope - **The reverse SQL → DSL echo** (ADR-0030 §13 OOS-5). - **App commands**, `show table`, `explain`, `replay` (Bucket C). - **A `blob` literal renderer** (§5 — moot; no blob literal exists). - **Closing the column-level UNIQUE/CHECK drop gap** (ADR-0035 Amendment 2 residual) — those stay Bucket C until that gap is closed. - **Surfacing category-1 engine internals** (the rebuild, rowid, MAX+1) — never (§6). ## Consequences - A new `Command → SQL` renderer plus the `Value → SQL-literal` renderer, worker-side (§4), gated by `SubmissionMode` (ADR-0037). Each Bucket A/B row is a round-trip test (§1); each category-3 row asserts the prose fires from the worker note. - The teaching surface is honest: a learner reads runnable, portable SQL in the playground's own type vocabulary; engine quirks stay hidden; playground-only conveniences are named, not mysterious. - The echo's coverage is **DDL-centric by construction** (the firing reality, Context). This is correct, not a shortfall — overlapping DML is already SQL in advanced mode. - Adding a new DSL-form DDL command later must add its catalogue row + round-trip test, or consciously place it in Bucket C. ## See also - ADR-0030 §10 — the teaching bridge this realises; §13 OOS-5 (no reverse echo). - ADR-0037 — the `SubmissionMode` side-channel that gates firing. - ADR-0035 Amendment 2 — the standard-first dialect + `ALTER COLUMN` gap-fill the echoes emit and rely on. - ADR-0033 Amendment 3 — the SQL-first dispatch that makes this a DDL + `show data` feature. - ADR-0028 — the `OutputLine` styled-runs the echo renders through. - ADR-0017 / ADR-0018 — the `client_side.*` notes feeding category-3.