feat(types)!: drop the blob column type (ADR-0005 Amendment 2)
ci / gate (push) Failing after 35s
ci / manifests (push) Successful in 3s
website / deploy (push) Successful in 35s

blob was a dead-end: declarable but never fillable (no literal in either
mode, seed-unsupported), so a blob column could only ever hold NULL. Remove
Type::Blob + Value/CellValue::Blob + the base64 CSV path + the grammar slot
+ completion/render/type-change/seed handling + the binder refusal (whose
message also carried a user-facing "DSL" copy-rule bug). The vocabulary is
now nine types; base64 stays (clipboard OSC-52).

Backward compat is the ADR-0015 migration framework's first real use: a
v1->v2 format bump (CURRENT_SCHEMA_VERSION across serializer/parser/skeleton)
with a migrator that rewrites `type: blob` -> `type: text`, and a forced .db
rebuild from the migrated text when a blob column was actually converted
(the stale .db keeps a STRICT BLOB engine column). Conversion to text is
non-destructive and CSV-free. Covered by a full-stack integration test and a
Tier-4 PTY test that opens a real legacy v1-blob project.

Also sweeps the nine-type vocabulary through CLAUDE.md, requirements.md, the
website (type reference, seed doc, highlight grammar), and the ADR-0030/0033/
0035 cross-references; CHANGELOG Removed entry; handoff-79.

