Files
rdbms-playground/docs/adr/0038-dsl-to-sql-teaching-echo.md
T
claude@clouddev1 5cb105b74b docs(adr): /runda DA cleanup — reflect Phases 1-3 done, pin Bucket C
Surfaces from a Devil's-Advocate audit of the DSL → SQL teaching echo
(ADR-0038) after Phases 1-3 landed: three doc-drift bugs introduced
by the earlier handoff-47 / ADR-promotion commits — requirements.md
M4 and both ADR-0038 README index entry + Status block still said
"Phase 2 / Phase 3 remain," but `275c726` and `e6ad1ae` shipped them.
Updated to reflect actual state: Buckets A + B complete plus the
category-3 prose; only the §4 styled-runs polish remains. ADR-0037's
README entry also touched to note all four shipping commits of its
consumer.

Plus a missing test slice the DA flagged: explicit no-echo coverage
for the Bucket C cases that flow through command_to_sql's catch-all
(show table, explain, replay, every Command::App variant). The
contract — ADR-0030 §10 / ADR-0038 §7 Bucket C — forbids echoes for
these; a future renderer arm added at the wrong place could silently
leak one. The new bucket_c_no_echo_commands_all_return_none pins
that.

Tests: 2015 passed / 0 failed / 1 ignored (pre-existing); clippy
clean. Nothing to escalate.
2026-05-28 10:06:16 +00:00

333 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# ADR-0038: The DSL → SQL teaching echo
## Status
Accepted. Design agreed with the user (2026-05-27); **Phases 13
implemented and verified** by round-tripping every catalogue row
(§7 Buckets A, B, plus the §6 category-3 prose) through the
advanced-mode walker (the §1 copy-paste contract; §6 category 2
holds it per line). Shipped across three feature commits:
**Phase 1 — Bucket A single-statement** (handoff-46's `04c8e42`
delivered the channel + create-table slice; handoff-47's `90479cb`
expanded to the full Bucket A — `Value → SQL-literal` + `Expr → SQL`,
`add`/`drop`/`rename`/`change column`, the four `add constraint` +
two `drop constraint` forms, `show data [where] [limit]` with
PK-sourced `ORDER BY`, and the `delete`/`update … --all-rows`
fall-throughs; also fixed a skeleton contract gap that silently
dropped per-column `DEFAULT` / `CHECK` on the create-table echo).
**Phase 2 — Bucket B resolved-name and multi-statement** (`275c726`):
`add index` (auto- and user-named, resolved from the post-execution
description), positional `drop index`, `add`/`drop relationship` in
both `Endpoints` and `Named` selector forms (the named drop
resolved by a small list-tables scan, acceptable for teaching-
playground schemas), `drop column --cascade` (multi-line via
`DropColumnResult::dropped_indexes`), and `add relationship
--create-fk` (multi-line iff the child column was newly created;
parent PK type + pre-state captured pre-execution via the runtime's
new `collect_echo_lookups`). **Phase 3 — category-3 prose**
(`e6ad1ae`): `shortid` generation and type-conversion transforms
already surfaced via the pre-existing `client_side.auto_fill_*` /
`client_side.transformed*` notes — Phase 3 only added the missing
`change column --dont-convert` **caveat** (the only Bucket A
caveat; every other category-3 line is illuminating), gated on an
advanced effective mode because it references "the line above".
**The §4 de-emphasised styled-runs rendering polish remains** — the
echo + caveat currently surface as plain `[system]` lines.
**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 `<col>`".
- **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 `<col>` from
`<old>` to `<new>` [N values with loss]".
- **`change column … --dont-convert`** *(caveat)* — the headline
`ALTER TABLE T ALTER COLUMN c SET DATA TYPE <ty>` 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). `<name>` 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 (<ty>) [not null] [unique] [default v] [check e]` | `ALTER TABLE T ADD COLUMN c <ty> [NOT NULL] [UNIQUE] [DEFAULT v] [CHECK (e)]` | shortid (if `<ty>` = 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 (<ty>)` | `ALTER TABLE T ALTER COLUMN c SET DATA TYPE <ty>` | 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 <v> to T.c` | `ALTER TABLE T ALTER COLUMN c SET DEFAULT <v>` | — |
| `add constraint unique to T.c` | `ALTER TABLE T ADD UNIQUE (c)` | — |
| `add constraint check <e> 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 <name> ON T (cols)` | — |
| `show data T [where …] [limit n]` | `SELECT * FROM T [WHERE …] [ORDER BY <pk> 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>` | 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 <name> 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>` | name resolved |
| `add 1:n relationship … --create-fk` *(child col created)* | `ALTER TABLE C ADD COLUMN cc <ty>` ⏎ `ALTER TABLE C ADD CONSTRAINT <name> 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 <ix1>` ⏎ … ⏎ `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 <path>` | 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 <ty>` (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.