use sql_insight::catalog::Catalog;
use sql_insight::diagnostic::TableLevelDiagnostic;
use sql_insight::error::Error;
use sql_insight::extractor::{
extract_column_operations_with_options, extract_crud_tables_with_options,
extract_table_operations_with_options, ColumnLineageKind, ColumnOperation, ColumnTarget,
ExtractorOptions, TableOperation,
};
use sql_insight::formatter::FormatterOptions;
use sql_insight::normalizer::NormalizerOptions;
use sql_insight::sqlparser::dialect::{self, Dialect};
use sql_insight::{
CaseRule, ColumnRead, ColumnWrite, IdentifierCasing, ResolutionKind, TableRead, TableWrite,
};
pub trait CliExecutable {
fn execute(&self) -> Result<Vec<String>, Error>;
}
fn get_dialect(dialect_name: Option<&str>) -> Result<Box<dyn dialect::Dialect>, Error> {
let dialect_name = dialect_name.unwrap_or("generic");
dialect::dialect_from_str(dialect_name)
.ok_or_else(|| Error::ArgumentError(format!("Dialect not found: {}", dialect_name)))
}
pub struct FormatExecutor {
sql: String,
dialect_name: Option<String>,
options: FormatterOptions,
}
impl FormatExecutor {
pub fn new(sql: String, dialect_name: Option<String>) -> Self {
Self {
sql,
dialect_name,
options: FormatterOptions::new(),
}
}
pub fn with_options(mut self, options: FormatterOptions) -> Self {
self.options = options;
self
}
}
impl CliExecutable for FormatExecutor {
fn execute(&self) -> Result<Vec<String>, Error> {
sql_insight::formatter::format_with_options(
get_dialect(self.dialect_name.as_deref())?.as_ref(),
self.sql.as_ref(),
self.options.clone(),
)
}
}
pub struct NormalizeExecutor {
sql: String,
dialect_name: Option<String>,
options: NormalizerOptions,
}
impl NormalizeExecutor {
pub fn new(sql: String, dialect_name: Option<String>) -> Self {
Self {
sql,
dialect_name,
options: NormalizerOptions::new(),
}
}
pub fn with_options(mut self, options: NormalizerOptions) -> Self {
self.options = options;
self
}
}
impl CliExecutable for NormalizeExecutor {
fn execute(&self) -> Result<Vec<String>, Error> {
sql_insight::normalizer::normalize_with_options(
get_dialect(self.dialect_name.as_deref())?.as_ref(),
self.sql.as_ref(),
self.options.clone(),
)
}
}
#[derive(Clone, Copy)]
pub enum ExtractKind {
Crud,
TableOps,
ColumnOps,
}
#[derive(Default)]
pub struct CasingOverride {
pub all: Option<CaseRule>,
pub table: Option<CaseRule>,
pub table_alias: Option<CaseRule>,
pub column: Option<CaseRule>,
}
impl CasingOverride {
fn resolve(&self, dialect: &dyn Dialect) -> Option<IdentifierCasing> {
if self.all.is_none()
&& self.table.is_none()
&& self.table_alias.is_none()
&& self.column.is_none()
{
return None;
}
let mut casing = match self.all {
Some(rule) => IdentifierCasing::uniform(rule),
None => IdentifierCasing::for_dialect(dialect),
};
if let Some(rule) = self.table {
casing.table = rule;
}
if let Some(rule) = self.table_alias {
casing.table_alias = rule;
}
if let Some(rule) = self.column {
casing.column = rule;
}
Some(casing)
}
}
pub struct ExtractExecutor {
pub kind: ExtractKind,
pub sql: String,
pub dialect_name: Option<String>,
pub ddl_file: Option<String>,
pub default_schema: Option<String>,
pub default_catalog: Option<String>,
pub casing: CasingOverride,
pub format: OutputFormat,
}
#[derive(Clone, Copy, Default)]
pub enum OutputFormat {
#[default]
Text,
Json,
}
impl CliExecutable for ExtractExecutor {
fn execute(&self) -> Result<Vec<String>, Error> {
let dialect = get_dialect(self.dialect_name.as_deref())?;
let dialect = dialect.as_ref();
let casing = self.casing.resolve(dialect);
let catalog = self.load_catalog(dialect, casing)?;
let mut options = ExtractorOptions::new();
if let Some(catalog) = &catalog {
options = options.with_catalog(catalog);
}
if let Some(casing) = casing {
options = options.with_casing(casing);
}
let sql = self.sql.as_ref();
match (self.kind, self.format) {
(ExtractKind::Crud, OutputFormat::Text) => Ok(render_display(
&extract_crud_tables_with_options(dialect, sql, options)?,
|c| &c.diagnostics,
)),
(ExtractKind::TableOps, OutputFormat::Text) => Ok(render_statements(
&extract_table_operations_with_options(dialect, sql, options)?,
format_table_operation,
)),
(ExtractKind::ColumnOps, OutputFormat::Text) => Ok(render_statements(
&extract_column_operations_with_options(dialect, sql, options)?,
format_column_operation,
)),
(ExtractKind::Crud, OutputFormat::Json) => {
render_json(&extract_crud_tables_with_options(dialect, sql, options)?)
}
(ExtractKind::TableOps, OutputFormat::Json) => render_json(
&extract_table_operations_with_options(dialect, sql, options)?,
),
(ExtractKind::ColumnOps, OutputFormat::Json) => render_json(
&extract_column_operations_with_options(dialect, sql, options)?,
),
}
}
}
impl ExtractExecutor {
fn load_catalog(
&self,
dialect: &dyn Dialect,
casing: Option<IdentifierCasing>,
) -> Result<Option<Catalog>, Error> {
if self.ddl_file.is_none()
&& self.default_schema.is_none()
&& self.default_catalog.is_none()
{
return Ok(None);
}
let casing = casing.unwrap_or_else(|| IdentifierCasing::for_dialect(dialect));
let mut catalog = match &self.ddl_file {
Some(path) => {
let ddl = std::fs::read_to_string(path).map_err(|e| {
Error::ArgumentError(format!("Failed to read DDL file {path}: {e}"))
})?;
Catalog::from_ddl_with_casing(dialect, &ddl, casing).map_err(|e| {
Error::ArgumentError(format!("Failed to parse DDL file {path}: {e}"))
})?
}
None => Catalog::new(),
};
if let Some(schema) = &self.default_schema {
catalog = catalog.default_schema(schema.clone());
}
if let Some(cat) = &self.default_catalog {
catalog = catalog.default_catalog(cat.clone());
}
Ok(Some(catalog))
}
}
fn render_statements<T>(
results: &[Result<T, Error>],
format_one: impl Fn(usize, &T) -> String,
) -> Vec<String> {
results
.iter()
.enumerate()
.map(|(i, r)| match r {
Ok(op) => format_one(i + 1, op),
Err(e) => format!("[{}] Error: {}", i + 1, e),
})
.collect()
}
fn render_json<T: serde::Serialize>(results: &[Result<T, Error>]) -> Result<Vec<String>, Error> {
let entries: Vec<serde_json::Value> = results
.iter()
.map(|r| match r {
Ok(value) => serde_json::to_value(value)
.unwrap_or_else(|e| serde_json::json!({ "error": e.to_string() })),
Err(e) => serde_json::json!({ "error": e.to_string() }),
})
.collect();
let json = serde_json::to_string_pretty(&entries)
.map_err(|e| Error::AnalysisError(format!("failed to serialize result as JSON: {e}")))?;
Ok(vec![json])
}
fn render_display<T: std::fmt::Display>(
results: &[Result<T, Error>],
diagnostics: impl Fn(&T) -> &[TableLevelDiagnostic],
) -> Vec<String> {
results
.iter()
.map(|r| match r {
Ok(value) => {
let mut lines: Vec<String> = Vec::new();
let body = value.to_string();
if !body.is_empty() {
lines.push(body);
}
for d in diagnostics(value) {
lines.push(format!(" ! {}", d.message));
}
lines.join("\n")
}
Err(e) => format!("Error: {e}"),
})
.collect()
}
fn labeled(label: &str, value: String) -> String {
format!(" {label:<8} {value}")
}
fn resolution_marker(resolution: ResolutionKind) -> &'static str {
match resolution {
ResolutionKind::Inferred => "",
ResolutionKind::Cataloged => " (cataloged)",
ResolutionKind::Ambiguous => " (ambiguous)",
ResolutionKind::Unresolved => " (unresolved)",
}
}
fn table_read(read: &TableRead) -> String {
format!("{}{}", read.reference, resolution_marker(read.resolution))
}
fn table_write(write: &TableWrite) -> String {
format!("{}{}", write.reference, resolution_marker(write.resolution))
}
fn column_read(read: &ColumnRead) -> String {
format!("{}{}", read.reference, resolution_marker(read.resolution))
}
fn column_write(write: &ColumnWrite) -> String {
format!("{}{}", write.reference, resolution_marker(write.resolution))
}
fn column_target(target: &ColumnTarget) -> String {
match target {
ColumnTarget::Relation(write) => column_write(write),
ColumnTarget::QueryOutput { name, position } => match name {
Some(name) => name.value.clone(),
None => format!("#{position}"),
},
}
}
fn lineage_block(edges: Vec<String>) -> String {
let pad = " ".repeat(labeled("lineage:", String::new()).len());
edges
.iter()
.enumerate()
.map(|(i, edge)| {
if i == 0 {
labeled("lineage:", edge.clone())
} else {
format!("{pad}{edge}")
}
})
.collect::<Vec<_>>()
.join("\n")
}
fn format_table_operation(n: usize, op: &TableOperation) -> String {
let mut lines = vec![format!("[{n}] {:?}", op.statement_kind)];
if !op.reads.is_empty() {
let reads = op.reads.iter().map(table_read).collect::<Vec<_>>();
lines.push(labeled("reads:", reads.join(", ")));
}
if !op.writes.is_empty() {
let writes = op.writes.iter().map(table_write).collect::<Vec<_>>();
lines.push(labeled("writes:", writes.join(", ")));
}
if !op.lineage.is_empty() {
let edges = op
.lineage
.iter()
.map(|e| format!("{} -> {}", table_read(&e.source), table_write(&e.target)))
.collect();
lines.push(lineage_block(edges));
}
for d in &op.diagnostics {
lines.push(format!(" ! {}", d.message));
}
lines.join("\n")
}
fn format_column_operation(n: usize, op: &ColumnOperation) -> String {
let mut lines = vec![format!("[{n}] {:?}", op.statement_kind)];
if !op.reads.is_empty() {
let reads = op.reads.iter().map(column_read).collect::<Vec<_>>();
lines.push(labeled("reads:", reads.join(", ")));
}
if !op.writes.is_empty() {
let writes = op.writes.iter().map(column_write).collect::<Vec<_>>();
lines.push(labeled("writes:", writes.join(", ")));
}
if !op.lineage.is_empty() {
let edges = op
.lineage
.iter()
.map(|e| {
let transform = match e.kind {
ColumnLineageKind::Transformation => " [transform]",
ColumnLineageKind::Passthrough => "",
};
format!(
"{} -> {}{transform}",
column_read(&e.source),
column_target(&e.target)
)
})
.collect();
lines.push(lineage_block(edges));
}
for d in &op.diagnostics {
lines.push(format!(" ! {}", d.message));
}
lines.join("\n")
}