docs(adr): design the DSL→SQL teaching echo (ADR-0038) + dependencies

Realises ADR-0030 §10 (the DSL→SQL teaching bridge) as a /runda'd design
set, before implementation:

- ADR-0037 (new): execution-time mode side-channel — SubmissionMode
  {Simple, Advanced, AdvancedOneShot} threaded Action→worker, output-only;
  redeems ADR-0033 Amendment 3's deferred follow-up. Replay stays silent.
- ADR-0038 (new): the teaching echo + full catalogue (Buckets A/B/C),
  the copy-paste round-trip contract, the three-category framework, and
  the Value→SQL-literal renderer. DDL + show-data centric (overlapping
  DML is SQL-first, so already SQL). Build-order deps recorded.
- ADR-0035 Amendment 2: standard-first dialect stance + ALTER COLUMN
  SET/DROP NOT NULL, SET/DROP DEFAULT, ISO SET DATA TYPE gap-fill.
- ADR-0033 Amendment 4: reclassifies the `update … --all-rows`
  non-fall-back as a bug; it now falls back to the DSL Update and echoes
  (keyed on adjacent `--`; spaced arithmetic preserved).
- ADR-0039 (new): EXPLAIN over advanced SQL — decision recorded, build
  deferred; supersedes ADR-0030 §13 OOS-2.
- ADR-0000: out-of-scope discipline (deferred vs rejected). README index
  updated for all of the above.

