1use std::cmp::Ordering;
5use std::collections::{BTreeMap, HashMap};
6use std::io::{self, Write};
7use std::num::NonZeroU64;
8use std::path::Path;
9use std::time::Duration;
10
11use anyhow::{bail, Context, Result};
12use clap::{Args, Subcommand};
13use rusqlite::hooks::{AuthAction, Authorization};
14use rusqlite::types::ValueRef;
15use rusqlite::{Connection, OpenFlags};
16use serde_json::{json, Number, Value};
17
18use crate::render::{csv_escape, OutputFormat};
19
20pub const DEFAULT_LIMIT: usize = 100;
21pub const DEFAULT_TIMEOUT_MS: u64 = 0;
24pub const DEFAULT_MCP_MAX_CELL_CHARS: usize = 1_000;
25const SESSION_INDEX_NOUN: &str = "local AI session-history tables";
26
27const QUERY_TIMEOUT_RECOVERY: &str = "message content and tool names are indexed, so `aise messages search` (MCP: search_messages) answers those without scanning, with --field tool-name for tool calls. Otherwise narrow the SQL or increase timeout_ms (CLI: --timeout-ms). Set timeout_ms to 0 only when an unbounded query is intentional";
35
36pub(crate) const SCHEMA_COLUMN_NOTES: &[(&str, &str, &str)] = &[
45 (
46 "messages",
47 "tool_name",
48 "Provider spelling, not a cross-provider identifier: Claude stores an MCP tool namespaced (mcp__codebase-memory-mcp__search_graph) where Codex stores the leaf name (search_graph). Matching one spelling drops the other provider silently. `aise messages search --field tool-name` (MCP: search_messages with tool_name_contains) matches both.",
49 ),
50 (
51 "messages",
52 "tool_call_id",
53 "Not unique, in either direction: the same id appears on more than one row of a session, and a resumed session replays the earlier calls under its new session_id keeping their original ids. One row is therefore not one call; use count(distinct tool_call_id).",
54 ),
55 (
56 "messages",
57 "kind",
58 "Stored with underscores: conversation, compaction, tool_call, tool_result, harness_notice, unknown. MCP's kinds and this SQL surface use those spellings; the CLI hyphenates three of them, as `--kind tool-call`, `tool-result`, and `harness-notice`. No hyphenated spelling appears in storage, so a hyphen in SQL matches no row and reports no error.",
59 ),
60 (
61 "sessions",
62 "raw_metadata_json",
63 "Provider-shaped, with no key common to all: Codex records $.model where Claude records none, so grouping on json_extract(raw_metadata_json, '$.model') reports a Claude session as having no model rather than as unmeasured. A Claude session can also switch model partway through, so no session-level value would be correct for it.",
64 ),
65];
66
67const SCHEMA_TABLE_ROWS: &[(&str, &str)] = &[
72 ("sessions", "one indexed session"),
73 (
74 "messages",
75 "one conversation turn, tool call, tool result, compaction, or harness notice",
76 ),
77 (
78 "file_edits",
79 "one file-editing tool call, with its path, tool, and resulting content",
80 ),
81 ("transcripts", "one session's full text, needing no join"),
82];
83
84const INDEXED_COMMAND_ALTERNATIVES: &[(&str, &str)] = &[
90 (
91 "aise messages search",
92 "message text, tool names, and tool arguments, with surrounding turns",
93 ),
94 (
95 "aise files search",
96 "files an edit tool wrote, by path or name, with edit and session counts",
97 ),
98 ("aise search", "sessions by keyword, ranked by relevance"),
99 ("aise list", "sessions by recency, provider, or path"),
100 ("aise stats", "message counts by role"),
101 (
102 "aise vocab",
105 "how often a term appears and in how many messages (--prefix looks one up)",
106 ),
107];
108
109fn db_query_long_help() -> String {
117 let width = INDEXED_COMMAND_ALTERNATIVES
118 .iter()
119 .map(|(command, _)| command.len())
120 .max()
121 .unwrap_or_default();
122 let mut help = String::from(
123 "Run one read-only SQL statement over the local AI session-history index.\n\nMost questions do not need SQL. These read the same index and return ranking, context, and cross-provider matching that a predicate over one column does not:\n\n",
124 );
125 for (command, answers) in INDEXED_COMMAND_ALTERNATIVES {
126 help.push_str(&format!(" {command:width$} {answers}\n"));
127 }
128 help.push_str("\nTables, one row per:\n\n");
129 let width = SCHEMA_TABLE_ROWS
130 .iter()
131 .map(|(table, _)| table.len())
132 .max()
133 .unwrap_or_default();
134 for (table, row) in SCHEMA_TABLE_ROWS {
135 help.push_str(&format!(" {table:width$} {row}\n"));
136 }
137 help.push_str(
138 "\nRun `aise db schema` for every table and `aise db schema --table NAME` for one table's columns, types, and notes.\n\nThese values a correct-looking predicate misreads, returning a wrong answer and no error:\n",
139 );
140 for (table, column, note) in SCHEMA_COLUMN_NOTES {
141 help.push_str(&format!("\n {table}.{column}\n {note}\n"));
142 }
143 help
144}
145
146#[derive(Debug, Subcommand)]
147pub enum DbCmd {
148 Schema(DbSchemaArgs),
152 #[command(long_about = db_query_long_help())]
154 Query(DbQueryArgs),
155}
156
157#[derive(Debug, Args, Clone)]
158pub struct DbSchemaArgs {
159 #[arg(long)]
163 pub table: Option<String>,
164 #[arg(long)]
166 pub include_internal: bool,
167 #[arg(long, value_enum, default_value_t = OutputFormat::Table)]
169 pub format: OutputFormat,
170}
171
172#[derive(Debug, Args, Clone)]
173pub struct DbQueryArgs {
174 pub sql: String,
178 #[arg(long)]
180 pub limit: Option<usize>,
181 #[arg(long, default_value_t = 0)]
184 pub offset: usize,
185 #[arg(long)]
189 pub timeout_ms: Option<u64>,
190 #[arg(long, value_enum, default_value_t = OutputFormat::Table)]
192 pub format: OutputFormat,
193}
194
195#[derive(Debug, Clone, PartialEq)]
196pub struct QueryResult {
197 pub columns: Vec<String>,
198 pub rows: Vec<BTreeMap<String, Value>>,
199 pub truncated: bool,
200}
201
202#[derive(Debug, Clone, PartialEq)]
203pub struct QueryResultPayload {
204 pub value: Value,
205}
206
207#[derive(Debug, Clone)]
208pub struct ResolvedDbQueryArgs {
209 pub sql: String,
210 pub limit: usize,
211 pub offset: usize,
212 pub timeout_ms: u64,
213 pub format: OutputFormat,
214}
215
216impl DbQueryArgs {
217 pub fn resolve(&self, defaults: &crate::config::DbConfig) -> ResolvedDbQueryArgs {
218 ResolvedDbQueryArgs {
219 sql: self.sql.clone(),
220 limit: self.limit.unwrap_or(defaults.query_limit),
221 offset: self.offset,
222 timeout_ms: self.timeout_ms.unwrap_or(defaults.query_timeout_ms),
223 format: self.format,
224 }
225 }
226}
227
228pub fn run(
229 path: &Path,
230 busy_timeout_ms: u64,
231 defaults: &crate::config::DbConfig,
232 cmd: DbCmd,
233) -> Result<()> {
234 match cmd {
235 DbCmd::Schema(args) => {
236 let result = schema_path(path, busy_timeout_ms, &args)?;
237 let stdout = io::stdout();
238 let mut out = stdout.lock();
239 render_query_result(&result, args.format, &mut out)?;
240 out.flush()?;
241 }
242 DbCmd::Query(args) => {
243 let resolved = args.resolve(defaults);
244 let result =
245 query_path(path, busy_timeout_ms, &resolved).map_err(format_cli_query_error)?;
246 let stdout = io::stdout();
247 let mut out = stdout.lock();
248 render_query_result(&result, resolved.format, &mut out)?;
249 out.flush()?;
250 }
251 }
252 Ok(())
253}
254
255pub fn format_cli_query_error(err: anyhow::Error) -> anyhow::Error {
256 anyhow::anyhow!(format_query_error(
257 err,
258 "aise db query",
259 "run `aise db schema` to list tables, then `aise db schema --table NAME` to inspect columns",
260 ))
261}
262
263pub fn format_query_error(err: anyhow::Error, caller: &str, schema_help: &str) -> String {
264 let detail = err.to_string();
265 let chain = format!("{err:#}");
266 if chain.contains("Authorization denied") || chain.contains("not authorized") {
267 format!(
268 "{caller} rejected this SQL because it is not read-only or uses a blocked SQLite operation. Use exactly one SELECT-style statement over the {SESSION_INDEX_NOUN}, or {schema_help}. Details: {detail}"
269 )
270 } else if detail.contains("provide exactly one SQL statement") {
271 format!(
272 "{caller} accepts exactly one SQL statement. Remove extra semicolon-separated statements, or run one query per call."
273 )
274 } else if detail.contains("query must return rows") {
275 format!(
276 "{caller} only returns row-producing read-only queries. Use SELECT, WITH ... SELECT, or {schema_help}."
277 )
278 } else if detail.contains("no table or view named") {
279 format!("{detail}. {schema_help}, then retry with one listed table or view name.")
280 } else {
281 format!("{caller} failed: {chain}")
282 }
283}
284
285pub fn schema_path(path: &Path, busy_timeout_ms: u64, args: &DbSchemaArgs) -> Result<QueryResult> {
286 let conn = open_read_only(path, busy_timeout_ms)?;
287 schema_connection(&conn, args)
288}
289
290pub(crate) fn schema_path_cancellable(
291 path: &Path,
292 busy_timeout_ms: u64,
293 args: &DbSchemaArgs,
294 cancellation: &crate::db::QueryCancellation,
295) -> Result<QueryResult> {
296 let conn = open_read_only(path, busy_timeout_ms)?;
297 cancellation.register(&conn);
298 crate::db::with_sqlite_query_control(
299 &conn,
300 None,
301 Some(cancellation.flag_arc()),
302 "schema inspection",
303 "retry the request after indexing or database contention subsides",
304 || schema_connection(&conn, args),
305 )
306}
307
308pub fn schema_summary_path(
309 path: &Path,
310 busy_timeout_ms: u64,
311 max_tables: usize,
312 max_columns: usize,
313) -> Result<String> {
314 let conn = open_read_only(path, busy_timeout_ms)?;
315 schema_summary_connection(&conn, max_tables, max_columns)
316}
317
318pub fn query_path(
319 path: &Path,
320 busy_timeout_ms: u64,
321 args: &ResolvedDbQueryArgs,
322) -> Result<QueryResult> {
323 validate_sql(&args.sql)?;
324 let conn = open_read_only(path, busy_timeout_ms)?;
325 query_connection(&conn, args)
326}
327
328pub(crate) fn query_path_cancellable(
329 path: &Path,
330 busy_timeout_ms: u64,
331 args: &ResolvedDbQueryArgs,
332 cancellation: &crate::db::QueryCancellation,
333) -> Result<QueryResult> {
334 validate_sql(&args.sql)?;
335 let conn = open_read_only(path, busy_timeout_ms)?;
336 cancellation.register(&conn);
337 query_connection_control(&conn, args, Some(cancellation.flag_arc()))
338}
339
340fn open_read_only(path: &Path, busy_timeout_ms: u64) -> Result<Connection> {
341 let flags = OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_URI;
342 let conn = Connection::open_with_flags(path, flags)
343 .with_context(|| format!("failed to open {} read-only", path.display()))?;
344 conn.busy_timeout(Duration::from_millis(busy_timeout_ms))?;
345 Ok(conn)
346}
347
348fn schema_connection(conn: &Connection, args: &DbSchemaArgs) -> Result<QueryResult> {
349 with_read_only_authorizer(conn, || {
350 if let Some(table) = args.table.as_deref() {
351 table_columns(conn, table)
352 } else {
353 schema_objects(conn, args.include_internal)
354 }
355 })
356}
357
358fn schema_summary_connection(
359 conn: &Connection,
360 max_tables: usize,
361 max_columns: usize,
362) -> Result<String> {
363 with_read_only_authorizer(conn, || {
364 let schema = load_schema_objects(conn, false)?;
365 let mut parts = Vec::new();
366 for name in prioritized_schema_table_names(&schema)
367 .into_iter()
368 .take(max_tables)
369 {
370 let columns = table_column_names(conn, &name, max_columns)?;
371 let suffix = if columns.truncated { ", ..." } else { "" };
372 parts.push(format!("{name}({}{suffix})", columns.names.join(", ")));
373 }
374 if parts.is_empty() {
375 Ok(
376 "No queryable tables found; call query_session_index with no sql to inspect schema objects."
377 .to_string(),
378 )
379 } else {
380 Ok(parts.join("; "))
381 }
382 })
383}
384
385fn with_read_only_authorizer<T>(conn: &Connection, f: impl FnOnce() -> Result<T>) -> Result<T> {
386 conn.execute_batch("pragma query_only = on")?;
387 conn.authorizer(Some(read_only_authorizer))
391 .context("could not install the read-only SQL authorizer")?;
392 let result = f();
393 let cleared = conn
394 .authorizer(None::<fn(rusqlite::hooks::AuthContext<'_>) -> Authorization>)
395 .context("could not clear the read-only SQL authorizer");
396 result.and_then(|value| cleared.map(|()| value))
399}
400
401struct ColumnNames {
402 names: Vec<String>,
403 truncated: bool,
404}
405
406fn table_column_names(conn: &Connection, table: &str, max_columns: usize) -> Result<ColumnNames> {
407 let columns = table_columns(conn, table)?;
408 let mut names = columns
409 .rows
410 .iter()
411 .filter_map(|row| row.get("name").map(value_to_cell))
412 .collect::<Vec<_>>();
413 let truncated = max_columns > 0 && names.len() > max_columns;
414 if truncated {
415 names.truncate(max_columns);
416 }
417 Ok(ColumnNames { names, truncated })
418}
419
420fn query_connection(conn: &Connection, args: &ResolvedDbQueryArgs) -> Result<QueryResult> {
421 query_connection_control(conn, args, None)
422}
423
424fn query_connection_control(
425 conn: &Connection,
426 args: &ResolvedDbQueryArgs,
427 cancellation: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>,
428) -> Result<QueryResult> {
429 with_read_only_authorizer(conn, || {
430 crate::db::with_sqlite_query_control(
431 conn,
432 NonZeroU64::new(args.timeout_ms),
433 cancellation,
434 "query",
435 QUERY_TIMEOUT_RECOVERY,
436 || collect_query_rows(conn, args),
437 )
438 })
439}
440
441const PRIMARY_SCHEMA_TABLES: &[&str] = &["sessions", "messages", "file_edits", "transcripts"];
442
443#[derive(Debug, Clone, Eq, PartialEq)]
444struct SchemaObject {
445 object_type: String,
446 name: String,
447 table_name: String,
448 sql: String,
449}
450
451impl SchemaObject {
452 fn to_query_row(&self) -> BTreeMap<String, Value> {
453 BTreeMap::from([
454 ("type".to_string(), Value::String(self.object_type.clone())),
455 ("name".to_string(), Value::String(self.name.clone())),
456 (
457 "table_name".to_string(),
458 Value::String(self.table_name.clone()),
459 ),
460 ("sql".to_string(), Value::String(self.sql.clone())),
461 ])
462 }
463}
464
465fn prioritized_schema_table_names(schema: &[SchemaObject]) -> Vec<String> {
466 let mut names = Vec::new();
467 for priority_name in PRIMARY_SCHEMA_TABLES {
468 if schema
469 .iter()
470 .any(|object| object.object_type == "table" && object.name == *priority_name)
471 {
472 names.push((*priority_name).to_string());
473 }
474 }
475 names.extend(
476 schema
477 .iter()
478 .filter(|object| {
479 object.object_type == "table"
480 && !PRIMARY_SCHEMA_TABLES.contains(&object.name.as_str())
481 })
482 .map(|object| object.name.clone()),
483 );
484 names
485}
486
487fn schema_objects(conn: &Connection, include_internal: bool) -> Result<QueryResult> {
488 let mut objects = load_schema_objects(conn, include_internal)?;
489 if !include_internal {
490 objects.sort_by(compare_schema_objects_for_users);
491 }
492 let rows = objects
493 .into_iter()
494 .map(|object| object.to_query_row())
495 .collect();
496 Ok(QueryResult {
497 columns: vec![
498 "type".to_string(),
499 "name".to_string(),
500 "table_name".to_string(),
501 "sql".to_string(),
502 ],
503 rows,
504 truncated: false,
505 })
506}
507
508fn load_schema_objects(conn: &Connection, include_internal: bool) -> Result<Vec<SchemaObject>> {
509 let mut stmt = conn.prepare(
510 "select type, name, tbl_name as table_name, sql
511 from sqlite_schema
512 where sql is not null
513 and (?1 or (
514 type in ('table', 'view')
515 and
516 name not like 'sqlite_%'
517 and not exists (
518 select 1
519 from sqlite_schema as fts
520 where fts.type = 'table'
521 and lower(ltrim(fts.sql)) like 'create virtual table%using fts5%'
522 and sqlite_schema.name in (
523 fts.name || '_content',
524 fts.name || '_data',
525 fts.name || '_idx',
526 fts.name || '_docsize',
527 fts.name || '_config'
528 )
529 )
530 -- A full-text index and the fts5vocab views over it are machinery behind the
531 -- documented tables, so they are internal whatever they are named. Reading the
532 -- declaration rather than the name keeps `messages_vocab` and
533 -- `messages_trigram_terms` on the same side of the line.
534 and lower(ltrim(sql)) not like 'create virtual table%using fts5%'
535 and name not glob '*_fts_content'
536 and name not glob '*_fts_data'
537 and name not glob '*_fts_idx'
538 and name not glob '*_fts_docsize'
539 and name not glob '*_fts_config'
540 and name not glob 'trigram_*'
541 and name not in ('files_seen', 'index_metadata')
542 ))
543 order by
544 case type when 'table' then 0 when 'view' then 1 when 'index' then 2 when 'trigger' then 3 else 4 end,
545 name",
546 )?;
547 let mut rows = Vec::new();
548 let mapped = stmt.query_map([include_internal], |row| {
549 Ok(SchemaObject {
550 object_type: row.get(0)?,
551 name: row.get(1)?,
552 table_name: row.get(2)?,
553 sql: row.get(3)?,
554 })
555 })?;
556 for row in mapped {
557 rows.push(row?);
558 }
559 Ok(rows)
560}
561
562fn compare_schema_objects_for_users(left: &SchemaObject, right: &SchemaObject) -> Ordering {
563 schema_object_priority(left)
564 .cmp(&schema_object_priority(right))
565 .then_with(|| left.name.cmp(&right.name))
566}
567
568fn schema_object_priority(object: &SchemaObject) -> usize {
569 PRIMARY_SCHEMA_TABLES
570 .iter()
571 .position(|name| object.object_type == "table" && object.name == *name)
572 .unwrap_or_else(|| match object.object_type.as_str() {
573 "table" => PRIMARY_SCHEMA_TABLES.len(),
574 "view" => PRIMARY_SCHEMA_TABLES.len() + 1,
575 _ => PRIMARY_SCHEMA_TABLES.len() + 2,
576 })
577}
578
579fn table_columns(conn: &Connection, table: &str) -> Result<QueryResult> {
580 let exists: bool = conn.query_row(
581 "select exists(
582 select 1 from sqlite_schema
583 where name = ?1 and type in ('table', 'view')
584 )",
585 [table],
586 |row| row.get(0),
587 )?;
588 if !exists {
589 bail!("no table or view named {table:?}; inspect schema objects to list valid names");
590 }
591
592 let mut stmt = conn.prepare(
593 "select name, type, \"notnull\", dflt_value, pk, hidden
594 from pragma_table_xinfo(?1)
595 order by cid",
596 )?;
597 let mapped = stmt.query_map([table], |row| {
598 let mut out = BTreeMap::new();
599 let name = row.get::<_, String>(0)?;
600 out.insert(
601 "note".to_string(),
602 schema_column_note(table, &name).map_or(Value::Null, |note| Value::String(note.into())),
603 );
604 out.insert("name".to_string(), Value::String(name));
605 out.insert("type".to_string(), Value::String(row.get::<_, String>(1)?));
606 out.insert(
607 "not_null".to_string(),
608 Value::Bool(row.get::<_, i64>(2)? != 0),
609 );
610 out.insert("default".to_string(), value_ref_to_json(row.get_ref(3)?));
611 out.insert(
612 "primary_key".to_string(),
613 Number::from(row.get::<_, i64>(4)?).into(),
614 );
615 out.insert(
616 "hidden".to_string(),
617 Number::from(row.get::<_, i64>(5)?).into(),
618 );
619 Ok(out)
620 })?;
621 let mut rows = Vec::new();
622 for row in mapped {
623 rows.push(row?);
624 }
625 Ok(QueryResult {
626 columns: vec![
627 "name".to_string(),
628 "type".to_string(),
629 "not_null".to_string(),
630 "default".to_string(),
631 "primary_key".to_string(),
632 "hidden".to_string(),
633 "note".to_string(),
634 ],
635 rows,
636 truncated: false,
637 })
638}
639
640fn schema_column_note(table: &str, column: &str) -> Option<&'static str> {
642 SCHEMA_COLUMN_NOTES
643 .iter()
644 .find(|(note_table, note_column, _)| *note_table == table && *note_column == column)
645 .map(|(_, _, note)| *note)
646}
647
648fn collect_query_rows(conn: &Connection, args: &ResolvedDbQueryArgs) -> Result<QueryResult> {
649 let mut stmt = conn.prepare(&args.sql)?;
650 let column_count = stmt.column_count();
651 if column_count == 0 {
652 bail!("query must return rows; writes and maintenance commands are not supported");
653 }
654 let columns = unique_column_names(stmt.column_names());
655 let mut query = stmt.query([])?;
656 let mut skipped = 0usize;
657 let mut rows = Vec::new();
658 let mut truncated = false;
659 while let Some(row) = query.next()? {
660 if skipped < args.offset {
661 skipped += 1;
662 continue;
663 }
664 if args.limit > 0 && rows.len() >= args.limit {
665 truncated = true;
666 break;
667 }
668 let mut out = BTreeMap::new();
669 for (idx, name) in columns.iter().enumerate().take(column_count) {
670 out.insert(name.clone(), value_ref_to_json(row.get_ref(idx)?));
671 }
672 rows.push(out);
673 }
674
675 Ok(QueryResult {
676 columns,
677 rows,
678 truncated,
679 })
680}
681
682fn read_only_authorizer(ctx: rusqlite::hooks::AuthContext<'_>) -> Authorization {
683 match ctx.action {
684 AuthAction::Select | AuthAction::Read { .. } => Authorization::Allow,
685 AuthAction::Function { function_name } if allowed_read_only_function(function_name) => {
686 Authorization::Allow
687 }
688 AuthAction::Pragma {
689 pragma_name,
690 pragma_value,
691 } if allowed_read_only_pragma(pragma_name, pragma_value) => Authorization::Allow,
692 _ => Authorization::Deny,
693 }
694}
695
696fn allowed_read_only_function(name: &str) -> bool {
697 !name.eq_ignore_ascii_case("load_extension")
698}
699
700fn allowed_read_only_pragma(name: &str, value: Option<&str>) -> bool {
701 let name = name.to_ascii_lowercase();
702 matches!(
703 (name.as_str(), value),
704 ("table_info", Some(_))
705 | ("table_xinfo", Some(_))
706 | ("index_info", Some(_))
707 | ("index_xinfo", Some(_))
708 | ("database_list", None)
709 | ("user_version", None)
710 | ("application_id", None)
711 | ("data_version", None)
712 )
713}
714
715fn validate_sql(sql: &str) -> Result<()> {
720 ensure_single_statement(sql)?;
721 ensure_vocabulary_predicate_can_match(sql)
722}
723
724const STORED_VOCABULARIES: &[StoredVocabulary] = &[
733 StoredVocabulary {
734 column: "kind",
735 flag: "--kind",
736 stored_for_typed: stored_kind_for_typed_spelling,
737 },
738 StoredVocabulary {
739 column: "role",
740 flag: "--role",
741 stored_for_typed: stored_role_for_typed_spelling,
742 },
743 StoredVocabulary {
744 column: "provider",
745 flag: "--provider",
746 stored_for_typed: stored_provider_for_typed_spelling,
747 },
748];
749
750struct StoredVocabulary {
751 column: &'static str,
753 flag: &'static str,
755 stored_for_typed: fn(&str) -> Option<&'static str>,
757}
758
759fn ensure_vocabulary_predicate_can_match(sql: &str) -> Result<()> {
772 let mut comparing: Option<&StoredVocabulary> = None;
773 for (token, text) in SqlTokens::new(sql) {
774 match token {
775 SqlToken::WhitespaceOrComment => {}
776 SqlToken::Semicolon => comparing = None,
777 SqlToken::StringLiteral => {
778 let value = unquote_sql_string(text);
779 let Some(vocabulary) = comparing else {
780 continue;
781 };
782 let Some(stored) = (vocabulary.stored_for_typed)(&value) else {
783 continue;
784 };
785 let (column, flag) = (vocabulary.column, vocabulary.flag);
786 bail!(
787 "{column} = '{value}' matches no row: this index stores {column} as \
788 '{stored}'. The spelling you used is the CLI's; MCP and this SQL surface use \
789 the stored one. Retry with '{stored}', or run \
790 `aise messages search {flag} {value}`, which takes the spelling you wrote and \
791 searches the index directly."
792 );
793 }
794 SqlToken::Other => {
795 let lowered = text.to_ascii_lowercase();
796 comparing = compared_vocabulary_column(&lowered)
797 .or_else(|| comparing.filter(|_| continues_a_comparison(&lowered)));
798 }
799 }
800 }
801 Ok(())
802}
803
804fn compared_vocabulary_column(token: &str) -> Option<&'static StoredVocabulary> {
807 let name = token.trim_end_matches(['=', '!', '<', '>', '(', ',']);
808 let name = name.rsplit('.').next().unwrap_or(name);
809 STORED_VOCABULARIES
810 .iter()
811 .find(|vocabulary| vocabulary.column == name)
812}
813
814fn continues_a_comparison(token: &str) -> bool {
817 matches!(
818 token.trim_matches(['(', ',']),
819 "" | "in" | "like" | "=" | "==" | "!=" | "<>"
820 )
821}
822
823pub(crate) fn stored_kind_for_typed_spelling(value: &str) -> Option<&'static str> {
830 use clap::ValueEnum;
831 crate::models::MessageKind::value_variants()
832 .iter()
833 .find_map(|kind| diverging_stored_spelling(kind, kind.as_str(), value))
834}
835
836fn stored_role_for_typed_spelling(value: &str) -> Option<&'static str> {
839 use clap::ValueEnum;
840 crate::models::Role::value_variants()
841 .iter()
842 .find_map(|role| diverging_stored_spelling(role, role.as_str(), value))
843}
844
845fn stored_provider_for_typed_spelling(value: &str) -> Option<&'static str> {
848 use clap::ValueEnum;
849 crate::models::Provider::value_variants()
850 .iter()
851 .find_map(|provider| diverging_stored_spelling(provider, provider.as_str(), value))
852}
853
854fn diverging_stored_spelling<T: clap::ValueEnum>(
856 variant: &T,
857 stored: &'static str,
858 value: &str,
859) -> Option<&'static str> {
860 let typed = variant.to_possible_value()?;
861 (typed.get_name() != stored && typed.get_name().eq_ignore_ascii_case(value)).then_some(stored)
862}
863
864fn unquote_sql_string(token: &str) -> String {
866 token
867 .strip_prefix('\'')
868 .map_or(token, |rest| rest.strip_suffix('\'').unwrap_or(rest))
869 .replace("''", "'")
870}
871
872fn ensure_single_statement(sql: &str) -> Result<()> {
873 if sql.trim().is_empty() {
874 bail!("SQL query cannot be empty");
875 }
876 let mut semicolon_seen = false;
877 for (token, _) in SqlTokens::new(sql) {
878 if semicolon_seen && !matches!(token, SqlToken::WhitespaceOrComment) {
879 bail!("provide exactly one SQL statement");
880 }
881 if matches!(token, SqlToken::Semicolon) {
882 semicolon_seen = true;
883 }
884 }
885 Ok(())
886}
887
888#[derive(Clone, Copy, Debug, Eq, PartialEq)]
889enum SqlToken {
890 Semicolon,
891 WhitespaceOrComment,
892 StringLiteral,
894 Other,
895}
896
897struct SqlTokens<'a> {
898 input: &'a str,
899 pos: usize,
900}
901
902impl<'a> SqlTokens<'a> {
903 fn new(input: &'a str) -> Self {
904 Self { input, pos: 0 }
905 }
906}
907
908impl<'a> Iterator for SqlTokens<'a> {
909 type Item = (SqlToken, &'a str);
910
911 fn next(&mut self) -> Option<Self::Item> {
912 let bytes = self.input.as_bytes();
913 if self.pos >= bytes.len() {
914 return None;
915 }
916 let start = self.pos;
917 let kind = self.next_kind(bytes, start)?;
918 Some((kind, &self.input[start..self.pos]))
919 }
920}
921
922impl SqlTokens<'_> {
923 fn next_kind(&mut self, bytes: &[u8], start: usize) -> Option<SqlToken> {
924 match bytes[self.pos] {
925 b';' => {
926 self.pos += 1;
927 Some(SqlToken::Semicolon)
928 }
929 b'\'' => {
930 skip_quoted(bytes, &mut self.pos, b'\'');
931 Some(SqlToken::StringLiteral)
932 }
933 b'"' | b'`' => {
934 let quote = bytes[self.pos];
935 skip_quoted(bytes, &mut self.pos, quote);
936 Some(SqlToken::Other)
937 }
938 b'[' => {
939 skip_bracket_quoted(bytes, &mut self.pos);
940 Some(SqlToken::Other)
941 }
942 b'-' if bytes.get(self.pos + 1) == Some(&b'-') => {
943 self.pos += 2;
944 while self.pos < bytes.len() && !matches!(bytes[self.pos], b'\n' | b'\r') {
945 self.pos += 1;
946 }
947 Some(SqlToken::WhitespaceOrComment)
948 }
949 b'/' if bytes.get(self.pos + 1) == Some(&b'*') => {
950 self.pos += 2;
951 while self.pos + 1 < bytes.len()
952 && !(bytes[self.pos] == b'*' && bytes[self.pos + 1] == b'/')
953 {
954 self.pos += 1;
955 }
956 self.pos = (self.pos + 2).min(bytes.len());
957 Some(SqlToken::WhitespaceOrComment)
958 }
959 b if b.is_ascii_whitespace() => {
960 while self.pos < bytes.len() && bytes[self.pos].is_ascii_whitespace() {
961 self.pos += 1;
962 }
963 Some(SqlToken::WhitespaceOrComment)
964 }
965 _ => {
966 while !is_sql_token_boundary(bytes, self.pos) {
967 self.pos += 1;
968 }
969 if self.pos == start {
970 self.pos += 1;
971 }
972 Some(SqlToken::Other)
973 }
974 }
975 }
976}
977
978fn is_sql_token_boundary(bytes: &[u8], pos: usize) -> bool {
979 pos >= bytes.len()
980 || matches!(bytes[pos], b';' | b'\'' | b'"' | b'`' | b'[')
981 || bytes[pos].is_ascii_whitespace()
982 || (bytes[pos] == b'-' && bytes.get(pos + 1) == Some(&b'-'))
983 || (bytes[pos] == b'/' && bytes.get(pos + 1) == Some(&b'*'))
984}
985
986fn skip_quoted(bytes: &[u8], pos: &mut usize, quote: u8) {
987 *pos += 1;
988 while *pos < bytes.len() {
989 if bytes[*pos] == quote {
990 *pos += 1;
991 if bytes.get(*pos) == Some("e) {
992 *pos += 1;
993 continue;
994 }
995 break;
996 }
997 *pos += 1;
998 }
999}
1000
1001fn skip_bracket_quoted(bytes: &[u8], pos: &mut usize) {
1002 *pos += 1;
1003 while *pos < bytes.len() {
1004 if bytes[*pos] == b']' {
1005 *pos += 1;
1006 break;
1007 }
1008 *pos += 1;
1009 }
1010}
1011
1012fn unique_column_names(names: Vec<&str>) -> Vec<String> {
1013 let mut seen = HashMap::<String, usize>::new();
1014 names
1015 .into_iter()
1016 .enumerate()
1017 .map(|(idx, raw)| {
1018 let base = if raw.is_empty() {
1019 format!("column_{}", idx + 1)
1020 } else {
1021 raw.to_string()
1022 };
1023 let count = seen.entry(base.clone()).or_insert(0);
1024 *count += 1;
1025 if *count == 1 {
1026 base
1027 } else {
1028 format!("{base}_{count}")
1029 }
1030 })
1031 .collect()
1032}
1033
1034fn value_ref_to_json(value: ValueRef<'_>) -> Value {
1035 match value {
1036 ValueRef::Null => Value::Null,
1037 ValueRef::Integer(value) => Value::Number(Number::from(value)),
1038 ValueRef::Real(value) => Number::from_f64(value).map_or(Value::Null, Value::Number),
1039 ValueRef::Text(value) => Value::String(String::from_utf8_lossy(value).into_owned()),
1040 ValueRef::Blob(value) => Value::String(format!("<blob {} bytes>", value.len())),
1041 }
1042}
1043
1044pub fn render_query_result<W: Write>(
1045 result: &QueryResult,
1046 format: OutputFormat,
1047 out: &mut W,
1048) -> Result<()> {
1049 match format {
1050 OutputFormat::Json => writeln!(out, "{}", serde_json::to_string_pretty(&result.rows)?)?,
1051 OutputFormat::Jsonl => {
1052 for row in &result.rows {
1053 writeln!(out, "{}", serde_json::to_string(row)?)?;
1054 }
1055 }
1056 OutputFormat::Csv => {
1057 writeln!(
1058 out,
1059 "{}",
1060 result
1061 .columns
1062 .iter()
1063 .map(|h| csv_escape(h))
1064 .collect::<Vec<_>>()
1065 .join(",")
1066 )?;
1067 for row in &result.rows {
1068 writeln!(out, "{}", csv_cells(result, row).join(","))?;
1069 }
1070 }
1071 OutputFormat::Plain => {
1072 for row in &result.rows {
1073 writeln!(out, "{}", plain_cells(result, row).join("\t"))?;
1074 }
1075 }
1076 OutputFormat::Table => render_table(result, out)?,
1077 }
1078 if result.truncated {
1079 writeln!(
1080 out,
1081 "# truncated at {} rows; rerun with --limit 0 for all rows",
1082 result.rows.len()
1083 )?;
1084 }
1085 Ok(())
1086}
1087
1088pub fn query_result_payload(
1089 result: &QueryResult,
1090 offset: usize,
1091 max_cell_chars: usize,
1092) -> QueryResultPayload {
1093 let mut cells_truncated = false;
1094 let rows: Vec<Value> = result
1095 .rows
1096 .iter()
1097 .map(|row| {
1098 let mut out = serde_json::Map::new();
1099 for column in &result.columns {
1100 let value = row
1101 .get(column)
1102 .cloned()
1103 .map(|value| truncate_json_value(value, max_cell_chars, &mut cells_truncated))
1104 .unwrap_or(Value::Null);
1105 out.insert(column.clone(), value);
1106 }
1107 Value::Object(out)
1108 })
1109 .collect();
1110 QueryResultPayload {
1111 value: json!({
1112 "columns": result.columns,
1113 "rows": rows,
1114 "next_offset": result
1115 .truncated
1116 .then(|| offset.saturating_add(result.rows.len())),
1117 "truncated_cell_char_limit": cells_truncated.then_some(max_cell_chars),
1118 }),
1119 }
1120}
1121
1122fn truncate_json_value(value: Value, max_chars: usize, truncated: &mut bool) -> Value {
1123 if max_chars == 0 {
1124 return value;
1125 }
1126 match value {
1127 Value::String(value) if value.chars().count() > max_chars => {
1128 *truncated = true;
1129 Value::String(format!(
1130 "{}... [truncated]",
1131 value.chars().take(max_chars).collect::<String>()
1132 ))
1133 }
1134 other => other,
1135 }
1136}
1137
1138fn csv_cells(result: &QueryResult, row: &BTreeMap<String, Value>) -> Vec<String> {
1139 plain_cells(result, row)
1140 .iter()
1141 .map(|cell| csv_escape(cell))
1142 .collect()
1143}
1144
1145fn plain_cells(result: &QueryResult, row: &BTreeMap<String, Value>) -> Vec<String> {
1146 result
1147 .columns
1148 .iter()
1149 .map(|column| row.get(column).map(value_to_cell).unwrap_or_default())
1150 .collect()
1151}
1152
1153fn value_to_cell(value: &Value) -> String {
1154 match value {
1155 Value::Null => String::new(),
1156 Value::String(value) => value.clone(),
1157 other => other.to_string(),
1158 }
1159}
1160
1161fn render_table<W: Write>(result: &QueryResult, out: &mut W) -> Result<()> {
1162 let mut widths: Vec<usize> = result.columns.iter().map(|h| h.chars().count()).collect();
1163 let body: Vec<Vec<String>> = result
1164 .rows
1165 .iter()
1166 .map(|row| {
1167 let cells = plain_cells(result, row);
1168 for (idx, cell) in cells.iter().enumerate() {
1169 if idx < widths.len() {
1170 widths[idx] = widths[idx].max(cell.chars().count());
1171 }
1172 }
1173 cells
1174 })
1175 .collect();
1176 let fmt_row = |cells: &[String]| -> String {
1177 cells
1178 .iter()
1179 .enumerate()
1180 .map(|(idx, cell)| format!("{:width$}", cell, width = widths[idx]))
1181 .collect::<Vec<_>>()
1182 .join(" ")
1183 .trim_end()
1184 .to_string()
1185 };
1186 writeln!(out, "{}", fmt_row(&result.columns))?;
1187 for row in body {
1188 writeln!(out, "{}", fmt_row(&row))?;
1189 }
1190 Ok(())
1191}
1192
1193#[cfg(test)]
1194mod tests {
1195 use super::*;
1196 use crate::db::Db;
1197
1198 fn fixture() -> (tempfile::TempDir, std::path::PathBuf) {
1199 let dir = tempfile::tempdir().unwrap();
1200 let path = dir.path().join("index.db");
1201 let conn = Connection::open(&path).unwrap();
1202 conn.execute_batch(
1203 "create table demo(id integer primary key, name text, note text);
1204 create index demo_name_idx on demo(name);
1205 create virtual table demo_fts using fts5(name, note);
1206 insert into demo(name, note) values ('alpha', '=formula');
1207 insert into demo(name, note) values ('beta', 'plain');",
1208 )
1209 .unwrap();
1210 (dir, path)
1211 }
1212
1213 fn args(sql: &str) -> ResolvedDbQueryArgs {
1214 ResolvedDbQueryArgs {
1215 sql: sql.to_string(),
1216 limit: 100,
1217 offset: 0,
1218 timeout_ms: DEFAULT_TIMEOUT_MS,
1219 format: OutputFormat::Json,
1220 }
1221 }
1222
1223 fn schema_args() -> DbSchemaArgs {
1224 DbSchemaArgs {
1225 table: None,
1226 include_internal: false,
1227 format: OutputFormat::Json,
1228 }
1229 }
1230
1231 fn schema_object(name: &str) -> SchemaObject {
1232 SchemaObject {
1233 object_type: "table".to_string(),
1234 name: name.to_string(),
1235 table_name: name.to_string(),
1236 sql: format!("create table {name}(id integer)"),
1237 }
1238 }
1239
1240 #[test]
1241 fn db_query_args_resolve_config_defaults_and_explicit_zero_overrides() {
1242 let defaults = crate::config::DbConfig {
1243 query_limit: 17,
1244 query_timeout_ms: 2500,
1245 };
1246 let args = DbQueryArgs {
1247 sql: "select 1".to_string(),
1248 limit: None,
1249 offset: 2,
1250 timeout_ms: None,
1251 format: OutputFormat::Json,
1252 };
1253 let resolved = args.resolve(&defaults);
1254 assert_eq!(resolved.limit, 17);
1255 assert_eq!(resolved.timeout_ms, 2500);
1256 assert_eq!(resolved.offset, 2);
1257
1258 let args = DbQueryArgs {
1259 sql: "select 1".to_string(),
1260 limit: Some(0),
1261 offset: 0,
1262 timeout_ms: Some(0),
1263 format: OutputFormat::Json,
1264 };
1265 let resolved = args.resolve(&defaults);
1266 assert_eq!(resolved.limit, 0, "explicit 0 keeps unlimited rows");
1267 assert_eq!(resolved.timeout_ms, 0, "explicit 0 keeps no timeout");
1268 }
1269
1270 #[test]
1271 fn native_default_does_not_interrupt_valid_read_only_sql() {
1272 assert_eq!(
1273 DEFAULT_TIMEOUT_MS, 0,
1274 "native CLI/Rust raw SQL stays unlimited unless the caller configures a timeout"
1275 );
1276 }
1277
1278 #[test]
1279 fn read_only_query_returns_typed_values() {
1280 let (_dir, path) = fixture();
1281 let result =
1282 query_path(&path, 100, &args("select id, name from demo order by id")).unwrap();
1283 assert_eq!(result.columns, vec!["id", "name"]);
1284 assert_eq!(result.rows.len(), 2);
1285 assert_eq!(result.rows[0]["id"], Value::Number(Number::from(1)));
1286 assert_eq!(result.rows[0]["name"], Value::String("alpha".into()));
1287 }
1288
1289 #[test]
1290 fn query_timeout_reports_effective_bound_and_recovery_options() {
1291 let (_dir, path) = fixture();
1292 let conn = Connection::open(&path).unwrap();
1293 conn.execute_batch(
1294 "create table workload(value integer);
1295 with recursive n(value) as (values(1) union all select value + 1 from n where value < 10000)
1296 insert into workload select value from n;",
1297 )
1298 .unwrap();
1299 drop(conn);
1300 let mut query = args("select sum(a.value * b.value) from workload a cross join workload b");
1301 query.timeout_ms = 1;
1302
1303 let error = query_path(&path, 100, &query).unwrap_err().to_string();
1304
1305 assert!(error.contains("timed out after 1 ms"), "{error}");
1306 assert!(error.contains("--timeout-ms"), "{error}");
1307 assert!(error.contains("timeout_ms to 0"), "{error}");
1308 assert!(error.contains("aise messages search"), "{error}");
1313 assert!(error.contains("search_messages"), "{error}");
1314 assert!(error.contains("tool-name"), "{error}");
1315 }
1316
1317 #[test]
1318 fn a_kind_predicate_that_can_never_match_is_rejected_with_the_stored_spelling() {
1319 let dir = tempfile::tempdir().unwrap();
1320 let path = dir.path().join("index.db");
1321 let _db = Db::open(&path).unwrap();
1322
1323 for sql in [
1324 "select * from messages where kind = 'harness-notice'",
1325 "select * from messages where kind='harness-notice'",
1326 "select * from messages m where m.kind in ('tool-call', 'conversation')",
1327 ] {
1328 let error = query_path(&path, 100, &args(sql)).unwrap_err().to_string();
1329 assert!(
1332 error.contains("harness_notice") || error.contains("tool_call"),
1333 "{sql}: {error}"
1334 );
1335 assert!(error.contains("aise messages search"), "{sql}: {error}");
1336 }
1337
1338 for sql in [
1341 "select kind from messages where content like '%tool-call%'",
1342 "select * from messages where content = 'harness-notice'",
1343 "select * from messages where kind = 'harness_notice'",
1344 ] {
1345 query_path(&path, 100, &args(sql))
1346 .unwrap_or_else(|error| panic!("rejected a valid query {sql}: {error}"));
1347 }
1348 }
1349
1350 #[test]
1351 fn schema_table_discloses_columns_a_correct_looking_predicate_misreads() {
1352 let dir = tempfile::tempdir().unwrap();
1353 let path = dir.path().join("index.db");
1354 let _db = Db::open(&path).unwrap();
1355 let mut args = schema_args();
1356 args.table = Some("messages".to_string());
1357
1358 let result = schema_path(&path, 100, &args).unwrap();
1359
1360 assert!(result.columns.contains(&"note".to_string()), "{result:?}");
1361 let note = |column: &str| {
1362 result
1363 .rows
1364 .iter()
1365 .find(|row| value_to_cell(&row["name"]) == column)
1366 .unwrap_or_else(|| panic!("no {column} column in {result:?}"))["note"]
1367 .clone()
1368 };
1369 let tool_name = note("tool_name");
1372 let tool_name = tool_name.as_str().unwrap_or_default();
1373 assert!(tool_name.contains("mcp__"), "{tool_name}");
1374 assert!(tool_name.contains("aise messages search"), "{tool_name}");
1375 let tool_call_id = note("tool_call_id");
1376 let tool_call_id = tool_call_id.as_str().unwrap_or_default();
1377 assert!(tool_call_id.contains("distinct"), "{tool_call_id}");
1378 let kind = note("kind");
1381 let kind = kind.as_str().unwrap_or_default();
1382 assert!(kind.contains("harness_notice"), "{kind}");
1383 assert!(kind.contains("harness-notice"), "{kind}");
1384 assert_eq!(note("seq"), Value::Null);
1386
1387 args.table = Some("sessions".to_string());
1388 let sessions = schema_path(&path, 100, &args).unwrap();
1389 let metadata = sessions
1390 .rows
1391 .iter()
1392 .find(|row| value_to_cell(&row["name"]) == "raw_metadata_json")
1393 .expect("raw_metadata_json column")["note"]
1394 .as_str()
1395 .unwrap_or_default()
1396 .to_string();
1397 assert!(metadata.contains("$.model"), "{metadata}");
1398 }
1399
1400 #[test]
1401 fn every_stored_vocabulary_either_matches_its_typed_spelling_or_is_refused_and_disclosed() {
1402 let dir = tempfile::tempdir().unwrap();
1403 let path = dir.path().join("index.db");
1404 let _db = Db::open(&path).unwrap();
1405 let messages = schema_path(
1406 &path,
1407 100,
1408 &DbSchemaArgs {
1409 table: Some("messages".to_string()),
1410 ..schema_args()
1411 },
1412 )
1413 .unwrap();
1414
1415 for vocabulary in STORED_VOCABULARIES {
1416 let column = vocabulary.column;
1417 let row = messages
1418 .rows
1419 .iter()
1420 .find(|row| value_to_cell(&row["name"]) == column)
1421 .unwrap_or_else(|| panic!("declared {column} is absent from messages"));
1422 let note = row["note"].as_str().unwrap_or_default();
1423
1424 for typed in typed_spellings_of(column) {
1425 let Some(stored) = (vocabulary.stored_for_typed)(&typed) else {
1426 continue;
1429 };
1430 assert!(
1434 note.contains(stored),
1435 "{column}: {stored} missing from {note}"
1436 );
1437 assert!(
1438 note.contains(&typed),
1439 "{column}: {typed} missing from {note}"
1440 );
1441 let sql = format!("select * from messages where {column} = '{typed}'");
1442 let error = query_path(&path, 100, &args(&sql))
1443 .expect_err(&format!("{sql} was answered instead of refused"))
1444 .to_string();
1445 assert!(error.contains(stored), "{error}");
1446 assert!(error.contains(vocabulary.flag), "{error}");
1447 }
1448 }
1449 }
1450
1451 fn typed_spellings_of(column: &str) -> Vec<String> {
1453 use clap::ValueEnum;
1454 fn names<T: ValueEnum>() -> Vec<String> {
1455 T::value_variants()
1456 .iter()
1457 .filter_map(|variant| Some(variant.to_possible_value()?.get_name().to_string()))
1458 .collect()
1459 }
1460 match column {
1461 "kind" => names::<crate::models::MessageKind>(),
1462 "role" => names::<crate::models::Role>(),
1463 "provider" => names::<crate::models::Provider>(),
1464 other => panic!("no typed vocabulary is declared for {other}"),
1465 }
1466 }
1467
1468 #[test]
1469 fn the_kind_note_lists_every_stored_spelling_a_predicate_can_match() {
1470 use clap::ValueEnum;
1471 let note = schema_column_note("messages", "kind").expect("kind note");
1472
1473 for kind in crate::models::MessageKind::value_variants() {
1474 let stored = kind.as_str();
1478 assert!(note.contains(stored), "{stored} is missing from: {note}");
1479 }
1480 }
1481
1482 #[test]
1483 fn every_documented_schema_column_exists_in_the_live_index() {
1484 let dir = tempfile::tempdir().unwrap();
1485 let path = dir.path().join("index.db");
1486 let _db = Db::open(&path).unwrap();
1487
1488 let listed = schema_path(&path, 100, &schema_args()).unwrap();
1489 let names = listed
1490 .rows
1491 .iter()
1492 .map(|row| value_to_cell(&row["name"]))
1493 .collect::<Vec<_>>();
1494 for (table, _) in SCHEMA_TABLE_ROWS {
1495 assert!(
1498 names.contains(&(*table).to_string()),
1499 "documented {table} is absent from the live schema: {names:?}"
1500 );
1501 }
1502
1503 for (table, column, _) in SCHEMA_COLUMN_NOTES {
1504 let mut args = schema_args();
1505 args.table = Some((*table).to_string());
1506 let result = schema_path(&path, 100, &args).unwrap();
1507 assert!(
1510 result
1511 .rows
1512 .iter()
1513 .any(|row| value_to_cell(&row["name"]) == *column),
1514 "documented {table}.{column} is absent from the live schema: {result:?}"
1515 );
1516 }
1517 }
1518
1519 #[test]
1520 fn schema_lists_queryable_objects_without_shadow_tables_by_default() {
1521 let (_dir, path) = fixture();
1522 let result = schema_path(&path, 100, &schema_args()).unwrap();
1523 let names = result
1524 .rows
1525 .iter()
1526 .map(|row| value_to_cell(&row["name"]))
1527 .collect::<Vec<_>>();
1528
1529 assert!(names.contains(&"demo".to_string()));
1530 assert!(!names.contains(&"demo_fts".to_string()));
1531 assert!(!names.contains(&"demo_fts_data".to_string()));
1532 assert!(!names.contains(&"demo_name_idx".to_string()));
1533 }
1534
1535 #[test]
1536 fn schema_hides_production_named_trigram_shadow_tables_by_default() {
1537 let dir = tempfile::tempdir().unwrap();
1538 let path = dir.path().join("index.db");
1539 let conn = Connection::open(&path).unwrap();
1540 conn.execute_batch(
1541 "create table messages(id integer primary key, content text);
1542 create virtual table messages_trigram using fts5(
1543 content,
1544 content='messages',
1545 content_rowid='id',
1546 tokenize='trigram',
1547 detail=none,
1548 columnsize=0
1549 );
1550 create virtual table messages_trigram_terms
1551 using fts5vocab(messages_trigram, row);",
1552 )
1553 .unwrap();
1554
1555 let result = schema_path(&path, 100, &schema_args()).unwrap();
1556 let names = result
1557 .rows
1558 .iter()
1559 .map(|row| value_to_cell(&row["name"]))
1560 .collect::<Vec<_>>();
1561
1562 assert!(names.contains(&"messages".to_string()));
1563 assert!(!names.contains(&"messages_trigram".to_string()));
1566 assert!(!names.contains(&"messages_trigram_terms".to_string()));
1567 assert!(!names.contains(&"messages_trigram_config".to_string()));
1568 assert!(!names.contains(&"messages_trigram_data".to_string()));
1569 assert!(!names.contains(&"messages_trigram_idx".to_string()));
1570 }
1571
1572 #[test]
1573 fn schema_summary_prioritizes_core_session_tables() {
1574 let names = prioritized_schema_table_names(&[
1575 schema_object("z_extra"),
1576 schema_object("messages"),
1577 schema_object("sessions"),
1578 schema_object("file_edits"),
1579 schema_object("transcripts"),
1580 ]);
1581 assert_eq!(
1582 names,
1583 vec![
1584 "sessions",
1585 "messages",
1586 "file_edits",
1587 "transcripts",
1588 "z_extra"
1589 ]
1590 );
1591 }
1592
1593 #[test]
1594 fn schema_listing_prioritizes_core_session_tables() {
1595 let dir = tempfile::tempdir().unwrap();
1596 let path = dir.path().join("index.db");
1597 let conn = Connection::open(&path).unwrap();
1598 conn.execute_batch(
1599 "create table z_extra(id integer);
1600 create table messages(id integer);
1601 create table sessions(id integer);
1602 create table file_edits(id integer);
1603 create table transcripts(id integer);",
1604 )
1605 .unwrap();
1606
1607 let result = schema_path(&path, 100, &schema_args()).unwrap();
1608 let names = result
1609 .rows
1610 .iter()
1611 .map(|row| value_to_cell(&row["name"]))
1612 .collect::<Vec<_>>();
1613 assert_eq!(
1614 &names[..5],
1615 [
1616 "sessions",
1617 "messages",
1618 "file_edits",
1619 "transcripts",
1620 "z_extra"
1621 ]
1622 );
1623 }
1624
1625 #[test]
1626 fn schema_summary_uses_actual_index_schema_columns() {
1627 let dir = tempfile::tempdir().unwrap();
1628 let path = dir.path().join("index.db");
1629 let _db = Db::open(&path).unwrap();
1630
1631 let summary = schema_summary_path(&path, 100, 4, 20).unwrap();
1632 assert!(summary.contains(
1633 "sessions(id, provider, provider_session_id, title, summary, cwd, repo_root, created_at, updated_at, last_message_at, preview_text, source_path, message_count, parse_version, raw_metadata_json, parse_warning, discovery_source, parent_session_id, agent_label)"
1634 ));
1635 assert!(summary.contains(
1636 "messages(id, session_id, provider, seq, role, ts, tool_name, kind, tool_call_id, is_compaction, content, authorship, correlation_authority, correlation_scope, correlation_id, record_relation)"
1637 ));
1638 assert!(summary.contains(
1639 "file_edits(id, session_id, provider, seq, ts, tool, file_path, file_name, new_content, edits_json)"
1640 ));
1641 assert!(summary.contains("transcripts(session_id, transcript_text)"));
1642 assert!(!summary.contains("messages_fts("));
1643 assert!(!summary.contains("files_seen("));
1644 assert!(!summary.contains("index_metadata("));
1645 }
1646
1647 #[test]
1648 fn schema_can_include_internal_shadow_tables() {
1649 let (_dir, path) = fixture();
1650 let mut args = schema_args();
1651 args.include_internal = true;
1652 let result = schema_path(&path, 100, &args).unwrap();
1653 let names = result
1654 .rows
1655 .iter()
1656 .map(|row| value_to_cell(&row["name"]))
1657 .collect::<Vec<_>>();
1658
1659 assert!(names.contains(&"demo_fts_data".to_string()));
1660 assert!(names.contains(&"demo_fts".to_string()));
1661 assert!(names.contains(&"demo_name_idx".to_string()));
1662 }
1663
1664 #[test]
1665 fn full_text_indexes_and_their_vocabularies_are_internal_whatever_they_are_named() {
1666 let dir = tempfile::tempdir().unwrap();
1670 let path = dir.path().join("index.db");
1671 let conn = Connection::open(&path).unwrap();
1672 conn.execute_batch(
1673 "create table demo(id integer primary key, name text);
1674 create virtual table demo_fts using fts5(name);
1675 create virtual table demo_vocab using fts5vocab('demo_fts', 'row');
1676 create virtual table demo_terms using fts5vocab('demo_fts', 'row');",
1677 )
1678 .unwrap();
1679 drop(conn);
1680
1681 let names = |include_internal: bool| -> Vec<String> {
1682 schema_path(
1683 &path,
1684 100,
1685 &DbSchemaArgs {
1686 include_internal,
1687 ..schema_args()
1688 },
1689 )
1690 .unwrap()
1691 .rows
1692 .iter()
1693 .map(|row| value_to_cell(&row["name"]))
1694 .collect()
1695 };
1696
1697 let listed = names(false);
1698 assert!(listed.contains(&"demo".to_string()), "{listed:?}");
1699 for internal in ["demo_fts", "demo_vocab", "demo_terms"] {
1700 assert!(
1701 !listed.contains(&internal.to_string()),
1702 "{internal} is a full-text index or its vocabulary, not part of the queryable \
1703 schema: {listed:?}"
1704 );
1705 }
1706
1707 let internal = names(true);
1709 for name in ["demo_fts", "demo_vocab", "demo_terms"] {
1710 assert!(
1711 internal.contains(&name.to_string()),
1712 "--include-internal must still reach {name}: {internal:?}"
1713 );
1714 }
1715 }
1716
1717 #[test]
1718 fn schema_table_prints_columns_using_table_xinfo() {
1719 let (_dir, path) = fixture();
1720 let mut args = schema_args();
1721 args.table = Some("demo".to_string());
1722 let result = schema_path(&path, 100, &args).unwrap();
1723 assert_eq!(
1724 result
1725 .rows
1726 .iter()
1727 .map(|row| value_to_cell(&row["name"]))
1728 .collect::<Vec<_>>(),
1729 vec!["id", "name", "note"]
1730 );
1731 assert!(schema_path(
1732 &path,
1733 100,
1734 &DbSchemaArgs {
1735 table: Some("missing".to_string()),
1736 ..schema_args()
1737 }
1738 )
1739 .is_err());
1740 }
1741
1742 #[test]
1743 fn query_limit_truncates_without_cap() {
1744 let (_dir, path) = fixture();
1745 let mut query = args("select id from demo order by id");
1746 query.limit = 1;
1747 let result = query_path(&path, 100, &query).unwrap();
1748 assert!(result.truncated);
1749 assert_eq!(result.rows.len(), 1);
1750
1751 query.limit = 0;
1752 let result = query_path(&path, 100, &query).unwrap();
1753 assert!(!result.truncated);
1754 assert_eq!(result.rows.len(), 2);
1755 }
1756
1757 #[test]
1758 fn query_offset_paginates_after_sql_results() {
1759 let (_dir, path) = fixture();
1760 let mut query = args("select id from demo order by id");
1761 query.limit = 1;
1762 query.offset = 1;
1763 let result = query_path(&path, 100, &query).unwrap();
1764 assert_eq!(result.rows.len(), 1);
1765 assert_eq!(result.rows[0]["id"], Value::Number(Number::from(2)));
1766 assert!(!result.truncated);
1767 }
1768
1769 #[test]
1770 fn rejects_write_and_multi_statement_sql() {
1771 let (_dir, path) = fixture();
1772 assert!(query_path(&path, 100, &args("delete from demo")).is_err());
1773 assert!(query_path(&path, 100, &args("select 1; select 2")).is_err());
1774 assert!(query_path(&path, 100, &args("pragma wal_checkpoint")).is_err());
1775 assert!(query_path(&path, 100, &args("attach database '/tmp/x.db' as x")).is_err());
1776 assert!(query_path(
1777 &path,
1778 100,
1779 &args("select load_extension('/tmp/not-real-extension')")
1780 )
1781 .is_err());
1782 assert!(query_path(
1783 &path,
1784 100,
1785 &args("select * from pragma_table_xinfo('demo')")
1786 )
1787 .is_ok());
1788 }
1789
1790 #[test]
1791 fn single_statement_validation_ignores_semicolons_in_strings_and_comments() {
1792 ensure_single_statement("select ';' as semi -- ;\n").unwrap();
1793 ensure_single_statement("select 'x'; /* trailing ; comment */").unwrap();
1794 assert!(ensure_single_statement("select 1; select 2").is_err());
1795 }
1796
1797 #[test]
1798 fn dynamic_csv_uses_existing_formula_guard() {
1799 let (_dir, path) = fixture();
1800 let result = query_path(&path, 100, &args("select note from demo where id = 1")).unwrap();
1801 let mut out = Vec::new();
1802 render_query_result(&result, OutputFormat::Csv, &mut out).unwrap();
1803 assert_eq!(String::from_utf8(out).unwrap(), "note\n'=formula\n");
1804 }
1805
1806 #[test]
1807 fn duplicate_column_names_are_disambiguated_for_json() {
1808 let (_dir, path) = fixture();
1809 let result = query_path(&path, 100, &args("select id, id from demo limit 1")).unwrap();
1810 assert_eq!(result.columns, vec!["id", "id_2"]);
1811 assert!(result.rows[0].contains_key("id"));
1812 assert!(result.rows[0].contains_key("id_2"));
1813 }
1814
1815 #[test]
1816 fn mcp_payload_shape_includes_columns_and_cell_truncation() {
1817 let (_dir, path) = fixture();
1818 let result = query_path(
1819 &path,
1820 100,
1821 &args("select id, 'abcdef' as long_text from demo limit 1"),
1822 )
1823 .unwrap();
1824 let payload = query_result_payload(&result, 0, 3);
1825
1826 assert_eq!(payload.value["columns"], json!(["id", "long_text"]));
1827 assert_eq!(payload.value["next_offset"], Value::Null);
1828 assert_eq!(payload.value["truncated_cell_char_limit"], 3);
1829 assert_eq!(payload.value["rows"][0]["long_text"], "abc... [truncated]");
1830
1831 let mut paged = result.clone();
1832 paged.truncated = true;
1833 let payload = query_result_payload(&paged, 7, 0);
1834 assert_eq!(payload.value["next_offset"], 7 + paged.rows.len());
1835 assert_eq!(payload.value["truncated_cell_char_limit"], Value::Null);
1836 }
1837}