chore(release): prepare public source release

This commit is contained in:
MercuryToolbox Release
2026-07-18 15:33:01 +08:00
commit 34d6a57f38
510 changed files with 163501 additions and 0 deletions
File diff suppressed because it is too large Load Diff
+275
View File
@@ -0,0 +1,275 @@
use std::ffi::OsString;
use std::time::Duration;
use common::{
CliError, ColorChoice, CommonArgs, RenderMode, parse_color_choice, parse_format_choice,
};
use lexopt::prelude::{Long, Short, Value as ArgValue};
use crate::connection::source_from_parts;
use crate::model::{ConnectionSource, Engine, InspectOptions, TableFilter};
#[derive(Debug, Clone)]
pub(crate) struct Cli {
pub(crate) common: CommonArgs,
pub(crate) command: CommandMode,
pub(crate) options: InspectOptions,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ParseOutcome {
Help,
Version,
Run,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum CommandMode {
Summary {
source: ConnectionSource,
},
Diff {
before: ConnectionSource,
after: ConnectionSource,
},
}
#[derive(Default)]
struct PendingSources {
engine: Option<Engine>,
url: Option<String>,
before_engine: Option<Engine>,
after_engine: Option<Engine>,
before_url: Option<String>,
after_url: Option<String>,
}
#[allow(clippy::too_many_lines)]
pub(crate) 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 common = CommonArgs {
color: ColorChoice::Auto,
..CommonArgs::default()
};
let mut options = InspectOptions::default();
let mut sources = PendingSources::default();
let mut diff_mode = false;
while let Some(argument) = parser
.next()
.map_err(|error| CliError::usage(error.to_string()))?
{
match argument {
Long("help") | Short('h') => return Ok((ParseOutcome::Help, default_cli()?)),
Long("version") | Short('V') => return Ok((ParseOutcome::Version, default_cli()?)),
Long("json") => common.set_render_mode(RenderMode::Json),
Long("toon") => common.set_render_mode(RenderMode::Toon),
Long("format") => {
common.set_render_mode(parse_format_choice(&parser_value_string(
&mut parser,
"--format",
)?)?);
}
Long("color") => {
common.color = parse_color_choice(&parser_value_string(&mut parser, "--color")?)?;
}
Long("quiet") => common.quiet = true,
Long("engine") => {
sources.engine = Some(parse_engine_value(
&parser_value_string(&mut parser, "--engine")?,
"--engine",
)?);
}
Long("url") => set_once(
&mut sources.url,
parser_value_string(&mut parser, "--url")?,
"--url",
)?,
Long("before-engine") => {
sources.before_engine = Some(parse_engine_value(
&parser_value_string(&mut parser, "--before-engine")?,
"--before-engine",
)?);
}
Long("after-engine") => {
sources.after_engine = Some(parse_engine_value(
&parser_value_string(&mut parser, "--after-engine")?,
"--after-engine",
)?);
}
Long("before-url") => set_once(
&mut sources.before_url,
parser_value_string(&mut parser, "--before-url")?,
"--before-url",
)?,
Long("after-url") => set_once(
&mut sources.after_url,
parser_value_string(&mut parser, "--after-url")?,
"--after-url",
)?,
Long("schema") => options
.schemas
.push(parser_value_string(&mut parser, "--schema")?),
Long("table") => options.tables.push(TableFilter::parse(&parser_value_string(
&mut parser,
"--table",
)?)),
Long("include-system") => options.include_system = true,
Long("connect-timeout") => {
options.connect_timeout =
parse_timeout(&parser_value_string(&mut parser, "--connect-timeout")?)?;
}
ArgValue(value) if value == "diff" => {
if diff_mode {
return Err(CliError::usage("duplicate diff subcommand"));
}
diff_mode = true;
}
ArgValue(value) => {
return Err(CliError::usage(format!(
"unexpected positional argument '{}'",
value.to_string_lossy()
)));
}
Short(flag) => return Err(CliError::usage(format!("unsupported flag -{flag}"))),
Long(flag) => return Err(CliError::usage(format!("unsupported flag --{flag}"))),
}
}
let command = if diff_mode {
if sources.url.is_some() || sources.engine.is_some() {
return Err(CliError::usage(
"diff expects --before-url/--after-url and optional --before-engine/--after-engine",
));
}
let before = source_from_parts(
sources.before_engine,
sources
.before_url
.ok_or_else(|| CliError::usage("diff expects --before-url"))?,
)?;
let after = source_from_parts(
sources.after_engine,
sources
.after_url
.ok_or_else(|| CliError::usage("diff expects --after-url"))?,
)?;
CommandMode::Diff { before, after }
} else {
if sources.before_url.is_some()
|| sources.after_url.is_some()
|| sources.before_engine.is_some()
|| sources.after_engine.is_some()
{
return Err(CliError::usage(
"summary mode expects --url and optional --engine, not diff source flags",
));
}
let source = source_from_parts(
sources.engine,
sources
.url
.ok_or_else(|| CliError::usage("provide --url <CONNECTION>"))?,
)?;
CommandMode::Summary { source }
};
Ok((
ParseOutcome::Run,
Cli {
common,
command,
options,
},
))
}
fn default_cli() -> Result<Cli, CliError> {
Ok(Cli {
common: CommonArgs::default(),
command: CommandMode::Summary {
source: source_from_parts(Some(Engine::Sqlite), ":memory:".to_string())?,
},
options: InspectOptions::default(),
})
}
fn parser_value_string(parser: &mut lexopt::Parser, flag: &str) -> Result<String, CliError> {
let value = parser
.value()
.map_err(|_| CliError::usage(format!("{flag} expects a value")))?;
Ok(value.to_string_lossy().into_owned())
}
fn parse_engine_value(value: &str, flag: &str) -> Result<Engine, CliError> {
Engine::parse(value).ok_or_else(|| {
CliError::usage(format!(
"invalid {flag} value '{value}'; expected postgres, mysql, mariadb, sqlite, sqlserver, mssql, or duckdb"
))
})
}
fn parse_timeout(value: &str) -> Result<Duration, CliError> {
let seconds = value
.parse::<u64>()
.map_err(|_| CliError::usage("--connect-timeout expects a positive integer"))?;
if seconds == 0 {
return Err(CliError::usage(
"--connect-timeout expects a positive integer",
));
}
Ok(Duration::from_secs(seconds))
}
fn set_once(target: &mut Option<String>, value: String, flag: &str) -> Result<(), CliError> {
if target.replace(value).is_some() {
Err(CliError::usage(format!("duplicate {flag}")))
} else {
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_summary_and_diff_modes() {
let (_, summary) = parse_cli_from(["sqlshape", "--engine", "sqlite", "--url", "db.sqlite"])
.expect("summary");
assert!(matches!(
summary.command,
CommandMode::Summary {
source: ConnectionSource {
engine: Engine::Sqlite,
..
}
}
));
let (_, diff) = parse_cli_from([
"sqlshape",
"diff",
"--before-engine",
"sqlite",
"--before-url",
"a.db",
"--after-engine",
"duckdb",
"--after-url",
"b.duckdb",
])
.expect("diff");
assert!(matches!(diff.command, CommandMode::Diff { .. }));
}
#[test]
fn rejects_ambiguous_local_paths_without_engine() {
let error = parse_cli_from(["sqlshape", "--url", "db.sqlite"]).expect_err("usage error");
assert!(error.to_string().contains("--engine"));
}
}
+138
View File
@@ -0,0 +1,138 @@
use common::CliError;
use url::Url;
use crate::model::{ConnectionSource, Engine};
pub(crate) fn source_from_parts(
engine: Option<Engine>,
raw_url: String,
) -> Result<ConnectionSource, CliError> {
let source_redacted = redact_source(&raw_url);
let engine = engine.or_else(|| infer_engine(&raw_url)).ok_or_else(|| {
CliError::usage(format!(
"--engine is required for ambiguous SQL source '{source_redacted}'"
))
})?;
Ok(ConnectionSource {
engine,
raw_url,
source_redacted,
})
}
pub(crate) fn infer_engine(raw_url: &str) -> Option<Engine> {
Url::parse(raw_url)
.ok()
.and_then(|url| Engine::parse(url.scheme()))
}
pub(crate) fn redact_source(raw: &str) -> String {
if let Ok(mut url) = Url::parse(raw) {
if url.password().is_some() {
let _ = url.set_password(Some("***"));
}
if url
.query_pairs()
.any(|(key, _)| is_sensitive_connection_key(&key))
{
let pairs = url
.query_pairs()
.map(|(key, value)| {
let rendered = if is_sensitive_connection_key(&key) {
"***".into()
} else {
value
};
(key.into_owned(), rendered.into_owned())
})
.collect::<Vec<_>>();
url.query_pairs_mut().clear().extend_pairs(pairs);
}
return url.to_string();
}
redact_key_value_passwords(raw)
}
fn redact_key_value_passwords(raw: &str) -> String {
let mut changed = false;
let parts = raw
.split(';')
.map(|part| {
let Some((key, value)) = part.split_once('=') else {
return part.to_string();
};
let key_trimmed = key.trim();
if is_sensitive_connection_key(key_trimmed) {
changed = true;
format!("{key}=***")
} else {
format!("{key}={value}")
}
})
.collect::<Vec<_>>();
if changed {
parts.join(";")
} else {
raw.to_string()
}
}
fn is_sensitive_connection_key(key: &str) -> bool {
let normalized = key
.chars()
.filter(|character| !matches!(character, '_' | '-' | ' '))
.flat_map(char::to_lowercase)
.collect::<String>();
matches!(
normalized.as_str(),
"password"
| "pwd"
| "token"
| "accesstoken"
| "apikey"
| "secret"
| "clientsecret"
| "credential"
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn redacts_url_and_ado_passwords() {
assert_eq!(
redact_source("postgres://user:secret@example.test/db"),
"postgres://user:***@example.test/db"
);
assert_eq!(
redact_source("server=tcp:host,1433;User ID=sa;Password=secret;Database=db"),
"server=tcp:host,1433;User ID=sa;Password=***;Database=db"
);
}
#[test]
fn redacts_url_query_and_key_value_secrets() {
assert_eq!(
redact_source(
"postgres://example.test/db?password=secret&access_token=abc&sslmode=require"
),
"postgres://example.test/db?password=***&access_token=***&sslmode=require"
);
assert_eq!(
redact_source("server=tcp:host,1433;User ID=sa;Access Token=abc;ApiKey=key"),
"server=tcp:host,1433;User ID=sa;Access Token=***;ApiKey=***"
);
}
#[test]
fn infers_engines_from_url_schemes_only() {
assert_eq!(
infer_engine("postgres://localhost/db"),
Some(Engine::Postgres)
);
assert_eq!(infer_engine("duckdb://local/path"), Some(Engine::DuckDb));
assert_eq!(infer_engine(r".\local.db"), None);
}
}
+297
View File
@@ -0,0 +1,297 @@
use std::collections::BTreeMap;
use crate::model::{
ColumnChange, ColumnSummary, ForeignKeySummary, IndexSummary, SqlObjectSummary, SqlShapeDiff,
SqlShapeReport, TableChange,
};
pub(crate) fn diff_reports(before: SqlShapeReport, after: SqlShapeReport) -> SqlShapeDiff {
let before_tables = table_map(&before.tables);
let after_tables = table_map(&after.tables);
let mut added_tables = Vec::new();
let mut removed_tables = Vec::new();
let mut changed_tables = Vec::new();
for (identity, table) in &after_tables {
if !before_tables.contains_key(identity) {
added_tables.push((*table).clone());
}
}
for (identity, table) in &before_tables {
if !after_tables.contains_key(identity) {
removed_tables.push((*table).clone());
}
}
for (identity, before_table) in &before_tables {
if let Some(after_table) = after_tables.get(identity) {
if let Some(change) = diff_table(before_table, after_table) {
changed_tables.push(change);
}
}
}
SqlShapeDiff {
before,
after,
added_tables,
removed_tables,
changed_tables,
}
}
fn table_map(tables: &[SqlObjectSummary]) -> BTreeMap<(String, String), &SqlObjectSummary> {
tables
.iter()
.map(|table| {
(
(
table.schema.to_ascii_lowercase(),
table.name.to_ascii_lowercase(),
),
table,
)
})
.collect()
}
fn diff_table(before: &SqlObjectSummary, after: &SqlObjectSummary) -> Option<TableChange> {
let added_columns = added_items(&before.columns, &after.columns, |column| &column.name);
let removed_columns = added_items(&after.columns, &before.columns, |column| &column.name);
let changed_columns = changed_columns(before, after);
let added_foreign_keys = added_items(&before.foreign_keys, &after.foreign_keys, foreign_key_id);
let removed_foreign_keys =
added_items(&after.foreign_keys, &before.foreign_keys, foreign_key_id);
let added_indexes = added_items(&before.indexes, &after.indexes, index_id);
let removed_indexes = added_items(&after.indexes, &before.indexes, index_id);
let changed = before.kind != after.kind
|| before.row_estimate != after.row_estimate
|| before.primary_key != after.primary_key
|| !added_columns.is_empty()
|| !removed_columns.is_empty()
|| !changed_columns.is_empty()
|| !added_foreign_keys.is_empty()
|| !removed_foreign_keys.is_empty()
|| !added_indexes.is_empty()
|| !removed_indexes.is_empty();
changed.then(|| TableChange {
schema: after.schema.clone(),
name: after.name.clone(),
kind_before: before.kind,
kind_after: after.kind,
row_estimate_before: before.row_estimate,
row_estimate_after: after.row_estimate,
added_columns,
removed_columns,
changed_columns,
primary_key_before: before.primary_key.clone(),
primary_key_after: after.primary_key.clone(),
added_foreign_keys,
removed_foreign_keys,
added_indexes,
removed_indexes,
})
}
fn changed_columns(before: &SqlObjectSummary, after: &SqlObjectSummary) -> Vec<ColumnChange> {
let before_columns = before
.columns
.iter()
.map(|column| (column.name.to_ascii_lowercase(), column))
.collect::<BTreeMap<_, _>>();
after
.columns
.iter()
.filter_map(|after_column| {
let before_column = before_columns.get(&after_column.name.to_ascii_lowercase())?;
(column_changed(before_column, after_column)).then(|| ColumnChange {
name: after_column.name.clone(),
data_type_before: before_column.data_type.clone(),
data_type_after: after_column.data_type.clone(),
nullable_before: before_column.nullable,
nullable_after: after_column.nullable,
default_before: before_column.default.clone(),
default_after: after_column.default.clone(),
})
})
.collect()
}
fn column_changed(before: &ColumnSummary, after: &ColumnSummary) -> bool {
before.data_type != after.data_type
|| before.nullable != after.nullable
|| before.default != after.default
}
fn added_items<T, F>(old: &[T], new: &[T], id: F) -> Vec<T>
where
T: Clone,
F: Fn(&T) -> &str,
{
let old_ids = old
.iter()
.map(|item| id(item).to_ascii_lowercase())
.collect::<std::collections::BTreeSet<_>>();
new.iter()
.filter(|item| !old_ids.contains(&id(item).to_ascii_lowercase()))
.cloned()
.collect()
}
fn foreign_key_id(foreign_key: &ForeignKeySummary) -> &str {
&foreign_key.name
}
fn index_id(index: &IndexSummary) -> &str {
&index.name
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::{
ColumnSummary, Engine, ForeignKeySummary, IndexSummary, SqlObjectKind, SqlObjectSummary,
};
#[test]
fn detects_table_additions_removals_and_kind_changes() {
let before = report(vec![
table(
"public",
"users",
SqlObjectKind::Table,
vec![column("id", "int", false, None)],
),
table(
"public",
"legacy",
SqlObjectKind::Table,
vec![column("id", "int", false, None)],
),
]);
let after = report(vec![
table(
"public",
"users",
SqlObjectKind::View,
vec![column("id", "int", false, None)],
),
table(
"public",
"events",
SqlObjectKind::Table,
vec![column("id", "int", false, None)],
),
]);
let diff = diff_reports(before, after);
assert_eq!(diff.added_tables[0].name, "events");
assert_eq!(diff.removed_tables[0].name, "legacy");
assert_eq!(diff.changed_tables[0].name, "users");
assert_eq!(diff.changed_tables[0].kind_before, SqlObjectKind::Table);
assert_eq!(diff.changed_tables[0].kind_after, SqlObjectKind::View);
}
#[test]
fn detects_column_type_nullability_and_default_changes() {
let before = report(vec![table(
"public",
"users",
SqlObjectKind::Table,
vec![column("name", "text", false, Some("'old'"))],
)]);
let after = report(vec![table(
"public",
"users",
SqlObjectKind::Table,
vec![column("name", "varchar(128)", true, Some("'new'"))],
)]);
let diff = diff_reports(before, after);
let change = &diff.changed_tables[0].changed_columns[0];
assert_eq!(change.name, "name");
assert_eq!(change.data_type_before, "text");
assert_eq!(change.data_type_after, "varchar(128)");
assert!(!change.nullable_before);
assert!(change.nullable_after);
assert_eq!(change.default_before.as_deref(), Some("'old'"));
assert_eq!(change.default_after.as_deref(), Some("'new'"));
}
#[test]
fn detects_foreign_key_and_index_changes() {
let before = report(vec![table(
"public",
"orders",
SqlObjectKind::Table,
vec![column("user_id", "int", false, None)],
)]);
let mut after_table = table(
"public",
"orders",
SqlObjectKind::Table,
vec![column("user_id", "int", false, None)],
);
after_table.foreign_keys.push(ForeignKeySummary {
name: "fk_orders_users".to_string(),
columns: vec!["user_id".to_string()],
referenced_schema: Some("public".to_string()),
referenced_table: "users".to_string(),
referenced_columns: vec!["id".to_string()],
});
after_table.indexes.push(IndexSummary {
name: "idx_orders_user_id".to_string(),
unique: false,
columns: vec!["user_id".to_string()],
});
let after = report(vec![after_table]);
let diff = diff_reports(before, after);
let change = &diff.changed_tables[0];
assert_eq!(change.added_foreign_keys[0].name, "fk_orders_users");
assert_eq!(change.added_indexes[0].name, "idx_orders_user_id");
}
fn report(tables: Vec<SqlObjectSummary>) -> SqlShapeReport {
SqlShapeReport {
engine: Engine::Postgres,
database: "db".to_string(),
source_redacted: "postgres://user:***@host/db".to_string(),
schemas: vec!["public".to_string()],
tables,
warnings: Vec::new(),
}
}
fn table(
schema: &str,
name: &str,
kind: SqlObjectKind,
columns: Vec<ColumnSummary>,
) -> SqlObjectSummary {
SqlObjectSummary {
schema: schema.to_string(),
name: name.to_string(),
kind,
row_estimate: None,
columns,
primary_key: Vec::new(),
foreign_keys: Vec::new(),
indexes: Vec::new(),
}
}
fn column(name: &str, data_type: &str, nullable: bool, default: Option<&str>) -> ColumnSummary {
ColumnSummary {
name: name.to_string(),
data_type: data_type.to_string(),
nullable,
default: default.map(str::to_string),
ordinal_position: 1,
}
}
}
+230
View File
@@ -0,0 +1,230 @@
#![allow(clippy::multiple_crate_versions, clippy::redundant_pub_crate)]
//! The `sqlshape` command inspects normalized SQL database schemas.
use common::{
CliError, ExitCode, RenderMode, print_quick_help_error, print_structured, write_stdout,
};
/// Database catalog adapters for supported engines.
pub(crate) mod adapters;
/// Command-line parsing and validation.
pub(crate) mod cli;
/// Connection source inference and redaction.
pub(crate) mod connection;
/// Normalized schema diffing.
pub(crate) mod diff;
/// Normalized SQL schema model.
pub(crate) mod model;
/// Text rendering for reports and diffs.
pub(crate) mod render;
const HELP: &str = "\
Inspect multi-database SQL schema shape through read-only connections.
Usage:
sqlshape [OPTIONS] --url <CONNECTION>
sqlshape [OPTIONS] diff --before-url <CONNECTION> --after-url <CONNECTION>
Options:
--format <FORMAT> Structured output format: text, json, toon
--json Shortcut for --format json
--toon Shortcut for --format toon
--color <WHEN> Control ANSI color output: auto, never
--quiet Suppress non-essential status output
--engine <ENGINE> Engine: postgres, mysql, mariadb, sqlite, sqlserver, mssql, duckdb
--url <CONNECTION> Database URL or local database path
--before-engine <ENGINE> Engine for diff --before-url
--after-engine <ENGINE> Engine for diff --after-url
--before-url <CONNECTION> Before database URL or local database path for diff
--after-url <CONNECTION> After database URL or local database path for diff
--schema <NAME> Limit output to one or more schemas
--table <NAME> Limit output to one or more table names or schema.table names
--include-system Include system schemas and tables
--connect-timeout <SECONDS> Connection timeout hint for service adapters
-h, --help Show this help text
-V, --version Show the command version
Examples:
sqlshape --engine postgres --url \"postgres://user:pass@host:5432/db\" --json
sqlshape --engine sqlite --url .\\data\\app.db --toon
sqlshape --engine duckdb --url .\\data\\warehouse.duckdb
sqlshape diff --before-engine sqlite --before-url before.db --after-engine duckdb --after-url after.duckdb --json
";
/// Parses CLI arguments and returns a process exit code.
#[must_use]
pub fn main_entry() -> i32 {
match cli::parse_cli_from(std::env::args_os()) {
Ok((cli::ParseOutcome::Help, _)) => {
print!("{HELP}");
ExitCode::Success.as_i32()
}
Ok((cli::ParseOutcome::Version, _)) => {
println!("sqlshape {}", env!("CARGO_PKG_VERSION"));
ExitCode::Success.as_i32()
}
Ok((cli::ParseOutcome::Run, cli)) => match run(&cli) {
Ok(code) => code.as_i32(),
Err(error) => {
print_quick_help_error(&error, HELP);
error.exit_code().as_i32()
}
},
Err(error) => {
print_quick_help_error(&error, HELP);
error.exit_code().as_i32()
}
}
}
fn run(cli: &cli::Cli) -> Result<ExitCode, CliError> {
match &cli.command {
cli::CommandMode::Summary { source } => {
let report = adapters::inspect(source, &cli.options)?;
emit_value(cli, &report, &render::render_report(&report))?;
Ok(common::map_result_count(report.tables.len()))
}
cli::CommandMode::Diff { before, after } => {
let before_report = adapters::inspect(before, &cli.options)?;
let after_report = adapters::inspect(after, &cli.options)?;
let report = diff::diff_reports(before_report, after_report);
let count = report.added_tables.len()
+ report.removed_tables.len()
+ report.changed_tables.len();
emit_value(cli, &report, &render::render_diff(&report))?;
Ok(common::map_result_count(count))
}
}
}
fn emit_value<T>(cli: &cli::Cli, value: &T, text: &str) -> Result<(), CliError>
where
T: serde::Serialize,
{
match cli.common.render_mode() {
RenderMode::Text => write_stdout(text),
RenderMode::Json | RenderMode::Toon => print_structured(value, cli.common.render_mode()),
}
}
#[cfg(test)]
mod tests {
use std::fs;
use tempfile::tempdir;
use super::*;
fn sqlite_fixture(name: &str, schema: &str) -> std::path::PathBuf {
let dir = tempdir().expect("tempdir").keep();
let path = dir.join(name);
let connection = rusqlite::Connection::open(&path).expect("sqlite");
connection.execute_batch(schema).expect("schema");
drop(connection);
path
}
#[test]
fn run_summary_emits_text_json_and_toon_for_sqlite_sources() {
let path = sqlite_fixture(
"summary.db",
"CREATE TABLE users(id INTEGER PRIMARY KEY, name TEXT NOT NULL);",
);
for flag in [None, Some("--json"), Some("--toon")] {
let mut args = vec![
"sqlshape".to_string(),
"--engine".to_string(),
"sqlite".to_string(),
"--url".to_string(),
path.display().to_string(),
];
if let Some(flag) = flag {
args.insert(1, flag.to_string());
}
let (_, cli) = cli::parse_cli_from(args).expect("cli parses");
assert_eq!(run(&cli).expect("run succeeds"), ExitCode::Success);
}
let _ = fs::remove_file(path);
}
#[test]
fn run_diff_maps_no_change_to_no_results_and_changes_to_success() {
let before = sqlite_fixture(
"before.db",
"CREATE TABLE users(id INTEGER PRIMARY KEY, name TEXT NOT NULL);",
);
let after_same = sqlite_fixture(
"after_same.db",
"CREATE TABLE users(id INTEGER PRIMARY KEY, name TEXT NOT NULL);",
);
let after_changed = sqlite_fixture(
"after_changed.db",
"CREATE TABLE users(id INTEGER PRIMARY KEY, name TEXT NOT NULL, email TEXT);",
);
let (_, no_change) = cli::parse_cli_from([
"sqlshape",
"diff",
"--before-engine",
"sqlite",
"--before-url",
&before.display().to_string(),
"--after-engine",
"sqlite",
"--after-url",
&after_same.display().to_string(),
])
.expect("no-change diff parses");
assert_eq!(
run(&no_change).expect("no-change diff"),
ExitCode::NoResults
);
let (_, changed) = cli::parse_cli_from([
"sqlshape",
"--json",
"diff",
"--before-engine",
"sqlite",
"--before-url",
&before.display().to_string(),
"--after-engine",
"sqlite",
"--after-url",
&after_changed.display().to_string(),
])
.expect("changed diff parses");
assert_eq!(run(&changed).expect("changed diff"), ExitCode::Success);
let _ = fs::remove_file(before);
let _ = fs::remove_file(after_same);
let _ = fs::remove_file(after_changed);
}
#[test]
fn emit_value_respects_text_and_structured_render_modes() {
let report = model::SqlShapeReport {
engine: model::Engine::Sqlite,
database: "demo.db".to_string(),
source_redacted: "demo.db".to_string(),
schemas: vec!["main".to_string()],
tables: Vec::new(),
warnings: Vec::new(),
};
for args in [
vec!["sqlshape", "--engine", "sqlite", "--url", "demo.db"],
vec![
"sqlshape", "--json", "--engine", "sqlite", "--url", "demo.db",
],
vec![
"sqlshape", "--toon", "--engine", "sqlite", "--url", "demo.db",
],
] {
let (_, cli) = cli::parse_cli_from(args).expect("cli parses");
emit_value(&cli, &report, "engine=sqlite\n").expect("emit succeeds");
}
}
}
+6
View File
@@ -0,0 +1,6 @@
#![allow(clippy::multiple_crate_versions)]
//! Binary entry point for `sqlshape`.
fn main() {
std::process::exit(sqlshape::main_entry());
}
+329
View File
@@ -0,0 +1,329 @@
use std::fmt;
use std::time::Duration;
use serde::{Serialize, Serializer};
/// Supported SQL database engines.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub(crate) enum Engine {
Postgres,
MySql,
MariaDb,
Sqlite,
SqlServer,
DuckDb,
}
impl Serialize for Engine {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(self.as_str())
}
}
impl Engine {
pub(crate) fn parse(value: &str) -> Option<Self> {
match value.to_ascii_lowercase().as_str() {
"postgres" | "postgresql" => Some(Self::Postgres),
"mysql" => Some(Self::MySql),
"mariadb" => Some(Self::MariaDb),
"sqlite" | "sqlite3" => Some(Self::Sqlite),
"sqlserver" | "mssql" => Some(Self::SqlServer),
"duckdb" => Some(Self::DuckDb),
_ => None,
}
}
pub(crate) const fn as_str(self) -> &'static str {
match self {
Self::Postgres => "postgres",
Self::MySql => "mysql",
Self::MariaDb => "mariadb",
Self::Sqlite => "sqlite",
Self::SqlServer => "sqlserver",
Self::DuckDb => "duckdb",
}
}
}
impl fmt::Display for Engine {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ConnectionSource {
pub(crate) engine: Engine,
pub(crate) raw_url: String,
pub(crate) source_redacted: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct InspectOptions {
pub(crate) schemas: Vec<String>,
pub(crate) tables: Vec<TableFilter>,
pub(crate) include_system: bool,
pub(crate) connect_timeout: Duration,
}
impl Default for InspectOptions {
fn default() -> Self {
Self {
schemas: Vec::new(),
tables: Vec::new(),
include_system: false,
connect_timeout: Duration::from_secs(10),
}
}
}
impl InspectOptions {
pub(crate) fn accepts_schema(&self, schema: &str) -> bool {
self.schemas.is_empty()
|| self
.schemas
.iter()
.any(|candidate| candidate.eq_ignore_ascii_case(schema))
}
pub(crate) fn accepts_table(&self, schema: &str, table: &str) -> bool {
self.tables.is_empty()
|| self
.tables
.iter()
.any(|candidate| candidate.matches(schema, table))
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct TableFilter {
pub(crate) schema: Option<String>,
pub(crate) name: String,
}
impl TableFilter {
pub(crate) fn parse(value: &str) -> Self {
if let Some((schema, name)) = value.rsplit_once('.') {
Self {
schema: Some(schema.to_string()),
name: name.to_string(),
}
} else {
Self {
schema: None,
name: value.to_string(),
}
}
}
fn matches(&self, schema: &str, table: &str) -> bool {
self.name.eq_ignore_ascii_case(table)
&& self
.schema
.as_ref()
.is_none_or(|expected| expected.eq_ignore_ascii_case(schema))
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub(crate) struct SqlShapeReport {
pub(crate) engine: Engine,
pub(crate) database: String,
pub(crate) source_redacted: String,
pub(crate) schemas: Vec<String>,
pub(crate) tables: Vec<SqlObjectSummary>,
pub(crate) warnings: Vec<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum SqlObjectKind {
Table,
View,
}
impl SqlObjectKind {
pub(crate) const fn as_str(self) -> &'static str {
match self {
Self::Table => "table",
Self::View => "view",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub(crate) struct SqlObjectSummary {
pub(crate) schema: String,
pub(crate) name: String,
pub(crate) kind: SqlObjectKind,
pub(crate) row_estimate: Option<i64>,
pub(crate) columns: Vec<ColumnSummary>,
pub(crate) primary_key: Vec<String>,
pub(crate) foreign_keys: Vec<ForeignKeySummary>,
pub(crate) indexes: Vec<IndexSummary>,
}
impl SqlObjectSummary {
#[cfg(test)]
pub(crate) fn identity(&self) -> String {
format!("{}.{}", self.schema, self.name)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub(crate) struct ColumnSummary {
pub(crate) name: String,
pub(crate) data_type: String,
pub(crate) nullable: bool,
pub(crate) default: Option<String>,
pub(crate) ordinal_position: i64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub(crate) struct ForeignKeySummary {
pub(crate) name: String,
pub(crate) columns: Vec<String>,
pub(crate) referenced_schema: Option<String>,
pub(crate) referenced_table: String,
pub(crate) referenced_columns: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub(crate) struct IndexSummary {
pub(crate) name: String,
pub(crate) unique: bool,
pub(crate) columns: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub(crate) struct SqlShapeDiff {
pub(crate) before: SqlShapeReport,
pub(crate) after: SqlShapeReport,
pub(crate) added_tables: Vec<SqlObjectSummary>,
pub(crate) removed_tables: Vec<SqlObjectSummary>,
pub(crate) changed_tables: Vec<TableChange>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub(crate) struct TableChange {
pub(crate) schema: String,
pub(crate) name: String,
pub(crate) kind_before: SqlObjectKind,
pub(crate) kind_after: SqlObjectKind,
pub(crate) row_estimate_before: Option<i64>,
pub(crate) row_estimate_after: Option<i64>,
pub(crate) added_columns: Vec<ColumnSummary>,
pub(crate) removed_columns: Vec<ColumnSummary>,
pub(crate) changed_columns: Vec<ColumnChange>,
pub(crate) primary_key_before: Vec<String>,
pub(crate) primary_key_after: Vec<String>,
pub(crate) added_foreign_keys: Vec<ForeignKeySummary>,
pub(crate) removed_foreign_keys: Vec<ForeignKeySummary>,
pub(crate) added_indexes: Vec<IndexSummary>,
pub(crate) removed_indexes: Vec<IndexSummary>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub(crate) struct ColumnChange {
pub(crate) name: String,
pub(crate) data_type_before: String,
pub(crate) data_type_after: String,
pub(crate) nullable_before: bool,
pub(crate) nullable_after: bool,
pub(crate) default_before: Option<String>,
pub(crate) default_after: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn engine_parse_display_and_serialization_cover_all_aliases() {
let cases = [
("postgres", Engine::Postgres, "postgres"),
("postgresql", Engine::Postgres, "postgres"),
("mysql", Engine::MySql, "mysql"),
("mariadb", Engine::MariaDb, "mariadb"),
("sqlite3", Engine::Sqlite, "sqlite"),
("mssql", Engine::SqlServer, "sqlserver"),
("duckdb", Engine::DuckDb, "duckdb"),
];
for (input, engine, label) in cases {
assert_eq!(Engine::parse(input), Some(engine));
assert_eq!(engine.as_str(), label);
assert_eq!(engine.to_string(), label);
assert_eq!(
serde_json::to_value(engine).expect("engine serializes"),
serde_json::Value::String(label.to_string())
);
}
assert_eq!(Engine::parse("oracle"), None);
}
#[test]
fn inspect_options_match_schema_and_table_filters_case_insensitively() {
let options = InspectOptions {
schemas: vec!["Main".to_string()],
tables: vec![
TableFilter::parse("main.Users"),
TableFilter::parse("orders"),
],
include_system: true,
connect_timeout: Duration::from_secs(3),
};
assert!(options.accepts_schema("main"));
assert!(!options.accepts_schema("archive"));
assert!(options.accepts_table("MAIN", "users"));
assert!(options.accepts_table("archive", "ORDERS"));
assert!(!options.accepts_table("archive", "users"));
assert_eq!(options.connect_timeout, Duration::from_secs(3));
let unfiltered = InspectOptions::default();
assert!(unfiltered.accepts_schema("anything"));
assert!(unfiltered.accepts_table("anything", "users"));
}
#[test]
fn object_identity_and_kind_labels_are_stable() {
let table = SqlObjectSummary {
schema: "main".to_string(),
name: "users".to_string(),
kind: SqlObjectKind::Table,
row_estimate: Some(10),
columns: vec![ColumnSummary {
name: "id".to_string(),
data_type: "INTEGER".to_string(),
nullable: false,
default: None,
ordinal_position: 1,
}],
primary_key: vec!["id".to_string()],
foreign_keys: vec![ForeignKeySummary {
name: "fk_users_parent".to_string(),
columns: vec!["id".to_string()],
referenced_schema: Some("main".to_string()),
referenced_table: "parents".to_string(),
referenced_columns: vec!["id".to_string()],
}],
indexes: vec![IndexSummary {
name: "idx_users_id".to_string(),
unique: true,
columns: vec!["id".to_string()],
}],
};
assert_eq!(table.identity(), "main.users");
assert_eq!(SqlObjectKind::Table.as_str(), "table");
assert_eq!(SqlObjectKind::View.as_str(), "view");
assert_eq!(
serde_json::to_value(&table).expect("table serializes")["kind"],
serde_json::Value::String("table".to_string())
);
}
}
+229
View File
@@ -0,0 +1,229 @@
use std::fmt::Write as _;
use crate::model::{SqlShapeDiff, SqlShapeReport};
pub(crate) fn render_report(report: &SqlShapeReport) -> String {
let mut output = String::new();
let _ = writeln!(
output,
"engine={} database={} source={} schemas={} tables={}",
report.engine,
report.database,
report.source_redacted,
report.schemas.len(),
report.tables.len()
);
for table in &report.tables {
let rows = table.row_estimate.map_or_else(
|| "unknown".to_string(),
|count| format!("estimate:{count}"),
);
let _ = writeln!(
output,
"table={}.{} kind={} rows={} columns={} pk={} indexes={} fks={}",
table.schema,
table.name,
table.kind.as_str(),
rows,
table.columns.len(),
table.primary_key.len(),
table.indexes.len(),
table.foreign_keys.len()
);
for column in &table.columns {
let default = column.default.as_deref().unwrap_or("");
let _ = writeln!(
output,
" column={} type={} nullable={} default={}",
column.name, column.data_type, column.nullable, default
);
}
}
for warning in &report.warnings {
let _ = writeln!(output, "warning={warning}");
}
output
}
pub(crate) fn render_diff(diff: &SqlShapeDiff) -> String {
let mut output = String::new();
let _ = writeln!(
output,
"before={} after={} added_tables={} removed_tables={} changed_tables={}",
diff.before.source_redacted,
diff.after.source_redacted,
diff.added_tables.len(),
diff.removed_tables.len(),
diff.changed_tables.len()
);
for table in &diff.added_tables {
let _ = writeln!(output, "added_table={}.{}", table.schema, table.name);
}
for table in &diff.removed_tables {
let _ = writeln!(output, "removed_table={}.{}", table.schema, table.name);
}
for table in &diff.changed_tables {
let _ = writeln!(
output,
"changed_table={}.{} added_columns={} removed_columns={} changed_columns={}",
table.schema,
table.name,
table.added_columns.len(),
table.removed_columns.len(),
table.changed_columns.len()
);
}
output
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::{
ColumnChange, ColumnSummary, ConnectionSource, Engine, ForeignKeySummary, IndexSummary,
SqlObjectKind, SqlObjectSummary, TableChange,
};
fn report(source: &str, tables: Vec<SqlObjectSummary>) -> SqlShapeReport {
SqlShapeReport {
engine: Engine::Sqlite,
database: "demo.db".to_string(),
source_redacted: source.to_string(),
schemas: vec!["main".to_string()],
tables,
warnings: vec!["check permissions".to_string()],
}
}
fn table(name: &str) -> SqlObjectSummary {
SqlObjectSummary {
schema: "main".to_string(),
name: name.to_string(),
kind: SqlObjectKind::Table,
row_estimate: Some(7),
columns: vec![
ColumnSummary {
name: "id".to_string(),
data_type: "INTEGER".to_string(),
nullable: false,
default: None,
ordinal_position: 1,
},
ColumnSummary {
name: "name".to_string(),
data_type: "TEXT".to_string(),
nullable: true,
default: Some("'unknown'".to_string()),
ordinal_position: 2,
},
],
primary_key: vec!["id".to_string()],
foreign_keys: vec![ForeignKeySummary {
name: "fk_demo".to_string(),
columns: vec!["id".to_string()],
referenced_schema: Some("main".to_string()),
referenced_table: "parents".to_string(),
referenced_columns: vec!["id".to_string()],
}],
indexes: vec![IndexSummary {
name: "idx_demo_name".to_string(),
unique: false,
columns: vec!["name".to_string()],
}],
}
}
#[test]
fn render_report_lists_tables_columns_counts_and_warnings() {
let rendered = render_report(&report("sqlite://demo", vec![table("users")]));
assert!(rendered.contains("engine=sqlite database=demo.db"));
assert!(rendered.contains("source=sqlite://demo"));
assert!(rendered.contains("table=main.users kind=table rows=estimate:7"));
assert!(rendered.contains("columns=2 pk=1 indexes=1 fks=1"));
assert!(rendered.contains("column=name type=TEXT nullable=true default='unknown'"));
assert!(rendered.contains("warning=check permissions"));
}
#[test]
fn render_diff_lists_added_removed_and_changed_tables() {
let before = report("before", vec![table("users")]);
let after = report("after", vec![table("accounts")]);
let diff = SqlShapeDiff {
before,
after,
added_tables: vec![table("accounts")],
removed_tables: vec![table("users")],
changed_tables: vec![TableChange {
schema: "main".to_string(),
name: "orders".to_string(),
kind_before: SqlObjectKind::Table,
kind_after: SqlObjectKind::View,
row_estimate_before: Some(1),
row_estimate_after: Some(2),
added_columns: vec![ColumnSummary {
name: "created_at".to_string(),
data_type: "TEXT".to_string(),
nullable: true,
default: None,
ordinal_position: 3,
}],
removed_columns: vec![ColumnSummary {
name: "old".to_string(),
data_type: "TEXT".to_string(),
nullable: true,
default: None,
ordinal_position: 4,
}],
changed_columns: vec![ColumnChange {
name: "name".to_string(),
data_type_before: "TEXT".to_string(),
data_type_after: "VARCHAR".to_string(),
nullable_before: true,
nullable_after: false,
default_before: None,
default_after: Some("'unknown'".to_string()),
}],
primary_key_before: vec!["id".to_string()],
primary_key_after: vec!["id".to_string(), "created_at".to_string()],
added_foreign_keys: Vec::new(),
removed_foreign_keys: Vec::new(),
added_indexes: Vec::new(),
removed_indexes: Vec::new(),
}],
};
let rendered = render_diff(&diff);
assert!(rendered.contains("before=before after=after"));
assert!(rendered.contains("added_table=main.accounts"));
assert!(rendered.contains("removed_table=main.users"));
assert!(rendered.contains(
"changed_table=main.orders added_columns=1 removed_columns=1 changed_columns=1"
));
}
#[test]
fn render_report_marks_unknown_row_estimates() {
let mut object = table("views");
object.kind = SqlObjectKind::View;
object.row_estimate = None;
let rendered = render_report(&SqlShapeReport {
warnings: Vec::new(),
..report("sqlite://demo", vec![object])
});
assert!(rendered.contains("kind=view rows=unknown"));
}
#[test]
fn connection_source_import_stays_used_for_render_tests() {
let source = ConnectionSource {
engine: Engine::DuckDb,
raw_url: "duckdb://warehouse".to_string(),
source_redacted: "duckdb://warehouse".to_string(),
};
assert_eq!(source.engine.as_str(), "duckdb");
}
}