Reconcile CLAUDE.md: simple-mode column ops are implemented, not pending
(requirements.md C2/B2 already [x]).
This commit is contained in:
claude@clouddev1
2026-05-27 20:44:38 +00:00
parent 2bcc55f939
commit 9f15f386d5
9 changed files with 775 additions and 8 deletions
@@ -38,6 +38,25 @@ The index lists ADRs in numerical order. Each entry shows the
number, title, and — where relevant — status annotations such as
"Superseded by ADR-NNNN" or "Deprecated".
## Out-of-scope discipline
ADRs (and the plans they spawn) lean heavily on "out of scope" language.
The phrase carries two very different meanings, and conflating them
misleads a later reader:
- **Deferred** — out of scope *for this plan / phase / step*, but a
reasonable thing to do later. A sequencing decision, effectively a
tracked TODO. Where possible, point at where it will be picked up.
- **Rejected** — considered and deliberately *not* done, on principle.
Durable. State the reason.
When writing an out-of-scope item, **say which kind it is** — e.g.
`OOS (deferred)` / `OOS (rejected: <reason>)`, or the equivalent in prose
— so a future reader can tell a standing decision from a not-yet. A bare
"out of scope" is ambiguous and tends to read, wrongly, as permanent.
(Motivating example: ADR-0030 §13 OOS-2 was a deferred scope exclusion
that read as a permanent rejection until ADR-0039 lifted it.)
## Consequences
- New significant decisions require an ADR before or alongside the
@@ -301,6 +301,9 @@ until then.
by the items panel's design (`S2`) but need their own model.
- **OOS-2.** `EXPLAIN` of advanced-mode SQL queries. The DSL
`explain` (ADR-0028) still works for what it already wraps.
**(Superseded by ADR-0039, 2026-05-27 — this was a *deferred*
scope exclusion, not a principled rejection; EXPLAIN over advanced
SQL is now in scope, as a deferred follow-up.)**
- **OOS-3.** A function/expression allowlist for full
expression-level engine neutrality (§7) — best-effort now.
- **OOS-4.** Multi-statement batches and transaction control.
+77
View File
@@ -1476,6 +1476,83 @@ forward-references that future ADR so the requirement is not lost.
side-channel until then. No structure built in 3j assumes mode is
irrelevant to execution.
## Amendment 4 — `update … --all-rows` falls back to the DSL; Amendment 3's counter-example was a bug (2026-05-27)
Designing the DSL → SQL teaching echo (ADR-0038) re-examined Amendment 3's
**counter-example** — the claim that `update T set x = 42 --all-rows` in
Advanced mode does *not* fall back to the DSL because the SQL `UPDATE`'s
`SET <expr>` "greedily consumes `--all-rows` as the expression
`42 - -all - rows` (with `all`/`rows` as column refs)." **That is a bug,
not correct behaviour**, and this amendment reverses it. Recorded with
explicit user approval (2026-05-27).
### Why it is a bug
- The walker has **no `--` comment support** (it lexes two minus
operators); the engine *does* treat `--` as a line comment. So the
walker's parse (`42 - -all - rows`) **diverges from execution** (which
runs `update T set x = 42`).
- Absent columns literally named `all` and `rows`, the walker is
**silently accepting a `SET` expression over non-existent columns** —
precisely the case ADR-0027 says to flag ("mark error if we know it
will fail at runtime"). It only *appears* to succeed because the
engine's comment leniency masks the divergence.
- **Trailing SQL comments are not a supported feature** of the surface,
so relying on the engine to treat `--all-rows` as one is wrong.
So the SQL `UPDATE` shape should **not** match `… --all-rows`.
### Decision
The `--all-rows` token sequence makes the **SQL `UPDATE` shape fail**, so
dispatch **falls back to the DSL** `Command::Update { filter: AllRows }`
— restoring symmetry with `delete … --all-rows`, which already falls
back (the SQL `DELETE` has no slot to absorb the flag). Consequences:
- `update … --all-rows` in Advanced mode is the **DSL** command, runs all
rows via the safe DSL path, and (ADR-0038) gains the teaching echo
`UPDATE T SET … ` with the `WHERE` omitted.
- **No `--` comment feature is introduced**; the playground still does
not support trailing SQL comments (consistent with the rest of the
surface, engine-neutral).
### Mechanism — key on the **adjacent `--`**
The DSL `--all-rows` is matched by `Node::Flag("all-rows")` via
`consume_flag` (`walker/lex_helpers.rs`), which requires an **adjacent
`--`**. The SQL expression grammar has no flag node, so it consumes the
same source as `- -all - rows` (two minus operators, `all`/`rows` as
column refs) and the SQL `UPDATE` shape wins (SQL-first). Crucially,
`--all-rows` and a legitimate `- -all` tokenise *identically* once split,
so the **only** robust discriminator is the adjacency of the two dashes:
- **The fix:** the SQL expression treats an **adjacent `--`** as a
boundary it will not consume, so the SQL `UPDATE` shape **fails** there
and dispatch falls back to the DSL, whose `Node::Flag` matches
`--all-rows`. (Adjacency is what separates the DSL flag from real
arithmetic — see the preserved case below.)
Verified consequences (probed against the current grammar), which the
build must lock down test-first:
- `update T set x = 42 --all-rows` → DSL `Update { AllRows }` *(the goal;
inverts `sql_dml_e2e.rs::e2e_update_all_rows_in_advanced_does_not_fall_back_to_dsl`)*.
- `update T set x = 42 - -3` → **unchanged**, stays `SqlUpdate` (= 45):
the dashes are space-separated, not an adjacent `--`. This invariant is
non-negotiable and gets its own test.
- `update T set x = 42--3` and `update T set x = 42 --col` *(adjacent
`--` before a number or a real column)* → today they parse `Ok` as
`SqlUpdate`; after the fix the SQL expression stops at `--` and the DSL
flag grammar (which accepts only `all-rows`) rejects the rest, so they
become **parse errors**. This is a deliberate, acceptable behaviour
change for these contrived adjacent-dash inputs — the playground does
not support `--` line comments, so there is no valid reading of an
adjacent `--` here. *(User heads-up flagged 2026-05-27.)*
The existing `delete … --all-rows` fall-back is unaffected. Folded into
the ADR-0038 echo effort (the fix that makes `update … --all-rows`
echoable).
## See also
- ADR-0005 — the ten-type vocabulary INSERT works with.
+111
View File
@@ -616,6 +616,117 @@ Single-column UNIQUE column drops are a **parallel** gap (different
mechanism — ADR-0029 column-level `drop constraint`) and are **out of
scope** here.
## Amendment 2 — Standard-first dialect + `ALTER COLUMN` constraint gap-fill (2026-05-27)
Designing the **DSL → SQL teaching echo** (ADR-0030 §10, specified in
ADR-0038) surfaced two related things about this ADR's surface. First, a
**dialect drift**: ADR-0030 frames advanced mode as "the **standard-SQL**
surface," but §4f shipped the type-change verb as bare
`ALTER COLUMN … TYPE` — the **PostgreSQL shorthand** — and explicitly
declined the ISO `SET DATA TYPE` synonym (§4f, line "no `SET DATA TYPE`
synonym"). Second, **gaps**: advanced mode has no way to toggle `NOT NULL`
or `DEFAULT` on an existing column, though simple mode does
(ADR-0029 `add`/`drop constraint`), and the rebuild primitive that would
back them is already in place (it backs §4f type-change and §4g
constraint-add). This amendment records a **dialect stance** and **fills
the clean gaps** so the echo can emit portable SQL that is also runnable
in advanced mode. Recorded with explicit user approval (2026-05-27).
### The dialect stance — standard-first (refines ADR-0030)
Where ISO SQL provides a spelling, the **authored grammar's canonical
form is the ISO one**, and the **echo emits the ISO form**. A widely-
recognised vendor shorthand *may* be **accepted** as a synonym (so a
learner who knows it is not punished), but it is never the canonical or
emitted form. Where ISO provides **no** spelling for an operation the
playground teaches, **one** widely-recognised vendor spelling is adopted
as a **deliberate, documented extension** — not silently. This realigns
the surface with ADR-0030's stated posture and makes the divergence a
conscious, recorded choice rather than drift.
The stance applies to the whole advanced surface going forward; this
amendment exercises it on the `ALTER COLUMN` family.
### Type change: ISO `SET DATA TYPE` canonical, `TYPE` retained as a synonym
Reverses §4f's "no `SET DATA TYPE` synonym." The grammar now accepts
**both** `ALTER COLUMN <c> SET DATA TYPE <type>` (ISO; canonical) and
`ALTER COLUMN <c> TYPE <type>` (PostgreSQL; accepted synonym, no
breakage for already-shipped usage). Both decompose to the same §4f
`AlterColumnType` action and the same `ChangeColumnMode::ForceConversion`
executor — semantics are **unchanged**; only the accepted spelling set
and the *canonical/echoed* form change. The echo (ADR-0038) emits
`SET DATA TYPE`.
### New: `SET/DROP DEFAULT` (ISO) and `SET/DROP NOT NULL` (the one extension)
Four new `AlterColumnType`-family actions under `ALTER COLUMN <c>`:
| Spelling | Standing | Decomposes to (ADR-0029 executor) |
|---|---|---|
| `SET DEFAULT <expr>` | **ISO-standard** | `do_add_constraint(Default)` |
| `DROP DEFAULT` | **ISO-standard** | `do_drop_constraint(Default)` |
| `SET NOT NULL` | **documented extension** | `do_add_constraint(NotNull)` |
| `DROP NOT NULL` | **documented extension** | `do_drop_constraint(NotNull)` |
`SET DEFAULT`/`DROP DEFAULT` are taken directly from the ISO
`<alter column action>` set. **`NOT NULL` toggling has no ISO spelling**
— in the standard `NOT NULL` is a column constraint, not an in-place
`ALTER COLUMN` verb, and the vendors diverge (PostgreSQL
`SET/DROP NOT NULL`; SQL Server `ALTER COLUMN <c> <type> NOT NULL`;
MySQL `MODIFY`; Oracle `MODIFY`). Per the stance, **one** spelling is
adopted as a deliberate extension: **PostgreSQL's `SET/DROP NOT NULL`**,
chosen because it is the only form that is type-independent (it does not
force the user to restate the column type), reads as plain English, and
composes uniformly with the ISO `SET/DROP DEFAULT` it sits beside.
`SET DEFAULT`'s value slot reuses the §4a.2 / §4e **raw `sql_expr`**
default mechanism (`default_sql`), so a default may be any expression
the create-table `DEFAULT` accepts — one syntax, not a third
(ADR-0030 §11).
### Execution — rebuild-backed, no new low-level op
Each new action **runtime-decomposes to an existing ADR-0029 executor**
(`do_add_constraint` / `do_drop_constraint`), exactly as §4e/§4f
decompose their actions — the populated-column **pre-flight dry-run
guard** (ADR-0029 §5) and the internal-`__rdbms_*` guard come for free.
No new worker layer. The grammar discriminates the `ALTER COLUMN <c> …`
tail by its leading keyword: `type` / `set data type` (type change),
`set not null` / `drop not null`, `set default` / `drop default` — the
`set`/`drop` lead is new alongside §4f's `type` lead.
### Parity reached, and the one residual gap
This brings advanced mode to **constraint-modification parity with
simple mode (ADR-0029) for `NOT NULL` and `DEFAULT`** — add and drop,
both directions. It closes the simple↔advanced asymmetry the echo design
flagged for those ops.
**Residual gap (deliberately not closed here):** dropping a
**column-level `UNIQUE` or `CHECK`** (the single-column, *anonymous*
constraints simple mode adds via ADR-0029 `add constraint unique/check`).
`DROP CONSTRAINT <name>` (§4g) + the derived composite-UNIQUE name
(Amendment 1) resolve *table-level* / *named* constraints; a single-
column column-level `UNIQUE` lives as the column's `unique` flag and a
column-level `CHECK` is likewise anonymous, so neither has a portable
name to address. This is the same class Amendment 1 called a "parallel
gap … out of scope." Consequently ADR-0038's catalogue marks
`drop constraint unique/check from T.col` as **no headline echo** (a
residual gap), rather than inventing a name or a recipe. Flagged for the
user; closing it (e.g. extending the derived-name approach to single-
column UNIQUE) would be its own small follow-up.
### Engine neutrality holds (the rebuild stays hidden)
The chosen spellings are **portable SQL**, not engine features. The fact
that *this* engine satisfies `SET NOT NULL` / `SET DATA TYPE` via a table
rebuild (because it lacks in-place `ALTER`) is a **Category-1 engine
implementation detail** (ADR-0038's taxonomy) and stays **invisible**
no recipe, no rebuild steps surfaced — exactly as §9 and ADR-0030 §7
require. A learner sees the standard statement; the engine's means of
honouring it is not the lesson.
## Consequences
- Advanced mode reaches DDL parity with simple mode and adds
@@ -0,0 +1,182 @@
# ADR-0037: Execution-time mode side-channel (the three-way submission mode)
## Status
Proposed (design agreed with the user 2026-05-27; pending `/runda` +
implementation). Redeems the follow-up **deferred by ADR-0033
Amendment 3**, which named this very ADR and its motivating consumer.
## Context
ADR-0033 Amendment 3 ("Execution-time mode is side-channel — deferred
to its own ADR") recorded a requirement and deferred it:
> every command should — at **execution time** — know which of three
> modes it ran under: `simple`, `advanced`, or `advanced-one-shot`
> (the `:` escape from Simple mode, ADR-0003), so execution can adjust
> **output** without changing **identity** (e.g. a Simple-mode
> `create table` echoing the generated SQL when run in Advanced mode,
> while staying silent in Simple mode).
That echo is the **DSL → SQL teaching bridge** (ADR-0030 §10), specified
as its own Phase-5 ADR (ADR-0038). ADR-0038 is the motivating — and, at
time of writing, only — consumer of this side-channel. This ADR
establishes the channel; ADR-0038 consumes it.
### What exists today
- The persistent input mode `Mode` (`src/mode.rs`) is **two-way**:
`Simple` / `Advanced`. Its doc comment is explicit that the one-shot
`:` escape "is handled at submission time in `app::App::submit`, **not
as additional state here**."
- The one-shot `:` is therefore a **transient, per-submission**
property, collapsed to an effective mode at submit time. It is not
persistent state and was deliberately kept out of `Mode`.
- The only mode information that survives past submission is a
**rendering** side-channel: `OutputLine.mode_at_submission: Mode`
(the `[simple]`/`[advanced]` echo-line tag in `ui.rs`). It is two-way
and exists purely to label the input-echo line.
- Neither `Action::ExecuteDsl` nor the database worker
(ADR-0010) carries any mode. Execution is mode-agnostic.
### Why the gap blocks the echo
The teaching echo (ADR-0038) renders the equivalent SQL **beneath the
`[ok]` summary**. That summary is built in the App's outcome handler
(`App::update`, the `Dsl*Succeeded` arms) from the **worker's result
event** — not at dispatch time. At that point:
- The typed `Command` is available (the success events already carry it).
- The submission mode is **not** available. `self.mode` is the *current*
persistent mode, which is unreliable for this purpose: it can change
between submission and outcome, and for a `:` one-shot it never
reflected the effective mode of that single line at all.
So the echo needs the **effective submission mode delivered to
execution/outcome**. And several echo forms (ADR-0038 — auto-resolved
index / relationship names, generated `shortid` values, lossy-conversion
counts) are only knowable **after the worker has run**, so the echo's
data is fundamentally worker-produced. The natural place to gate and
build mode-dependent output is therefore the execution path, with the
mode threaded to it — exactly the side-channel Amendment 3 anticipated.
## Decision
Introduce a **three-way effective submission mode**, computed at submit
time and threaded through the `Action` → worker interface, available at
**execution time** to adjust **output only**. Command identity, dispatch,
and execution semantics are **unchanged** (ADR-0033 Amendment 3: identity
is intrinsic to the mode-rooted grammar path, not a flag).
### 1. A new per-submission enum, distinct from persistent `Mode`
```rust
/// The effective mode of a single submitted line, resolved at submit
/// time. Distinct from the persistent input `Mode` (which stays
/// two-way) because the one-shot `:` escape is a transient per-line
/// property, never persistent state (ADR-0003; `mode.rs`).
pub enum SubmissionMode {
Simple,
Advanced,
AdvancedOneShot,
}
```
**This refines Amendment 3's framing.** Amendment 3 sketched "widening
`Mode` to the three-way distinction." On implementation review, a
**separate enum** is cleaner: `Mode` models *persistent input state*,
and `mode.rs` deliberately keeps the one-shot out of it. Folding a
transient `AdvancedOneShot` into the persistent `Mode` would contradict
that design note and let a non-persistable value leak into persistent-
mode call sites. `SubmissionMode` carries the three-way distinction on
the per-submission channel where it belongs; `Mode` stays two-way and
untouched. The *requirement* Amendment 3 recorded is met; only the
**shape** is refined. (Flagged for `/runda`/user challenge — it is an
internal architecture choice with no user-facing effect.)
The effective mode is resolved at submit time from the persistent
`Mode` plus whether the `:` sigil was used:
- persistent `Simple`, no `:``Simple`
- persistent `Simple`, `:` prefix → `AdvancedOneShot`
- persistent `Advanced``Advanced` (the `:` is a no-op there)
### 2. Threaded through `Action::ExecuteDsl` to the worker
`Action::ExecuteDsl` gains a `submission_mode: SubmissionMode` field;
the worker request mirrors it. Execution thus knows, per command, the
effective mode it ran under. The value is **output-only**: no executor
branches its *effect* on it (that would be a behavioural mode dependency,
which ADR-0033 Amendment 3 forbids — identity and effect are intrinsic).
### 3. The worker produces mode-dependent output; the App renders it
For the first consumer (ADR-0038): when the command is a **DSL-form**
command (`Command::CreateTable`/`Insert`/… — *not* the `Sql*` variants)
and `submission_mode` is `Advanced` or `AdvancedOneShot`, the worker
builds the teaching echo (equivalent SQL + any category-3 expansion
data — ADR-0038) and returns it on the result event. In `Simple` mode,
or for a command typed as SQL, no echo is produced. The App renders the
returned echo as de-emphasised `OutputLine`(s) beneath `[ok]`.
Co-locating echo construction with execution is deliberate: the echo's
harder forms (resolved auto-names, generated `shortid`s, conversion
counts) are facts the worker already computes. Gating on the threaded
mode means the work happens **only when an echo will be shown**.
**Non-interactive re-execution does not echo.** `replay` (ADR-0034)
re-runs recorded commands through the dispatch pipeline in advanced mode
(ADR-0033 Amendment 3) — but a per-line teaching echo there would bury
the replay summary in noise. So replayed lines (and any future
programmatic re-execution) dispatch with a `SubmissionMode` that
**suppresses** mode-dependent output: command *identity* still parses in
advanced mode (Amendment 3, unchanged), but no echo fires. The mechanism
— a fourth non-echoing context, or an `interactive: bool` alongside the
mode — is a build choice; the contract fixed here is *replay is silent*.
### 4. Scope of this ADR
This ADR establishes the channel and the resolution rule **only**. The
echo renderer, its catalogue, and the `Value → SQL-literal` machinery
are ADR-0038. The advanced-mode `ALTER COLUMN` gap-fill the echo relies
on is the ADR-0035 amendment. No echo behaviour is specified here beyond
the gating contract in §3.
## Alternatives considered
- **Widen `Mode` to three-way (Amendment 3's literal sketch).** Avoids a
new type but conflates transient per-submission state with persistent
input state, against `mode.rs`'s explicit design note. Rejected for
§1's separate enum.
- **App-side gating, worker always returns echo data.** Keep the worker
mode-agnostic; have it return echo data on *every* result, and let the
App decide whether to render from `mode_at_submission` + command shape.
Rejected: it computes the echo unconditionally (including in Simple
mode, where it is never shown), does not generalise to other mode-
dependent output, and re-opens exactly the "the echo is purely render-
side" framing the user has twice ruled against — the settled direction
is execution-time mode awareness, per Amendment 3.
## Consequences
- Additive and small: a new enum, one field on `Action::ExecuteDsl` and
the worker request, and the submit-time resolution rule. No change to
parsing, dispatch, command identity, or any executor's effect.
- The persistent `Mode` enum and `OutputLine.mode_at_submission` are
unchanged. (ADR-0038 may enrich the render tag separately; not required
here.)
- Future mode-dependent **output** has a home. Anything touching command
*identity* or *effect* does not belong here (Amendment 3).
- Tests: submit-time resolution for all three cases (incl. `:` one-shot
and the Advanced-mode `:`-is-a-no-op case); the field survives the
`Action` → worker round-trip; a Simple-mode DSL command yields no echo
request while an Advanced / one-shot one does (the gating contract).
## See also
- ADR-0033 Amendment 3 — deferred this side-channel; defines the
intrinsic command-identity model this ADR must not disturb.
- ADR-0030 §10 — the DSL → SQL teaching bridge (the motivating consumer).
- ADR-0038 — the teaching echo; the consumer built on this channel.
- ADR-0003 — input modes and the one-shot `:` escape.
- ADR-0010 — the database worker thread this mode is threaded to.
+299
View File
@@ -0,0 +1,299 @@
# 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 **worker builds** the echo (ADR-0037 §3) — it alone holds the facts
several echoes need: auto-resolved index / relationship names, generated
`shortid` values, and lossy-conversion counts. It returns the echo
payload on the result event. 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
itself 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.
@@ -0,0 +1,73 @@
# ADR-0039: EXPLAIN over advanced-mode SQL queries
## Status
**Accepted** — decision recorded 2026-05-27. **Implementation deferred**
as a follow-up to the ADR-0037/ADR-0038 teaching-echo effort: the
decision below is settled, but the full design has not been `/runda`'d
or built, and is *not* part of that pass. **Supersedes ADR-0030 §13
OOS-2.**
## Context
ADR-0028 gave the DSL `explain` command: a prefix over `show data` /
`update` / `delete` that runs `EXPLAIN QUERY PLAN` and renders an
annotated, span-styled plan tree. ADR-0030 §13 **OOS-2** excluded
"`EXPLAIN` of advanced-mode SQL queries."
On readback (2026-05-27) that exclusion is a **deferred** out-of-scope
item, not a **rejected** one (see ADR-0000's out-of-scope discipline):
its own wording — *"the DSL `explain` still works for what it already
wraps"* — shows it was "not included in this surface," never "undesirable
for teaching." There was no pedagogical argument against it; it simply
fell outside the Phase-4/5 SQL-surface scope. It surfaced while
characterising advanced-mode `explain` (briefly suspected a bug; it was
OOS-2 behaving exactly as written).
Letting a learner see the plan for the SQL they *wrote* is a natural
extension of ADR-0028's intent, so OOS-2 is lifted.
## Decision
`explain` works over advanced-mode SQL queries — the SQL commands
`Select` / `SqlInsert` / `SqlUpdate` / `SqlDelete` — in addition to the
DSL `ShowData` / `Update` / `Delete` it already wraps (ADR-0028). It runs
`EXPLAIN QUERY PLAN` over the command's validated SQL text and renders
through the **existing ADR-0028 plan tree**. Advanced mode only (the SQL
commands are advanced-only); the DSL `explain` stays available in both
modes, unchanged. **Supersedes ADR-0030 §13 OOS-2.**
## Design sketch (deferred to the build)
- **Grammar.** The `explain` inner gains the SQL statement shapes in
advanced mode, alongside the DSL trio — mirroring how `explain` already
wraps the DSL nodes, here wrapping the SQL command shapes.
- **Execution.** Run `EXPLAIN QUERY PLAN` over the carried SQL text (the
`Sql*` / `Select` commands already hold validated text); reuse
ADR-0028's plan capture + renderer. `EXPLAIN QUERY PLAN` never executes
the statement, so explaining a destructive SQL command is safe — the
same property ADR-0028 already relies on.
- **Mode.** SQL inner only in advanced mode; DSL inner in both, unchanged.
Built test-first when picked up.
## Out of scope
- **EXPLAIN of DDL** (`CREATE` / `ALTER` / `DROP`). `EXPLAIN QUERY PLAN`
applies to `SELECT` / `INSERT` / `UPDATE` / `DELETE`; DDL has no query
plan. *(Deferred — may be revisited if a useful rendering emerges; per
ADR-0000's out-of-scope discipline, this is deferred, not rejected.)*
## Consequences
- A self-contained feature, orthogonal to the DSL → SQL echo (ADR-0038):
the echo renders SQL *from* DSL commands; this explains SQL the user
*wrote*. They share nothing but the plan renderer's lineage.
- One OOS item in ADR-0030 §13 is now superseded; the rest stand.
## See also
- ADR-0028 — the DSL `explain` and the span-styled plan tree this reuses.
- ADR-0030 §13 — OOS-2, superseded here.
- ADR-0032 / ADR-0033 — the SQL `SELECT` / DML this explains.
- ADR-0000 — the out-of-scope discipline that reframed OOS-2 as deferred.
+6 -3
View File
File diff suppressed because one or more lines are too long