use roomrs_migrate::SchemaSnapshot;
use sqlparser::ast::{
Expr, Query, Select, SelectItem, SetExpr, Statement, TableFactor, TableWithJoins,
};
use sqlparser::dialect::SQLiteDialect;
use sqlparser::parser::Parser;
use std::path::Path;
pub fn load_validation_snapshots() -> Result<Vec<SchemaSnapshot>, String> {
let manifest = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_default();
let dir = roomrs_migrate::resolve_schema_dir(&manifest);
load_snapshots_in_dir(&dir)
}
fn load_snapshots_in_dir(dir: &Path) -> Result<Vec<SchemaSnapshot>, String> {
let rd = match std::fs::read_dir(dir) {
Ok(rd) => rd,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(e) => {
return Err(format!(
"스냅샷 디렉토리 읽기 실패: {} — {e} (명세 §7.4)",
dir.display()
));
}
};
let mut latest: std::collections::BTreeMap<String, (u32, std::path::PathBuf)> =
std::collections::BTreeMap::new();
for entry in rd.flatten() {
let name = entry.file_name();
let Some(name) = name.to_str() else { continue };
let Some((db, num)) = parse_snapshot_file_name(name) else {
continue;
};
let Ok(ver) = num.parse::<u32>() else {
return Err(format!("스냅샷 파일명 버전이 u32 범위를 넘습니다: {name}"));
};
match latest.get(&db) {
Some((v, _)) if *v >= ver => {}
_ => {
latest.insert(db, (ver, entry.path()));
}
}
}
let mut out = Vec::new();
for (_, (_, path)) in latest {
match SchemaSnapshot::read_from(&path) {
Ok(s) => out.push(s),
Err(e) => {
return Err(format!(
"스냅샷 파일 파손: {} — 파스 실패: {e} (명세 §7.4)",
path.display()
));
}
}
}
Ok(out)
}
fn parse_snapshot_file_name(name: &str) -> Option<(String, &str)> {
let stem = name.strip_suffix(".json")?;
let (db, num) = stem.rsplit_once('.')?;
if db.is_empty() || num.is_empty() || !num.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
if num.len() > 1 && num.starts_with('0') {
return None;
}
Some((db.to_string(), num))
}
pub fn depends_on(sql: &str) -> Option<Vec<String>> {
let stmts = Parser::parse_sql(&SQLiteDialect {}, sql).ok()?;
let mut refs = Refs::default();
for stmt in &stmts {
collect_stmt(stmt, &mut refs);
}
if refs.cte_self_shadow {
return None;
}
let mut out: Vec<String> = Vec::new();
for t in refs.tables {
if !out.iter().any(|e| e.eq_ignore_ascii_case(&t)) {
out.push(t);
}
}
if out.is_empty() { None } else { Some(out) }
}
#[derive(Default)]
struct Refs {
tables: Vec<String>,
columns: Vec<String>,
aliased: bool,
cte_self_shadow: bool,
}
pub fn validate_sql(sql: &str, snaps: &[SchemaSnapshot]) -> Option<String> {
let stmts = Parser::parse_sql(&SQLiteDialect {}, sql).ok()?;
let find_table = |name: &str| {
snaps
.iter()
.find_map(|s| s.tables.iter().find(|t| t.name.eq_ignore_ascii_case(name)))
};
for stmt in &stmts {
let mut refs = Refs::default();
collect_stmt(stmt, &mut refs);
for t in &refs.tables {
if find_table(t).is_none() {
return Some(format!("스냅샷에 없는 테이블 참조: \"{t}\" (명세 §7.2)"));
}
}
if !refs.aliased && refs.tables.len() == 1 {
if let Some(table) = find_table(&refs.tables[0]) {
let ambiguous = snaps
.iter()
.filter_map(|s| {
s.tables
.iter()
.find(|t| t.name.eq_ignore_ascii_case(&table.name))
})
.any(|other| !same_column_names(table, other));
if ambiguous {
continue;
}
for c in &refs.columns {
if !table
.columns
.iter()
.any(|col| col.name.eq_ignore_ascii_case(c))
{
return Some(format!(
"테이블 \"{}\"에 없는 컬럼 참조: \"{c}\" (명세 §7.2)",
table.name
));
}
}
}
}
}
None
}
fn same_column_names(a: &roomrs_migrate::TableSnapshot, b: &roomrs_migrate::TableSnapshot) -> bool {
a.columns.len() == b.columns.len()
&& a.columns.iter().all(|ca| {
b.columns
.iter()
.any(|cb| cb.name.eq_ignore_ascii_case(&ca.name))
})
}
fn collect_stmt(stmt: &Statement, refs: &mut Refs) {
match stmt {
Statement::Query(q) => collect_query(q, refs),
Statement::Insert(ins) => {
refs.tables.push(object_name_last(&ins.table_name));
for c in &ins.columns {
refs.columns.push(c.value.clone());
}
if let Some(src) = &ins.source {
collect_query(src, refs);
}
}
Statement::Update {
table,
assignments,
selection,
..
} => {
collect_table_with_joins(table, refs);
for a in assignments {
if let sqlparser::ast::AssignmentTarget::ColumnName(name) = &a.target {
refs.columns.push(object_name_last(name));
}
}
if let Some(sel) = selection {
collect_expr(sel, refs);
}
}
Statement::Delete(del) => {
let from = match &del.from {
sqlparser::ast::FromTable::WithFromKeyword(v)
| sqlparser::ast::FromTable::WithoutKeyword(v) => v,
};
for t in from {
collect_table_with_joins(t, refs);
}
if let Some(sel) = &del.selection {
collect_expr(sel, refs);
}
}
_ => {
}
}
}
fn collect_query(q: &Query, refs: &mut Refs) {
let scope_start = refs.tables.len();
let mut cte_names: Vec<String> = Vec::new();
if let Some(with) = &q.with {
refs.aliased = true;
for cte in &with.cte_tables {
let name = cte.alias.name.value.clone();
let body_start = refs.tables.len();
collect_query(&cte.query, refs);
if !with.recursive
&& refs.tables[body_start..]
.iter()
.any(|t| t.eq_ignore_ascii_case(&name))
{
refs.cte_self_shadow = true;
}
cte_names.push(name);
}
}
collect_set_expr(q.body.as_ref(), refs);
if !cte_names.is_empty() {
let scoped = refs.tables.split_off(scope_start);
refs.tables.extend(
scoped
.into_iter()
.filter(|t| !cte_names.iter().any(|c| c.eq_ignore_ascii_case(t))),
);
}
}
fn collect_set_expr(se: &SetExpr, refs: &mut Refs) {
match se {
SetExpr::Select(sel) => collect_select(sel, refs),
SetExpr::Query(q) => collect_query(q, refs),
SetExpr::SetOperation { left, right, .. } => {
collect_set_expr(left, refs);
collect_set_expr(right, refs);
}
SetExpr::Insert(s) | SetExpr::Update(s) => collect_stmt(s, refs),
_ => {
}
}
}
fn collect_select(sel: &Select, refs: &mut Refs) {
for twj in &sel.from {
collect_table_with_joins(twj, refs);
}
for item in &sel.projection {
match item {
SelectItem::UnnamedExpr(e) => collect_expr(e, refs),
SelectItem::ExprWithAlias { expr, .. } => collect_expr(expr, refs),
_ => {}
}
}
if let Some(where_) = &sel.selection {
collect_expr(where_, refs);
}
}
fn collect_table_with_joins(twj: &TableWithJoins, refs: &mut Refs) {
collect_table_factor(&twj.relation, refs);
for j in &twj.joins {
collect_table_factor(&j.relation, refs);
}
}
fn collect_table_factor(tf: &TableFactor, refs: &mut Refs) {
match tf {
TableFactor::Table { name, alias, .. } => {
if alias.is_some() {
refs.aliased = true;
}
refs.tables.push(object_name_last(name));
}
TableFactor::Derived { subquery, .. } => {
refs.aliased = true;
collect_query(subquery, refs);
}
_ => {
refs.aliased = true;
}
}
}
fn collect_expr(e: &Expr, refs: &mut Refs) {
match e {
Expr::Identifier(id) => refs.columns.push(id.value.clone()),
Expr::CompoundIdentifier(_) => {
refs.aliased = true;
}
Expr::BinaryOp { left, right, .. } => {
collect_expr(left, refs);
collect_expr(right, refs);
}
Expr::UnaryOp { expr, .. } | Expr::Nested(expr) => collect_expr(expr, refs),
Expr::IsNull(inner) | Expr::IsNotNull(inner) => collect_expr(inner, refs),
Expr::InList { expr, .. } => collect_expr(expr, refs),
Expr::Between {
expr, low, high, ..
} => {
collect_expr(expr, refs);
collect_expr(low, refs);
collect_expr(high, refs);
}
Expr::Like { expr, pattern, .. } | Expr::ILike { expr, pattern, .. } => {
collect_expr(expr, refs);
collect_expr(pattern, refs);
}
Expr::InSubquery { expr, subquery, .. } => {
collect_expr(expr, refs);
refs.aliased = true;
collect_query(subquery, refs);
}
Expr::Subquery(q) => {
refs.aliased = true;
collect_query(q, refs);
}
Expr::Exists { subquery, .. } => {
refs.aliased = true;
collect_query(subquery, refs);
}
_ => {
}
}
}
fn object_name_last(name: &sqlparser::ast::ObjectName) -> String {
name.0.last().map(|id| id.value.clone()).unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
use roomrs_migrate::{ColumnSnapshot, TableSnapshot};
fn col(name: &str) -> ColumnSnapshot {
ColumnSnapshot {
name: name.into(),
sql_type: "TEXT".into(),
not_null: false,
pk: false,
renamed_from: None,
}
}
fn snap_with(table: &str, cols: &[&str]) -> SchemaSnapshot {
SchemaSnapshot {
version: 1,
tables: vec![TableSnapshot {
name: table.into(),
columns: cols.iter().map(|c| col(c)).collect(),
ddl: vec![],
}],
}
}
#[test]
fn cte_exclusion_scoped_to_own_query() {
let deps =
depends_on("SELECT * FROM x, (WITH x AS (SELECT 1 FROM other) SELECT * FROM x) d")
.expect("의존 수집");
assert!(
deps.iter().any(|t| t == "x"),
"바깥 실 테이블 x 유지: {deps:?}"
);
assert!(
deps.iter().any(|t| t == "other"),
"CTE 본문 실 테이블: {deps:?}"
);
}
#[test]
fn cte_self_shadow_degrades_to_none() {
assert_eq!(
depends_on("WITH todos AS (SELECT * FROM todos WHERE done = 0) SELECT * FROM todos"),
None
);
}
#[test]
fn cte_normal_excludes_alias_keeps_real_tables() {
let deps =
depends_on("WITH recent AS (SELECT * FROM logs) SELECT * FROM recent").expect("수집");
assert_eq!(deps, vec!["logs".to_string()]);
}
#[test]
fn recursive_cte_self_reference_not_degraded() {
let deps = depends_on(
"WITH RECURSIVE r AS (SELECT id FROM t UNION ALL SELECT id + 1 FROM r) SELECT * FROM r",
)
.expect("재귀 CTE 수집");
assert_eq!(deps, vec!["t".to_string()]);
}
#[test]
fn union_same_table_name_different_columns_skips_column_check() {
let snaps = vec![
snap_with("items", &["id", "name"]),
snap_with("items", &["id", "price"]),
];
assert_eq!(
validate_sql("SELECT price FROM items", &snaps),
None,
"동명·컬럼 상이 = 컬럼 검증 스킵"
);
assert!(
validate_sql("SELECT id FROM ghost", &snaps).is_some(),
"없는 테이블은 여전히 에러"
);
}
#[test]
fn union_same_table_same_columns_still_checks() {
let snaps = vec![
snap_with("items", &["id", "name"]),
snap_with("items", &["id", "name"]),
];
assert!(
validate_sql("SELECT ghost_col FROM items", &snaps).is_some(),
"컬럼 집합 동일 = 검증 유지"
);
assert_eq!(validate_sql("SELECT name FROM items", &snaps), None);
}
}