1591 lines
54 KiB
Rust
1591 lines
54 KiB
Rust
#![allow(clippy::multiple_crate_versions)]
|
|
//! The `sqliteshape` command inspects `SQLite` databases.
|
|
|
|
use std::ffi::OsString;
|
|
use std::fmt::Write as _;
|
|
use std::io::{self, Read};
|
|
use std::path::PathBuf;
|
|
use std::time::Duration;
|
|
|
|
use common::{
|
|
CliError, CommonArgs, ExitCode, InputFormat, RenderMode, map_result_count, parse_color_choice,
|
|
parse_format_choice, parse_input_format, print_error, print_json, print_quick_help_error,
|
|
print_structured, should_read_stdin,
|
|
};
|
|
use lexopt::prelude::{Long, Short, Value as ArgValue};
|
|
use rusqlite::{Connection, OpenFlags};
|
|
use serde::Serialize;
|
|
|
|
const SQLITE_BUSY_TIMEOUT: Duration = Duration::from_millis(100);
|
|
const SQLITE_EXPRESSION_INDEX_COLUMN: &str = "<expression>";
|
|
|
|
const HELP: &str = "\
|
|
Inspect SQLite schema and table stats without leaving the terminal.
|
|
|
|
Usage:
|
|
sqliteshape [OPTIONS] [PATH...]
|
|
sqliteshape [OPTIONS] diff <BEFORE> <AFTER>
|
|
|
|
Options:
|
|
--format <FORMAT> Structured output format: text, json, toon
|
|
--json Shortcut for --format json
|
|
--toon Shortcut for --format toon
|
|
--input-format <FORMAT> Override stdin parsing mode: auto, lines, jsonl
|
|
--color <WHEN> Control ANSI color output: auto, never
|
|
--quiet Suppress non-essential status output
|
|
--table <NAME> Limit output to one or more table names
|
|
--sample-rows <COUNT> Maximum rows to sample per table
|
|
--include-indexes Include index metadata in the summary
|
|
--count-rows Use exact COUNT(*) for each table instead of estimates/unknown
|
|
-h, --help Show this help text
|
|
-V, --version Show the command version
|
|
|
|
Examples:
|
|
sqliteshape .\\fixtures\\sqliteshape\\sample.db
|
|
'C:\\data\\events.db' | sqliteshape --input-format lines --json | ConvertFrom-Json
|
|
sqliteshape .\\fixtures\\sqliteshape\\sample.db --table users --include-indexes
|
|
sqliteshape diff before.db after.db --json | ConvertFrom-Json
|
|
";
|
|
|
|
#[derive(Debug, Clone)]
|
|
struct Cli {
|
|
common: CommonArgs,
|
|
command: CommandMode,
|
|
tables: Vec<String>,
|
|
sample_rows: usize,
|
|
include_indexes: bool,
|
|
count_rows: bool,
|
|
paths: Vec<PathBuf>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
enum ParseOutcome {
|
|
Help,
|
|
Version,
|
|
Run,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
enum CommandMode {
|
|
Summary,
|
|
Diff { before: PathBuf, after: PathBuf },
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
|
struct DatabaseSummary {
|
|
path: String,
|
|
page_size: i64,
|
|
page_count: i64,
|
|
journal_mode: String,
|
|
table_count: usize,
|
|
requested_tables: Vec<String>,
|
|
unmatched_tables: Vec<String>,
|
|
tables: Vec<TableSummary>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
struct TableSelection {
|
|
matched: Vec<String>,
|
|
unmatched: Vec<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
|
struct TableSummary {
|
|
name: String,
|
|
row_count: Option<i64>,
|
|
row_count_mode: String,
|
|
columns: Vec<ColumnSummary>,
|
|
sample_rows: Vec<serde_json::Value>,
|
|
indexes: Vec<IndexSummary>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
|
struct ColumnSummary {
|
|
name: String,
|
|
declared_type: String,
|
|
not_null: bool,
|
|
primary_key_position: i64,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
|
struct IndexSummary {
|
|
name: String,
|
|
unique: bool,
|
|
columns: Vec<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
|
struct DatabaseDiff {
|
|
path_before: String,
|
|
path_after: String,
|
|
page_size_before: i64,
|
|
page_size_after: i64,
|
|
page_count_before: i64,
|
|
page_count_after: i64,
|
|
journal_mode_before: String,
|
|
journal_mode_after: String,
|
|
added_tables: Vec<TableSummary>,
|
|
removed_tables: Vec<TableSummary>,
|
|
changed_tables: Vec<TableDiff>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
|
struct TableDiff {
|
|
name: String,
|
|
row_count_before: Option<i64>,
|
|
row_count_after: Option<i64>,
|
|
row_count_mode_before: String,
|
|
row_count_mode_after: String,
|
|
added_columns: Vec<ColumnSummary>,
|
|
removed_columns: Vec<ColumnSummary>,
|
|
changed_columns: Vec<ColumnChange>,
|
|
added_indexes: Vec<IndexSummary>,
|
|
removed_indexes: Vec<IndexSummary>,
|
|
}
|
|
|
|
const ROW_COUNT_MODE_EXACT: &str = "exact";
|
|
const ROW_COUNT_MODE_ESTIMATED: &str = "estimated";
|
|
const ROW_COUNT_MODE_UNKNOWN: &str = "unknown";
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
|
struct ColumnChange {
|
|
name: String,
|
|
declared_type_before: String,
|
|
declared_type_after: String,
|
|
not_null_before: bool,
|
|
not_null_after: bool,
|
|
primary_key_position_before: i64,
|
|
primary_key_position_after: i64,
|
|
}
|
|
|
|
/// Parses CLI arguments and returns a process exit code.
|
|
#[must_use]
|
|
pub fn main_entry() -> i32 {
|
|
match parse_cli_from(std::env::args_os()) {
|
|
Ok((ParseOutcome::Help, _)) => {
|
|
print!("{HELP}");
|
|
ExitCode::Success.as_i32()
|
|
}
|
|
Ok((ParseOutcome::Version, _)) => {
|
|
println!("sqliteshape {}", env!("CARGO_PKG_VERSION"));
|
|
ExitCode::Success.as_i32()
|
|
}
|
|
Ok((ParseOutcome::Run, cli)) => match run(&cli) {
|
|
Ok(code) => code.as_i32(),
|
|
Err(error) => {
|
|
match error {
|
|
CliError::Usage(_) => print_quick_help_error(&error, HELP),
|
|
CliError::Runtime(_) => print_error(&error),
|
|
}
|
|
error.exit_code().as_i32()
|
|
}
|
|
},
|
|
Err(error) => {
|
|
print_quick_help_error(&error, HELP);
|
|
error.exit_code().as_i32()
|
|
}
|
|
}
|
|
}
|
|
|
|
fn parse_cli_from<I, T>(args: I) -> Result<(ParseOutcome, Cli), CliError>
|
|
where
|
|
I: IntoIterator<Item = T>,
|
|
T: Into<OsString>,
|
|
{
|
|
let mut parser = lexopt::Parser::from_iter(args);
|
|
let mut cli = Cli {
|
|
common: CommonArgs::default(),
|
|
command: CommandMode::Summary,
|
|
tables: Vec::new(),
|
|
sample_rows: 3,
|
|
include_indexes: false,
|
|
count_rows: false,
|
|
paths: Vec::new(),
|
|
};
|
|
let mut diff_paths = Vec::<PathBuf>::new();
|
|
|
|
while let Some(argument) = parser
|
|
.next()
|
|
.map_err(|error| CliError::usage(error.to_string()))?
|
|
{
|
|
match argument {
|
|
Long("help") | Short('h') => return Ok((ParseOutcome::Help, cli)),
|
|
Long("version") | Short('V') => return Ok((ParseOutcome::Version, cli)),
|
|
Long("json") => cli.common.set_render_mode(RenderMode::Json),
|
|
Long("toon") => cli.common.set_render_mode(RenderMode::Toon),
|
|
Long("format") => {
|
|
let value = parser_value_string(&mut parser, "--format")?;
|
|
cli.common.set_render_mode(parse_format_choice(&value)?);
|
|
}
|
|
Long("input-format") => {
|
|
cli.common.input_format =
|
|
parse_input_format(&parser_value_string(&mut parser, "--input-format")?)?;
|
|
}
|
|
Long("color") => {
|
|
cli.common.color =
|
|
parse_color_choice(&parser_value_string(&mut parser, "--color")?)?;
|
|
}
|
|
Long("quiet") => cli.common.quiet = true,
|
|
Long("table") => cli
|
|
.tables
|
|
.push(parser_value_string(&mut parser, "--table")?),
|
|
Long("sample-rows") => {
|
|
cli.sample_rows = parse_usize_flag(
|
|
"--sample-rows",
|
|
&parser_value_string(&mut parser, "--sample-rows")?,
|
|
)?;
|
|
}
|
|
Long("include-indexes") => cli.include_indexes = true,
|
|
Long("count-rows") => cli.count_rows = true,
|
|
ArgValue(path) => {
|
|
if matches!(cli.command, CommandMode::Summary)
|
|
&& cli.paths.is_empty()
|
|
&& diff_paths.is_empty()
|
|
&& path == "diff"
|
|
{
|
|
cli.command = CommandMode::Diff {
|
|
before: PathBuf::new(),
|
|
after: PathBuf::new(),
|
|
};
|
|
} else if matches!(cli.command, CommandMode::Diff { .. }) {
|
|
diff_paths.push(PathBuf::from(path));
|
|
} else {
|
|
cli.paths.push(PathBuf::from(path));
|
|
}
|
|
}
|
|
_ => {
|
|
return Err(CliError::usage(
|
|
"unsupported argument; use --help to see available options",
|
|
));
|
|
}
|
|
}
|
|
}
|
|
|
|
if matches!(cli.command, CommandMode::Diff { .. }) {
|
|
if diff_paths.len() != 2 {
|
|
return Err(CliError::usage("diff expects exactly two SQLite paths"));
|
|
}
|
|
cli.command = CommandMode::Diff {
|
|
before: diff_paths[0].clone(),
|
|
after: diff_paths[1].clone(),
|
|
};
|
|
}
|
|
|
|
Ok((ParseOutcome::Run, cli))
|
|
}
|
|
|
|
fn parser_value_string(parser: &mut lexopt::Parser, flag: &str) -> Result<String, CliError> {
|
|
let value = parser
|
|
.value()
|
|
.map_err(|error| CliError::usage(error.to_string()))?;
|
|
value.into_string().map_err(|invalid| {
|
|
CliError::usage(format!(
|
|
"{flag} expects UTF-8 text, got '{}'",
|
|
invalid.to_string_lossy()
|
|
))
|
|
})
|
|
}
|
|
|
|
fn parse_usize_flag(flag: &str, value: &str) -> Result<usize, CliError> {
|
|
value
|
|
.parse::<usize>()
|
|
.map_err(|error| CliError::usage(format!("invalid {flag} value '{value}': {error}")))
|
|
}
|
|
|
|
fn run(cli: &Cli) -> Result<ExitCode, CliError> {
|
|
if cli.sample_rows == 0 {
|
|
return Err(CliError::usage("--sample-rows must be greater than 0"));
|
|
}
|
|
|
|
match &cli.command {
|
|
CommandMode::Summary => {
|
|
let paths = collect_paths(cli)?;
|
|
if paths.is_empty() {
|
|
return Err(CliError::usage(
|
|
"provide at least one SQLite path or pipe paths into stdin",
|
|
));
|
|
}
|
|
|
|
let mut summaries = paths
|
|
.iter()
|
|
.map(|path| inspect_database(path, cli))
|
|
.collect::<Result<Vec<_>, _>>()?;
|
|
|
|
let summary_count = summaries.len();
|
|
let matched_table_count = summaries
|
|
.iter()
|
|
.map(|summary| summary.table_count)
|
|
.sum::<usize>();
|
|
|
|
match cli.common.render_mode() {
|
|
RenderMode::Json => {
|
|
if summaries.len() == 1 {
|
|
print_json(&summaries.remove(0))?;
|
|
} else {
|
|
print_json(&summaries)?;
|
|
}
|
|
}
|
|
RenderMode::Toon => {
|
|
if summaries.len() == 1 {
|
|
print_structured(&summaries.remove(0), RenderMode::Toon)?;
|
|
} else {
|
|
print_structured(&summaries, RenderMode::Toon)?;
|
|
}
|
|
}
|
|
RenderMode::Text => {
|
|
for summary in &summaries {
|
|
print!("{}", render_summary(summary));
|
|
}
|
|
}
|
|
}
|
|
|
|
if !cli.tables.is_empty() && matched_table_count == 0 {
|
|
Ok(ExitCode::NoResults)
|
|
} else {
|
|
Ok(map_result_count(summary_count))
|
|
}
|
|
}
|
|
CommandMode::Diff { before, after } => {
|
|
let before_path = common::require_exactly_one_input_path(
|
|
&common::expand_input_patterns(std::slice::from_ref(before), "sqliteshape")?,
|
|
"sqliteshape diff before",
|
|
)?;
|
|
let after_path = common::require_exactly_one_input_path(
|
|
&common::expand_input_patterns(std::slice::from_ref(after), "sqliteshape")?,
|
|
"sqliteshape diff after",
|
|
)?;
|
|
let before_summary = inspect_database(&before_path, cli)?;
|
|
let after_summary = inspect_database(&after_path, cli)?;
|
|
let diff = diff_databases(&before_summary, &after_summary);
|
|
|
|
match cli.common.render_mode() {
|
|
RenderMode::Json => print_json(&diff)?,
|
|
RenderMode::Toon => print_structured(&diff, RenderMode::Toon)?,
|
|
RenderMode::Text => print!("{}", render_diff(&diff)),
|
|
}
|
|
|
|
Ok(map_result_count(
|
|
diff.added_tables.len() + diff.removed_tables.len() + diff.changed_tables.len(),
|
|
))
|
|
}
|
|
}
|
|
}
|
|
|
|
fn collect_paths(cli: &Cli) -> Result<Vec<PathBuf>, CliError> {
|
|
if should_read_stdin(!cli.paths.is_empty(), cli.common.stdin_is_terminal()) {
|
|
let mut buffer = String::new();
|
|
io::stdin()
|
|
.read_to_string(&mut buffer)
|
|
.map_err(|error| CliError::runtime(format!("failed to read stdin: {error}")))?;
|
|
let parsed = parse_paths_from_string(&buffer, cli.common.input_format)?;
|
|
if !parsed.is_empty() {
|
|
return Ok(parsed);
|
|
}
|
|
}
|
|
|
|
common::expand_input_patterns(&cli.paths, "sqliteshape")
|
|
}
|
|
|
|
fn parse_paths_from_string(
|
|
buffer: &str,
|
|
input_format: InputFormat,
|
|
) -> Result<Vec<PathBuf>, CliError> {
|
|
common::read_existing_stdin_path_records(buffer, input_format, "sqliteshape")?
|
|
.map_or_else(|| Ok(Vec::new()), Ok)
|
|
}
|
|
|
|
fn inspect_database(path: &PathBuf, cli: &Cli) -> Result<DatabaseSummary, CliError> {
|
|
if !path.exists() {
|
|
return Err(CliError::runtime(format!(
|
|
"database path does not exist: {}",
|
|
path.display()
|
|
)));
|
|
}
|
|
|
|
let connection = Connection::open_with_flags(
|
|
path,
|
|
OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
|
|
)
|
|
.map_err(|error| CliError::runtime(format!("failed to open {}: {error}", path.display())))?;
|
|
connection
|
|
.busy_timeout(SQLITE_BUSY_TIMEOUT)
|
|
.map_err(|error| {
|
|
CliError::runtime(format!(
|
|
"failed to configure sqlite busy timeout for {}: {error}",
|
|
path.display()
|
|
))
|
|
})?;
|
|
|
|
let page_size = pragma_i64(&connection, "page_size")?;
|
|
let page_count = pragma_i64(&connection, "page_count")?;
|
|
let journal_mode = pragma_string(&connection, "journal_mode")?;
|
|
|
|
let table_selection = load_table_selection(&connection, &cli.tables)?;
|
|
let tables = table_selection
|
|
.matched
|
|
.iter()
|
|
.map(|name| inspect_table(&connection, name, cli))
|
|
.collect::<Result<Vec<_>, _>>()?;
|
|
|
|
Ok(DatabaseSummary {
|
|
path: path.display().to_string(),
|
|
page_size,
|
|
page_count,
|
|
journal_mode,
|
|
table_count: tables.len(),
|
|
requested_tables: cli.tables.clone(),
|
|
unmatched_tables: table_selection.unmatched,
|
|
tables,
|
|
})
|
|
}
|
|
|
|
fn pragma_i64(connection: &Connection, pragma: &str) -> Result<i64, CliError> {
|
|
connection
|
|
.query_row(&format!("PRAGMA {pragma};"), [], |row| row.get::<_, i64>(0))
|
|
.map_err(|error| CliError::runtime(format!("failed to read PRAGMA {pragma}: {error}")))
|
|
}
|
|
|
|
fn pragma_string(connection: &Connection, pragma: &str) -> Result<String, CliError> {
|
|
connection
|
|
.query_row(&format!("PRAGMA {pragma};"), [], |row| {
|
|
row.get::<_, String>(0)
|
|
})
|
|
.map_err(|error| CliError::runtime(format!("failed to read PRAGMA {pragma}: {error}")))
|
|
}
|
|
|
|
fn load_table_selection(
|
|
connection: &Connection,
|
|
filter: &[String],
|
|
) -> Result<TableSelection, CliError> {
|
|
let mut statement = connection
|
|
.prepare(
|
|
"SELECT name FROM sqlite_master \
|
|
WHERE type = 'table' AND name NOT LIKE 'sqlite_%' \
|
|
ORDER BY name",
|
|
)
|
|
.map_err(|error| CliError::runtime(format!("failed to list tables: {error}")))?;
|
|
let rows = statement
|
|
.query_map([], |row| row.get::<_, String>(0))
|
|
.map_err(|error| CliError::runtime(format!("failed to query tables: {error}")))?;
|
|
|
|
let mut names = Vec::new();
|
|
for row in rows {
|
|
names.push(
|
|
row.map_err(|error| CliError::runtime(format!("failed to read table name: {error}")))?,
|
|
);
|
|
}
|
|
if filter.is_empty() {
|
|
return Ok(TableSelection {
|
|
matched: names,
|
|
unmatched: Vec::new(),
|
|
});
|
|
}
|
|
|
|
let filtered = names
|
|
.iter()
|
|
.filter(|name| filter.iter().any(|wanted| wanted == *name))
|
|
.cloned()
|
|
.collect::<Vec<_>>();
|
|
let unmatched = filter
|
|
.iter()
|
|
.filter(|wanted| !names.iter().any(|name| name == *wanted))
|
|
.cloned()
|
|
.collect::<Vec<_>>();
|
|
Ok(TableSelection {
|
|
matched: filtered,
|
|
unmatched,
|
|
})
|
|
}
|
|
|
|
fn inspect_table(
|
|
connection: &Connection,
|
|
table: &str,
|
|
cli: &Cli,
|
|
) -> Result<TableSummary, CliError> {
|
|
let columns = load_columns(connection, table)?;
|
|
let sample_rows = load_sample_rows(connection, table, cli.sample_rows)?;
|
|
let (row_count, row_count_mode) = if cli.count_rows {
|
|
(
|
|
Some(count_rows(connection, table)?),
|
|
ROW_COUNT_MODE_EXACT.to_string(),
|
|
)
|
|
} else {
|
|
let estimated = estimate_row_count(connection, table)?;
|
|
(
|
|
estimated,
|
|
if estimated.is_some() {
|
|
ROW_COUNT_MODE_ESTIMATED.to_string()
|
|
} else {
|
|
ROW_COUNT_MODE_UNKNOWN.to_string()
|
|
},
|
|
)
|
|
};
|
|
let indexes = if cli.include_indexes {
|
|
load_indexes(connection, table)?
|
|
} else {
|
|
Vec::new()
|
|
};
|
|
|
|
Ok(TableSummary {
|
|
name: table.to_string(),
|
|
row_count,
|
|
row_count_mode,
|
|
columns,
|
|
sample_rows,
|
|
indexes,
|
|
})
|
|
}
|
|
|
|
fn load_columns(connection: &Connection, table: &str) -> Result<Vec<ColumnSummary>, CliError> {
|
|
let escaped = escape_identifier(table);
|
|
let sql = format!("PRAGMA table_info({escaped});");
|
|
let mut statement = connection.prepare(&sql).map_err(|error| {
|
|
CliError::runtime(format!("failed to inspect columns for {table}: {error}"))
|
|
})?;
|
|
let rows = statement
|
|
.query_map([], |row| {
|
|
Ok(ColumnSummary {
|
|
name: row.get(1)?,
|
|
declared_type: row.get::<_, Option<String>>(2)?.unwrap_or_default(),
|
|
not_null: row.get::<_, i64>(3)? != 0,
|
|
primary_key_position: row.get(5)?,
|
|
})
|
|
})
|
|
.map_err(|error| {
|
|
CliError::runtime(format!("failed to query columns for {table}: {error}"))
|
|
})?;
|
|
|
|
let mut columns = Vec::new();
|
|
for row in rows {
|
|
columns.push(row.map_err(|error| {
|
|
CliError::runtime(format!(
|
|
"failed to read column metadata for {table}: {error}"
|
|
))
|
|
})?);
|
|
}
|
|
Ok(columns)
|
|
}
|
|
|
|
fn load_sample_rows(
|
|
connection: &Connection,
|
|
table: &str,
|
|
sample_rows: usize,
|
|
) -> Result<Vec<serde_json::Value>, CliError> {
|
|
let escaped = escape_identifier(table);
|
|
let sql = format!("SELECT * FROM {escaped} LIMIT {sample_rows}");
|
|
let mut statement = connection.prepare(&sql).map_err(|error| {
|
|
CliError::runtime(format!("failed to sample rows for {table}: {error}"))
|
|
})?;
|
|
let names = statement
|
|
.column_names()
|
|
.iter()
|
|
.map(ToString::to_string)
|
|
.collect::<Vec<_>>();
|
|
|
|
let rows = statement
|
|
.query_map([], |row| {
|
|
let mut object = serde_json::Map::new();
|
|
for (index, name) in names.iter().enumerate() {
|
|
let value = row.get_ref(index)?;
|
|
object.insert(name.clone(), sqlite_value_to_json(value));
|
|
}
|
|
Ok(serde_json::Value::Object(object))
|
|
})
|
|
.map_err(|error| {
|
|
CliError::runtime(format!("failed to read sample rows for {table}: {error}"))
|
|
})?;
|
|
|
|
let mut samples = Vec::new();
|
|
for row in rows {
|
|
samples.push(row.map_err(|error| {
|
|
CliError::runtime(format!("failed to decode sample row for {table}: {error}"))
|
|
})?);
|
|
}
|
|
Ok(samples)
|
|
}
|
|
|
|
fn count_rows(connection: &Connection, table: &str) -> Result<i64, CliError> {
|
|
let escaped = escape_identifier(table);
|
|
connection
|
|
.query_row(&format!("SELECT COUNT(*) FROM {escaped}"), [], |row| {
|
|
row.get(0)
|
|
})
|
|
.map_err(|error| CliError::runtime(format!("failed to count rows for {table}: {error}")))
|
|
}
|
|
|
|
fn estimate_row_count(connection: &Connection, table: &str) -> Result<Option<i64>, CliError> {
|
|
let mut statement =
|
|
match connection.prepare("SELECT stat FROM sqlite_stat1 WHERE tbl = ?1 LIMIT 1") {
|
|
Ok(statement) => statement,
|
|
Err(rusqlite::Error::SqliteFailure(_, Some(message)))
|
|
if message.contains("no such table: sqlite_stat1") =>
|
|
{
|
|
return Ok(None);
|
|
}
|
|
Err(error) => {
|
|
return Err(CliError::runtime(format!(
|
|
"failed to inspect sqlite_stat1: {error}"
|
|
)));
|
|
}
|
|
};
|
|
let result = statement.query_row([table], |row| row.get::<_, String>(0));
|
|
match result {
|
|
Ok(stat) => Ok(stat
|
|
.split_whitespace()
|
|
.next()
|
|
.and_then(|part| part.parse::<i64>().ok())),
|
|
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
|
|
Err(rusqlite::Error::SqliteFailure(_, Some(message)))
|
|
if message.contains("no such table: sqlite_stat1") =>
|
|
{
|
|
Ok(None)
|
|
}
|
|
Err(error) => Err(CliError::runtime(format!(
|
|
"failed to estimate rows for {table}: {error}"
|
|
))),
|
|
}
|
|
}
|
|
|
|
fn load_indexes(connection: &Connection, table: &str) -> Result<Vec<IndexSummary>, CliError> {
|
|
let escaped = escape_identifier(table);
|
|
let sql = format!("PRAGMA index_list({escaped});");
|
|
let mut statement = connection.prepare(&sql).map_err(|error| {
|
|
CliError::runtime(format!("failed to inspect indexes for {table}: {error}"))
|
|
})?;
|
|
let rows = statement
|
|
.query_map([], |row| {
|
|
Ok((row.get::<_, String>(1)?, row.get::<_, i64>(2)? != 0))
|
|
})
|
|
.map_err(|error| {
|
|
CliError::runtime(format!("failed to query indexes for {table}: {error}"))
|
|
})?;
|
|
|
|
let mut indexes = Vec::new();
|
|
for row in rows {
|
|
let (name, unique) = row.map_err(|error| {
|
|
CliError::runtime(format!(
|
|
"failed to read index metadata for {table}: {error}"
|
|
))
|
|
})?;
|
|
indexes.push(IndexSummary {
|
|
columns: load_index_columns(connection, &name)?,
|
|
name,
|
|
unique,
|
|
});
|
|
}
|
|
Ok(indexes)
|
|
}
|
|
|
|
fn load_index_columns(connection: &Connection, index: &str) -> Result<Vec<String>, CliError> {
|
|
let escaped = escape_identifier(index);
|
|
let sql = format!("PRAGMA index_info({escaped});");
|
|
let mut statement = connection
|
|
.prepare(&sql)
|
|
.map_err(|error| CliError::runtime(format!("failed to inspect index {index}: {error}")))?;
|
|
let rows = statement
|
|
.query_map([], |row| {
|
|
let position = row.get::<_, i64>(0)?;
|
|
let name = row.get::<_, Option<String>>(2)?;
|
|
Ok((
|
|
position,
|
|
name.unwrap_or_else(|| SQLITE_EXPRESSION_INDEX_COLUMN.to_string()),
|
|
))
|
|
})
|
|
.map_err(|error| {
|
|
CliError::runtime(format!(
|
|
"failed to query index columns for {index}: {error}"
|
|
))
|
|
})?;
|
|
|
|
let mut columns = Vec::new();
|
|
for row in rows {
|
|
columns.push(row.map_err(|error| {
|
|
CliError::runtime(format!("failed to read index column for {index}: {error}"))
|
|
})?);
|
|
}
|
|
columns.sort_by_key(|(position, _)| *position);
|
|
Ok(columns.into_iter().map(|(_, name)| name).collect())
|
|
}
|
|
|
|
fn escape_identifier(value: &str) -> String {
|
|
format!("\"{}\"", value.replace('"', "\"\""))
|
|
}
|
|
|
|
fn sqlite_value_to_json(value: rusqlite::types::ValueRef<'_>) -> serde_json::Value {
|
|
match value {
|
|
rusqlite::types::ValueRef::Null => serde_json::Value::Null,
|
|
rusqlite::types::ValueRef::Integer(number) => serde_json::Value::Number(number.into()),
|
|
rusqlite::types::ValueRef::Real(number) => serde_json::Number::from_f64(number)
|
|
.map_or(serde_json::Value::Null, serde_json::Value::Number),
|
|
rusqlite::types::ValueRef::Text(text) => {
|
|
serde_json::Value::String(String::from_utf8_lossy(text).to_string())
|
|
}
|
|
rusqlite::types::ValueRef::Blob(blob) => {
|
|
serde_json::Value::String(format!("<blob:{}>", blob.len()))
|
|
}
|
|
}
|
|
}
|
|
|
|
fn render_summary(summary: &DatabaseSummary) -> String {
|
|
let mut rendered = String::new();
|
|
writeln!(
|
|
rendered,
|
|
"path={} page_size={} page_count={} journal_mode={} tables={}",
|
|
summary.path,
|
|
summary.page_size,
|
|
summary.page_count,
|
|
summary.journal_mode,
|
|
summary.table_count
|
|
)
|
|
.expect("writing to a String cannot fail");
|
|
if !summary.requested_tables.is_empty() {
|
|
writeln!(
|
|
rendered,
|
|
"requested_tables={} unmatched_tables={}",
|
|
summary.requested_tables.join("|"),
|
|
summary.unmatched_tables.join("|")
|
|
)
|
|
.expect("writing to a String cannot fail");
|
|
}
|
|
for table in &summary.tables {
|
|
writeln!(
|
|
rendered,
|
|
"table={} rows={} columns={} sample_rows={} indexes={}",
|
|
table.name,
|
|
render_row_count(table.row_count, &table.row_count_mode),
|
|
table.columns.len(),
|
|
table.sample_rows.len(),
|
|
table.indexes.len()
|
|
)
|
|
.expect("writing to a String cannot fail");
|
|
for column in &table.columns {
|
|
writeln!(
|
|
rendered,
|
|
"column={} declared_type={} not_null={} pk={}",
|
|
column.name, column.declared_type, column.not_null, column.primary_key_position
|
|
)
|
|
.expect("writing to a String cannot fail");
|
|
}
|
|
}
|
|
rendered
|
|
}
|
|
|
|
fn render_row_count(row_count: Option<i64>, row_count_mode: &str) -> String {
|
|
match (row_count_mode, row_count) {
|
|
(ROW_COUNT_MODE_EXACT, Some(count)) => format!("exact:{count}"),
|
|
(ROW_COUNT_MODE_ESTIMATED, Some(count)) => format!("estimate:{count}"),
|
|
_ => ROW_COUNT_MODE_UNKNOWN.to_string(),
|
|
}
|
|
}
|
|
|
|
fn diff_databases(before: &DatabaseSummary, after: &DatabaseSummary) -> DatabaseDiff {
|
|
let before_tables = before
|
|
.tables
|
|
.iter()
|
|
.map(|table| (table.name.clone(), table))
|
|
.collect::<std::collections::BTreeMap<_, _>>();
|
|
let after_tables = after
|
|
.tables
|
|
.iter()
|
|
.map(|table| (table.name.clone(), table))
|
|
.collect::<std::collections::BTreeMap<_, _>>();
|
|
|
|
let mut added_tables = Vec::new();
|
|
let mut removed_tables = Vec::new();
|
|
let mut changed_tables = Vec::new();
|
|
|
|
for name in before_tables.keys().chain(after_tables.keys()) {
|
|
match (before_tables.get(name), after_tables.get(name)) {
|
|
(None, Some(table)) => added_tables.push((*table).clone()),
|
|
(Some(table), None) => removed_tables.push((*table).clone()),
|
|
(Some(before_table), Some(after_table)) if before_table != after_table => {
|
|
changed_tables.push(diff_tables(before_table, after_table));
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
dedup_tables(&mut added_tables);
|
|
dedup_tables(&mut removed_tables);
|
|
dedup_table_diffs(&mut changed_tables);
|
|
|
|
DatabaseDiff {
|
|
path_before: before.path.clone(),
|
|
path_after: after.path.clone(),
|
|
page_size_before: before.page_size,
|
|
page_size_after: after.page_size,
|
|
page_count_before: before.page_count,
|
|
page_count_after: after.page_count,
|
|
journal_mode_before: before.journal_mode.clone(),
|
|
journal_mode_after: after.journal_mode.clone(),
|
|
added_tables,
|
|
removed_tables,
|
|
changed_tables,
|
|
}
|
|
}
|
|
|
|
fn diff_tables(before: &TableSummary, after: &TableSummary) -> TableDiff {
|
|
let before_columns = before
|
|
.columns
|
|
.iter()
|
|
.map(|column| (column.name.clone(), column))
|
|
.collect::<std::collections::BTreeMap<_, _>>();
|
|
let after_columns = after
|
|
.columns
|
|
.iter()
|
|
.map(|column| (column.name.clone(), column))
|
|
.collect::<std::collections::BTreeMap<_, _>>();
|
|
let before_indexes = before
|
|
.indexes
|
|
.iter()
|
|
.map(|index| (index.name.clone(), index))
|
|
.collect::<std::collections::BTreeMap<_, _>>();
|
|
let after_indexes = after
|
|
.indexes
|
|
.iter()
|
|
.map(|index| (index.name.clone(), index))
|
|
.collect::<std::collections::BTreeMap<_, _>>();
|
|
|
|
let mut added_columns = Vec::new();
|
|
let mut removed_columns = Vec::new();
|
|
let mut changed_columns = Vec::new();
|
|
let mut added_indexes = Vec::new();
|
|
let mut removed_indexes = Vec::new();
|
|
|
|
for name in before_columns.keys().chain(after_columns.keys()) {
|
|
match (before_columns.get(name), after_columns.get(name)) {
|
|
(None, Some(column)) => added_columns.push((*column).clone()),
|
|
(Some(column), None) => removed_columns.push((*column).clone()),
|
|
(Some(before_column), Some(after_column)) if before_column != after_column => {
|
|
changed_columns.push(ColumnChange {
|
|
name: name.clone(),
|
|
declared_type_before: before_column.declared_type.clone(),
|
|
declared_type_after: after_column.declared_type.clone(),
|
|
not_null_before: before_column.not_null,
|
|
not_null_after: after_column.not_null,
|
|
primary_key_position_before: before_column.primary_key_position,
|
|
primary_key_position_after: after_column.primary_key_position,
|
|
});
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
for name in before_indexes.keys().chain(after_indexes.keys()) {
|
|
match (before_indexes.get(name), after_indexes.get(name)) {
|
|
(None, Some(index)) => added_indexes.push((*index).clone()),
|
|
(Some(index), None) => removed_indexes.push((*index).clone()),
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
dedup_columns(&mut added_columns);
|
|
dedup_columns(&mut removed_columns);
|
|
dedup_column_changes(&mut changed_columns);
|
|
dedup_indexes(&mut added_indexes);
|
|
dedup_indexes(&mut removed_indexes);
|
|
|
|
TableDiff {
|
|
name: before.name.clone(),
|
|
row_count_before: before.row_count,
|
|
row_count_after: after.row_count,
|
|
row_count_mode_before: before.row_count_mode.clone(),
|
|
row_count_mode_after: after.row_count_mode.clone(),
|
|
added_columns,
|
|
removed_columns,
|
|
changed_columns,
|
|
added_indexes,
|
|
removed_indexes,
|
|
}
|
|
}
|
|
|
|
fn dedup_tables(tables: &mut Vec<TableSummary>) {
|
|
let mut seen = std::collections::BTreeSet::<String>::new();
|
|
tables.retain(|table| seen.insert(table.name.clone()));
|
|
}
|
|
|
|
fn dedup_table_diffs(tables: &mut Vec<TableDiff>) {
|
|
let mut seen = std::collections::BTreeSet::<String>::new();
|
|
tables.retain(|table| seen.insert(table.name.clone()));
|
|
}
|
|
|
|
fn dedup_columns(columns: &mut Vec<ColumnSummary>) {
|
|
let mut seen = std::collections::BTreeSet::<String>::new();
|
|
columns.retain(|column| seen.insert(column.name.clone()));
|
|
}
|
|
|
|
fn dedup_column_changes(columns: &mut Vec<ColumnChange>) {
|
|
let mut seen = std::collections::BTreeSet::<String>::new();
|
|
columns.retain(|column| seen.insert(column.name.clone()));
|
|
}
|
|
|
|
fn dedup_indexes(indexes: &mut Vec<IndexSummary>) {
|
|
let mut seen = std::collections::BTreeSet::<String>::new();
|
|
indexes.retain(|index| seen.insert(index.name.clone()));
|
|
}
|
|
|
|
fn render_diff(diff: &DatabaseDiff) -> String {
|
|
let mut rendered = String::new();
|
|
writeln!(
|
|
rendered,
|
|
"before={} after={} page_size={}=>{} page_count={}=>{} journal_mode={}=>{} added_tables={} removed_tables={} changed_tables={}",
|
|
diff.path_before,
|
|
diff.path_after,
|
|
diff.page_size_before,
|
|
diff.page_size_after,
|
|
diff.page_count_before,
|
|
diff.page_count_after,
|
|
diff.journal_mode_before,
|
|
diff.journal_mode_after,
|
|
diff.added_tables.len(),
|
|
diff.removed_tables.len(),
|
|
diff.changed_tables.len()
|
|
)
|
|
.expect("writing to a String cannot fail");
|
|
for table in &diff.added_tables {
|
|
writeln!(
|
|
rendered,
|
|
"table={} change=added rows={} columns={} indexes={}",
|
|
table.name,
|
|
render_row_count(table.row_count, &table.row_count_mode),
|
|
table.columns.len(),
|
|
table.indexes.len()
|
|
)
|
|
.expect("writing to a String cannot fail");
|
|
}
|
|
for table in &diff.removed_tables {
|
|
writeln!(
|
|
rendered,
|
|
"table={} change=removed rows={} columns={} indexes={}",
|
|
table.name,
|
|
render_row_count(table.row_count, &table.row_count_mode),
|
|
table.columns.len(),
|
|
table.indexes.len()
|
|
)
|
|
.expect("writing to a String cannot fail");
|
|
}
|
|
for table in &diff.changed_tables {
|
|
writeln!(
|
|
rendered,
|
|
"table={} change=changed rows={}=>{} added_columns={} removed_columns={} changed_columns={} added_indexes={} removed_indexes={}",
|
|
table.name,
|
|
render_row_count(table.row_count_before, &table.row_count_mode_before),
|
|
render_row_count(table.row_count_after, &table.row_count_mode_after),
|
|
table.added_columns.len(),
|
|
table.removed_columns.len(),
|
|
table.changed_columns.len(),
|
|
table.added_indexes.len(),
|
|
table.removed_indexes.len()
|
|
)
|
|
.expect("writing to a String cannot fail");
|
|
}
|
|
rendered
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use std::fmt::Write as _;
|
|
use std::fs;
|
|
use std::time::Instant;
|
|
|
|
use common::{ColorChoice, InputFormat};
|
|
use tempfile::tempdir;
|
|
|
|
use super::*;
|
|
|
|
fn common_args(json: bool, input_format: InputFormat) -> CommonArgs {
|
|
CommonArgs {
|
|
json,
|
|
format: None,
|
|
input_format,
|
|
color: ColorChoice::Never,
|
|
quiet: false,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn path_parsing_supports_lines_jsonl_and_auto() {
|
|
let temp = tempdir().expect("tempdir");
|
|
let first = temp.path().join("one.db");
|
|
let second = temp.path().join("two.db");
|
|
fs::write(&first, "one").expect("first");
|
|
fs::write(&second, "two").expect("second");
|
|
|
|
assert!(matches!(
|
|
parse_paths_from_string(
|
|
&format!(
|
|
"{}\n{{\"path\":{}}}\n",
|
|
serde_json::to_string(&first.display().to_string()).expect("json path"),
|
|
serde_json::to_string(&second.display().to_string()).expect("json path"),
|
|
),
|
|
InputFormat::Jsonl,
|
|
),
|
|
Ok(paths) if paths == vec![first.clone(), second.clone()]
|
|
));
|
|
assert_eq!(
|
|
parse_paths_from_string(
|
|
&format!("{}\n{}\n", first.display(), second.display()),
|
|
InputFormat::Auto,
|
|
)
|
|
.expect("paths"),
|
|
vec![first, second]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn inspect_database_reads_schema_and_samples() {
|
|
let temp = tempdir().expect("tempdir");
|
|
let path = temp.path().join("sample.db");
|
|
let connection = Connection::open(&path).expect("db");
|
|
connection
|
|
.execute_batch(
|
|
"CREATE TABLE users(id INTEGER PRIMARY KEY, name TEXT NOT NULL, city TEXT);
|
|
CREATE UNIQUE INDEX idx_users_name ON users(name);
|
|
INSERT INTO users(name, city) VALUES ('Ada', 'London'), ('Bob', NULL);",
|
|
)
|
|
.expect("schema");
|
|
drop(connection);
|
|
|
|
let summary = inspect_database(
|
|
&path,
|
|
&Cli {
|
|
common: common_args(true, InputFormat::Auto),
|
|
command: CommandMode::Summary,
|
|
tables: Vec::new(),
|
|
sample_rows: 2,
|
|
include_indexes: true,
|
|
count_rows: true,
|
|
paths: Vec::new(),
|
|
},
|
|
)
|
|
.expect("summary");
|
|
|
|
assert_eq!(summary.table_count, 1);
|
|
assert_eq!(summary.tables.len(), 1);
|
|
assert_eq!(summary.tables[0].name, "users");
|
|
assert_eq!(summary.tables[0].row_count, Some(2));
|
|
assert_eq!(summary.tables[0].row_count_mode, ROW_COUNT_MODE_EXACT);
|
|
assert_eq!(summary.tables[0].columns[1].declared_type, "TEXT");
|
|
assert_eq!(summary.tables[0].indexes[0].name, "idx_users_name");
|
|
assert_eq!(summary.tables[0].sample_rows.len(), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn inspect_database_handles_many_tables_without_sampling_or_counts() {
|
|
let temp = tempdir().expect("tempdir");
|
|
let path = temp.path().join("many_tables.db");
|
|
let connection = Connection::open(&path).expect("db");
|
|
let mut schema = String::from("BEGIN;\n");
|
|
for index in 0..96 {
|
|
writeln!(
|
|
schema,
|
|
"CREATE TABLE t_{index:03}(id INTEGER PRIMARY KEY, c1 TEXT, c2 INTEGER, c3 REAL, c4 BLOB, c5 TEXT);"
|
|
)
|
|
.expect("write schema");
|
|
}
|
|
schema.push_str("COMMIT;\n");
|
|
connection.execute_batch(&schema).expect("schema");
|
|
drop(connection);
|
|
|
|
let summary = inspect_database(
|
|
&path,
|
|
&Cli {
|
|
common: common_args(true, InputFormat::Auto),
|
|
command: CommandMode::Summary,
|
|
tables: Vec::new(),
|
|
sample_rows: 0,
|
|
include_indexes: false,
|
|
count_rows: false,
|
|
paths: Vec::new(),
|
|
},
|
|
)
|
|
.expect("summary");
|
|
|
|
assert_eq!(summary.table_count, 96);
|
|
assert_eq!(summary.tables.len(), 96);
|
|
assert_eq!(summary.tables[0].name, "t_000");
|
|
assert_eq!(summary.tables[95].name, "t_095");
|
|
assert_eq!(summary.tables[0].columns.len(), 6);
|
|
assert!(
|
|
summary
|
|
.tables
|
|
.iter()
|
|
.all(|table| table.sample_rows.is_empty())
|
|
);
|
|
assert!(summary.tables.iter().all(|table| table.indexes.is_empty()));
|
|
}
|
|
|
|
#[test]
|
|
fn corrupt_sqlite_file_reports_database_parse_error() {
|
|
let temp = tempdir().expect("tempdir");
|
|
let path = temp.path().join("corrupt.db");
|
|
fs::write(&path, b"not a sqlite database").expect("corrupt fixture");
|
|
|
|
let error = inspect_database(
|
|
&path,
|
|
&Cli {
|
|
common: common_args(true, InputFormat::Auto),
|
|
command: CommandMode::Summary,
|
|
tables: Vec::new(),
|
|
sample_rows: 1,
|
|
include_indexes: false,
|
|
count_rows: false,
|
|
paths: Vec::new(),
|
|
},
|
|
)
|
|
.expect_err("corrupt sqlite should fail");
|
|
|
|
assert!(
|
|
matches!(&error, CliError::Runtime(message) if message.contains("file is not a database") || message.contains("database disk image is malformed")),
|
|
"unexpected corrupt database error: {error}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn malicious_sqlite_catalog_null_table_name_fails_closed() {
|
|
let temp = tempdir().expect("tempdir");
|
|
let path = temp.path().join("malicious_catalog.db");
|
|
let connection = Connection::open(&path).expect("db");
|
|
connection
|
|
.execute_batch(
|
|
"CREATE TABLE users(id INTEGER PRIMARY KEY);
|
|
PRAGMA writable_schema = ON;
|
|
UPDATE sqlite_schema SET name = NULL WHERE type = 'table' AND name = 'users';
|
|
PRAGMA writable_schema = OFF;",
|
|
)
|
|
.expect("malicious catalog");
|
|
drop(connection);
|
|
|
|
let error = inspect_database(
|
|
&path,
|
|
&Cli {
|
|
common: common_args(true, InputFormat::Auto),
|
|
command: CommandMode::Summary,
|
|
tables: Vec::new(),
|
|
sample_rows: 1,
|
|
include_indexes: true,
|
|
count_rows: false,
|
|
paths: Vec::new(),
|
|
},
|
|
)
|
|
.expect_err("malicious sqlite catalog should fail closed");
|
|
|
|
assert!(
|
|
matches!(&error, CliError::Runtime(message) if message.contains("failed to read table name") || message.contains("malformed")),
|
|
"unexpected malicious catalog error: {error}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn malicious_sqlite_catalog_invalid_table_sql_fails_closed() {
|
|
let temp = tempdir().expect("tempdir");
|
|
let path = temp.path().join("malicious_catalog_invalid_sql.db");
|
|
let connection = Connection::open(&path).expect("db");
|
|
connection
|
|
.execute_batch(
|
|
"CREATE TABLE users(id INTEGER PRIMARY KEY);
|
|
PRAGMA writable_schema = ON;
|
|
UPDATE sqlite_schema
|
|
SET sql = 'CREATE TABLE users('
|
|
WHERE type = 'table' AND name = 'users';
|
|
PRAGMA writable_schema = OFF;",
|
|
)
|
|
.expect("malicious catalog");
|
|
drop(connection);
|
|
|
|
let error = inspect_database(
|
|
&path,
|
|
&Cli {
|
|
common: common_args(true, InputFormat::Auto),
|
|
command: CommandMode::Summary,
|
|
tables: Vec::new(),
|
|
sample_rows: 1,
|
|
include_indexes: true,
|
|
count_rows: false,
|
|
paths: Vec::new(),
|
|
},
|
|
)
|
|
.expect_err("malicious sqlite catalog should fail closed");
|
|
|
|
let message = error.to_string();
|
|
assert!(
|
|
message.contains("malformed")
|
|
|| message.contains("failed to list tables")
|
|
|| message.contains("failed to inspect columns"),
|
|
"unexpected malicious catalog error: {error}"
|
|
);
|
|
assert!(
|
|
message.len() < 512,
|
|
"unbounded sqlite catalog error: {error}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn locked_sqlite_file_reports_busy_without_long_wait() {
|
|
let temp = tempdir().expect("tempdir");
|
|
let path = temp.path().join("locked.db");
|
|
let writer = Connection::open(&path).expect("sqlite");
|
|
writer
|
|
.execute_batch("CREATE TABLE users(id INTEGER PRIMARY KEY); BEGIN EXCLUSIVE;")
|
|
.expect("exclusive transaction");
|
|
|
|
let started = Instant::now();
|
|
let error = inspect_database(
|
|
&path,
|
|
&Cli {
|
|
common: common_args(true, InputFormat::Auto),
|
|
command: CommandMode::Summary,
|
|
tables: Vec::new(),
|
|
sample_rows: 1,
|
|
include_indexes: false,
|
|
count_rows: false,
|
|
paths: Vec::new(),
|
|
},
|
|
)
|
|
.expect_err("locked sqlite should fail");
|
|
|
|
assert!(
|
|
started.elapsed().as_secs_f32() < 2.0,
|
|
"locked sqlite inspection waited too long"
|
|
);
|
|
assert!(
|
|
matches!(&error, CliError::Runtime(message) if message.contains("locked") || message.contains("busy")),
|
|
"unexpected locked database error: {error}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn render_summary_is_compact() {
|
|
let text = render_summary(&DatabaseSummary {
|
|
path: "sample.db".to_string(),
|
|
page_size: 4096,
|
|
page_count: 2,
|
|
journal_mode: "delete".to_string(),
|
|
table_count: 1,
|
|
requested_tables: Vec::new(),
|
|
unmatched_tables: Vec::new(),
|
|
tables: vec![TableSummary {
|
|
name: "users".to_string(),
|
|
row_count: Some(2),
|
|
row_count_mode: ROW_COUNT_MODE_EXACT.to_string(),
|
|
columns: vec![ColumnSummary {
|
|
name: "id".to_string(),
|
|
declared_type: "INTEGER".to_string(),
|
|
not_null: false,
|
|
primary_key_position: 1,
|
|
}],
|
|
sample_rows: vec![],
|
|
indexes: vec![],
|
|
}],
|
|
});
|
|
|
|
assert!(text.contains("path=sample.db"));
|
|
assert!(text.contains("table=users rows=exact:2"));
|
|
assert!(text.contains("column=id declared_type=INTEGER"));
|
|
}
|
|
|
|
#[test]
|
|
fn parse_cli_and_helpers_cover_indexes_counts_and_identifiers() {
|
|
let (outcome, _) = parse_cli_from(["sqliteshape", "--help"]).expect("help");
|
|
assert_eq!(outcome, ParseOutcome::Help);
|
|
|
|
let (_, cli) = parse_cli_from([
|
|
"sqliteshape",
|
|
"--json",
|
|
"--table",
|
|
"users",
|
|
"--sample-rows",
|
|
"5",
|
|
"--include-indexes",
|
|
"--count-rows",
|
|
"sample.db",
|
|
])
|
|
.expect("cli");
|
|
assert!(cli.common.json);
|
|
assert_eq!(cli.tables, vec!["users".to_string()]);
|
|
assert_eq!(cli.sample_rows, 5);
|
|
assert!(cli.include_indexes);
|
|
assert!(cli.count_rows);
|
|
assert_eq!(escape_identifier("a\"b"), "\"a\"\"b\"");
|
|
}
|
|
|
|
#[test]
|
|
fn sqlite_helpers_cover_value_and_estimate_paths() {
|
|
let temp = tempdir().expect("tempdir");
|
|
let path = temp.path().join("estimate.db");
|
|
let connection = Connection::open(&path).expect("db");
|
|
connection
|
|
.execute_batch(
|
|
"CREATE TABLE items(id INTEGER PRIMARY KEY, score REAL, payload BLOB);
|
|
INSERT INTO items(score, payload) VALUES (1.5, x'0102');
|
|
ANALYZE;",
|
|
)
|
|
.expect("schema");
|
|
|
|
assert_eq!(count_rows(&connection, "items").expect("count"), 1);
|
|
assert!(
|
|
estimate_row_count(&connection, "items")
|
|
.expect("estimate")
|
|
.is_some()
|
|
);
|
|
assert_eq!(
|
|
sqlite_value_to_json(rusqlite::types::ValueRef::Blob(&[1, 2])),
|
|
serde_json::Value::String("<blob:2>".to_string())
|
|
);
|
|
assert_eq!(
|
|
sqlite_value_to_json(rusqlite::types::ValueRef::Integer(7)),
|
|
serde_json::Value::Number(7.into())
|
|
);
|
|
assert_eq!(
|
|
sqlite_value_to_json(rusqlite::types::ValueRef::Null),
|
|
serde_json::Value::Null
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_cli_supports_diff_subcommand() {
|
|
let (_, cli) = parse_cli_from([
|
|
"sqliteshape",
|
|
"--json",
|
|
"diff",
|
|
"--include-indexes",
|
|
"before.db",
|
|
"after.db",
|
|
])
|
|
.expect("cli");
|
|
|
|
assert!(cli.common.json);
|
|
assert!(cli.include_indexes);
|
|
assert_eq!(
|
|
cli.command,
|
|
CommandMode::Diff {
|
|
before: PathBuf::from("before.db"),
|
|
after: PathBuf::from("after.db"),
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn diff_summary_reports_table_and_column_changes() {
|
|
let before = DatabaseSummary {
|
|
path: "before.db".to_string(),
|
|
page_size: 4096,
|
|
page_count: 2,
|
|
journal_mode: "delete".to_string(),
|
|
table_count: 1,
|
|
requested_tables: Vec::new(),
|
|
unmatched_tables: Vec::new(),
|
|
tables: vec![TableSummary {
|
|
name: "users".to_string(),
|
|
row_count: Some(2),
|
|
row_count_mode: ROW_COUNT_MODE_EXACT.to_string(),
|
|
columns: vec![ColumnSummary {
|
|
name: "name".to_string(),
|
|
declared_type: "TEXT".to_string(),
|
|
not_null: true,
|
|
primary_key_position: 0,
|
|
}],
|
|
sample_rows: vec![],
|
|
indexes: vec![],
|
|
}],
|
|
};
|
|
let after = DatabaseSummary {
|
|
path: "after.db".to_string(),
|
|
page_size: 4096,
|
|
page_count: 3,
|
|
journal_mode: "wal".to_string(),
|
|
table_count: 2,
|
|
requested_tables: Vec::new(),
|
|
unmatched_tables: Vec::new(),
|
|
tables: vec![
|
|
TableSummary {
|
|
name: "users".to_string(),
|
|
row_count: Some(3),
|
|
row_count_mode: ROW_COUNT_MODE_EXACT.to_string(),
|
|
columns: vec![
|
|
ColumnSummary {
|
|
name: "name".to_string(),
|
|
declared_type: "TEXT".to_string(),
|
|
not_null: false,
|
|
primary_key_position: 0,
|
|
},
|
|
ColumnSummary {
|
|
name: "city".to_string(),
|
|
declared_type: "TEXT".to_string(),
|
|
not_null: false,
|
|
primary_key_position: 0,
|
|
},
|
|
],
|
|
sample_rows: vec![],
|
|
indexes: vec![],
|
|
},
|
|
TableSummary {
|
|
name: "events".to_string(),
|
|
row_count: Some(1),
|
|
row_count_mode: ROW_COUNT_MODE_EXACT.to_string(),
|
|
columns: vec![],
|
|
sample_rows: vec![],
|
|
indexes: vec![],
|
|
},
|
|
],
|
|
};
|
|
|
|
let diff = diff_databases(&before, &after);
|
|
|
|
assert_eq!(diff.added_tables.len(), 1);
|
|
assert_eq!(diff.added_tables[0].name, "events");
|
|
assert_eq!(diff.changed_tables.len(), 1);
|
|
assert_eq!(diff.changed_tables[0].name, "users");
|
|
assert_eq!(diff.changed_tables[0].added_columns.len(), 1);
|
|
assert_eq!(diff.changed_tables[0].added_columns[0].name, "city");
|
|
assert_eq!(diff.changed_tables[0].changed_columns.len(), 1);
|
|
assert_eq!(diff.changed_tables[0].changed_columns[0].name, "name");
|
|
assert_eq!(diff.journal_mode_before, "delete");
|
|
assert_eq!(diff.journal_mode_after, "wal");
|
|
}
|
|
|
|
#[test]
|
|
fn schema_helpers_cover_filters_indexes_and_rendering() {
|
|
let temp = tempdir().expect("tempdir");
|
|
let path = temp.path().join("helpers.db");
|
|
let connection = Connection::open(&path).expect("db");
|
|
connection
|
|
.execute_batch(
|
|
"CREATE TABLE users(id INTEGER PRIMARY KEY, name TEXT NOT NULL, city TEXT);
|
|
CREATE INDEX idx_users_city ON users(city);
|
|
CREATE TABLE events(id INTEGER PRIMARY KEY, kind TEXT);
|
|
INSERT INTO users(name, city) VALUES ('Ada', 'London');",
|
|
)
|
|
.expect("schema");
|
|
|
|
assert_eq!(
|
|
load_table_selection(&connection, &[])
|
|
.expect("tables")
|
|
.matched,
|
|
vec!["events".to_string(), "users".to_string()]
|
|
);
|
|
assert_eq!(
|
|
load_table_selection(&connection, &[String::from("users")])
|
|
.expect("filtered")
|
|
.matched,
|
|
vec!["users".to_string()]
|
|
);
|
|
|
|
let columns = load_columns(&connection, "users").expect("columns");
|
|
assert_eq!(columns.len(), 3);
|
|
assert!(columns.iter().any(|column| column.name == "city"));
|
|
|
|
let indexes = load_indexes(&connection, "users").expect("indexes");
|
|
assert_eq!(indexes.len(), 1);
|
|
assert_eq!(indexes[0].name, "idx_users_city");
|
|
assert_eq!(indexes[0].columns, vec!["city".to_string()]);
|
|
|
|
let table = inspect_table(
|
|
&connection,
|
|
"users",
|
|
&Cli {
|
|
common: common_args(false, InputFormat::Auto),
|
|
command: CommandMode::Summary,
|
|
tables: Vec::new(),
|
|
sample_rows: 1,
|
|
include_indexes: true,
|
|
count_rows: true,
|
|
paths: Vec::new(),
|
|
},
|
|
)
|
|
.expect("table");
|
|
assert_eq!(table.row_count, Some(1));
|
|
assert_eq!(table.sample_rows.len(), 1);
|
|
assert_eq!(table.indexes.len(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn table_selection_tracks_unmatched_requested_tables() {
|
|
let temp = tempdir().expect("tempdir");
|
|
let path = temp.path().join("helpers.db");
|
|
let connection = Connection::open(&path).expect("db");
|
|
connection
|
|
.execute_batch("CREATE TABLE users(id INTEGER PRIMARY KEY, name TEXT);")
|
|
.expect("schema");
|
|
let selection = load_table_selection(
|
|
&connection,
|
|
&[String::from("users"), String::from("missing")],
|
|
)
|
|
.expect("selection");
|
|
assert_eq!(selection.matched, vec!["users".to_string()]);
|
|
assert_eq!(selection.unmatched, vec!["missing".to_string()]);
|
|
}
|
|
|
|
#[test]
|
|
fn diff_and_dedup_helpers_cover_removed_indexes_and_render_output() {
|
|
let before = TableSummary {
|
|
name: "users".to_string(),
|
|
row_count: Some(2),
|
|
row_count_mode: ROW_COUNT_MODE_EXACT.to_string(),
|
|
columns: vec![
|
|
ColumnSummary {
|
|
name: "id".to_string(),
|
|
declared_type: "INTEGER".to_string(),
|
|
not_null: false,
|
|
primary_key_position: 1,
|
|
},
|
|
ColumnSummary {
|
|
name: "name".to_string(),
|
|
declared_type: "TEXT".to_string(),
|
|
not_null: true,
|
|
primary_key_position: 0,
|
|
},
|
|
],
|
|
sample_rows: vec![],
|
|
indexes: vec![IndexSummary {
|
|
name: "idx_users_name".to_string(),
|
|
unique: true,
|
|
columns: vec!["name".to_string()],
|
|
}],
|
|
};
|
|
let after = TableSummary {
|
|
name: "users".to_string(),
|
|
row_count: Some(2),
|
|
row_count_mode: ROW_COUNT_MODE_ESTIMATED.to_string(),
|
|
columns: vec![ColumnSummary {
|
|
name: "id".to_string(),
|
|
declared_type: "INTEGER".to_string(),
|
|
not_null: false,
|
|
primary_key_position: 1,
|
|
}],
|
|
sample_rows: vec![],
|
|
indexes: vec![],
|
|
};
|
|
|
|
let diff = diff_tables(&before, &after);
|
|
assert_eq!(diff.removed_columns.len(), 1);
|
|
assert_eq!(diff.removed_columns[0].name, "name");
|
|
assert_eq!(diff.removed_indexes.len(), 1);
|
|
assert_eq!(diff.removed_indexes[0].name, "idx_users_name");
|
|
|
|
let mut duplicate_tables = vec![before.clone(), before];
|
|
dedup_tables(&mut duplicate_tables);
|
|
assert_eq!(duplicate_tables.len(), 1);
|
|
|
|
let mut duplicate_diffs = vec![diff.clone(), diff];
|
|
dedup_table_diffs(&mut duplicate_diffs);
|
|
assert_eq!(duplicate_diffs.len(), 1);
|
|
|
|
let rendered = render_diff(&DatabaseDiff {
|
|
path_before: "before.db".to_string(),
|
|
path_after: "after.db".to_string(),
|
|
page_size_before: 4096,
|
|
page_size_after: 4096,
|
|
page_count_before: 1,
|
|
page_count_after: 2,
|
|
journal_mode_before: "delete".to_string(),
|
|
journal_mode_after: "wal".to_string(),
|
|
added_tables: vec![],
|
|
removed_tables: vec![],
|
|
changed_tables: duplicate_diffs,
|
|
});
|
|
assert!(rendered.contains("before=before.db"));
|
|
assert!(rendered.contains("table=users change=changed"));
|
|
}
|
|
}
|