feat(types)!: drop the blob column type (ADR-0005 Amendment 2)
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:
@@ -3169,7 +3169,10 @@ fn row_value_to_cell(row: &rusqlite::Row<'_>, idx: usize) -> Result<CellValue, D
|
||||
ValueRef::Integer(n) => CellValue::Integer(n),
|
||||
ValueRef::Real(f) => CellValue::Real(f),
|
||||
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
|
||||
/// shortids for shortid) and the column gains UNIQUE +
|
||||
/// 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(
|
||||
conn: &Connection,
|
||||
persistence: Option<&Persistence>,
|
||||
@@ -5043,7 +5041,7 @@ fn do_rename_table(
|
||||
///
|
||||
/// 1. Target is `serial` (auto-increment is create-table-only).
|
||||
/// 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
|
||||
/// FK) — drop the relationship first.
|
||||
/// 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
|
||||
/// (D14); a compound FK reads all its child columns from one sampled
|
||||
/// parent row. An empty parent is refused with a friendly error. A
|
||||
/// `NOT NULL blob` column (which seed cannot generate) is refused by
|
||||
/// the block guard (D1); a nullable blob is omitted (→ NULL).
|
||||
/// parent row. An empty parent is refused with a friendly error.
|
||||
///
|
||||
/// **Phase 2 (SD2):** when `target_column` is `Some`, this delegates to
|
||||
/// [`do_seed_column_fill`] (fill one column across existing rows, D1
|
||||
@@ -8719,17 +8715,6 @@ fn do_seed(
|
||||
if matches!(ty, Type::Serial) {
|
||||
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());
|
||||
if let Some(&(fk_idx, pos)) = fk_child_pos.get(c.name.as_str()) {
|
||||
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
|
||||
/// **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
|
||||
/// the column being filled (the rest of the per-column heuristics do not
|
||||
/// apply — there is exactly one column). A UNIQUE / identifier target
|
||||
@@ -9178,12 +9163,6 @@ fn do_seed_column_fill(
|
||||
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).
|
||||
for ov in overrides {
|
||||
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::Real(f) => Value::Real(*f),
|
||||
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]
|
||||
async fn change_column_type_outbound_fk_refused() {
|
||||
// Child-side FK column: §4.2 says always refuse,
|
||||
|
||||
Reference in New Issue
Block a user