BREAKING CHANGE: the `blob` column type is removed. Existing projects that
declared a blob column are migrated on first open (the column becomes text;
the original project.yaml is kept as a .v1.bak).
This commit is contained in:
claude@clouddev1
2026-06-22 21:25:38 +00:00
parent 1a2002dbf6
commit 6b4c4dcea4
41 changed files with 506 additions and 390 deletions
+8
View File
@@ -21,6 +21,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
foreground/background contrast and its syntax-colour distinctness verified foreground/background contrast and its syntax-colour distinctness verified
on each build. on each build.
### Removed
- The `blob` column type. It could be declared but never given a value
(there was no way to type a binary value in either mode), so it was a
dead end with nothing to teach. The vocabulary is now nine types.
Existing projects that used a `blob` column still open — the column is
converted to `text` on first load (a backup of the original project file
is kept alongside it).
### Fixed ### Fixed
- Pasting or scripting several commands at once no longer occasionally - Pasting or scripting several commands at once no longer occasionally
rejects a valid simple-mode `insert` — submitted right after adding a rejects a valid simple-mode `insert` — submitted right after adding a
+2 -2
View File
@@ -58,8 +58,8 @@ Current decisions at a glance (each backed by an ADR):
`[temp]`-marker / contents-allowlist guards. Anything `[temp]`-marker / contents-allowlist guards. Anything
unexpected → refuse, never delete the wrong thing. unexpected → refuse, never delete the wrong thing.
- **Types:** `text`, `int`, `real`, `decimal`, `bool`, `date`, - **Types:** `text`, `int`, `real`, `decimal`, `bool`, `date`,
`datetime`, `blob`, `serial`, `shortid`. Compound primary keys `datetime`, `serial`, `shortid`. Compound primary keys
supported. No real UUIDs (ADR-0005). FK column type supported. No real UUIDs; `blob` dropped (ADR-0005 Amendment 2). FK column type
compatibility via `Type::fk_target_type()``serial → int`, compatibility via `Type::fk_target_type()``serial → int`,
`shortid → text`, others identity (ADR-0011). `shortid → text`, others identity (ADR-0011).
- **Safety:** append-only `history.log` for replay and scripting - **Safety:** append-only `history.log` for replay and scripting
+2 -2
View File
@@ -172,7 +172,7 @@ representation; the SQL surface does not round-trip through it.
### 5. Type vocabulary — the playground's, not the engine's ### 5. Type vocabulary — the playground's, not the engine's
Advanced-mode DDL uses the playground's own ten-type Advanced-mode DDL uses the playground's own nine-type
vocabulary (ADR-0005). There is **no fallback to engine vocabulary (ADR-0005). There is **no fallback to engine
storage types**: a column created in advanced mode is a storage types**: a column created in advanced mode is a
first-class `serial` / `decimal` / `date` / … exactly as a first-class `serial` / `decimal` / `date` / … exactly as a
@@ -373,7 +373,7 @@ ADR when taken up (ADR-0026-style).
- ADR-0002 — the engine is an implementation detail; "no - ADR-0002 — the engine is an implementation detail; "no
engine name in user-facing strings" — §7 extends it. engine name in user-facing strings" — §7 extends it.
- ADR-0003 — the simple / advanced mode model this builds on. - ADR-0003 — the simple / advanced mode model this builds on.
- ADR-0005 — the ten-type vocabulary advanced DDL uses (§5). - ADR-0005 — the nine-type vocabulary advanced DDL uses (§5).
- ADR-0009 — the DSL conventions; the DSL stays usable in - ADR-0009 — the DSL conventions; the DSL stays usable in
advanced mode. advanced mode.
- ADR-0012 / ADR-0013 — the metadata tables the `Command` core - ADR-0012 / ADR-0013 — the metadata tables the `Command` core
+1 -1
View File
@@ -1649,7 +1649,7 @@ more.
## See also ## See also
- ADR-0005 — the ten-type vocabulary INSERT works with. - ADR-0005 — the nine-type vocabulary INSERT works with.
- ADR-0006 — the auto-snapshot before destructive ops. - ADR-0006 — the auto-snapshot before destructive ops.
- ADR-0014 — the DSL DML model + cascade-summary precedent. - ADR-0014 — the DSL DML model + cascade-summary precedent.
- ADR-0015 — the persistence write-through path. - ADR-0015 — the persistence write-through path.
+3 -3
View File
@@ -58,7 +58,7 @@ Two things from the earlier phases shape this one:
executed as-is, the engine would make the table, but the executed as-is, the engine would make the table, but the
playground would lose what the user meant: that `id` is `serial`, playground would lose what the user meant: that `id` is `serial`,
that a `REFERENCES` clause is a *named relationship*, that `STRICT` that a `REFERENCES` clause is a *named relationship*, that `STRICT`
applies, that the ten-type vocabulary governs. Recovering that applies, that the nine-type vocabulary governs. Recovering that
needs the parsed statement either way. needs the parsed statement either way.
ADR-0030 §4 said "DDL → a `Command` … run the typed executor." That ADR-0030 §4 said "DDL → a `Command` … run the typed executor." That
@@ -92,7 +92,7 @@ mode. Unlike the DML `Sql*` commands they **execute structurally**:
the handler reads the parsed structure and performs the schema change the handler reads the parsed structure and performs the schema change
through the playground's metadata-maintaining machinery — writing through the playground's metadata-maintaining machinery — writing
`__rdbms_playground_columns` / `__rdbms_playground_relationships`, `__rdbms_playground_columns` / `__rdbms_playground_relationships`,
applying `STRICT`, using the ten-type vocabulary — so an applying `STRICT`, using the nine-type vocabulary — so an
advanced-mode-created object is a first-class playground object, advanced-mode-created object is a first-class playground object,
identical to a simple-mode-created one (ADR-0030 §5). identical to a simple-mode-created one (ADR-0030 §5).
@@ -772,5 +772,5 @@ honouring it is not the lesson.
- **ADR-0017** — the column type-change classification §7 shares. - **ADR-0017** — the column type-change classification §7 shares.
- **ADR-0029** — column constraints; **ADR-0025** — indexes; - **ADR-0029** — column constraints; **ADR-0025** — indexes;
**ADR-0011** — FK column-type compatibility; **ADR-0005** — the **ADR-0011** — FK column-type compatibility; **ADR-0005** — the
ten-type vocabulary. nine-type vocabulary.
- **ADR-0006** — undo; each DDL statement is one undo step (§10). - **ADR-0006** — undo; each DDL statement is one undo step (§10).
+107
View File
@@ -0,0 +1,107 @@
# Session handoff — 2026-06-22 (79)
A long session continuing the open-issue sweep (handoff-77/78). Cleared the
**help** issue (#36) and the **schema-cache** bug (#39) earlier; this note
mainly records the big piece: **dropping the `blob` column type** (ADR-0005
Amendment 2), which grew out of a one-line copy-rule fix.
## §1. State
**Branch `main`.** Commits this session so far (all on `main`, **not pushed**):
`07575da` (#39 gate) · `e88fa79` (handoff-77 + changelog rule) · `3ad4aff`
(#36 help) · `64818c0` (handoff-78) · `1a2002d` (**ADR-0005 Amendment 2** —
the blob-drop decision record).
**Uncommitted: the blob-removal implementation** (39 files) — staged for review,
commit proposed at session end (see §4). **Full suite green: 2520 passed / 0
failed / 1 ignored** (1810 lib + 7 e2e_pty + 503 it + 200 typing).
`clippy --all-targets -D warnings` + `fmt --check` clean.
**Closed this session:** #36, #39. **Open:** #37 (clause hints), #38
(diagnostic route — needs a do/defer/close call), #40 (winget release notes).
## §2. What shipped — drop `blob` (ADR-0005 Amendment 2)
**Why** (recorded in the ADR): `blob` was a dead-end type — declarable but
never fillable (no literal in either mode, `seed`-unsupported), so a blob
column could only ever hold NULL. It rode along as the engine's fourth
storage class and never got the pedagogical scrutiny UUIDs (excluded) and
compound PKs (defended) did. The vocabulary is now **nine types**.
**Discovered via:** the `value.rs:106` binder message *"…not supported in DSL
yet"* — a user-facing copy-rule violation (it `passthrough`es to the user via
`DbError::InvalidValue`). Rather than reword, we removed the type; the message
is deleted outright.
**Implementation (test-first throughout):**
- **Removal** (compiler-guided): `Type::Blob` + its `keyword`/`sqlite`/`all()`/
`from_sql_name` alias arms; `Value::Blob`; `CellValue::Blob(Vec<u8>)` + the
base64 CSV encode/decode; the `BLOB_SLOT` grammar slot; completion's `blob`
candidate; `output_render`'s `BLOB`; the type-change `↔ blob` static refusal;
the seed blob arms; the binder refusal. User-facing strings: dropped `blob`
from `help.types_reference` and removed the `hint.value_slot_blob` slot hint.
`base64` **stays** (clipboard OSC-52).
- **Backward-compat migration** (the ADR-0015 framework's **first real use**):
a **v1→v2 format bump**. `parse_schema` hard-pinned the version to 1, so this
rippled into a `CURRENT_SCHEMA_VERSION = 2` constant used by the serializer +
parser + skeleton, the first registered migrator (`migrate_v1_to_v2`:
rewrites `type: blob``type: text` + bumps the version), and ~30 `version: 1`
test fixtures. The runtime (already wired to `ensure_project_yaml_migrated`)
**forces a `.db` rebuild** from the migrated text **only when a blob column
was actually converted** (`body_declares_blob_column` pre-read at both open
sites) — the stale `.db` keeps a `STRICT … BLOB` engine column + `"blob"`
metadata it would otherwise use as-is. Conversion target **text** (not drop)
was the user's call (non-destructive, CSV-free, PK/FK-safe).
- **Tests:** migrator unit tests (incl. a column *named* `blob` + a `blob`
substring in a CHECK left untouched); a **full-stack** integration test
`tests/it/blob_removal_migration.rs` (v1 blob project → migrate → `text`
column + row data preserved + `.bak`); a **Tier-4 PTY** end-to-end test
`e2e_pty::opens_a_legacy_v1_blob_project_by_migrating_to_text` (the real
binary opens a seeded legacy v1-blob project via `--resume` → migrate +
rebuild + `text` column) — added in the implementation `/runda` pass to
cover the runtime's migrate-on-open glue; updated all-types tests to nine;
regenerated 5 typing-surface snapshots (reviewed — only the `blob`/`data`
candidate vanished).
- **Docs:** ADR-0005 Amendment 2 (committed `1a2002d`) + README index; swept
`ten-type``nine-type` / removed `blob` from CLAUDE.md, `requirements.md`,
the website (`reference/types.md`, the seed doc's stale `not null blob`
bullet, the `rdbms.mjs` highlight grammar), and the cross-reference counts in
ADR-0030/0033/0035. CHANGELOG `[Unreleased] → Removed`.
## §3. Decisions & notes for next time
- **convert-to-text, not drop-the-column** (user, 2026-06-22): drop would force
rewriting every `data/*.csv` past the rebuild's strict header check — not
worth it for a few-days-old, few-users tool.
- **Full v2 framework migration, not parse-time leniency** (user): the proper
recorded migration with `.bak`, exercising the dormant framework.
- **`BlobLit` grammar terminal deliberately left** — a pre-existing,
`#[allow(dead_code)]` speculative token never used by any grammar; orthogonal
to the type vocabulary. Its "blob literal" string is unreachable. Removing it
is a separate optional cleanup (touches the grammar core).
- **Known assumption:** the migrator + `body_declares_blob_column` match the
serializer's *inline* column format (`- { name: …, type: … }`). A
hand-written *block-style* `project.yaml` with `type: blob` wouldn't convert
(and would then fail to parse). `project.yaml` is always machine-serialized
inline, so this is fine in practice — noted in case a future format change
moves away from inline columns.
- **Other ADRs (0030/0035) keep their historical `blob` content** (type-change
matrix, `binary`/`varbinary` aliases) per the project's supersede-don't-rewrite
ADR philosophy; ADR-0005 Amendment 2 is the authoritative record.
## §4. Process pins / next
- Commits user-confirmed, no AI attribution, append-only, on `main`; **push is
the user's step** (everything this session is unpushed).
- **`/runda` was run on both the ADR (design) and the implementation.** The
implementation pass found one real gap — the runtime's migrate-on-open glue
was untested — closed with the new Tier-4 PTY test. It also verified the
import path migrates (extract→migrate→rebuild) and the non-migration
`parse_schema` callers fail-safe.
- Proposed commit split for the implementation: (1) `feat(types)!` — code +
tests + CHANGELOG; (2) `docs` — the CLAUDE.md/requirements/website/ADR-x-ref
sweep; (3) `docs(handoff-79)`.
- Consider a `cargo sweep` at this milestone.
- **Next open issues:** #37 (clause hints, on-mission), #38 (escalate
do/defer/close first), #40 (winget release notes). Plus: blob removal means a
`cargo sweep`-worthy build and a good moment to push.
+6 -5
View File
@@ -404,7 +404,7 @@ since ADR-0027.)
referenced column), then `ALTER TABLE … ALTER COLUMN TYPE` (4f — referenced column), then `ALTER TABLE … ALTER COLUMN TYPE` (4f —
runtime-decomposed to `change_column_type` with `ForceConversion`, the runtime-decomposed to `change_column_type` with `ForceConversion`, the
§7 advanced policy: lossy converts with a note, incompatible + static §7 advanced policy: lossy converts with a note, incompatible + static
refusals (`↔ blob`, non-`int → serial`) refuse, `int → serial` allowed; refusals (non-`int → serial`) refuse, `int → serial` allowed;
the internal-`__rdbms_*` guard folded into `do_change_column_type`), the internal-`__rdbms_*` guard folded into `do_change_column_type`),
then `ALTER TABLE` add/drop constraint + add FK (4g — `ADD [CONSTRAINT then `ALTER TABLE` add/drop constraint + add FK (4g — `ADD [CONSTRAINT
<name>] (CHECK | UNIQUE | FOREIGN KEY)` + `DROP CONSTRAINT <name>`; <name>] (CHECK | UNIQUE | FOREIGN KEY)` + `DROP CONSTRAINT <name>`;
@@ -430,7 +430,7 @@ since ADR-0027.)
refused with an engine-neutral message naming the construct. refused with an engine-neutral message naming the construct.
Implementation pending.)* Implementation pending.)*
- [x] **Q3** User-facing simplified types map transparently to - [x] **Q3** User-facing simplified types map transparently to
SQLite STRICT types in generated DDL. *(All ten types implemented SQLite STRICT types in generated DDL. *(All nine types implemented
and tested.)* and tested.)*
- [x] **Q4** Supported SQL subset specification — **ADR-0030**. - [x] **Q4** Supported SQL subset specification — **ADR-0030**.
Advanced mode is a standard-SQL surface, engine-neutral; the Advanced mode is a standard-SQL surface, engine-neutral; the
@@ -462,10 +462,11 @@ since ADR-0027.)
## Type system (per ADR-0005) ## Type system (per ADR-0005)
- [x] **T1** All ten user-facing types implemented: `text`, - [x] **T1** All nine user-facing types implemented: `text`,
`int`, `real`, `decimal`, `bool`, `date`, `datetime`, `blob`, `int`, `real`, `decimal`, `bool`, `date`, `datetime`,
`serial`, `shortid`. *(Mapping to SQLite STRICT covered by `serial`, `shortid`. *(Mapping to SQLite STRICT covered by
ADR-0005; FK target type rule by ADR-0011.)* ADR-0005; FK target type rule by ADR-0011; `blob` dropped by
ADR-0005 Amendment 2.)*
- [x] **T2** `shortid` generation: base58, 1012 characters, - [x] **T2** `shortid` generation: base58, 1012 characters,
omits ambiguous characters; generated client-side at insert. omits ambiguous characters; generated client-side at insert.
*(Implemented per ADR-0014; auto-fills omitted shortid *(Implemented per ADR-0014; auto-fills omitted shortid
+2 -3
View File
@@ -631,7 +631,7 @@ pub fn candidates_at_cursor_with_in_mode(
// Source 1.5: type-name candidates when the walker expects // Source 1.5: type-name candidates when the walker expects
// a column-type slot. Type names are a closed set sourced // a column-type slot. Type names are a closed set sourced
// from `Type::all()` (ADR-0005 declaration order: // from `Type::all()` (ADR-0005 declaration order:
// text/int/real/decimal/bool/date/datetime/blob/serial/ // text/int/real/decimal/bool/date/datetime/serial/
// shortid). The walker surfaces this as // shortid). The walker surfaces this as
// `Expectation::Ident { source: Types }`. // `Expectation::Ident { source: Types }`.
let type_names: Vec<String> = if expected.iter().any(|e| { let type_names: Vec<String> = if expected.iter().any(|e| {
@@ -2309,7 +2309,7 @@ mod tests {
#[test] #[test]
fn type_slot_offers_full_type_vocabulary_when_partial_empty() { fn type_slot_offers_full_type_vocabulary_when_partial_empty() {
// After `add column to T: Name (` the parser expects // After `add column to T: Name (` the parser expects
// a column type. With no partial typed, all ten types // a column type. With no partial typed, all nine types
// from `Type::all()` are offered in declaration order. // from `Type::all()` are offered in declaration order.
let cs = cands("add column to T: Name (", 23); let cs = cands("add column to T: Name (", 23);
assert_eq!( assert_eq!(
@@ -2322,7 +2322,6 @@ mod tests {
"bool".to_string(), "bool".to_string(),
"date".to_string(), "date".to_string(),
"datetime".to_string(), "datetime".to_string(),
"blob".to_string(),
"serial".to_string(), "serial".to_string(),
"shortid".to_string(), "shortid".to_string(),
], ],
+7 -53
View File
@@ -3169,7 +3169,10 @@ fn row_value_to_cell(row: &rusqlite::Row<'_>, idx: usize) -> Result<CellValue, D
ValueRef::Integer(n) => CellValue::Integer(n), ValueRef::Integer(n) => CellValue::Integer(n),
ValueRef::Real(f) => CellValue::Real(f), ValueRef::Real(f) => CellValue::Real(f),
ValueRef::Text(bytes) => CellValue::Text(String::from_utf8_lossy(bytes).into_owned()), ValueRef::Text(bytes) => CellValue::Text(String::from_utf8_lossy(bytes).into_owned()),
ValueRef::Blob(bytes) => CellValue::Blob(bytes.to_vec()), // `blob` was removed from the vocabulary (ADR-0005 Amendment 2), so no
// column is BLOB-typed any more and a blob value-ref is not expected.
// Surface any stray bytes as lossy text rather than panicking.
ValueRef::Blob(bytes) => CellValue::Text(String::from_utf8_lossy(bytes).into_owned()),
}) })
} }
@@ -3627,11 +3630,6 @@ fn do_drop_table(
/// rows receive a generated value (1..N for serial, fresh /// rows receive a generated value (1..N for serial, fresh
/// shortids for shortid) and the column gains UNIQUE + /// shortids for shortid) and the column gains UNIQUE +
/// NOT NULL. /// NOT NULL.
///
/// `blob` is statically refused as a column-add target by the
/// existing infrastructure (no DSL literal, downstream INSERT
/// would fail), but the path itself is allowed via the plain
/// branch — same as today.
fn do_add_column( fn do_add_column(
conn: &Connection, conn: &Connection,
persistence: Option<&Persistence>, persistence: Option<&Persistence>,
@@ -5043,7 +5041,7 @@ fn do_rename_table(
/// ///
/// 1. Target is `serial` (auto-increment is create-table-only). /// 1. Target is `serial` (auto-increment is create-table-only).
/// 2. Source ↔ target is statically refused per the matrix /// 2. Source ↔ target is statically refused per the matrix
/// (same-type, blob, date↔datetime, undefined cross-domain). /// (same-type, date↔datetime, undefined cross-domain).
/// 3. Column is the *child* side of any relationship (outbound /// 3. Column is the *child* side of any relationship (outbound
/// FK) — drop the relationship first. /// FK) — drop the relationship first.
/// 4. Column has any inbound FK (parent side) and the new type /// 4. Column has any inbound FK (parent side) and the new type
@@ -8641,9 +8639,7 @@ fn sample_parent_key_tuples(
/// ///
/// Foreign-key columns are filled by sampling existing parent rows /// Foreign-key columns are filled by sampling existing parent rows
/// (D14); a compound FK reads all its child columns from one sampled /// (D14); a compound FK reads all its child columns from one sampled
/// parent row. An empty parent is refused with a friendly error. A /// parent row. An empty parent is refused with a friendly error.
/// `NOT NULL blob` column (which seed cannot generate) is refused by
/// the block guard (D1); a nullable blob is omitted (→ NULL).
/// ///
/// **Phase 2 (SD2):** when `target_column` is `Some`, this delegates to /// **Phase 2 (SD2):** when `target_column` is `Some`, this delegates to
/// [`do_seed_column_fill`] (fill one column across existing rows, D1 /// [`do_seed_column_fill`] (fill one column across existing rows, D1
@@ -8719,17 +8715,6 @@ fn do_seed(
if matches!(ty, Type::Serial) { if matches!(ty, Type::Serial) {
continue; continue;
} }
// blob has no DSL value path: refuse if required (D1), else omit.
if matches!(ty, Type::Blob) {
if c.notnull {
return Err(DbError::Unsupported(format!(
"cannot seed `{table}`: column `{}` is `NOT NULL` but has type `blob`, \
which seed cannot generate. Add the rows another way or make it nullable.",
c.name,
)));
}
continue;
}
col_names.push(c.name.clone()); col_names.push(c.name.clone());
if let Some(&(fk_idx, pos)) = fk_child_pos.get(c.name.as_str()) { if let Some(&(fk_idx, pos)) = fk_child_pos.get(c.name.as_str()) {
plans.push(SeedColPlan::ForeignKey { fk_idx, pos }); plans.push(SeedColPlan::ForeignKey { fk_idx, pos });
@@ -9122,7 +9107,7 @@ fn seed_override_literal(value: &Value, column: &str) -> Result<String, DbError>
/// Column-fill (ADR-0048 D1 form 2): fill one column across the table's /// Column-fill (ADR-0048 D1 form 2): fill one column across the table's
/// **existing** rows (an UPDATE), the natural follow-up to `add column`. /// **existing** rows (an UPDATE), the natural follow-up to `add column`.
/// ///
/// Refuses PK and auto-generated (`serial`/`shortid`/`blob`) targets; /// Refuses PK and auto-generated (`serial`/`shortid`) targets;
/// an empty table is a friendly no-op. The `set` clause may only adjust /// an empty table is a friendly no-op. The `set` clause may only adjust
/// the column being filled (the rest of the per-column heuristics do not /// the column being filled (the rest of the per-column heuristics do not
/// apply — there is exactly one column). A UNIQUE / identifier target /// apply — there is exactly one column). A UNIQUE / identifier target
@@ -9178,12 +9163,6 @@ fn do_seed_column_fill(
ty.keyword(), ty.keyword(),
))); )));
} }
if matches!(ty, Type::Blob) {
return Err(DbError::Unsupported(format!(
"cannot fill `{table}.{canonical_col}`: seed cannot generate `blob` values."
)));
}
// The `set` clause may only adjust the filled column (user decision). // The `set` clause may only adjust the filled column (user decision).
for ov in overrides { for ov in overrides {
if !ov.column.eq_ignore_ascii_case(&canonical_col) { if !ov.column.eq_ignore_ascii_case(&canonical_col) {
@@ -11230,7 +11209,6 @@ fn cell_value_to_sqlite(cell: &CellValue) -> rusqlite::types::Value {
CellValue::Integer(n) => Value::Integer(*n), CellValue::Integer(n) => Value::Integer(*n),
CellValue::Real(f) => Value::Real(*f), CellValue::Real(f) => Value::Real(*f),
CellValue::Text(s) => Value::Text(s.clone()), CellValue::Text(s) => Value::Text(s.clone()),
CellValue::Blob(b) => Value::Blob(b.clone()),
} }
} }
@@ -12912,30 +12890,6 @@ mod tests {
} }
} }
#[tokio::test]
async fn change_column_type_blob_target_refused_statically() {
let db = db();
make_id_table(&db, "T").await;
db.add_column(
"T".to_string(),
ColumnSpec::new("Note".to_string(), Type::Text),
None,
)
.await
.unwrap();
let err = db
.change_column_type(
"T".to_string(),
"Note".to_string(),
Type::Blob,
ChangeColumnMode::Default,
None,
)
.await
.unwrap_err();
assert!(matches!(err, DbError::Unsupported(_)), "got {err:?}");
}
#[tokio::test] #[tokio::test]
async fn change_column_type_outbound_fk_refused() { async fn change_column_type_outbound_fk_refused() {
// Child-side FK column: §4.2 says always refuse, // Child-side FK column: §4.2 says always refuse,
+1 -7
View File
@@ -263,11 +263,6 @@ const DATETIME_SLOT: Node = Node::TypedValueSlot {
column_name: None, column_name: None,
inner: &TEXT_SLOT_INNER, inner: &TEXT_SLOT_INNER,
}; };
const BLOB_SLOT: Node = Node::TypedValueSlot {
ty: Type::Blob,
column_name: None,
inner: &TEXT_SLOT_INNER,
};
// shortid columns store base58 text (ADR-0011 fk_target_type // shortid columns store base58 text (ADR-0011 fk_target_type
// shortid → text); the slot accepts a quoted-text literal or // shortid → text); the slot accepts a quoted-text literal or
// null. // null.
@@ -297,7 +292,6 @@ pub const fn slot_for_type(ty: Type) -> Node {
Type::Text => TEXT_SLOT, Type::Text => TEXT_SLOT,
Type::Date => DATE_SLOT, Type::Date => DATE_SLOT,
Type::DateTime => DATETIME_SLOT, Type::DateTime => DATETIME_SLOT,
Type::Blob => BLOB_SLOT,
} }
} }
@@ -310,7 +304,7 @@ const fn slot_inner_for_type(ty: Type) -> &'static Node {
Type::Real => &REAL_SLOT_INNER, Type::Real => &REAL_SLOT_INNER,
Type::Decimal => &DECIMAL_SLOT_INNER, Type::Decimal => &DECIMAL_SLOT_INNER,
Type::Bool => &BOOL_SLOT_INNER, Type::Bool => &BOOL_SLOT_INNER,
Type::Text | Type::Date | Type::DateTime | Type::Blob | Type::ShortId => &TEXT_SLOT_INNER, Type::Text | Type::Date | Type::DateTime | Type::ShortId => &TEXT_SLOT_INNER,
} }
} }
+2 -3
View File
@@ -567,7 +567,7 @@ mod tests {
fn standard_sql_type_aliases() { fn standard_sql_type_aliases() {
good("table t (a integer, b varchar, c boolean, d timestamp)"); good("table t (a integer, b varchar, c boolean, d timestamp)");
good("table t (e bigint, f smallint, g char, h numeric)"); good("table t (e bigint, f smallint, g char, h numeric)");
good("table t (i binary, j varbinary, k float)"); good("table t (k float)");
} }
#[test] #[test]
@@ -734,7 +734,7 @@ mod builder_tests {
fn standard_sql_aliases_map_to_playground_types() { fn standard_sql_aliases_map_to_playground_types() {
let (_, cols, _, _) = sct( let (_, cols, _, _) = sct(
"create table t (a bigint, b varchar, c boolean, d timestamp, \ "create table t (a bigint, b varchar, c boolean, d timestamp, \
e numeric, f float, g binary)", e numeric, f float)",
); );
assert_eq!( assert_eq!(
cols, cols,
@@ -745,7 +745,6 @@ mod builder_tests {
("d".to_string(), Type::DateTime), ("d".to_string(), Type::DateTime),
("e".to_string(), Type::Decimal), ("e".to_string(), Type::Decimal),
("f".to_string(), Type::Real), ("f".to_string(), Type::Real),
("g".to_string(), Type::Blob),
] ]
); );
} }
+17 -31
View File
@@ -1,7 +1,8 @@
//! User-facing column types and their mapping to SQLite STRICT. //! User-facing column types and their mapping to SQLite STRICT.
//! //!
//! Implements the full ten-type vocabulary committed to in //! Implements the nine-type vocabulary committed to in ADR-0005
//! ADR-0005. Storage choices for the text-backed types //! (ten as originally decided; `blob` dropped by Amendment 2).
//! Storage choices for the text-backed types
//! (`decimal`, `date`, `datetime`) preserve precision and ISO //! (`decimal`, `date`, `datetime`) preserve precision and ISO
//! readability; comparisons rely on lexicographic ordering or //! readability; comparisons rely on lexicographic ordering or
//! explicit casts at query time, which is acceptable for a //! explicit casts at query time, which is acceptable for a
@@ -27,8 +28,6 @@ pub enum Type {
Date, Date,
/// ISO 8601 datetime stored as `YYYY-MM-DDTHH:MM:SS[.fff][Z]` (TEXT). /// ISO 8601 datetime stored as `YYYY-MM-DDTHH:MM:SS[.fff][Z]` (TEXT).
DateTime, DateTime,
/// Arbitrary binary data.
Blob,
/// Auto-incrementing integer; intended as a default primary key. /// Auto-incrementing integer; intended as a default primary key.
Serial, Serial,
/// 1012 character base58 random identifier (no ambiguous chars). /// 1012 character base58 random identifier (no ambiguous chars).
@@ -47,7 +46,6 @@ impl Type {
Self::Bool => "bool", Self::Bool => "bool",
Self::Date => "date", Self::Date => "date",
Self::DateTime => "datetime", Self::DateTime => "datetime",
Self::Blob => "blob",
Self::Serial => "serial", Self::Serial => "serial",
Self::ShortId => "shortid", Self::ShortId => "shortid",
} }
@@ -62,7 +60,6 @@ impl Type {
Self::Text | Self::ShortId | Self::Decimal | Self::Date | Self::DateTime => "TEXT", Self::Text | Self::ShortId | Self::Decimal | Self::Date | Self::DateTime => "TEXT",
Self::Int | Self::Serial | Self::Bool => "INTEGER", Self::Int | Self::Serial | Self::Bool => "INTEGER",
Self::Real => "REAL", Self::Real => "REAL",
Self::Blob => "BLOB",
} }
} }
@@ -79,8 +76,8 @@ impl Type {
/// All types known in this iteration, in stable order. /// All types known in this iteration, in stable order.
/// Ordering groups numeric types together, then boolean, /// Ordering groups numeric types together, then boolean,
/// then temporal, then binary, then identity-flavoured /// then temporal, then identity-flavoured auto-generated
/// auto-generated types. /// types.
#[must_use] #[must_use]
pub const fn all() -> &'static [Self] { pub const fn all() -> &'static [Self] {
&[ &[
@@ -91,15 +88,14 @@ impl Type {
Self::Bool, Self::Bool,
Self::Date, Self::Date,
Self::DateTime, Self::DateTime,
Self::Blob,
Self::Serial, Self::Serial,
Self::ShortId, Self::ShortId,
] ]
} }
/// True for the numeric types — `int`, `real`, `decimal`, /// True for the numeric types — `int`, `real`, `decimal`,
/// `serial`. `bool` (stored 0/1) and the text- / blob-backed /// `serial`. `bool` (stored 0/1) and the text-backed types
/// types are not numeric. Used to flag a `LIKE` text-pattern /// are not numeric. Used to flag a `LIKE` text-pattern
/// match against a numeric column (ADR-0027, Amendment 1). /// match against a numeric column (ADR-0027, Amendment 1).
#[must_use] #[must_use]
pub const fn is_numeric(self) -> bool { pub const fn is_numeric(self) -> bool {
@@ -129,7 +125,7 @@ impl Type {
} }
/// Resolve a type name from the **advanced-mode SQL** type slot /// Resolve a type name from the **advanced-mode SQL** type slot
/// (ADR-0035 §3). Accepts the ten playground keywords *and* the /// (ADR-0035 §3). Accepts the nine playground keywords *and* the
/// standard-SQL aliases mapped onto them. Case-insensitive; /// standard-SQL aliases mapped onto them. Case-insensitive;
/// internal whitespace is collapsed so `double precision` resolves /// internal whitespace is collapsed so `double precision` resolves
/// regardless of spacing. Returns `None` for an unrecognised name — /// regardless of spacing. Returns `None` for an unrecognised name —
@@ -137,7 +133,7 @@ impl Type {
/// diagnostic. /// diagnostic.
/// ///
/// Deliberately distinct from [`FromStr`](std::str::FromStr), which /// Deliberately distinct from [`FromStr`](std::str::FromStr), which
/// is the *simple-mode* parser and accepts only the ten keywords /// is the *simple-mode* parser and accepts only the nine keywords
/// (no aliases), so simple mode teaches the playground's own /// (no aliases), so simple mode teaches the playground's own
/// vocabulary. A length/precision argument (`varchar(255)`) is /// vocabulary. A length/precision argument (`varchar(255)`) is
/// stripped by the grammar before the name reaches this resolver. /// stripped by the grammar before the name reaches this resolver.
@@ -157,8 +153,7 @@ impl Type {
"timestamp" => Some(Self::DateTime), "timestamp" => Some(Self::DateTime),
"numeric" => Some(Self::Decimal), "numeric" => Some(Self::Decimal),
"float" | "double precision" => Some(Self::Real), "float" | "double precision" => Some(Self::Real),
"binary" | "varbinary" => Some(Self::Blob), // Fall through to the canonical nine keywords.
// Fall through to the canonical ten keywords.
other => other.parse::<Self>().ok(), other => other.parse::<Self>().ok(),
} }
} }
@@ -270,20 +265,19 @@ mod tests {
Type::Bool, Type::Bool,
Type::Date, Type::Date,
Type::DateTime, Type::DateTime,
Type::Blob,
] { ] {
assert_eq!(ty.fk_target_type(), ty); assert_eq!(ty.fk_target_type(), ty);
} }
} }
#[test] #[test]
fn all_ten_types_are_present_and_distinct() { fn all_nine_types_are_present_and_distinct() {
let kws: Vec<&'static str> = Type::all().iter().map(|t| t.keyword()).collect(); let kws: Vec<&'static str> = Type::all().iter().map(|t| t.keyword()).collect();
assert_eq!(kws.len(), 10); assert_eq!(kws.len(), 9);
let mut sorted = kws.clone(); let mut sorted = kws.clone();
sorted.sort_unstable(); sorted.sort_unstable();
sorted.dedup(); sorted.dedup();
assert_eq!(sorted.len(), 10, "keywords must be unique"); assert_eq!(sorted.len(), 9, "keywords must be unique");
} }
#[test] #[test]
@@ -294,16 +288,10 @@ mod tests {
} }
#[test] #[test]
fn blob_type_maps_to_blob_storage() { fn unknown_type_message_lists_all_nine() {
assert_eq!(Type::Blob.sqlite_strict_type(), "BLOB");
}
#[test]
fn unknown_type_message_lists_all_ten() {
let err = "varchar".parse::<Type>().unwrap_err(); let err = "varchar".parse::<Type>().unwrap_err();
for kw in [ for kw in [
"text", "int", "real", "decimal", "bool", "date", "datetime", "blob", "serial", "text", "int", "real", "decimal", "bool", "date", "datetime", "serial", "shortid",
"shortid",
] { ] {
assert!( assert!(
err.expected.contains(kw), err.expected.contains(kw),
@@ -314,12 +302,12 @@ mod tests {
} }
// --- ADR-0035 §3: advanced-mode SQL type-name resolution --- // --- ADR-0035 §3: advanced-mode SQL type-name resolution ---
// `from_sql_name` accepts the ten playground keywords *and* the // `from_sql_name` accepts the nine playground keywords *and* the
// standard-SQL aliases. Simple-mode `FromStr` is unchanged (it // standard-SQL aliases. Simple-mode `FromStr` is unchanged (it
// still rejects aliases — see `unknown_type_lists_expected_alternatives`). // still rejects aliases — see `unknown_type_lists_expected_alternatives`).
#[test] #[test]
fn sql_resolver_accepts_the_ten_canonical_keywords() { fn sql_resolver_accepts_the_nine_canonical_keywords() {
for &ty in Type::all() { for &ty in Type::all() {
assert_eq!(Type::from_sql_name(ty.keyword()), Some(ty)); assert_eq!(Type::from_sql_name(ty.keyword()), Some(ty));
} }
@@ -338,8 +326,6 @@ mod tests {
("numeric", Type::Decimal), ("numeric", Type::Decimal),
("float", Type::Real), ("float", Type::Real),
("double precision", Type::Real), ("double precision", Type::Real),
("binary", Type::Blob),
("varbinary", Type::Blob),
] { ] {
assert_eq!( assert_eq!(
Type::from_sql_name(alias), Type::from_sql_name(alias),
-11
View File
@@ -101,10 +101,6 @@ impl Value {
Type::Bool => self.bind_bool(column), Type::Bool => self.bind_bool(column),
Type::Date => self.bind_date(column), Type::Date => self.bind_date(column),
Type::DateTime => self.bind_datetime(column), Type::DateTime => self.bind_datetime(column),
Type::Blob => Err(ValueError::Format {
column: column.to_string(),
message: "literal `blob` values are not supported in DSL yet".to_string(),
}),
} }
} }
@@ -320,7 +316,6 @@ mod tests {
#[test] #[test]
fn null_binds_to_null_for_any_type() { fn null_binds_to_null_for_any_type() {
for ty in Type::all() { for ty in Type::all() {
// Skip blob — null still works there too.
assert_eq!(Value::Null.bind_for_column("c", *ty).unwrap(), Bound::Null); assert_eq!(Value::Null.bind_for_column("c", *ty).unwrap(), Bound::Null);
} }
} }
@@ -429,10 +424,4 @@ mod tests {
let err = n("3..14").bind_for_column("c", Type::Decimal).unwrap_err(); let err = n("3..14").bind_for_column("c", Type::Decimal).unwrap_err();
assert!(matches!(err, ValueError::Format { .. })); assert!(matches!(err, ValueError::Format { .. }));
} }
#[test]
fn blob_inserts_are_explicitly_unsupported_for_now() {
let err = t("0xdead").bind_for_column("c", Type::Blob).unwrap_err();
assert!(matches!(err, ValueError::Format { message, .. } if message.contains("blob")));
}
} }
-2
View File
@@ -240,7 +240,6 @@ const fn catalog_key_for_value_type(ty: crate::dsl::types::Type) -> &'static str
Type::Text => "hint.value_slot_text", Type::Text => "hint.value_slot_text",
Type::Date => "hint.value_slot_date", Type::Date => "hint.value_slot_date",
Type::DateTime => "hint.value_slot_datetime", Type::DateTime => "hint.value_slot_datetime",
Type::Blob => "hint.value_slot_blob",
Type::Serial => "hint.value_slot_serial", Type::Serial => "hint.value_slot_serial",
Type::ShortId => "hint.value_slot_shortid", Type::ShortId => "hint.value_slot_shortid",
} }
@@ -4745,7 +4744,6 @@ mod tests {
(Type::Text, "hint.value_slot_text"), (Type::Text, "hint.value_slot_text"),
(Type::Date, "hint.value_slot_date"), (Type::Date, "hint.value_slot_date"),
(Type::DateTime, "hint.value_slot_datetime"), (Type::DateTime, "hint.value_slot_datetime"),
(Type::Blob, "hint.value_slot_blob"),
] { ] {
let schema = schema_with("T", &[("c", ty)]); let schema = schema_with("T", &[("c", ty)]);
let mode = hint_mode_at_input_with_schema("insert into T values (", &schema); let mode = hint_mode_at_input_with_schema("insert into T values (", &schema);
-1
View File
@@ -425,7 +425,6 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
("hint.value_literal_slot", &[]), ("hint.value_literal_slot", &[]),
("hint.ambient_typing_name_then", &["next"]), ("hint.ambient_typing_name_then", &["next"]),
// Per-column-type value-slot hints (ADR-0024 §Phase D). // Per-column-type value-slot hints (ADR-0024 §Phase D).
("hint.value_slot_blob", &[]),
("hint.value_slot_bool", &[]), ("hint.value_slot_bool", &[]),
("hint.value_slot_date", &[]), ("hint.value_slot_date", &[]),
("hint.value_slot_datetime", &[]), ("hint.value_slot_datetime", &[]),
+1 -2
View File
@@ -395,7 +395,7 @@ help:
without running it (advanced SQL) without running it (advanced SQL)
# Type reference, appended after the command list. # Type reference, appended after the command list.
types_reference: | types_reference: |
Types: text, int, real, decimal, bool, date, datetime, blob, serial, shortid Types: text, int, real, decimal, bool, date, datetime, serial, shortid
Auto-generated types (serial, shortid): Auto-generated types (serial, shortid):
serial — integer that auto-fills with the next sequence value serial — integer that auto-fills with the next sequence value
(MAX(col)+1) on insert. Outside a primary key it carries (MAX(col)+1) on insert. Outside a primary key it carries
@@ -714,7 +714,6 @@ hint:
value_slot_text: "Type a quoted string (e.g. 'Alice') or null" value_slot_text: "Type a quoted string (e.g. 'Alice') or null"
value_slot_date: "Type a quoted date as 'YYYY-MM-DD' or null" value_slot_date: "Type a quoted date as 'YYYY-MM-DD' or null"
value_slot_datetime: "Type a quoted datetime as 'YYYY-MM-DD HH:MM:SS' or null" value_slot_datetime: "Type a quoted datetime as 'YYYY-MM-DD HH:MM:SS' or null"
value_slot_blob: "Type a quoted blob literal or null"
# Serial / shortid in `values (…)` form: the user must enter # Serial / shortid in `values (…)` form: the user must enter
# something at this position (no "skip column" syntax). `null` # something at this position (no "skip column" syntax). `null`
# is the auto-fill path (ADR-0018: serial / shortid columns # is the auto-fill path (ADR-0018: serial / shortid columns
+1 -3
View File
@@ -345,7 +345,7 @@ const fn alignment_for(ty: Option<Type>) -> Alignment {
match ty { match ty {
Some(Type::Int | Type::Real | Type::Decimal | Type::Serial) => Alignment::Right, Some(Type::Int | Type::Real | Type::Decimal | Type::Serial) => Alignment::Right,
Some(Type::Text) | Some(Type::Bool) | Some(Type::Date) | Some(Type::DateTime) Some(Type::Text) | Some(Type::Bool) | Some(Type::Date) | Some(Type::DateTime)
| Some(Type::Blob) | Some(Type::ShortId) | None => Alignment::Left, | Some(Type::ShortId) | None => Alignment::Left,
} }
} }
@@ -1334,7 +1334,6 @@ mod tests {
sqlite_type: match ty { sqlite_type: match ty {
Type::Int | Type::Serial | Type::Bool => "INTEGER".to_string(), Type::Int | Type::Serial | Type::Bool => "INTEGER".to_string(),
Type::Real => "REAL".to_string(), Type::Real => "REAL".to_string(),
Type::Blob => "BLOB".to_string(),
_ => "TEXT".to_string(), _ => "TEXT".to_string(),
}, },
notnull, notnull,
@@ -1361,7 +1360,6 @@ mod tests {
Type::Bool, Type::Bool,
Type::Date, Type::Date,
Type::DateTime, Type::DateTime,
Type::Blob,
Type::ShortId, Type::ShortId,
] { ] {
assert_eq!(alignment_for(Some(ty)), Alignment::Left, "{ty:?}"); assert_eq!(alignment_for(Some(ty)), Alignment::Left, "{ty:?}");
-29
View File
@@ -25,8 +25,6 @@
use std::io::Write as _; use std::io::Write as _;
use base64::Engine as _;
use crate::dsl::types::Type; use crate::dsl::types::Type;
use super::{CellValue, TableSnapshot}; use super::{CellValue, TableSnapshot};
@@ -149,12 +147,6 @@ fn encode_cell(ty: Type, value: &CellValue) -> Result<Cell, String> {
CellValue::Text(s) => Ok(Cell::Plain(s.clone())), CellValue::Text(s) => Ok(Cell::Plain(s.clone())),
other => Err(format!("expected date/datetime (text), got {other:?}")), other => Err(format!("expected date/datetime (text), got {other:?}")),
}, },
Type::Blob => match value {
CellValue::Blob(bytes) => Ok(Cell::Plain(
base64::engine::general_purpose::STANDARD.encode(bytes),
)),
other => Err(format!("expected blob, got {other:?}")),
},
Type::Serial => match value { Type::Serial => match value {
CellValue::Integer(n) => Ok(Cell::Plain(n.to_string())), CellValue::Integer(n) => Ok(Cell::Plain(n.to_string())),
other => Err(format!("expected serial (int), got {other:?}")), other => Err(format!("expected serial (int), got {other:?}")),
@@ -362,10 +354,6 @@ pub(crate) fn decode_cell(ty: Type, cell: &RawCell) -> Result<CellValue, String>
"false" => Ok(CellValue::Integer(0)), "false" => Ok(CellValue::Integer(0)),
other => Err(format!("expected `true` or `false`, got `{other}`")), other => Err(format!("expected `true` or `false`, got `{other}`")),
}, },
Type::Blob => base64::engine::general_purpose::STANDARD
.decode(cell.content.as_bytes())
.map(CellValue::Blob)
.map_err(|e| format!("invalid base64 blob: {e}")),
} }
} }
@@ -467,18 +455,6 @@ mod tests {
assert_eq!(s, "b\ntrue\nfalse\n"); assert_eq!(s, "b\ntrue\nfalse\n");
} }
#[test]
fn blobs_use_base64() {
let body = serialize_table(&TableSnapshot {
name: "T".to_string(),
columns: vec![col("blob", Type::Blob)],
rows: vec![vec![CellValue::Blob(b"hello".to_vec())]],
})
.unwrap();
let s = String::from_utf8(body).unwrap();
assert!(s.contains("aGVsbG8="));
}
#[test] #[test]
fn dates_and_datetimes_pass_through() { fn dates_and_datetimes_pass_through() {
let body = serialize_table(&TableSnapshot { let body = serialize_table(&TableSnapshot {
@@ -548,13 +524,11 @@ mod tests {
col("n", Type::Int), col("n", Type::Int),
col("r", Type::Real), col("r", Type::Real),
col("b", Type::Bool), col("b", Type::Bool),
col("blob", Type::Blob),
], ],
rows: vec![vec![ rows: vec![vec![
CellValue::Integer(42), CellValue::Integer(42),
CellValue::Real(std::f64::consts::PI), CellValue::Real(std::f64::consts::PI),
CellValue::Integer(1), CellValue::Integer(1),
CellValue::Blob(b"hi".to_vec()),
]], ]],
}; };
let body = serialize_table(&table).unwrap(); let body = serialize_table(&table).unwrap();
@@ -572,9 +546,6 @@ mod tests {
decode_cell(Type::Bool, &row[2]).unwrap(), decode_cell(Type::Bool, &row[2]).unwrap(),
CellValue::Integer(1) CellValue::Integer(1)
)); ));
assert!(
matches!(decode_cell(Type::Blob, &row[3]).unwrap(), CellValue::Blob(b) if b == b"hi")
);
} }
#[test] #[test]
+104 -16
View File
@@ -44,6 +44,35 @@ use serde::Deserialize;
/// migrator's job is purely the format transformation. /// migrator's job is purely the format transformation.
pub type MigrateFn = fn(&str) -> Result<String, MigrateError>; pub type MigrateFn = fn(&str) -> Result<String, MigrateError>;
/// The newest `project.yaml` format version this build writes and reads.
///
/// Must equal `MigratorRegistry::production().latest_version()` (asserted
/// in tests) and is what the YAML serializer stamps and the parser
/// accepts. Bumped 1 → 2 by **ADR-0005 Amendment 2** (drop `blob`).
pub const CURRENT_SCHEMA_VERSION: u32 = 2;
/// v1 → v2 migrator (ADR-0005 Amendment 2). `blob` was dropped from the
/// type vocabulary, so any `type: blob` column is rewritten to
/// `type: text` (the chosen conversion target — see the ADR). A pure,
/// line-oriented text transform: only column-definition lines (the
/// `- { name: …, type: … }` shape the serializer emits) are touched, so a
/// `blob` substring inside a CHECK expression or a default value is left
/// alone. Also bumps the `version:` field to 2 (the framework asserts the
/// advertised version, so this must stay in step).
fn migrate_v1_to_v2(body: &str) -> Result<String, MigrateError> {
let mut out = String::with_capacity(body.len() + 1);
for line in body.split_inclusive('\n') {
if line.trim_end() == "version: 1" {
out.push_str(&line.replacen("version: 1", "version: 2", 1));
} else if line.contains("{ name:") {
out.push_str(&line.replace(", type: blob", ", type: text"));
} else {
out.push_str(line);
}
}
Ok(out)
}
/// Ordered list of migrators. `migrators[i]` runs from /// Ordered list of migrators. `migrators[i]` runs from
/// version `i + 1` to version `i + 2` (so index 0 is v1→v2, /// version `i + 1` to version `i + 2` (so index 0 is v1→v2,
/// index 1 is v2→v3, etc.). /// index 1 is v2→v3, etc.).
@@ -56,13 +85,12 @@ pub struct MigratorRegistry {
} }
impl MigratorRegistry { impl MigratorRegistry {
/// Production-default registry: empty. As new versions /// Production-default registry. Migrators are listed in
/// land, register the migrators here in source-version /// source-version order (index 0 = v1→v2, …).
/// order.
#[must_use] #[must_use]
pub const fn production() -> Self { pub fn production() -> Self {
Self { Self {
migrators: Vec::new(), migrators: vec![migrate_v1_to_v2],
} }
} }
@@ -156,6 +184,20 @@ pub struct MigrationOutcome {
pub migrated_from: Option<u32>, pub migrated_from: Option<u32>,
} }
/// Whether `body` declares at least one `type: blob` column.
///
/// The runtime uses this as the signal to force a `.db` rebuild after the
/// v1→v2 migration: the migration rewrites the YAML, but the derived
/// `.db` keeps a `STRICT … BLOB` engine column + `"blob"` metadata that
/// load otherwise uses as-is (ADR-0005 Amendment 2). Scoped to
/// column-definition lines, mirroring [`migrate_v1_to_v2`], so a `blob`
/// substring inside a CHECK expression or default doesn't trip it.
#[must_use]
pub fn body_declares_blob_column(body: &str) -> bool {
body.lines()
.any(|l| l.contains("{ name:") && l.contains(", type: blob"))
}
/// Detect the version of `body` and migrate it to the /// Detect the version of `body` and migrate it to the
/// registry's `latest_version()`. /// registry's `latest_version()`.
/// ///
@@ -304,22 +346,68 @@ mod tests {
.to_string() .to_string()
} }
/// A project at the current (latest) version — nothing to migrate.
fn v2_body() -> String {
"version: 2\nproject:\n created_at: 2026-01-01T00:00:00Z\ntables: []\nrelationships: []\n"
.to_string()
}
#[test] #[test]
fn production_registry_latest_version_is_1() { fn production_registry_latest_version_matches_current_schema_version() {
let r = MigratorRegistry::production(); let r = MigratorRegistry::production();
assert_eq!(r.latest_version(), 1); assert_eq!(r.latest_version(), CURRENT_SCHEMA_VERSION);
assert_eq!(r.latest_version(), 2);
} }
#[test] #[test]
fn no_migration_runs_when_body_already_latest() { fn no_migration_runs_when_body_already_latest() {
let tmp = tempdir(); let tmp = tempdir();
let outcome = let outcome =
migrate_to_latest(&v1_body(), &MigratorRegistry::production(), tmp.path()).unwrap(); migrate_to_latest(&v2_body(), &MigratorRegistry::production(), tmp.path()).unwrap();
assert_eq!(outcome.body, v1_body()); assert_eq!(outcome.body, v2_body());
assert_eq!(outcome.migrated_from, None); assert_eq!(outcome.migrated_from, None);
// No .bak written when nothing migrated. // No .bak written when nothing migrated.
let bak = tmp.path().join("project.yaml.v1.bak"); assert!(
assert!(!bak.exists(), "no .bak when no migration"); !tmp.path().join("project.yaml.v2.bak").exists(),
"no .bak when no migration"
);
}
/// ADR-0005 Amendment 2: the real production v1→v2 migrator rewrites a
/// `blob` column to `text` and bumps the version, leaving everything
/// else (incl. a column literally named `blob`, and a `blob` substring
/// inside a CHECK) untouched.
#[test]
fn production_v1_to_v2_converts_blob_columns_to_text() {
let tmp = tempdir();
let body = concat!(
"version: 1\n",
"project:\n created_at: x\n",
"tables:\n",
" - name: Files\n",
" primary_key: [id]\n",
" columns:\n",
" - { name: id, type: serial }\n",
" - { name: payload, type: blob }\n",
" - { name: blob, type: text }\n",
" check_constraints:\n",
" - \"note <> 'type: blob'\"\n",
"relationships: []\n",
);
let outcome =
migrate_to_latest(body, &MigratorRegistry::production(), tmp.path()).unwrap();
assert_eq!(outcome.migrated_from, Some(1));
assert!(outcome.body.contains("version: 2"));
// The blob column became text.
assert!(
outcome.body.contains("{ name: payload, type: text }"),
"blob column converted: {}",
outcome.body
);
// A column NAMED blob is untouched; the CHECK string is untouched.
assert!(outcome.body.contains("{ name: blob, type: text }"));
assert!(outcome.body.contains("note <> 'type: blob'"));
assert!(!outcome.body.contains("type: blob }"));
} }
#[test] #[test]
@@ -332,7 +420,7 @@ mod tests {
err, err,
MigrateError::NewerThanSupported { MigrateError::NewerThanSupported {
file: 99, file: 99,
latest: 1 latest: 2
} }
), ),
"got: {err:?}", "got: {err:?}",
@@ -393,17 +481,17 @@ mod tests {
} }
#[test] #[test]
fn ensure_yaml_migrated_no_op_on_v1_with_empty_registry() { fn ensure_yaml_migrated_no_op_when_already_latest() {
let tmp = tempdir(); let tmp = tempdir();
let yaml_path = tmp.path().join("project.yaml"); let yaml_path = tmp.path().join("project.yaml");
std::fs::write(&yaml_path, v1_body()).unwrap(); std::fs::write(&yaml_path, v2_body()).unwrap();
let outcome = let outcome =
ensure_project_yaml_migrated(tmp.path(), &MigratorRegistry::production()).unwrap(); ensure_project_yaml_migrated(tmp.path(), &MigratorRegistry::production()).unwrap();
assert_eq!(outcome.migrated_from, None); assert_eq!(outcome.migrated_from, None);
// File unchanged. // File unchanged.
let on_disk = std::fs::read_to_string(&yaml_path).unwrap(); let on_disk = std::fs::read_to_string(&yaml_path).unwrap();
assert_eq!(on_disk, v1_body()); assert_eq!(on_disk, v2_body());
assert!(!tmp.path().join("project.yaml.v1.bak").exists()); assert!(!tmp.path().join("project.yaml.v2.bak").exists());
} }
#[test] #[test]
+1 -2
View File
@@ -280,7 +280,6 @@ pub enum CellValue {
Integer(i64), Integer(i64),
Real(f64), Real(f64),
Text(String), Text(String),
Blob(Vec<u8>),
} }
impl Persistence { impl Persistence {
@@ -561,7 +560,7 @@ mod tests {
}; };
p.write_schema(&schema).unwrap(); p.write_schema(&schema).unwrap();
let body = fs::read_to_string(dir.path().join(PROJECT_YAML)).unwrap(); let body = fs::read_to_string(dir.path().join(PROJECT_YAML)).unwrap();
assert!(body.contains("version: 1")); assert!(body.contains("version: 2"));
assert!(body.contains("created_at:")); assert!(body.contains("created_at:"));
} }
+22 -14
View File
@@ -32,7 +32,11 @@ use super::{
#[must_use] #[must_use]
pub(super) fn serialize_schema(schema: &SchemaSnapshot) -> String { pub(super) fn serialize_schema(schema: &SchemaSnapshot) -> String {
let mut out = String::new(); let mut out = String::new();
let _ = writeln!(out, "version: 1"); let _ = writeln!(
out,
"version: {}",
crate::persistence::migrations::CURRENT_SCHEMA_VERSION
);
let _ = writeln!(out, "project:"); let _ = writeln!(out, "project:");
let _ = writeln!(out, " created_at: {}", quote_if_needed(&schema.created_at)); let _ = writeln!(out, " created_at: {}", quote_if_needed(&schema.created_at));
// ADR-0015 mode-restore amendment (issue #14): the input mode // ADR-0015 mode-restore amendment (issue #14): the input mode
@@ -283,7 +287,11 @@ const fn is_safe_yaml_char(c: char) -> bool {
pub(crate) fn parse_schema(body: &str) -> Result<SchemaSnapshot, YamlError> { pub(crate) fn parse_schema(body: &str) -> Result<SchemaSnapshot, YamlError> {
let raw: RawProject = let raw: RawProject =
serde_norway::from_str(body).map_err(|e| YamlError::Syntax(e.to_string()))?; serde_norway::from_str(body).map_err(|e| YamlError::Syntax(e.to_string()))?;
if raw.version != 1 { // The migration framework (ADR-0015) upgrades older project files to
// `CURRENT_SCHEMA_VERSION` before `parse_schema` ever runs, so the
// parser only accepts the current version (a stale version reaching
// here means migration was skipped — a bug worth surfacing).
if raw.version != crate::persistence::migrations::CURRENT_SCHEMA_VERSION {
return Err(YamlError::UnsupportedVersion(raw.version)); return Err(YamlError::UnsupportedVersion(raw.version));
} }
let mut tables: Vec<TableSchema> = Vec::with_capacity(raw.tables.len()); let mut tables: Vec<TableSchema> = Vec::with_capacity(raw.tables.len());
@@ -617,7 +625,7 @@ mod tests {
let body = serialize_schema(&snapshot()); let body = serialize_schema(&snapshot());
// Spot-check structural lines rather than asserting on // Spot-check structural lines rather than asserting on
// the whole blob — easier to read in failure output. // the whole blob — easier to read in failure output.
assert!(body.contains("version: 1")); assert!(body.contains("version: 2"));
assert!(body.contains("created_at: 2026-05-07T14:30:12Z")); assert!(body.contains("created_at: 2026-05-07T14:30:12Z"));
assert!(body.contains("- name: Customers")); assert!(body.contains("- name: Customers"));
assert!(body.contains("primary_key: [id]")); assert!(body.contains("primary_key: [id]"));
@@ -752,7 +760,7 @@ mod tests {
// Older project files (written before unique indexes) omit the // Older project files (written before unique indexes) omit the
// `unique` field; the `#[serde(default)]` makes it `false`. // `unique` field; the `#[serde(default)]` makes it `false`.
let body = "\ let body = "\
version: 1 version: 2
project: project:
created_at: 2026-05-25T00:00:00Z created_at: 2026-05-25T00:00:00Z
tables: tables:
@@ -922,7 +930,7 @@ indexes:
// Back-compat: a project file written before §4g (bare-string // Back-compat: a project file written before §4g (bare-string
// check_constraints) parses with name = None. // check_constraints) parses with name = None.
let body = "\ let body = "\
version: 1 version: 2
project: project:
created_at: \"2026-05-25T00:00:00Z\" created_at: \"2026-05-25T00:00:00Z\"
tables: tables:
@@ -949,7 +957,7 @@ indexes: []
// A project file written before table-level CHECK existed (no // A project file written before table-level CHECK existed (no
// `check_constraints:` key) parses with an empty list. // `check_constraints:` key) parses with an empty list.
let body = "\ let body = "\
version: 1 version: 2
project: project:
created_at: 2026-05-25T00:00:00Z created_at: 2026-05-25T00:00:00Z
tables: tables:
@@ -966,7 +974,7 @@ relationships: []
#[test] #[test]
fn parses_minimal_yaml_with_no_tables() { fn parses_minimal_yaml_with_no_tables() {
let body = "\ let body = "\
version: 1 version: 2
project: project:
created_at: 2026-05-07T14:30:12Z created_at: 2026-05-07T14:30:12Z
tables: [] tables: []
@@ -993,7 +1001,7 @@ relationships: []
#[test] #[test]
fn rejects_unknown_column_type() { fn rejects_unknown_column_type() {
let body = "\ let body = "\
version: 1 version: 2
project: project:
created_at: x created_at: x
tables: tables:
@@ -1012,7 +1020,7 @@ relationships: []
#[test] #[test]
fn rejects_unknown_action() { fn rejects_unknown_action() {
let body = "\ let body = "\
version: 1 version: 2
project: project:
created_at: x created_at: x
tables: [] tables: []
@@ -1090,7 +1098,7 @@ relationships:
fn parse_schema_defaults_mode_to_simple_when_field_absent() { fn parse_schema_defaults_mode_to_simple_when_field_absent() {
// A pre-#14 project file carries no `mode:` field; it must // A pre-#14 project file carries no `mode:` field; it must
// parse with the default mode, not fail. // parse with the default mode, not fail.
let body = "version: 1\nproject:\n created_at: x\ntables: []\nrelationships: []\n"; let body = "version: 2\nproject:\n created_at: x\ntables: []\nrelationships: []\n";
let parsed = parse_schema(body).expect("legacy file parses"); let parsed = parse_schema(body).expect("legacy file parses");
assert_eq!(parsed.mode, Mode::Simple); assert_eq!(parsed.mode, Mode::Simple);
} }
@@ -1100,13 +1108,13 @@ relationships:
// `None` (no stored preference) must be distinct from an // `None` (no stored preference) must be distinct from an
// explicit `simple`, so restore-on-open precedence can tell // explicit `simple`, so restore-on-open precedence can tell
// "fall back to default" from "the user chose simple". // "fall back to default" from "the user chose simple".
let absent = "version: 1\nproject:\n created_at: x\ntables: []\n"; let absent = "version: 2\nproject:\n created_at: x\ntables: []\n";
assert_eq!(parse_stored_mode(absent), None); assert_eq!(parse_stored_mode(absent), None);
let explicit_simple = "version: 1\nproject:\n created_at: x\n mode: simple\ntables: []\n"; let explicit_simple = "version: 2\nproject:\n created_at: x\n mode: simple\ntables: []\n";
assert_eq!(parse_stored_mode(explicit_simple), Some(Mode::Simple)); assert_eq!(parse_stored_mode(explicit_simple), Some(Mode::Simple));
let advanced = "version: 1\nproject:\n created_at: x\n mode: advanced\ntables: []\n"; let advanced = "version: 2\nproject:\n created_at: x\n mode: advanced\ntables: []\n";
assert_eq!(parse_stored_mode(advanced), Some(Mode::Advanced)); assert_eq!(parse_stored_mode(advanced), Some(Mode::Advanced));
} }
@@ -1114,7 +1122,7 @@ relationships:
fn parse_stored_mode_falls_back_to_none_on_unknown_value() { fn parse_stored_mode_falls_back_to_none_on_unknown_value() {
// An unrecognised mode keyword degrades to "no preference" // An unrecognised mode keyword degrades to "no preference"
// rather than rejecting the whole file over a UI hint. // rather than rejecting the whole file over a UI hint.
let body = "version: 1\nproject:\n created_at: x\n mode: expert\ntables: []\n"; let body = "version: 2\nproject:\n created_at: x\n mode: expert\ntables: []\n";
assert_eq!(parse_stored_mode(body), None); assert_eq!(parse_stored_mode(body), None);
} }
} }
+4 -3
View File
@@ -384,7 +384,7 @@ impl Project {
/// Build the on-disk skeleton for a fresh project: the /// Build the on-disk skeleton for a fresh project: the
/// directory itself, an empty `data/`, an empty /// directory itself, an empty `data/`, an empty
/// `history.log`, a placeholder `project.yaml` with just /// `history.log`, a placeholder `project.yaml` with just
/// `version: 1` and `created_at`, and a `.gitignore`. /// the current schema `version` and `created_at`, and a `.gitignore`.
/// ///
/// `playground.db` is not created here; it's created the /// `playground.db` is not created here; it's created the
/// first time `Database::open` runs against the path /// first time `Database::open` runs against the path
@@ -401,7 +401,8 @@ impl Project {
// schema mutation; for now we just ensure the file // schema mutation; for now we just ensure the file
// exists and carries the version + creation timestamp. // exists and carries the version + creation timestamp.
let yaml = format!( let yaml = format!(
"version: 1\nproject:\n created_at: {}\ntables: []\nrelationships: []\n", "version: {}\nproject:\n created_at: {}\ntables: []\nrelationships: []\n",
crate::persistence::migrations::CURRENT_SCHEMA_VERSION,
iso8601_now(), iso8601_now(),
); );
write_if_missing(&path.join(PROJECT_YAML), &yaml)?; write_if_missing(&path.join(PROJECT_YAML), &yaml)?;
@@ -830,7 +831,7 @@ mod tests {
// YAML carries version + created_at. // YAML carries version + created_at.
let yaml = fs::read_to_string(path.join(PROJECT_YAML)).unwrap(); let yaml = fs::read_to_string(path.join(PROJECT_YAML)).unwrap();
assert!(yaml.contains("version: 1")); assert!(yaml.contains("version: 2"));
assert!(yaml.contains("created_at:")); assert!(yaml.contains("created_at:"));
// .gitignore matches ADR-0015. // .gitignore matches ADR-0015.
+25 -3
View File
@@ -176,6 +176,13 @@ pub async fn run(args: Args) -> Result<()> {
// `project.yaml.v<N>.bak` breadcrumb on disk; that's // `project.yaml.v<N>.bak` breadcrumb on disk; that's
// sufficient v1 UX and lets us defer dedicated event // sufficient v1 UX and lets us defer dedicated event
// plumbing until a real migrator demands it. // plumbing until a real migrator demands it.
// ADR-0005 Amendment 2: note a pre-migration `blob` column so we can
// force a `.db` rebuild after the v1→v2 migration converts it to text
// (the stale `.db` keeps a `STRICT … BLOB` engine column + `"blob"`
// metadata that load otherwise uses as-is).
let had_blob_column = std::fs::read_to_string(project.path().join(crate::project::PROJECT_YAML))
.map(|b| crate::persistence::migrations::body_declares_blob_column(&b))
.unwrap_or(false);
let migrate_registry = crate::persistence::migrations::MigratorRegistry::production(); let migrate_registry = crate::persistence::migrations::MigratorRegistry::production();
let migration_outcome = crate::persistence::migrations::ensure_project_yaml_migrated( let migration_outcome = crate::persistence::migrations::ensure_project_yaml_migrated(
project.path(), project.path(),
@@ -235,7 +242,12 @@ pub async fn run(args: Args) -> Result<()> {
Database::open_with_persistence_and_undo(db_path.as_path(), persistence, undo_enabled) Database::open_with_persistence_and_undo(db_path.as_path(), persistence, undo_enabled)
.context("open database")?; .context("open database")?;
let mut initial_events: Vec<AppEvent> = Vec::new(); let mut initial_events: Vec<AppEvent> = Vec::new();
if !db_existed { // ADR-0005 Amendment 2: a blob→text migration leaves the existing
// `.db` with a stale `STRICT … BLOB` engine column, so rebuild it from
// the migrated text. A pure version bump with no schema change does not.
let force_rebuild_after_migration =
migration_outcome.migrated_from.is_some() && had_blob_column;
if !db_existed || force_rebuild_after_migration {
match database.rebuild_from_text(project_path.clone(), None).await { match database.rebuild_from_text(project_path.clone(), None).await {
Ok(()) => { Ok(()) => {
// Surface the silent rebuild as a system note // Surface the silent rebuild as a system note
@@ -927,7 +939,13 @@ async fn perform_switch(
// state momentarily, but the next user action will // state momentarily, but the next user action will
// surface the error and they can retry). // surface the error and they can retry).
let migrate_registry = crate::persistence::migrations::MigratorRegistry::production(); let migrate_registry = crate::persistence::migrations::MigratorRegistry::production();
crate::persistence::migrations::ensure_project_yaml_migrated( // ADR-0005 Amendment 2: see the matching note in `run()` — a blob→text
// migration needs the `.db` rebuilt from the migrated text.
let had_blob_column =
std::fs::read_to_string(new_project.path().join(crate::project::PROJECT_YAML))
.map(|b| crate::persistence::migrations::body_declares_blob_column(&b))
.unwrap_or(false);
let migration_outcome = crate::persistence::migrations::ensure_project_yaml_migrated(
new_project.path(), new_project.path(),
&migrate_registry, &migrate_registry,
) )
@@ -949,7 +967,11 @@ async fn perform_switch(
let new_database = let new_database =
Database::open_with_persistence_and_undo(&db_path, persistence, undo_enabled) Database::open_with_persistence_and_undo(&db_path, persistence, undo_enabled)
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
if !db_existed && let Err(e) = new_database.rebuild_from_text(new_path.clone(), None).await { let force_rebuild_after_migration =
migration_outcome.migrated_from.is_some() && had_blob_column;
if (!db_existed || force_rebuild_after_migration)
&& let Err(e) = new_database.rebuild_from_text(new_path.clone(), None).await
{
return Err(e.friendly_message()); return Err(e.friendly_message());
} }
+6 -12
View File
@@ -138,7 +138,7 @@ fn range_value(low: &str, high: &str, ty: Type, rng: &mut SeedRng) -> Value {
Type::DateTime => parse_datetime_range(low, high) Type::DateTime => parse_datetime_range(low, high)
.map(|(lo, hi)| Value::Text(random_datetime_between(rng, lo, hi))) .map(|(lo, hi)| Value::Text(random_datetime_between(rng, lo, hi)))
.unwrap_or_else(|| generic_for_type(ty, rng)), .unwrap_or_else(|| generic_for_type(ty, rng)),
// text / bool / blob / shortid have no range meaning. // text / bool / shortid have no range meaning.
_ => generic_for_type(ty, rng), _ => generic_for_type(ty, rng),
} }
} }
@@ -155,8 +155,8 @@ pub fn range_bounds_reason(ty: Type, low: &str, high: &str) -> Option<String> {
Type::Real | Type::Decimal => parse_real_range(low, high).is_some(), Type::Real | Type::Decimal => parse_real_range(low, high).is_some(),
Type::Date => parse_date_range(low, high).is_some(), Type::Date => parse_date_range(low, high).is_some(),
Type::DateTime => parse_datetime_range(low, high).is_some(), Type::DateTime => parse_datetime_range(low, high).is_some(),
// text / bool / blob / shortid have no range meaning. // text / bool / shortid have no range meaning.
Type::Text | Type::Bool | Type::Blob | Type::ShortId => false, Type::Text | Type::Bool | Type::ShortId => false,
}; };
if ok { if ok {
return None; return None;
@@ -169,7 +169,7 @@ pub fn range_bounds_reason(ty: Type, low: &str, high: &str) -> Option<String> {
"expected two quoted datetimes, e.g. `between '2023-01-01T00:00:00' and '2024-12-31T23:59:59'`" "expected two quoted datetimes, e.g. `between '2023-01-01T00:00:00' and '2024-12-31T23:59:59'`"
.to_string() .to_string()
} }
Type::Text | Type::Bool | Type::Blob | Type::ShortId => { Type::Text | Type::Bool | Type::ShortId => {
"a `between` range only applies to numeric and date/datetime columns".to_string() "a `between` range only applies to numeric and date/datetime columns".to_string()
} }
}) })
@@ -242,9 +242,8 @@ fn random_datetime_between(
} }
/// Type-based fallback generation (D8). Never produces NULL for a /// Type-based fallback generation (D8). Never produces NULL for a
/// generatable type; `blob`/`serial`/`shortid` are handled by the /// generatable type; `serial`/`shortid` are handled by the executor
/// executor (autogen / block guard) and yield NULL here only as a /// (autogen) and yield NULL here only as a last resort.
/// last resort.
fn generic_for_type(ty: Type, rng: &mut SeedRng) -> Value { fn generic_for_type(ty: Type, rng: &mut SeedRng) -> Value {
use fake::faker::lorem::en as lorem; use fake::faker::lorem::en as lorem;
match ty { match ty {
@@ -267,7 +266,6 @@ fn generic_for_type(ty: Type, rng: &mut SeedRng) -> Value {
Type::Bool => Value::Bool(rng.random_range(0..2) == 1), Type::Bool => Value::Bool(rng.random_range(0..2) == 1),
Type::Date => Value::Text(format_date(random_past_date(rng, 0, RECENT_WINDOW_DAYS))), Type::Date => Value::Text(format_date(random_past_date(rng, 0, RECENT_WINDOW_DAYS))),
Type::DateTime => Value::Text(random_recent_datetime(rng)), Type::DateTime => Value::Text(random_recent_datetime(rng)),
Type::Blob => Value::Null,
} }
} }
@@ -705,10 +703,6 @@ mod tests {
generate_value(&Generator::Generic, Type::Bool, &mut rng), generate_value(&Generator::Generic, Type::Bool, &mut rng),
Value::Bool(_) Value::Bool(_)
)); ));
assert!(matches!(
generate_value(&Generator::Generic, Type::Blob, &mut rng),
Value::Null
));
// shortid fallback is a valid base58 id. // shortid fallback is a valid base58 id.
let Value::Text(sid) = generate_value(&Generator::Generic, Type::ShortId, &mut rng) else { let Value::Text(sid) = generate_value(&Generator::Generic, Type::ShortId, &mut rng) else {
panic!("shortid not text") panic!("shortid not text")
+4 -21
View File
@@ -17,8 +17,8 @@
//! //!
//! Pairs not present in the matrix are statically refused via //! Pairs not present in the matrix are statically refused via
//! [`static_refusal`] before any per-cell pass runs. Same-type //! [`static_refusal`] before any per-cell pass runs. Same-type
//! identity, anything → `serial`, anything ↔ `blob`, and //! identity, anything → `serial`, and `date` ↔ `datetime` direct
//! `date` ↔ `datetime` direct are all statically refused. //! are all statically refused.
use rusqlite::types::Value; use rusqlite::types::Value;
@@ -46,9 +46,8 @@ pub enum CellOutcome {
/// refused; `None` when the per-cell matrix should be consulted. /// refused; `None` when the per-cell matrix should be consulted.
/// ///
/// Static refusals cover: same-type identity, anything → /// Static refusals cover: same-type identity, anything →
/// `serial`, anything ↔ `blob`, `date` ↔ `datetime` direct, and /// `serial`, `date` ↔ `datetime` direct, and any cross-domain
/// any cross-domain pair not present in the matrix /// pair not present in the matrix (e.g. `bool` → `date`).
/// (e.g. `bool` → `date`).
#[must_use] #[must_use]
pub fn static_refusal(src: Type, target: Type) -> Option<String> { pub fn static_refusal(src: Type, target: Type) -> Option<String> {
if src == target { if src == target {
@@ -63,12 +62,6 @@ pub fn static_refusal(src: Type, target: Type) -> Option<String> {
first; only `int serial` is supported directly." first; only `int serial` is supported directly."
)); ));
} }
if matches!(src, Type::Blob) || matches!(target, Type::Blob) {
return Some(format!(
"conversion between `{src}` and `{target}` is not supported \
in this version."
));
}
if matches!( if matches!(
(src, target), (src, target),
(Type::Date, Type::DateTime) | (Type::DateTime, Type::Date) (Type::Date, Type::DateTime) | (Type::DateTime, Type::Date)
@@ -601,16 +594,6 @@ mod tests {
); );
} }
#[test]
fn anything_involving_blob_is_statically_refused() {
for &other in Type::all() {
if other != Type::Blob {
assert!(static_refusal(Type::Blob, other).is_some(), "{other:?}");
assert!(static_refusal(other, Type::Blob).is_some(), "{other:?}");
}
}
}
#[test] #[test]
fn date_to_datetime_direct_is_statically_refused() { fn date_to_datetime_direct_is_statically_refused() {
assert!(static_refusal(Type::Date, Type::DateTime).is_some()); assert!(static_refusal(Type::Date, Type::DateTime).is_some());
+47
View File
@@ -32,6 +32,7 @@
// (the guard *is* the serialization) or pointless here. // (the guard *is* the serialization) or pointless here.
#![allow(clippy::significant_drop_tightening)] #![allow(clippy::significant_drop_tightening)]
use std::fs;
use std::io::{Read, Write}; use std::io::{Read, Write};
use std::path::Path; use std::path::Path;
use std::sync::{Arc, Mutex, MutexGuard}; use std::sync::{Arc, Mutex, MutexGuard};
@@ -404,6 +405,52 @@ fn back_to_back_insert_after_ddl_still_succeeds() {
app.quit(); app.quit();
} }
/// Flow 6 — ADR-0005 Amendment 2 backward-compat: opening a **legacy v1
/// project that declares a `blob` column** migrates it to `text` and opens
/// cleanly. This drives the real runtime's migrate-on-open + rebuild path
/// end-to-end — the post-removal binary would otherwise reject the v1/blob
/// project file. (The integration test covers the migrator + rebuild via
/// direct calls; this covers the runtime glue that decides to run them.)
#[test]
fn opens_a_legacy_v1_blob_project_by_migrating_to_text() {
let dir = TempDir::new().expect("data dir");
let root = dir.path();
// Seed a v1 project (the old format) with a `blob` column, under the
// data root's projects/ dir, and point `--resume` at it. No
// playground.db is shipped, so the open rebuilds from the migrated text.
let proj = root.join("projects").join("Legacy");
fs::create_dir_all(proj.join("data")).expect("create project dirs");
fs::write(
proj.join("project.yaml"),
concat!(
"version: 1\n",
"project:\n created_at: 2026-01-01T00:00:00Z\n mode: simple\n",
"tables:\n",
" - name: Files\n",
" primary_key: [id]\n",
" columns:\n",
" - { name: id, type: serial }\n",
" - { name: payload, type: blob }\n",
"relationships: []\n",
"indexes: []\n",
),
)
.expect("write project.yaml");
fs::write(proj.join("data").join("Files.csv"), "id,payload\n1,aGVsbG8=\n").expect("write csv");
fs::write(root.join("last_project"), format!("{}\n", proj.display())).expect("write resume");
// Open via --resume: the runtime migrates blob→text and rebuilds, so
// the project opens and the table shows in the sidebar.
let mut app = PtyApp::launch_in(root, &["--resume"]);
app.wait_for_table("Files");
// The former blob column is now a normal text column — `show data`
// renders it (header + the base64 value preserved as text).
app.submit("show data Files");
app.wait_for("payload");
app.quit();
}
// ===================== NFR perf (measured, generous) =================== // ===================== NFR perf (measured, generous) ===================
// //
// These run against the DEBUG binary, so the bounds are loose // These run against the DEBUG binary, so the bounds are loose
+107
View File
@@ -0,0 +1,107 @@
//! Full-stack test for **ADR-0005 Amendment 2** (drop `blob`): a saved
//! v1 project that declares a `blob` column is migrated to v2 (`blob` →
//! `text`) on open, and a rebuild from the migrated text reconstructs the
//! column as `text` with the row data preserved — the base64 cell survives
//! as a recoverable string. This exercises the real migrator + the
//! `rebuild_from_text` path the runtime forces when a blob column was
//! converted (the runtime loop itself isn't booted in integration tests).
use std::fs;
use rdbms_playground::db::Database;
use rdbms_playground::dsl::Type;
use rdbms_playground::persistence::Persistence;
use rdbms_playground::persistence::migrations::{
MigratorRegistry, body_declares_blob_column, ensure_project_yaml_migrated,
};
fn rt() -> tokio::runtime::Runtime {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("tokio rt")
}
#[test]
fn v1_blob_project_migrates_to_text_and_rebuilds() {
let dir = tempfile::tempdir().expect("tempdir");
let root = dir.path();
// A hand-written v1 project: a serial PK + a `blob` column carrying a
// (hand-edited, base64) value — the only way bytes ever reached a blob
// column, since no command can insert one.
let yaml = concat!(
"version: 1\n",
"project:\n created_at: 2026-01-01T00:00:00Z\n mode: simple\n",
"tables:\n",
" - name: Files\n",
" primary_key: [id]\n",
" columns:\n",
" - { name: id, type: serial }\n",
" - { name: data, type: blob }\n",
"relationships: []\n",
"indexes: []\n",
);
fs::write(root.join("project.yaml"), yaml).expect("write yaml");
fs::create_dir(root.join("data")).expect("data dir");
fs::write(root.join("data").join("Files.csv"), "id,data\n1,aGVsbG8=\n").expect("write csv");
// The runtime's blob-detection signal sees the column…
assert!(body_declares_blob_column(yaml), "blob column detected");
// 1. Migration runs (as the runtime does on open).
let outcome = ensure_project_yaml_migrated(root, &MigratorRegistry::production())
.expect("migration succeeds");
assert_eq!(outcome.migrated_from, Some(1), "v1 project was migrated");
let migrated = fs::read_to_string(root.join("project.yaml")).expect("read migrated");
assert!(migrated.contains("version: 2"), "version bumped: {migrated}");
assert!(
migrated.contains("{ name: data, type: text }"),
"blob column rewritten to text: {migrated}"
);
assert!(!migrated.contains("type: blob"), "no blob type remains: {migrated}");
// The pre-migration original is preserved as a .bak.
let bak = fs::read_to_string(root.join("project.yaml.v1.bak")).expect("read bak");
assert!(bak.contains("type: blob"), "bak keeps the original blob column");
// 2. Rebuild from the migrated text (as the runtime forces when a blob
// column was converted): the column is now `text` and the row data
// survives as a recoverable string.
let db = Database::open_with_persistence(
root.join("playground.db"),
Persistence::new(root.to_path_buf()),
)
.expect("open db");
rt().block_on(async {
db.rebuild_from_text(root.to_path_buf(), None)
.await
.expect("rebuild");
let desc = db
.describe_table("Files".to_string())
.await
.expect("describe");
let data_col = desc
.columns
.iter()
.find(|c| c.name == "data")
.expect("data column present");
assert_eq!(
data_col.user_type,
Some(Type::Text),
"the former blob column is now text",
);
let rows = db
.query_data("Files".to_string(), None, None)
.await
.expect("query");
assert_eq!(rows.rows.len(), 1, "row preserved through the migration");
assert_eq!(
rows.rows[0][1].as_deref(),
Some("aGVsbG8="),
"the base64 cell survives as recoverable text",
);
});
}
+1
View File
@@ -7,6 +7,7 @@
//! `tests/typing_surface_matrix.rs` stays a separate binary (it is //! `tests/typing_surface_matrix.rs` stays a separate binary (it is
//! already a consolidated `mod`-based target). //! already a consolidated `mod`-based target).
mod blob_removal_migration;
mod case_insensitive_names; mod case_insensitive_names;
mod column_op_guards; mod column_op_guards;
mod compound_fk; mod compound_fk;
-63
View File
@@ -617,69 +617,6 @@ fn seed_refuses_when_a_parent_table_is_empty() {
); );
} }
#[test]
fn seed_refuses_a_not_null_blob_column() {
let (_project, db, _dir) = open_project_db();
let rt = rt();
let mut payload = ColumnSpec::new("payload", Type::Blob);
payload.not_null = true;
rt.block_on(db.create_table(
"Files".to_string(),
vec![ColumnSpec::new("id", Type::Serial), payload],
vec!["id".to_string()],
None,
))
.expect("create Files");
let err = rt
.block_on(db.seed(
"Files".into(),
None,
Some(2),
Vec::new(),
Some(1),
Some("seed Files 2".into()),
))
.expect_err("seed must refuse a NOT NULL blob");
let msg = err.to_string();
assert!(
msg.contains("payload") && msg.to_lowercase().contains("blob"),
"error should name the un-generatable blob column: {msg}"
);
}
#[test]
fn seed_omits_a_nullable_blob_column() {
let (project, db, _dir) = open_project_db();
let rt = rt();
rt.block_on(db.create_table(
"Files".to_string(),
vec![
ColumnSpec::new("id", Type::Serial),
ColumnSpec::new("name", Type::Text),
// nullable blob → omitted (→ NULL), seed still succeeds.
ColumnSpec::new("payload", Type::Blob),
],
vec!["id".to_string()],
None,
))
.expect("create Files");
let res = rt
.block_on(db.seed(
"Files".into(),
None,
Some(3),
Vec::new(),
Some(1),
Some("seed Files 3".into()),
))
.expect("seed succeeds despite the nullable blob");
assert_eq!(res.produced, 3);
let csv = read_csv(&project, "Files").expect("Files CSV");
assert_eq!(data_row_count(&csv), 3);
}
// — uniqueness, junction distinct-combos, IN-CHECK (D10 / D14 / D17) — // — uniqueness, junction distinct-combos, IN-CHECK (D10 / D14 / D17) —
/// The `n`th comma-separated field of each data row (the generated /// The `n`th comma-separated field of each data row (the generated
-8
View File
@@ -356,14 +356,6 @@ fn e2e_alter_column_type_static_refusals() {
), ),
"text→serial is refused (only int→serial is allowed)" "text→serial is refused (only int→serial is allowed)"
); );
assert!(
replay_is_refused(
"create table T with pk id(int)\n\
add column T: v (text)\n\
alter table T alter column v type blob\n",
),
"↔ blob is statically refused"
);
} }
#[test] #[test]
+11 -14
View File
@@ -208,13 +208,12 @@ fn e2e_insert_select_cross_table_copies_rows_and_persists_both() {
// =============================================================== // ===============================================================
#[test] #[test]
fn e2e_multirow_insert_all_ten_types_roundtrips_and_returning_recovers_each_type() { fn e2e_multirow_insert_all_nine_types_roundtrips_and_returning_recovers_each_type() {
let (project, db, _dir) = open_project_db(); let (project, db, _dir) = open_project_db();
let rt = rt(); let rt = rt();
// serial PK + shortid auto-fill; the other eight columns are // serial PK + shortid auto-fill; the other seven columns are
// user-supplied. `blob` has no value-literal grammar yet // user-supplied. Their *types* round-trip through the RETURNING
// (see src/dsl/value.rs), so it is inserted NULL — its *type* // column-origin path.
// still round-trips through the RETURNING column-origin path.
create_cols( create_cols(
&db, &db,
&rt, &rt,
@@ -228,7 +227,6 @@ fn e2e_multirow_insert_all_ten_types_roundtrips_and_returning_recovers_each_type
("flag", Type::Bool), ("flag", Type::Bool),
("d", Type::Date), ("d", Type::Date),
("ts", Type::DateTime), ("ts", Type::DateTime),
("bl", Type::Blob),
("sid", Type::ShortId), ("sid", Type::ShortId),
], ],
&["ser"], &["ser"],
@@ -237,17 +235,17 @@ fn e2e_multirow_insert_all_ten_types_roundtrips_and_returning_recovers_each_type
let result = run_insert( let result = run_insert(
&db, &db,
&rt, &rt,
"insert into allten (txt, i, r, dec, flag, d, ts, bl) values \ "insert into allten (txt, i, r, dec, flag, d, ts) values \
('hi', 42, 1.5, 9.50, true, '2026-05-23', '2026-05-23 10:00:00', null), \ ('hi', 42, 1.5, 9.50, true, '2026-05-23', '2026-05-23 10:00:00'), \
('yo', 7, 2.5, 3.25, false, '2025-01-01', '2025-01-01 00:00:00', null) \ ('yo', 7, 2.5, 3.25, false, '2025-01-01', '2025-01-01 00:00:00') \
returning ser, txt, i, r, dec, flag, d, ts, bl, sid", returning ser, txt, i, r, dec, flag, d, ts, sid",
) )
.expect("multi-row INSERT … RETURNING runs"); .expect("multi-row INSERT … RETURNING runs");
assert_eq!(result.rows_affected, 2, "two rows inserted"); assert_eq!(result.rows_affected, 2, "two rows inserted");
assert_eq!(result.data.rows.len(), 2, "RETURNING yields both rows"); assert_eq!(result.data.rows.len(), 2, "RETURNING yields both rows");
// Every one of the ten playground types is recovered via the // Every one of the nine playground types is recovered via the
// RETURNING column-origin path (matrix R5). // RETURNING column-origin path (matrix R5).
assert_eq!( assert_eq!(
result.data.column_types, result.data.column_types,
@@ -260,10 +258,9 @@ fn e2e_multirow_insert_all_ten_types_roundtrips_and_returning_recovers_each_type
Some(Type::Bool), Some(Type::Bool),
Some(Type::Date), Some(Type::Date),
Some(Type::DateTime), Some(Type::DateTime),
Some(Type::Blob),
Some(Type::ShortId), Some(Type::ShortId),
], ],
"RETURNING recovers each of the ten playground types; got {:?}", "RETURNING recovers each of the nine playground types; got {:?}",
result.data.column_types, result.data.column_types,
); );
@@ -281,7 +278,7 @@ fn e2e_multirow_insert_all_ten_types_roundtrips_and_returning_recovers_each_type
"dates round-trip: {csv:?}" "dates round-trip: {csv:?}"
); );
let sids: Vec<&str> = rows.iter().filter_map(|r| r[9].as_deref()).collect(); let sids: Vec<&str> = rows.iter().filter_map(|r| r[8].as_deref()).collect();
assert_eq!(sids.len(), 2, "both shortids present"); assert_eq!(sids.len(), 2, "both shortids present");
assert!( assert!(
sids.iter().all(|s| !s.is_empty()), sids.iter().all(|s| !s.is_empty()),
+4 -31
View File
@@ -575,11 +575,7 @@ fn database_run_select_type_recovery_works_on_empty_table() {
// when no row matches. // when no row matches.
// //
// This test pins that invariant: a fresh table with no // This test pins that invariant: a fresh table with no
// rows still yields the right `column_types` entry. It // rows still yields the right `column_types` entry.
// also justifies the all-types test below using NULL for
// col_blob (the DSL Value enum has no Blob variant, but
// since metadata doesn't read row values, a NULL cell
// doesn't compromise the recovery).
let (_p, db, _dir) = open_project_db(); let (_p, db, _dir) = open_project_db();
let rt = rt(); let rt = rt();
rt.block_on(async { rt.block_on(async {
@@ -588,7 +584,6 @@ fn database_run_select_type_recovery_works_on_empty_table() {
vec![ vec![
ColumnSpec::new("id", Type::Serial), ColumnSpec::new("id", Type::Serial),
ColumnSpec::new("col_text", Type::Text), ColumnSpec::new("col_text", Type::Text),
ColumnSpec::new("col_blob", Type::Blob),
], ],
vec!["id".to_string()], vec!["id".to_string()],
None, None,
@@ -602,32 +597,16 @@ fn database_run_select_type_recovery_works_on_empty_table() {
.expect("SELECT runs even on empty table"); .expect("SELECT runs even on empty table");
assert!(data_text.rows.is_empty()); assert!(data_text.rows.is_empty());
assert_eq!(data_text.column_types, vec![Some(Type::Text)]); assert_eq!(data_text.column_types, vec![Some(Type::Text)]);
let data_blob = rt
.block_on(db.run_select("select col_blob from Empty".to_string()))
.expect("SELECT runs even on empty table");
assert!(data_blob.rows.is_empty());
assert_eq!(
data_blob.column_types,
vec![Some(Type::Blob)],
"Blob metadata must be recoverable even with no row data",
);
} }
#[test] #[test]
fn database_run_select_recovers_all_ten_playground_types() { fn database_run_select_recovers_all_nine_playground_types() {
// ADR-0032 §12 + Amendment 1 — every playground type // ADR-0032 §12 + Amendment 1 — every playground type
// round-trips through column-origin metadata on a bare // round-trips through column-origin metadata on a bare
// projection ref. One table holds one column of each // projection ref. One table holds one column of each
// type; a SELECT of that column produces the right // type; a SELECT of that column produces the right
// `column_types[0]` entry. // `column_types[0]` entry. `serial` and `shortid` are
// // auto-generated.
// `serial` and `shortid` are auto-generated. `col_blob`
// is left NULL in the inserted row because the DSL Value
// enum has no Blob variant — but per
// `database_run_select_type_recovery_works_on_empty_table`
// above, column-origin metadata is row-independent, so
// the NULL cell doesn't compromise this test's correctness.
let (_p, db, _dir) = open_project_db(); let (_p, db, _dir) = open_project_db();
let rt = rt(); let rt = rt();
rt.block_on(async { rt.block_on(async {
@@ -642,7 +621,6 @@ fn database_run_select_recovers_all_ten_playground_types() {
ColumnSpec::new("col_bool", Type::Bool), ColumnSpec::new("col_bool", Type::Bool),
ColumnSpec::new("col_date", Type::Date), ColumnSpec::new("col_date", Type::Date),
ColumnSpec::new("col_datetime", Type::DateTime), ColumnSpec::new("col_datetime", Type::DateTime),
ColumnSpec::new("col_blob", Type::Blob),
ColumnSpec::new("col_shortid", Type::ShortId), ColumnSpec::new("col_shortid", Type::ShortId),
], ],
vec!["pk".to_string()], vec!["pk".to_string()],
@@ -650,10 +628,6 @@ fn database_run_select_recovers_all_ten_playground_types() {
) )
.await .await
.expect("create table"); .expect("create table");
// Blob has no DSL literal form, so col_blob takes the
// default NULL on insert. Column-origin metadata is
// based on the column DEFINITION, not the row value
// (Amendment 1), so the type recovery still succeeds.
db.insert( db.insert(
"AllTypes".to_string(), "AllTypes".to_string(),
Some(vec![ Some(vec![
@@ -691,7 +665,6 @@ fn database_run_select_recovers_all_ten_playground_types() {
("col_bool", Type::Bool), ("col_bool", Type::Bool),
("col_date", Type::Date), ("col_date", Type::Date),
("col_datetime", Type::DateTime), ("col_datetime", Type::DateTime),
("col_blob", Type::Blob),
("col_shortid", Type::ShortId), ("col_shortid", Type::ShortId),
]; ];
for (col, expected_type) in cases { for (col, expected_type) in cases {
-1
View File
@@ -117,7 +117,6 @@ pub fn schema_every_type() -> SchemaCache {
("b", Type::Bool), ("b", Type::Bool),
("dt", Type::Date), ("dt", Type::Date),
("ts", Type::DateTime), ("ts", Type::DateTime),
("data", Type::Blob),
("sid", Type::ShortId), ("sid", Type::ShortId),
("auto", Type::Serial), ("auto", Type::Serial),
("note", Type::Text), ("note", Type::Text),
@@ -1,5 +1,6 @@
--- ---
source: tests/typing_surface/create_table.rs source: tests/typing_surface/create_table.rs
assertion_line: 89
description: "input=\"create table Customers with pk Code(\" cursor=36" description: "input=\"create table Customers with pk Code(\" cursor=36"
expression: "& a" expression: "& a"
--- ---
@@ -45,11 +46,6 @@ Assessment {
kind: Keyword, kind: Keyword,
mode: Both, mode: Both,
}, },
Candidate {
text: "blob",
kind: Keyword,
mode: Both,
},
Candidate { Candidate {
text: "serial", text: "serial",
kind: Keyword, kind: Keyword,
@@ -107,11 +103,6 @@ Assessment {
kind: Keyword, kind: Keyword,
mode: Both, mode: Both,
}, },
Candidate {
text: "blob",
kind: Keyword,
mode: Both,
},
Candidate { Candidate {
text: "serial", text: "serial",
kind: Keyword, kind: Keyword,
@@ -1,5 +1,6 @@
--- ---
source: tests/typing_surface/delete_with_where.rs source: tests/typing_surface/delete_with_where.rs
assertion_line: 76
description: "input=\"delete from Things where ts=\" cursor=28" description: "input=\"delete from Things where ts=\" cursor=28"
expression: "& a" expression: "& a"
--- ---
@@ -35,11 +36,6 @@ Assessment {
kind: Identifier, kind: Identifier,
mode: Both, mode: Both,
}, },
Candidate {
text: "data",
kind: Identifier,
mode: Both,
},
Candidate { Candidate {
text: "dt", text: "dt",
kind: Identifier, kind: Identifier,
@@ -1,5 +1,6 @@
--- ---
source: tests/typing_surface/rename_change_column.rs source: tests/typing_surface/rename_change_column.rs
assertion_line: 69
description: "input=\"change column in Customers: Email (\" cursor=35" description: "input=\"change column in Customers: Email (\" cursor=35"
expression: "& a" expression: "& a"
--- ---
@@ -45,11 +46,6 @@ Assessment {
kind: Keyword, kind: Keyword,
mode: Both, mode: Both,
}, },
Candidate {
text: "blob",
kind: Keyword,
mode: Both,
},
Candidate { Candidate {
text: "serial", text: "serial",
kind: Keyword, kind: Keyword,
@@ -107,11 +103,6 @@ Assessment {
kind: Keyword, kind: Keyword,
mode: Both, mode: Both,
}, },
Candidate {
text: "blob",
kind: Keyword,
mode: Both,
},
Candidate { Candidate {
text: "serial", text: "serial",
kind: Keyword, kind: Keyword,
@@ -1,5 +1,6 @@
--- ---
source: tests/typing_surface/where_expression.rs source: tests/typing_surface/where_expression.rs
assertion_line: 50
description: "input=\"delete from Things where k between \" cursor=35" description: "input=\"delete from Things where k between \" cursor=35"
expression: "& a" expression: "& a"
--- ---
@@ -35,11 +36,6 @@ Assessment {
kind: Identifier, kind: Identifier,
mode: Both, mode: Both,
}, },
Candidate {
text: "data",
kind: Identifier,
mode: Both,
},
Candidate { Candidate {
text: "dt", text: "dt",
kind: Identifier, kind: Identifier,
@@ -1,5 +1,6 @@
--- ---
source: tests/typing_surface/where_expression.rs source: tests/typing_surface/where_expression.rs
assertion_line: 58
description: "input=\"delete from Things where k in (\" cursor=31" description: "input=\"delete from Things where k in (\" cursor=31"
expression: "& a" expression: "& a"
--- ---
@@ -35,11 +36,6 @@ Assessment {
kind: Identifier, kind: Identifier,
mode: Both, mode: Both,
}, },
Candidate {
text: "data",
kind: Identifier,
mode: Both,
},
Candidate { Candidate {
text: "dt", text: "dt",
kind: Identifier, kind: Identifier,
@@ -306,9 +306,6 @@ automatically — no override needed.
(a guard against a typo like `seed members 1000000`). Seed in smaller (a guard against a typo like `seed members 1000000`). Seed in smaller
batches if you genuinely need more. batches if you genuinely need more.
- `seed members 0` does nothing. - `seed members 0` does nothing.
- A `not null` column seed cannot produce a value for — the only real case is
a `not null blob` — makes seed refuse the whole command and name the column,
rather than fail partway through.
A whole `seed` is a **single step** in the history: one [`undo`](/using-the-playground/undo-and-history/) A whole `seed` is a **single step** in the history: one [`undo`](/using-the-playground/undo-and-history/)
removes every row it added, not one row at a time. removes every row it added, not one row at a time.
+4 -5
View File
@@ -1,14 +1,14 @@
--- ---
title: Types title: Types
description: The ten column types, what they store, and the auto-generating serial and shortid types. description: The nine column types, what they store, and the auto-generating serial and shortid types.
sidebar: sidebar:
order: 1 order: 1
--- ---
Every column has a type. The playground offers ten, chosen to cover what a Every column has a type. The playground offers nine, chosen to cover what a
learner needs without the sprawl of a production database. learner needs without the sprawl of a production database.
## The ten types ## The nine types
| Type | Stores | | Type | Stores |
|---|---| |---|---|
@@ -19,7 +19,6 @@ learner needs without the sprawl of a production database.
| `bool` | A truth value, shown as `true` / `false`. | | `bool` | A truth value, shown as `true` / `false`. |
| `date` | A calendar date, `YYYY-MM-DD`. | | `date` | A calendar date, `YYYY-MM-DD`. |
| `datetime` | A date and time, `YYYY-MM-DDTHH:MM:SS`. | | `datetime` | A date and time, `YYYY-MM-DDTHH:MM:SS`. |
| `blob` | Arbitrary binary data. |
| `serial` | An auto-incrementing whole number — see below. | | `serial` | An auto-incrementing whole number — see below. |
| `shortid` | A short random identifier — see below. | | `shortid` | A short random identifier — see below. |
@@ -68,5 +67,5 @@ In advanced mode you may also use familiar standard-SQL spellings, which map
onto the types above — for example `integer`, `bigint`, and `smallint` are onto the types above — for example `integer`, `bigint`, and `smallint` are
all `int`; `varchar` and `char` are `text`; `boolean` is `bool`; `timestamp` all `int`; `varchar` and `char` are `text`; `boolean` is `bool`; `timestamp`
is `datetime`; `numeric` is `decimal`; `float` and `double precision` are is `datetime`; `numeric` is `decimal`; `float` and `double precision` are
`real`. Simple mode uses only the ten names in the table, so it teaches one `real`. Simple mode uses only the nine names in the table, so it teaches one
clear vocabulary. clear vocabulary.
+1 -1
View File
@@ -48,7 +48,7 @@ const repository = {
name: 'constant.language.rdbms', name: 'constant.language.rdbms',
}, },
type: { type: {
match: '(?i)\\b(text|int|real|decimal|bool|date|datetime|blob|serial|shortid)\\b', match: '(?i)\\b(text|int|real|decimal|bool|date|datetime|serial|shortid)\\b',
name: 'support.type.rdbms', name: 'support.type.rdbms',
}, },
keyword: { keyword: {