chore(release): prepare public source release

This commit is contained in:
MercuryToolbox Release
2026-07-18 15:41:59 +08:00
commit e365e5df4d
508 changed files with 163373 additions and 0 deletions
+37
View File
@@ -0,0 +1,37 @@
[package]
name = "sqlshape"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
readme.workspace = true
publish.workspace = true
description = "Inspect multi-database SQL schema shapes with compact CLI output."
keywords.workspace = true
categories.workspace = true
build = "build.rs"
[lints]
workspace = true
[dependencies]
common = { path = "../common", default-features = false }
duckdb.workspace = true
lexopt.workspace = true
mysql.workspace = true
native-tls.workspace = true
postgres.workspace = true
postgres-native-tls.workspace = true
rusqlite = { version = "0.37.0", default-features = false, features = ["bundled"] }
serde.workspace = true
serde_json.workspace = true
tiberius.workspace = true
tokio.workspace = true
tokio-util.workspace = true
url.workspace = true
[dev-dependencies]
assert_cmd.workspace = true
predicates.workspace = true
tempfile.workspace = true
+10
View File
@@ -0,0 +1,10 @@
//! Build script for `sqlshape`.
fn main() {
let target_family = std::env::var("CARGO_CFG_TARGET_FAMILY").unwrap_or_default();
let target = std::env::var("TARGET").unwrap_or_default();
if target_family.split(',').any(|family| family == "windows") || target.contains("windows") {
println!("cargo:rustc-link-lib=Rstrtmgr");
println!("cargo:rustc-link-arg-tests=Rstrtmgr.lib");
}
}
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");
}
}
+501
View File
@@ -0,0 +1,501 @@
//! Integration tests for the `sqlshape` command.
use assert_cmd::Command;
use duckdb::Connection as DuckConnection;
use mysql::prelude::Queryable as _;
use predicates::prelude::*;
use rusqlite::Connection as SqliteConnection;
use serde_json::Value;
use tempfile::tempdir;
fn cargo_command() -> Command {
Command::cargo_bin("sqlshape").expect("binary")
}
#[test]
fn help_includes_engines_diff_and_shared_flags() {
let mut command = cargo_command();
command
.arg("--help")
.assert()
.success()
.stdout(predicate::str::contains("sqlshape [OPTIONS] --url"))
.stdout(predicate::str::contains("sqlshape [OPTIONS] diff"))
.stdout(predicate::str::contains("--format <FORMAT>"))
.stdout(predicate::str::contains("--engine <ENGINE>"))
.stdout(predicate::str::contains("--before-url <CONNECTION>"))
.stdout(predicate::str::contains("--include-system"))
.stdout(predicate::str::contains("postgres"))
.stdout(predicate::str::contains("duckdb"));
}
#[test]
fn ambiguous_path_requires_engine() {
let mut command = cargo_command();
command
.args(["--url", r".\data\app.db"])
.assert()
.code(2)
.stderr(predicate::str::contains("--engine"))
.stderr(predicate::str::contains("ambiguous"));
}
#[test]
fn invalid_engine_errors_do_not_echo_password() {
let mut command = cargo_command();
command
.args([
"--engine",
"oracle",
"--url",
"postgres://user:secret@localhost/db",
])
.assert()
.code(2)
.stderr(predicate::str::contains("oracle"))
.stderr(predicate::str::contains("secret").not());
}
#[test]
fn summarizes_sqlite_schema_as_json() {
let temp = tempdir().expect("tempdir");
let path = temp.path().join("sample.db");
let connection = SqliteConnection::open(&path).expect("db");
connection
.execute_batch(
"PRAGMA foreign_keys = ON;
CREATE TABLE users(id INTEGER PRIMARY KEY, name TEXT NOT NULL, city TEXT DEFAULT 'Paris');
CREATE TABLE orders(id INTEGER PRIMARY KEY, user_id INTEGER NOT NULL,
total REAL, FOREIGN KEY(user_id) REFERENCES users(id));
CREATE INDEX idx_orders_user_id ON orders(user_id);",
)
.expect("schema");
drop(connection);
let mut command = cargo_command();
command
.args(["--engine", "sqlite", "--url"])
.arg(&path)
.arg("--json")
.assert()
.success()
.stdout(predicate::str::contains("\"engine\":\"sqlite\""))
.stdout(predicate::str::contains("\"source_redacted\""))
.stdout(predicate::str::contains("\"schema\":\"main\""))
.stdout(predicate::str::contains("\"name\":\"users\""))
.stdout(predicate::str::contains("\"primary_key\":[\"id\"]"))
.stdout(predicate::str::contains("\"foreign_keys\""))
.stdout(predicate::str::contains("\"idx_orders_user_id\""));
}
#[test]
fn infers_sqlite_engine_from_url_scheme() {
let temp = tempdir().expect("tempdir");
let path = temp.path().join("scheme.db");
let connection = SqliteConnection::open(&path).expect("db");
connection
.execute_batch("CREATE TABLE users(id INTEGER PRIMARY KEY, name TEXT NOT NULL);")
.expect("schema");
drop(connection);
let url = sqlite_url(&path);
let mut command = cargo_command();
command
.args(["--url", &url, "--json"])
.assert()
.success()
.stdout(predicate::str::contains("\"engine\":\"sqlite\""))
.stdout(predicate::str::contains("\"name\":\"users\""));
}
#[test]
fn supports_format_json_and_toon_flags() {
let temp = tempdir().expect("tempdir");
let path = temp.path().join("formats.db");
let connection = SqliteConnection::open(&path).expect("db");
connection
.execute_batch("CREATE TABLE users(id INTEGER PRIMARY KEY, name TEXT NOT NULL);")
.expect("schema");
drop(connection);
let mut json_command = cargo_command();
json_command
.args(["--engine", "sqlite", "--url"])
.arg(&path)
.args(["--format", "json"])
.assert()
.success()
.stdout(predicate::str::contains("\"engine\":\"sqlite\""));
let mut toon_command = cargo_command();
toon_command
.args(["--engine", "sqlite", "--url"])
.arg(&path)
.arg("--toon")
.assert()
.success()
.stdout(predicate::str::contains("engine: sqlite"))
.stdout(predicate::str::contains("tables:"));
}
#[test]
fn summarizes_duckdb_schema_as_json() {
let temp = tempdir().expect("tempdir");
let path = temp.path().join("warehouse.duckdb");
let connection = DuckConnection::open(&path).expect("db");
connection
.execute_batch(
"CREATE TABLE users(id INTEGER PRIMARY KEY, name VARCHAR NOT NULL);
CREATE VIEW active_users AS SELECT id, name FROM users;",
)
.expect("schema");
drop(connection);
let mut command = cargo_command();
command
.args(["--engine", "duckdb", "--url"])
.arg(&path)
.arg("--json")
.assert()
.success()
.stdout(predicate::str::contains("\"engine\":\"duckdb\""))
.stdout(predicate::str::contains("\"schema\":\"main\""))
.stdout(predicate::str::contains("\"name\":\"users\""))
.stdout(predicate::str::contains("\"kind\":\"table\""))
.stdout(predicate::str::contains("\"name\":\"active_users\""))
.stdout(predicate::str::contains("\"kind\":\"view\""));
}
#[test]
fn diff_reports_table_and_column_changes() {
let temp = tempdir().expect("tempdir");
let before_path = temp.path().join("before.db");
let after_path = temp.path().join("after.db");
let before = SqliteConnection::open(&before_path).expect("before db");
before
.execute_batch("CREATE TABLE users(id INTEGER PRIMARY KEY, name TEXT NOT NULL);")
.expect("before schema");
drop(before);
let after = SqliteConnection::open(&after_path).expect("after db");
after
.execute_batch(
"CREATE TABLE users(id INTEGER PRIMARY KEY, name TEXT NOT NULL, city TEXT);
CREATE TABLE events(id INTEGER PRIMARY KEY, kind TEXT);",
)
.expect("after schema");
drop(after);
let mut command = cargo_command();
command
.args(["diff", "--before-engine", "sqlite", "--before-url"])
.arg(&before_path)
.args(["--after-engine", "sqlite", "--after-url"])
.arg(&after_path)
.arg("--json")
.assert()
.success()
.stdout(predicate::str::contains("\"added_tables\""))
.stdout(predicate::str::contains("\"name\":\"events\""))
.stdout(predicate::str::contains("\"changed_tables\""))
.stdout(predicate::str::contains("\"added_columns\""))
.stdout(predicate::str::contains("\"city\""));
}
#[test]
fn live_postgres_smoke_when_configured() {
let Some(database_url) = env_url("SQLSHAPE_POSTGRES_URL") else {
return;
};
let schema = format!("sqlshape_smoke_{}", std::process::id());
let tls = postgres_native_tls::MakeTlsConnector::new(
native_tls::TlsConnector::builder()
.danger_accept_invalid_certs(true)
.build()
.expect("tls connector"),
);
let mut client = postgres::Client::connect(&database_url, tls).expect("postgres connection");
client
.batch_execute(&format!(
"DROP SCHEMA IF EXISTS {schema} CASCADE;
CREATE SCHEMA {schema};
CREATE TABLE {schema}.users(id INTEGER PRIMARY KEY, name TEXT NOT NULL);
CREATE TABLE {schema}.orders(
id INTEGER PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES {schema}.users(id)
);
CREATE INDEX idx_orders_user_id ON {schema}.orders(user_id);"
))
.expect("postgres fixture");
drop(client);
let report = run_sqlshape_json(&[
"sqlshape",
"--engine",
"postgres",
"--url",
&database_url,
"--schema",
&schema,
"--json",
]);
assert_report_has_table(&report, "users");
assert_report_has_table(&report, "orders");
assert_report_has_index(&report, "orders", "idx_orders_user_id");
assert_report_has_foreign_key(&report, "orders");
let tls = postgres_native_tls::MakeTlsConnector::new(
native_tls::TlsConnector::builder()
.danger_accept_invalid_certs(true)
.build()
.expect("tls connector"),
);
let mut client = postgres::Client::connect(&database_url, tls).expect("postgres cleanup");
client
.batch_execute(&format!("DROP SCHEMA IF EXISTS {schema} CASCADE;"))
.expect("postgres cleanup");
}
#[test]
fn live_mysql_smoke_when_configured() {
let Some(database_url) = env_url("SQLSHAPE_MYSQL_URL") else {
return;
};
let suffix = std::process::id();
let users = format!("sqlshape_users_{suffix}");
let orders = format!("sqlshape_orders_{suffix}");
let index = format!("idx_sqlshape_orders_user_id_{suffix}");
let opts = mysql::Opts::from_url(&database_url).expect("mysql url");
let pool = mysql::Pool::new(opts).expect("mysql pool");
let mut connection = pool.get_conn().expect("mysql connection");
connection
.query_drop(format!("DROP TABLE IF EXISTS `{orders}`"))
.expect("drop orders");
connection
.query_drop(format!("DROP TABLE IF EXISTS `{users}`"))
.expect("drop users");
connection
.query_drop(format!(
"CREATE TABLE `{users}`(id INT NOT NULL PRIMARY KEY, name VARCHAR(64) NOT NULL) ENGINE=InnoDB"
))
.expect("create users");
connection
.query_drop(format!(
"CREATE TABLE `{orders}`(
id INT NOT NULL PRIMARY KEY,
user_id INT NOT NULL,
CONSTRAINT fk_{orders}_users FOREIGN KEY (user_id) REFERENCES `{users}`(id)
) ENGINE=InnoDB"
))
.expect("create orders");
connection
.query_drop(format!("CREATE INDEX `{index}` ON `{orders}`(user_id)"))
.expect("create index");
drop(connection);
let report = run_sqlshape_json(&[
"sqlshape",
"--engine",
"mysql",
"--url",
&database_url,
"--table",
&users,
"--table",
&orders,
"--json",
]);
assert_report_has_table(&report, &users);
assert_report_has_table(&report, &orders);
assert_report_has_index(&report, &orders, &index);
assert_report_has_foreign_key(&report, &orders);
let mut connection = pool.get_conn().expect("mysql cleanup connection");
connection
.query_drop(format!("DROP TABLE IF EXISTS `{orders}`"))
.expect("drop orders");
connection
.query_drop(format!("DROP TABLE IF EXISTS `{users}`"))
.expect("drop users");
}
#[test]
fn live_sqlserver_smoke_when_configured() {
let Some(database_url) = env_url("SQLSHAPE_MSSQL_URL") else {
return;
};
let schema = format!("sqlshape_smoke_{}", std::process::id());
let create_sql = format!(
"IF SCHEMA_ID(N'{schema}') IS NOT NULL EXEC(N'DROP SCHEMA {schema}');
EXEC(N'CREATE SCHEMA {schema}');
CREATE TABLE {schema}.users(id INT NOT NULL PRIMARY KEY, name NVARCHAR(64) NOT NULL);
CREATE TABLE {schema}.orders(
id INT NOT NULL PRIMARY KEY,
user_id INT NOT NULL,
CONSTRAINT fk_{schema}_orders_users FOREIGN KEY(user_id) REFERENCES {schema}.users(id)
);
CREATE INDEX idx_{schema}_orders_user_id ON {schema}.orders(user_id);"
);
sqlserver_batch(
&database_url,
&format!(
"DROP TABLE IF EXISTS {schema}.orders; DROP TABLE IF EXISTS {schema}.users; IF SCHEMA_ID(N'{schema}') IS NOT NULL EXEC(N'DROP SCHEMA {schema}'); {create_sql}"
),
);
let report = run_sqlshape_json(&[
"sqlshape",
"--engine",
"sqlserver",
"--url",
&database_url,
"--schema",
&schema,
"--json",
]);
assert_report_has_table(&report, "users");
assert_report_has_table(&report, "orders");
assert_report_has_index(&report, "orders", &format!("idx_{schema}_orders_user_id"));
assert_report_has_foreign_key(&report, "orders");
sqlserver_batch(
&database_url,
&format!(
"DROP TABLE IF EXISTS {schema}.orders;
DROP TABLE IF EXISTS {schema}.users;
IF SCHEMA_ID(N'{schema}') IS NOT NULL EXEC(N'DROP SCHEMA {schema}');"
),
);
}
fn env_url(name: &str) -> Option<String> {
std::env::var(name)
.ok()
.filter(|value| !value.trim().is_empty())
}
fn sqlite_url(path: &std::path::Path) -> String {
format!("sqlite:///{}", path.to_string_lossy().replace('\\', "/"))
}
fn run_sqlshape_json(args: &[&str]) -> Value {
let source_url = args
.windows(2)
.find_map(|pair| (pair[0] == "--url").then_some(pair[1]))
.unwrap_or("");
let output = std::process::Command::new(env!("CARGO_BIN_EXE_sqlshape"))
.args(&args[1..])
.output()
.expect("run sqlshape");
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
output.status.success(),
"sqlshape failed with {}:\n{}",
output.status,
sanitize_output(&format!("{stdout}\n{stderr}"), source_url)
);
serde_json::from_str(&stdout).unwrap_or_else(|error| {
panic!(
"invalid JSON: {error}\n{}",
sanitize_output(&stdout, source_url)
)
})
}
fn sanitize_output(text: &str, source_url: &str) -> String {
let mut redacted = text.replace(source_url, "<redacted-url>");
if let Ok(url) = url::Url::parse(source_url) {
if let Some(password) = url.password() {
redacted = redacted.replace(password, "***");
}
}
redacted
}
fn assert_report_has_table(report: &Value, name: &str) {
assert!(
report["tables"]
.as_array()
.expect("tables array")
.iter()
.any(|table| table["name"] == name),
"expected table {name} in {report}"
);
}
fn assert_report_has_index(report: &Value, table_name: &str, index_name: &str) {
let table = report["tables"]
.as_array()
.expect("tables array")
.iter()
.find(|table| table["name"] == table_name)
.expect("table");
assert!(
table["indexes"]
.as_array()
.expect("indexes array")
.iter()
.any(|index| index["name"] == index_name),
"expected index {index_name} on {table_name} in {report}"
);
}
fn assert_report_has_foreign_key(report: &Value, table_name: &str) {
let table = report["tables"]
.as_array()
.expect("tables array")
.iter()
.find(|table| table["name"] == table_name)
.expect("table");
assert!(
!table["foreign_keys"]
.as_array()
.expect("foreign keys array")
.is_empty(),
"expected foreign key on {table_name} in {report}"
);
}
fn sqlserver_batch(database_url: &str, sql: &str) {
let runtime = tokio::runtime::Runtime::new().expect("sqlserver runtime");
runtime
.block_on(async {
use tokio_util::compat::TokioAsyncWriteCompatExt as _;
let ado = sqlserver_ado_string(database_url);
let config = tiberius::Config::from_ado_string(&ado)?;
let tcp = tokio::net::TcpStream::connect(config.get_addr()).await?;
tcp.set_nodelay(true)?;
let mut client = tiberius::Client::connect(config, tcp.compat_write()).await?;
client.simple_query(sql).await?.into_results().await?;
Ok::<_, Box<dyn std::error::Error>>(())
})
.unwrap_or_else(|error| panic!("sqlserver fixture failed: {error}"));
}
fn sqlserver_ado_string(raw: &str) -> String {
let url = url::Url::parse(raw).expect("sqlserver url");
let host = url.host_str().expect("sqlserver host");
let server = url.port().map_or_else(
|| format!("tcp:{host}"),
|port| format!("tcp:{host},{port}"),
);
let mut parts = vec![format!("server={server}")];
if !url.username().is_empty() {
parts.push(format!("User ID={}", url.username()));
}
if let Some(password) = url.password() {
parts.push(format!("Password={password}"));
}
let database = url.path().trim_start_matches('/');
if !database.is_empty() {
parts.push(format!("Database={database}"));
}
let trust_cert = url
.query_pairs()
.any(|(key, value)| key.eq_ignore_ascii_case("trust_cert") && value == "true");
parts.push(format!("TrustServerCertificate={trust_cert}"));
parts.join(";")
}