From 489f2976b438ea9ec7187f1f178fd48856fc5ef4 Mon Sep 17 00:00:00 2001 From: GGWA <65288322+rech4210@users.noreply.github.com> Date: Tue, 3 Nov 2026 00:29:10 +0900 Subject: [PATCH] SQLite rowid alias columns show the rowid instead of NULL (#409) db_sqlite_show shows an INTEGER PRIMARY KEY column's value from the rowid instead of NULL. Fixes #405. --- crates/formats/src/sqlite.rs | 184 ++++++++++++++++++++++++++++++++++- crates/testkit/src/sqlite.rs | 21 ++++ docs/ROADMAP.md | 1 + docs/STATUS.md | 1 + 4 files changed, 206 insertions(+), 1 deletion(-) diff --git a/crates/formats/src/sqlite.rs b/crates/formats/src/sqlite.rs index 449c6390..4a29c318 100644 --- a/crates/formats/src/sqlite.rs +++ b/crates/formats/src/sqlite.rs @@ -397,7 +397,15 @@ impl<'a> SqliteDb<'a> { self.collect_table_rows(&mut w, rootpage, 0); let hit_cap = w.out.len() >= want && want > 0; let skipped = offset.min(w.out.len()); - let page: Vec = w.out.into_iter().skip(offset).take(limit).collect(); + let mut page: Vec = w.out.into_iter().skip(offset).take(limit).collect(); + let (schema, _) = self.schema(); + if let Some(index) = schema.iter().find(|o| o.obj_type == "table" && o.rootpage == rootpage).and_then(|o| rowid_alias_index(&o.sql)) { + for row in &mut page { + if let Some(value @ CellValue::Null) = row.values.get_mut(index) { + *value = CellValue::Int(row.rowid); + } + } + } RowPage { rows: page, offset: skipped, truncated: hit_cap, notes: w.notes } } @@ -690,6 +698,120 @@ pub fn parse_create_columns(sql: &str) -> Vec { cols } +/// Identify the single INTEGER primary key, excluding the column-level DESC exception. +fn rowid_alias_index(sql: &str) -> Option { + let tokens = sql_tokens(sql); + let is = |token: &&str, word: &str| token.eq_ignore_ascii_case(word); + let open = tokens.iter().position(|t| *t == "(")?; + let mut depth = 0usize; + let close = tokens.iter().enumerate().skip(open).find_map(|(i, t)| { + match *t { + "(" => depth = depth.saturating_add(1), + ")" => depth = depth.saturating_sub(1), + _ => {} + } + (depth == 0).then_some(i) + })?; + let tail = tokens.get(close.saturating_add(1)..)?; + if tail.windows(2).any(|w| w.first().is_some_and(|t| is(t, "WITHOUT")) && w.get(1).is_some_and(|t| is(t, "ROWID"))) { + return None; + } + let columns = parse_create_columns(sql); + let mut key = None; + let mut column = 0usize; + let body_start = open.saturating_add(1); + let mut start = body_start; + depth = 0; + for i in body_start..=close { + let token = *tokens.get(i)?; + if i == close || (token == "," && depth == 0) { + let mut segment = tokens.get(start..i)?; + start = i.saturating_add(1); + if segment.first().is_some_and(|t| is(t, "CONSTRAINT")) { + segment = segment.get(2..)?; + } + if segment.first().is_some_and(|t| is(t, "PRIMARY")) { + if key.is_some() || !segment.get(1).is_some_and(|t| is(t, "KEY")) || segment.get(2) != Some(&"(") { + return None; + } + let end = segment.iter().position(|t| *t == ")")?; + let members = segment.get(3..end)?; + if members.contains(&",") { + return None; + } + let (name, _) = take_identifier(members.first()?); + key = Some(columns.iter().position(|c| c.name.eq_ignore_ascii_case(&name))?); + } else if !segment.first().is_some_and(|t| ["UNIQUE", "CHECK", "FOREIGN"].iter().any(|word| is(t, word))) { + let mut nesting = 0usize; + for (j, t) in segment.iter().enumerate().skip(1) { + match *t { + "(" => nesting = nesting.saturating_add(1), + ")" => nesting = nesting.saturating_sub(1), + _ => {} + } + if nesting == 0 && is(t, "PRIMARY") && segment.get(j.saturating_add(1)).is_some_and(|t| is(t, "KEY")) { + if key.is_some() || segment.get(j.saturating_add(2)).is_some_and(|t| is(t, "DESC")) { + return None; + } + key = Some(column); + } + } + column = column.saturating_add(1); + } + } else { + match token { + "(" => depth = depth.saturating_add(1), + ")" => depth = depth.saturating_sub(1), + _ => {} + } + } + } + let index = key?; + columns.get(index)?.type_name.eq_ignore_ascii_case("INTEGER").then_some(index) +} + +/// Keep quoted text intact so defaults and constraint names cannot become SQL keywords. +fn sql_tokens(mut sql: &str) -> Vec<&str> { + let mut tokens = Vec::new(); + while !sql.is_empty() { + sql = sql.trim_start(); + if sql.is_empty() { + break; + } + if sql.starts_with("--") { + sql = sql.get(sql.find('\n').unwrap_or(sql.len())..).unwrap_or(""); + continue; + } + if sql.starts_with("/*") { + sql = sql.get(2..).and_then(|s| s.find("*/").and_then(|end| s.get(end.saturating_add(2)..))).unwrap_or(""); + continue; + } + let mut chars = sql.char_indices().peekable(); + let Some((_, first)) = chars.next() else { break }; + let mut end = first.len_utf8(); + if matches!(first, '\'' | '"' | '`' | '[') { + let quote = if first == '[' { ']' } else { first }; + while let Some((i, c)) = chars.next() { + end = i.saturating_add(c.len_utf8()); + if c == quote { + if first != '[' && chars.peek().is_some_and(|(_, c)| *c == quote) { + if let Some((i, c)) = chars.next() { + end = i.saturating_add(c.len_utf8()); + } + } else { + break; + } + } + } + } else if !matches!(first, '(' | ')' | ',') { + end = sql.find(|c: char| c.is_whitespace() || matches!(c, '(' | ')' | ',' | '\'' | '"' | '`' | '[')).unwrap_or(sql.len()); + } + tokens.push(sql.get(..end).unwrap_or("")); + sql = sql.get(end..).unwrap_or(""); + } + tokens +} + /// Split on top-level commas (ignoring commas inside parentheses). fn split_top_level(s: &str) -> Vec<&str> { let mut out = Vec::new(); @@ -878,6 +1000,66 @@ mod tests { assert!(parsed.overview().integrity_notes.iter().any(|n| n.contains("WAL"))); } + fn check_rowid_alias(declaration: &str, column: usize, alias: bool) { + use craft_anything_testkit::sqlite::{Val, table_db}; + let sql = format!("CREATE TABLE t({declaration})"); + let columns = parse_create_columns(&sql); + let rows: Vec<_> = [-3, 7] + .into_iter() + .map(|rowid| { + let mut values = vec![Val::Text("x".into()); columns.len()]; + values[column] = Val::Null; + (rowid, values) + }) + .collect(); + let bytes = table_db(&sql, &rows); + let db = SqliteDb::parse(&bytes).expect("fixture parses"); + let page = db.read_rows(2, 0, 10); + assert_eq!(page.rows.len(), 2); + for (row, (rowid, _)) in page.rows.iter().zip(&rows) { + assert_eq!(row.rowid, *rowid); + let mut expected = vec![CellValue::Text { text: "x".into(), truncated: false }; columns.len()]; + expected[column] = if alias { CellValue::Int(*rowid) } else { CellValue::Null }; + assert_eq!(row.values, expected, "{declaration}, rowid {rowid}"); + } + // Even an alias must preserve a non-NULL stored value. + let mut values = vec![Val::Null; columns.len()]; + values[column] = Val::Int(99); + let bytes = table_db(&sql, &[(7, values)]); + let db = SqliteDb::parse(&bytes).expect("fixture parses"); + assert_eq!(db.read_rows(2, 0, 1).rows[0].values[column], CellValue::Int(99)); + } + + #[test] + fn integer_primary_key_aliases_show_the_rowid() { + // (declaration, column, is an alias): the rule from SQLite's CREATE TABLE docs. + for (declaration, column, alias) in [ + ("id INTEGER PRIMARY KEY, v TEXT", 0, true), + ("id integer primary key, v text", 0, true), + ("id INTEGER, v TEXT, PRIMARY KEY(id)", 0, true), + ("id INTEGER PRIMARY KEY DESC, v TEXT", 0, false), + ("id INT PRIMARY KEY, v TEXT", 0, false), + ("v TEXT, id INTEGER PRIMARY KEY, w TEXT", 1, true), + ("id INTEGER PRIMARY KEY AUTOINCREMENT, v TEXT", 0, true), + ("a INTEGER, b INTEGER, PRIMARY KEY(a, b)", 0, false), + ("id INTEGER PRIMARY KEY ASC, v TEXT", 0, true), + ("id INTEGER NOT NULL PRIMARY KEY, v TEXT", 0, true), + ("id BIGINT PRIMARY KEY, v TEXT", 0, false), + ("id INTEGER, v TEXT, CONSTRAINT pk PRIMARY KEY(id DESC)", 0, true), + ] { + check_rowid_alias(declaration, column, alias); + } + } + + #[test] + fn a_without_rowid_table_has_no_rowid_alias() { + // Only test alias classification here; index b-tree reading is a separate defect. + let sql = "CREATE TABLE t(id INTEGER PRIMARY KEY) WITHOUT ROWID"; + let bytes = craft_anything_testkit::sqlite::table_db(sql, &[(7, vec![craft_anything_testkit::sqlite::Val::Null])]); + let db = SqliteDb::parse(&bytes).expect("fixture parses"); + assert_eq!(db.read_rows(2, 0, 1).rows[0].values, vec![CellValue::Null]); + } + #[test] fn truncation_never_panics() { let db = craft_anything_testkit::sqlite::simple_db(); diff --git a/crates/testkit/src/sqlite.rs b/crates/testkit/src/sqlite.rs index 67b2e1e5..80fe1053 100644 --- a/crates/testkit/src/sqlite.rs +++ b/crates/testkit/src/sqlite.rs @@ -21,6 +21,16 @@ pub enum Val { } fn sqlite_varint(mut v: u64) -> Vec { + if v > 0x00ff_ffff_ffff_ffff { + let mut out = vec![0u8; 9]; + out[8] = v as u8; + v >>= 8; + for byte in out[..8].iter_mut().rev() { + *byte = (v as u8 & 0x7f) | 0x80; + v >>= 7; + } + return out; + } if v == 0 { return vec![0]; } @@ -175,6 +185,17 @@ pub fn simple_db() -> Vec { file } +/// A two-page database with one table named `t` and explicitly stored rowids and values. +pub fn table_db(sql: &str, rows: &[(i64, Vec)]) -> Vec { + let mut file = vec![0u8; PAGE_SIZE * 2]; + let cells: Vec<_> = rows.iter().map(|(rowid, values)| (*rowid, build_record(values))).collect(); + write_table_leaf(&mut file[PAGE_SIZE..], 0, &cells); + let schema = build_record(&[Val::Text("table".into()), Val::Text("t".into()), Val::Text("t".into()), Val::Int(2), Val::Text(sql.into())]); + write_db_header(&mut file[..PAGE_SIZE], 2); + write_table_leaf(&mut file[..PAGE_SIZE], 100, &[(1, schema)]); + file +} + /// A database whose header declares WAL mode (write/read version 2) but no sidecar is present. pub fn wal_mode_db() -> Vec { let mut db = simple_db(); diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 2a13b700..5b26259c 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -141,6 +141,7 @@ No `deny.toml` exists. The survey found no GPL, AGPL or SSPL crates, and only MP ### R0.5 Small correctness items from the survey Owner: open · Status: done (branch chore/r0.5-cleanups) +- [x] Restored rowid aliases in SQLite INTEGER primary-key columns while preserving stored values and non-alias keys. - [x] Decode Objective-C chained pointer offsets past file-empty PAGEZERO mappings (#296). - [x] Recognise root-relative macOS bundles and nested XPC services in Apple app graphs (#282). - [x] Checked arithmetic on file offsets in `elf.rs`: `va_to_off` adds a segment's file offset to an diff --git a/docs/STATUS.md b/docs/STATUS.md index 98d4b88e..21617fdf 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -27,6 +27,7 @@ workspace clippy, layers, fmt and the secret scan of all history were clean. Chi | Questions and approvals | A file inbox per project; `core_person_ask`, `core_action_approve` and MCP consent requests become cards in the app | Cross-handle round-trip tests, a full approval round trip through the engine | | Desktop app | Simple mode: sidebar with New project and the project history (kept across restarts); New project either makes something or just looks inside an app or file, a website or a game's files; a Game files screen (a game's archives; pictures from Unreal paks, Unity .assets files and Unity bundles saved as PNG after the person confirms they may unpack the game; uncompressed textures and the block formats DXT1, DXT5 and BC4–BC7), make prompt with picture attachments and, for anything recreated, a Clean room / Deep choice (also in the title bar; in Clean room, Just look inside, Game files, opening a file and the workbench ask for Deep mode instead), project screen (steps with the time spent and the AI cost and tokens of every build, saved in the project's usage.json by the run (a coding agent's figure is shown as an estimate at API prices, not the bill), what the build is on now and its activity as it arrives (kept in `build-activity.log`), pinned builder checklist, feed, question and approval cards, a Continue card when a build was paused or stopped part-way (the app or computer closed; the project shows Interrupted) that carries on the builder's own session instead of starting over, and "It's done" beside Continue or Try again to keep the build as it is (`project_build_finish`), checks with screenshots and the requirements not met yet, the builder's open questions, preview, play, folder, follow-ups while it builds, which Claude Code takes at its next step; Up in the prompt box takes back the last note still waiting). Projects can start in a folder the person picks, whose files are either the app, edited in place, or material to start from (asked, or the answer remembered in Settings), and are listed from any number of folders (`project_folders`, `project_list`, `rebuild --project-folder`). Make says which AI builds ("Built by": automatic, or any AI that can build, by name); a pick stays with the project and builds its later changes. With nothing to build with, Start offers the coding agents found on the computer. Settings → AI turns an AI off or removes it. In the prompt box, Up at the very start and Down at the very end go through earlier prompts when no note is waiting. Questions about a build ("Just ask" under the prompt box, or a message starting with `/btw`, the first slash command; `rebuild_question_ask`) are answered in the conversation, where each folds away and is kept in the project, without a build or a round and changing nothing: Claude Code answers from a read-only copy of the builder's own session. Under each project's prompt box, "Builder's questions: Ask me / Decide yourself" chooses whether the builder's questions come to the person as cards or are decided by the builder, which says what it chose (`project_questions_set`; the default is in Settings and follows Auto mode until picked). Projects can be renamed, duplicated and deleted (right-click in the sidebar, or the project's page; a delete keeps the person's own files). A build can be pinned (Pin this build on its page, or `project_pin_*` from the CLI and MCP): a frozen copy in the project's pinned/ folder (the default) or a light one that shares dependencies and media with the project through hard links (each one's size shown first), opened from the sidebar's collapsible Pinned section (icons with a running dot when folded); a pin or Play runs once per folder unless another copy is asked for, and a start that quits at once says so with its last output. Typed drafts survive a restart. In a narrow window the sidebar folds to icons and the project's details move above its feed; messages float over the screen and errors stay until dismissed. Advanced mode: workbench with every panel wired to real tools, as a screen beside the projects (builds and their questions stay a click away) | UI unit and smoke tests (egui_kittest), offscreen snapshots of every screen | | CLI | Every tool, `--deep` or `--clean-room` for one run (never saved), `rebuild` (including `--continue-in`, and `--resume` to carry on a stopped build), `mcp --inbox`, `ai`, `setup`, `doctor`, `report` | CLI integration tests | +| SQLite database files (`db_sqlite_show`) | A rowid alias column (`INTEGER PRIMARY KEY`, including the table-constraint form) shows the rowid; `DESC`, non-`INTEGER` and composite keys keep their stored values | Hand-built fixtures; generated files compared with the official SQLite library | ### Linux Unreal setup