chore(release): prepare public source release
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
#![allow(clippy::multiple_crate_versions)]
|
||||
//! Binary entry point for `sqlshape`.
|
||||
|
||||
fn main() {
|
||||
std::process::exit(sqlshape::main_entry());
|
||||
}
|
||||
@@ -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())
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user