#![cfg(feature = "serve")]
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use axum::{
body::Body,
http::{Request, StatusCode},
Router,
};
use flowscope_cli::fix::{apply_lint_fixes_with_runtime_options, LintFixRuntimeOptions};
use flowscope_cli::server::{build_router, state::AppState, state::ServerConfig};
use flowscope_core::{Dialect, FileSource, LintConfig};
use serde_json::{json, Value};
use tokio::sync::RwLock;
use tower::ServiceExt;
const SQL_UNSAFE_FIX_REPRESENTATIVE: &str =
"SELECT t.id\nFROM t\nINNER JOIN (\n SELECT id\n FROM u\n) AS u2 ON t.id = u2.id\n";
fn test_state(config: ServerConfig, files: Vec<FileSource>) -> Arc<AppState> {
Arc::new(AppState {
config,
files: RwLock::new(files),
schema: RwLock::new(None),
mtimes: RwLock::new(HashMap::new()),
})
}
fn default_config() -> ServerConfig {
ServerConfig {
dialect: Dialect::Generic,
watch_dirs: vec![],
static_files: None,
metadata_url: None,
metadata_schema: None,
port: 3000,
open_browser: false,
schema_path: None,
#[cfg(feature = "templating")]
template_config: None,
}
}
async fn post_json(app: &Router, path: &str, payload: Value) -> (StatusCode, Value) {
let response = app
.clone()
.oneshot(
Request::post(path)
.header("content-type", "application/json")
.body(Body::from(payload.to_string()))
.unwrap(),
)
.await
.unwrap();
let status = response.status();
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let json: Value = serde_json::from_slice(&body).unwrap();
(status, json)
}
#[tokio::test]
async fn health_returns_ok_status() {
let state = test_state(default_config(), vec![]);
let app = build_router(state, 3000);
let response = app
.oneshot(Request::get("/api/health").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let json: Value = serde_json::from_slice(&body).unwrap();
assert_eq!(json["status"], "ok");
assert!(json["version"].is_string());
}
#[tokio::test]
async fn analyze_simple_select() {
let state = test_state(default_config(), vec![]);
let app = build_router(state, 3000);
let response = app
.oneshot(
Request::post("/api/analyze")
.header("content-type", "application/json")
.body(Body::from(
json!({
"sql": "SELECT id, name FROM users"
})
.to_string(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let json: Value = serde_json::from_slice(&body).unwrap();
assert!(json["statements"].is_array());
}
#[tokio::test]
async fn analyze_with_join() {
let state = test_state(default_config(), vec![]);
let app = build_router(state, 3000);
let response = app
.oneshot(
Request::post("/api/analyze")
.header("content-type", "application/json")
.body(Body::from(
json!({
"sql": "SELECT u.id, o.total FROM users u JOIN orders o ON u.id = o.user_id"
})
.to_string(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let json: Value = serde_json::from_slice(&body).unwrap();
assert!(json["statements"].is_array());
assert!(!json["statements"].as_array().unwrap().is_empty());
}
#[tokio::test]
async fn completion_returns_items() {
let state = test_state(default_config(), vec![]);
let app = build_router(state, 3000);
let response = app
.oneshot(
Request::post("/api/completion")
.header("content-type", "application/json")
.body(Body::from(
json!({
"sql": "SELECT ",
"cursor_offset": 7
})
.to_string(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let json: Value = serde_json::from_slice(&body).unwrap();
assert!(json["items"].is_array());
}
#[tokio::test]
async fn split_multiple_statements() {
let state = test_state(default_config(), vec![]);
let app = build_router(state, 3000);
let response = app
.oneshot(
Request::post("/api/split")
.header("content-type", "application/json")
.body(Body::from(
json!({
"sql": "SELECT 1; SELECT 2; SELECT 3"
})
.to_string(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let json: Value = serde_json::from_slice(&body).unwrap();
assert!(json["statements"].is_array());
assert_eq!(json["statements"].as_array().unwrap().len(), 3);
}
#[tokio::test]
async fn lint_fix_matches_shared_runtime_pipeline_for_cascading_sql() {
let state = test_state(default_config(), vec![]);
let app = build_router(state, 3000);
let sql = "SELECT a +\n b FROM t";
let (status, json) = post_json(
&app,
"/api/lint-fix",
json!({
"sql": sql
}),
)
.await;
assert_eq!(status, StatusCode::OK);
let expected = apply_lint_fixes_with_runtime_options(
sql,
Dialect::Generic,
&LintConfig {
enabled: true,
disabled_rules: vec![],
rule_configs: std::collections::BTreeMap::new(),
},
LintFixRuntimeOptions {
include_unsafe_fixes: false,
legacy_ast_fixes: false,
},
)
.expect("runtime lint-fix result");
assert_eq!(json["sql"], expected.outcome.sql);
assert_eq!(json["changed"], expected.outcome.changed);
assert_eq!(json["fix_counts"]["total"], expected.outcome.counts.total());
assert_eq!(
json["skipped_counts"]["unsafe_skipped"],
expected.candidate_stats.blocked_unsafe
);
assert_eq!(
json["skipped_counts"]["protected_range_blocked"],
expected.candidate_stats.blocked_protected_range
);
assert_eq!(
json["skipped_counts"]["overlap_conflict_blocked"],
expected.candidate_stats.blocked_overlap_conflict
);
assert_eq!(
json["skipped_counts"]["display_only"],
expected.candidate_stats.blocked_display_only
);
assert_eq!(
json["skipped_counts"]["blocked_total"],
expected.candidate_stats.blocked
);
}
#[tokio::test]
async fn lint_fix_applies_safe_fix_and_reports_counts() {
let state = test_state(default_config(), vec![]);
let app = build_router(state, 3000);
let (status, json) = post_json(
&app,
"/api/lint-fix",
json!({
"sql": "SELECT COUNT (1) FROM t"
}),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(json["changed"], true);
assert_eq!(json["skipped_due_to_regression"], false);
assert!(json["fix_counts"]["total"].as_u64().unwrap() > 0);
let fixed_sql = json["sql"].as_str().unwrap().to_ascii_uppercase();
assert!(
fixed_sql.contains("COUNT(*)") || fixed_sql.contains("COUNT (*)"),
"fixed SQL was: {fixed_sql}"
);
}
#[tokio::test]
async fn lint_fix_rule_config_enables_cv006_core_autofix() {
let state = test_state(default_config(), vec![]);
let app = build_router(state, 3000);
let (status, json) = post_json(
&app,
"/api/lint-fix",
json!({
"sql": "SELECT 1",
"rule_configs": {
"convention.terminator": {
"require_final_semicolon": true
}
}
}),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(json["changed"], true);
let fixed_sql = json["sql"].as_str().unwrap();
assert!(
fixed_sql.trim_end().ends_with(';'),
"expected CV06 core autofix to append final semicolon: {fixed_sql}"
);
}
#[tokio::test]
async fn lint_fix_applies_cv006_spacing_core_autofix_in_patch_mode() {
let state = test_state(default_config(), vec![]);
let app = build_router(state, 3000);
let (status, json) = post_json(
&app,
"/api/lint-fix",
json!({
"sql": "SELECT a FROM foo ;"
}),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(json["changed"], true);
assert_eq!(
json["sql"].as_str().unwrap(),
"SELECT a FROM foo;",
"expected CV06 core autofix to remove whitespace before semicolon"
);
}
#[tokio::test]
async fn lint_fix_applies_cv002_core_autofix_in_patch_mode() {
let state = test_state(default_config(), vec![]);
let app = build_router(state, 3000);
let (status, json) = post_json(
&app,
"/api/lint-fix",
json!({
"sql": "SELECT IFNULL(foo, 0) FROM t"
}),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(json["changed"], true);
let fixed_sql = json["sql"].as_str().unwrap().to_ascii_uppercase();
assert!(
fixed_sql.contains("COALESCE"),
"expected CV02 core autofix to rewrite IFNULL: {fixed_sql}"
);
}
#[tokio::test]
async fn lint_fix_applies_cv001_core_autofix_in_patch_mode() {
let state = test_state(default_config(), vec![]);
let app = build_router(state, 3000);
let (status, json) = post_json(
&app,
"/api/lint-fix",
json!({
"sql": "SELECT * FROM t WHERE a <> b AND c != d"
}),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(json["changed"], true);
let fixed_sql = json["sql"].as_str().unwrap();
let compact: String = fixed_sql.chars().filter(|ch| !ch.is_whitespace()).collect();
let has_c_style = compact.contains("a!=b") && compact.contains("c!=d");
let has_ansi_style = compact.contains("a<>b") && compact.contains("c<>d");
assert!(
has_c_style || has_ansi_style,
"expected CV01 core autofix to normalize not-equal style: {fixed_sql}"
);
}
#[tokio::test]
async fn lint_fix_applies_cv005_core_autofix_in_patch_mode() {
let state = test_state(default_config(), vec![]);
let app = build_router(state, 3000);
let (status, json) = post_json(
&app,
"/api/lint-fix",
json!({
"sql": "SELECT * FROM t WHERE a = NULL AND b <> NULL"
}),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(json["changed"], true);
let fixed_sql = json["sql"].as_str().unwrap();
assert!(
fixed_sql.contains("a IS NULL"),
"expected CV05 core autofix to rewrite '= NULL': {fixed_sql}"
);
assert!(
fixed_sql.contains("b IS NOT NULL"),
"expected CV05 core autofix to rewrite '<> NULL': {fixed_sql}"
);
}
#[tokio::test]
async fn lint_fix_applies_cv007_core_autofix_in_patch_mode() {
let state = test_state(default_config(), vec![]);
let app = build_router(state, 3000);
let (status, json) = post_json(
&app,
"/api/lint-fix",
json!({
"sql": "(SELECT 1)"
}),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(json["changed"], true);
assert_eq!(
json["sql"].as_str().unwrap(),
"SELECT 1\n",
"expected CV07 core autofix to remove outer wrapper brackets"
);
}
#[tokio::test]
async fn lint_fix_applies_lt006_core_autofix_in_patch_mode() {
let state = test_state(default_config(), vec![]);
let app = build_router(state, 3000);
let (status, json) = post_json(
&app,
"/api/lint-fix",
json!({
"sql": "SELECT COUNT (1) FROM t"
}),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(json["changed"], true);
let fixed_sql = json["sql"].as_str().unwrap();
assert!(
fixed_sql.contains("COUNT("),
"expected LT06 core autofix to keep function call parenthesis tight: {fixed_sql}"
);
assert!(
!fixed_sql.contains("COUNT ("),
"expected LT06 core autofix to remove spacing before function parenthesis: {fixed_sql}"
);
}
#[tokio::test]
async fn lint_fix_applies_lt005_core_autofix_in_patch_mode_with_config() {
let state = test_state(default_config(), vec![]);
let app = build_router(state, 3000);
let sql = format!("SELECT {} FROM t\n", vec!["column_name"; 60].join(" "));
let (status, json) = post_json(
&app,
"/api/lint-fix",
json!({
"sql": sql,
"rule_configs": {
"layout.long_lines": {
"max_line_length": 300
}
}
}),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(json["changed"], true);
let fixed_sql = json["sql"].as_str().unwrap();
assert!(
fixed_sql.lines().all(|line| line.len() <= 300),
"expected LT005 core autofix output lines to stay under configured threshold: {fixed_sql:?}"
);
assert!(
fixed_sql.lines().count() > 1,
"expected LT005 core autofix to split an extremely long line: {fixed_sql:?}"
);
}
#[tokio::test]
async fn lint_fix_applies_al005_core_autofix_in_patch_mode() {
let state = test_state(default_config(), vec![]);
let app = build_router(state, 3000);
let (status, json) = post_json(
&app,
"/api/lint-fix",
json!({
"sql": "SELECT users.name FROM users AS u JOIN orders AS o ON users.id = orders.user_id\n",
"disabled_rules": ["LINT_AM_005"]
}),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(json["changed"], true);
assert_eq!(
json["sql"].as_str().unwrap(),
"SELECT users.name FROM users JOIN orders ON users.id = orders.user_id\n",
"expected AL005 core autofix to remove unused table aliases"
);
}
#[tokio::test]
async fn lint_fix_applies_al001_core_autofix_in_patch_mode() {
let state = test_state(default_config(), vec![]);
let app = build_router(state, 3000);
let (status, json) = post_json(
&app,
"/api/lint-fix",
json!({
"sql": "SELECT u.id FROM users u\n"
}),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(json["changed"], true);
assert_eq!(
json["sql"].as_str().unwrap(),
"SELECT u.id FROM users AS u\n",
"expected AL001 core autofix to insert explicit AS for table aliases"
);
}
#[tokio::test]
async fn lint_fix_applies_al009_core_autofix_in_patch_mode() {
let state = test_state(default_config(), vec![]);
let app = build_router(state, 3000);
let (status, json) = post_json(
&app,
"/api/lint-fix",
json!({
"sql": "SELECT a AS a FROM t\n"
}),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(json["changed"], true);
assert_eq!(
json["sql"].as_str().unwrap(),
"SELECT a FROM t\n",
"expected AL009 core autofix to remove self-aliasing projection alias"
);
}
#[tokio::test]
async fn lint_fix_applies_st001_core_autofix_in_patch_mode() {
let state = test_state(default_config(), vec![]);
let app = build_router(state, 3000);
let (status, json) = post_json(
&app,
"/api/lint-fix",
json!({
"sql": "SELECT CASE WHEN x > 1 THEN 'a' ELSE NULL END FROM t\n"
}),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(json["changed"], true);
assert_eq!(
json["sql"].as_str().unwrap(),
"SELECT CASE WHEN x > 1 THEN 'a' END FROM t\n",
"expected ST001 core autofix to remove redundant ELSE NULL branch"
);
}
#[tokio::test]
async fn lint_fix_applies_am001_core_autofix_in_patch_mode() {
let state = test_state(default_config(), vec![]);
let app = build_router(state, 3000);
let (status, json) = post_json(
&app,
"/api/lint-fix",
json!({
"sql": "SELECT DISTINCT a FROM t GROUP BY a\n",
"disabled_rules": ["LINT_LT_014"]
}),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(json["changed"], true);
assert_eq!(
json["sql"].as_str().unwrap(),
"SELECT a FROM t GROUP BY a\n",
"expected AM001 core autofix to remove DISTINCT when GROUP BY is present"
);
}
#[tokio::test]
async fn lint_fix_applies_am002_core_autofix_in_patch_mode() {
let state = test_state(default_config(), vec![]);
let app = build_router(state, 3000);
let (status, json) = post_json(
&app,
"/api/lint-fix",
json!({
"sql": "SELECT 1 UNION SELECT 2\n",
"disabled_rules": ["LINT_LT_011"]
}),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(json["changed"], true);
assert_eq!(
json["sql"].as_str().unwrap(),
"SELECT 1 UNION DISTINCT SELECT 2\n",
"expected AM002 core autofix to expand bare UNION to explicit UNION DISTINCT"
);
}
#[tokio::test]
async fn lint_fix_applies_am003_core_autofix_in_patch_mode() {
let state = test_state(default_config(), vec![]);
let app = build_router(state, 3000);
let (status, json) = post_json(
&app,
"/api/lint-fix",
json!({
"sql": "SELECT * FROM t ORDER BY a DESC, b NULLS LAST\n",
"disabled_rules": ["LINT_LT_014"]
}),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(json["changed"], true);
assert_eq!(
json["sql"].as_str().unwrap(),
"SELECT * FROM t ORDER BY a DESC, b ASC NULLS LAST\n",
"expected AM003 core autofix to add ASC to implicit ORDER BY terms in mixed clauses"
);
}
#[tokio::test]
async fn lint_fix_applies_am005_core_autofix_in_patch_mode() {
let state = test_state(default_config(), vec![]);
let app = build_router(state, 3000);
let (status, json) = post_json(
&app,
"/api/lint-fix",
json!({
"sql": "SELECT a FROM t JOIN u ON t.id = u.id\n"
}),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(json["changed"], true);
assert_eq!(
json["sql"].as_str().unwrap(),
"SELECT a FROM t INNER JOIN u ON t.id = u.id\n",
"expected AM005 core autofix to qualify bare JOIN with INNER"
);
}
#[tokio::test]
async fn lint_fix_applies_am008_core_autofix_in_patch_mode() {
let state = test_state(default_config(), vec![]);
let app = build_router(state, 3000);
let (status, json) = post_json(
&app,
"/api/lint-fix",
json!({
"sql": "SELECT foo.a, bar.b FROM foo INNER JOIN bar\n"
}),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(json["changed"], true);
assert_eq!(
json["sql"].as_str().unwrap(),
"SELECT\n foo.a,\n bar.b\nFROM foo CROSS JOIN bar\n",
"expected AM008 core autofix to rewrite conditionless INNER JOIN to CROSS JOIN"
);
}
#[tokio::test]
async fn lint_fix_applies_st006_core_autofix_in_patch_mode() {
let state = test_state(default_config(), vec![]);
let app = build_router(state, 3000);
let (status, json) = post_json(
&app,
"/api/lint-fix",
json!({
"sql": "SELECT a + 1, a FROM t\n"
}),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(json["changed"], true);
assert_eq!(
json["sql"].as_str().unwrap(),
"SELECT\n a,\n a + 1\nFROM t\n",
"expected ST006 core autofix to reorder simple projection targets before complex expressions"
);
}
#[tokio::test]
async fn lint_fix_applies_st009_core_autofix_in_patch_mode() {
let state = test_state(default_config(), vec![]);
let app = build_router(state, 3000);
let (status, json) = post_json(
&app,
"/api/lint-fix",
json!({
"sql": "SELECT foo.a, bar.b FROM foo LEFT JOIN bar ON bar.a = foo.a\n"
}),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(json["changed"], true);
assert_eq!(
json["sql"].as_str().unwrap(),
"SELECT\n foo.a,\n bar.b\nFROM foo LEFT JOIN bar ON foo.a = bar.a\n",
"expected ST009 core autofix to reorder join predicate source sides"
);
}
#[tokio::test]
async fn lint_fix_applies_st002_core_autofix_in_patch_mode() {
let state = test_state(default_config(), vec![]);
let app = build_router(state, 3000);
let (status, json) = post_json(
&app,
"/api/lint-fix",
json!({
"sql": "SELECT CASE WHEN x > 0 THEN true ELSE false END FROM t\n"
}),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(json["changed"], true);
assert_eq!(
json["sql"].as_str().unwrap(),
"SELECT coalesce(x > 0, false) FROM t\n",
"expected ST002 core autofix to rewrite unnecessary CASE to coalesce"
);
}
#[tokio::test]
async fn lint_fix_applies_st008_core_autofix_in_patch_mode() {
let state = test_state(default_config(), vec![]);
let app = build_router(state, 3000);
let (status, json) = post_json(
&app,
"/api/lint-fix",
json!({
"sql": "SELECT DISTINCT(a) FROM t\n"
}),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(json["changed"], true);
assert_eq!(
json["sql"].as_str().unwrap(),
"SELECT DISTINCT a FROM t\n",
"expected ST008 core autofix to remove DISTINCT parentheses"
);
}
#[tokio::test]
async fn lint_fix_applies_lt004_core_autofix_in_patch_mode() {
let state = test_state(default_config(), vec![]);
let app = build_router(state, 3000);
let (status, json) = post_json(
&app,
"/api/lint-fix",
json!({
"sql": "SELECT a,b FROM t\n"
}),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(json["changed"], true);
assert!(
!json["sql"].as_str().unwrap().contains("a,b"),
"expected LT004 core autofix to enforce spacing after comma"
);
}
#[tokio::test]
async fn lint_fix_applies_lt003_core_autofix_in_patch_mode() {
let state = test_state(default_config(), vec![]);
let app = build_router(state, 3000);
let (status, json) = post_json(
&app,
"/api/lint-fix",
json!({
"sql": "SELECT a +\n b FROM t\n"
}),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(json["changed"], true);
assert_eq!(
json["sql"].as_str().unwrap(),
"SELECT a\n + b FROM t\n",
"expected LT003 core autofix to move trailing operator to leading style"
);
}
#[tokio::test]
async fn lint_fix_applies_lt001_core_autofix_in_patch_mode() {
let state = test_state(default_config(), vec![]);
let app = build_router(state, 3000);
let (status, json) = post_json(
&app,
"/api/lint-fix",
json!({
"sql": "SELECT payload->>'id' FROM t\n"
}),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(json["changed"], true);
assert_eq!(
json["sql"].as_str().unwrap(),
"SELECT payload ->> 'id' FROM t\n",
"expected LT001 core autofix to normalize json arrow spacing"
);
}
#[tokio::test]
async fn lint_fix_applies_lt002_core_autofix_in_patch_mode() {
let state = test_state(default_config(), vec![]);
let app = build_router(state, 3000);
let (status, json) = post_json(
&app,
"/api/lint-fix",
json!({
"sql": "SELECT 1\n -- comment\nFROM t\n"
}),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(json["changed"], false);
assert_eq!(
json["sql"].as_str().unwrap(),
"SELECT 1\n -- comment\nFROM t\n",
"expected LT002 core autofix to normalize comment-line indentation"
);
}
#[tokio::test]
async fn lint_fix_applies_tq003_core_autofix_in_patch_mode() {
let mut config = default_config();
config.dialect = Dialect::Mssql;
let state = test_state(config, vec![]);
let app = build_router(state, 3000);
let (status, json) = post_json(
&app,
"/api/lint-fix",
json!({
"sql": "SELECT 1\nGO\nGO\nSELECT 2\n"
}),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(json["changed"], true);
assert_eq!(
json["sql"].as_str().unwrap(),
"SELECT 1\nGO\nSELECT 2\n",
"expected TQ003 core autofix to collapse redundant GO batch separators"
);
}
#[tokio::test]
async fn lint_fix_applies_tq002_core_autofix_in_patch_mode() {
let mut config = default_config();
config.dialect = Dialect::Mssql;
let state = test_state(config, vec![]);
let app = build_router(state, 3000);
let (status, json) = post_json(
&app,
"/api/lint-fix",
json!({
"sql": "CREATE PROCEDURE p AS SELECT 1; SELECT 2;\n"
}),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(json["changed"], true);
let fixed_sql = json["sql"].as_str().unwrap().to_ascii_uppercase();
assert!(
fixed_sql.contains(" AS BEGIN "),
"expected TQ002 core autofix to insert BEGIN: {fixed_sql}"
);
assert!(
fixed_sql.contains(" END"),
"expected TQ002 core autofix to insert END: {fixed_sql}"
);
}
#[tokio::test]
async fn lint_fix_applies_st005_core_autofix_in_unsafe_mode_with_from_config() {
let state = test_state(default_config(), vec![]);
let app = build_router(state, 3000);
let (status, json) = post_json(
&app,
"/api/lint-fix",
json!({
"sql": "SELECT * FROM (SELECT 1) sub\n",
"unsafe_fixes": true,
"rule_configs": {
"structure.subquery": {
"forbid_subquery_in": "from"
}
}
}),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(json["changed"], true);
assert_eq!(
json["sql"].as_str().unwrap(),
"WITH sub AS (SELECT 1)\n\nSELECT * FROM sub\n",
"expected unsafe ST005 core autofix to rewrite FROM subquery to CTE"
);
}
#[tokio::test]
async fn lint_fix_applies_cp001_core_autofix_in_patch_mode() {
let state = test_state(default_config(), vec![]);
let app = build_router(state, 3000);
let (status, json) = post_json(
&app,
"/api/lint-fix",
json!({
"sql": "SELECT a from t\n"
}),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(json["changed"], true);
assert_eq!(
json["sql"].as_str().unwrap(),
"SELECT a FROM t\n",
"expected CP001 core autofix to normalize keyword capitalisation"
);
}
#[tokio::test]
async fn lint_fix_applies_cp003_core_autofix_in_patch_mode() {
let state = test_state(default_config(), vec![]);
let app = build_router(state, 3000);
let (status, json) = post_json(
&app,
"/api/lint-fix",
json!({
"sql": "SELECT COUNT(*), sum(a) FROM t\n"
}),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(json["changed"], true);
assert_eq!(
json["sql"].as_str().unwrap(),
"SELECT\n COUNT(*),\n SUM(a)\nFROM t\n",
"expected CP003 core autofix to normalize function capitalisation"
);
}
#[tokio::test]
async fn lint_fix_applies_cp002_core_autofix_in_patch_mode() {
let state = test_state(default_config(), vec![]);
let app = build_router(state, 3000);
let (status, json) = post_json(
&app,
"/api/lint-fix",
json!({
"sql": "SELECT Col, col FROM t\n"
}),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(json["changed"], true);
assert_eq!(
json["sql"].as_str().unwrap(),
"SELECT\n Col,\n Col\nFROM T\n",
"expected CP002 core autofix to normalize identifier capitalisation"
);
}
#[tokio::test]
async fn lint_fix_applies_cp004_core_autofix_in_patch_mode() {
let state = test_state(default_config(), vec![]);
let app = build_router(state, 3000);
let (status, json) = post_json(
&app,
"/api/lint-fix",
json!({
"sql": "SELECT NULL, true FROM t\n"
}),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(json["changed"], true);
assert_eq!(
json["sql"].as_str().unwrap(),
"SELECT\n NULL,\n TRUE\nFROM t\n",
"expected CP004 core autofix to normalize literal capitalisation"
);
}
#[tokio::test]
async fn lint_fix_applies_cp005_core_autofix_in_patch_mode() {
let state = test_state(default_config(), vec![]);
let app = build_router(state, 3000);
let (status, json) = post_json(
&app,
"/api/lint-fix",
json!({
"sql": "CREATE TABLE t (a INT, b varchar(10))\n"
}),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(json["changed"], true);
assert_eq!(
json["sql"].as_str().unwrap(),
"CREATE TABLE t (a INT, b VARCHAR(10))\n",
"expected CP005 core autofix to normalize type capitalisation"
);
}
#[tokio::test]
async fn lint_fix_applies_cv010_core_autofix_in_patch_mode() {
let state = test_state(default_config(), vec![]);
let app = build_router(state, 3000);
let (status, json) = post_json(
&app,
"/api/lint-fix",
json!({
"sql": "SELECT 'abc', \"def\"\n",
"dialect": "Bigquery",
"unsafe_fixes": true
}),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(json["changed"], true);
let fixed = json["sql"].as_str().unwrap();
assert!(
!fixed.contains("\"def\""),
"expected CV010 core autofix to convert double-quoted string to single-quoted: {fixed}"
);
}
#[tokio::test]
async fn lint_fix_applies_rf004_core_autofix_in_patch_mode() {
let state = test_state(default_config(), vec![]);
let app = build_router(state, 3000);
let (status, json) = post_json(
&app,
"/api/lint-fix",
json!({
"sql": "select a from users as select\n"
}),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(json["changed"], true);
let fixed = json["sql"].as_str().unwrap();
assert!(
!fixed.contains(" as select") && !fixed.contains(" AS select"),
"expected RF004 core autofix to eliminate keyword table alias: {fixed}"
);
}
#[tokio::test]
async fn lint_fix_applies_rf003_core_autofix_in_patch_mode() {
let state = test_state(default_config(), vec![]);
let app = build_router(state, 3000);
let (status, json) = post_json(
&app,
"/api/lint-fix",
json!({
"sql": "select a.id, id2 from a\n"
}),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(json["changed"], true);
assert_eq!(
json["sql"].as_str().unwrap(),
"select\n a.id,\n a.id2\nfrom a\n",
"expected RF003 core autofix to qualify unqualified references consistently"
);
}
#[tokio::test]
async fn lint_fix_applies_rf006_core_autofix_in_patch_mode() {
let state = test_state(default_config(), vec![]);
let app = build_router(state, 3000);
let (status, json) = post_json(
&app,
"/api/lint-fix",
json!({
"sql": "SELECT \"good_name\" FROM t\n"
}),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(json["changed"], true);
assert_eq!(
json["sql"].as_str().unwrap(),
"SELECT good_name FROM t\n",
"expected RF006 core autofix to unquote safe identifiers"
);
}
#[tokio::test]
async fn lint_fix_applies_cv003_core_autofix_in_patch_mode() {
let state = test_state(default_config(), vec![]);
let app = build_router(state, 3000);
let (status, json) = post_json(
&app,
"/api/lint-fix",
json!({
"sql": "SELECT a, FROM t"
}),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(json["changed"], true);
assert_eq!(
json["sql"].as_str().unwrap(),
"SELECT a FROM t\n",
"expected CV003 core autofix to remove trailing comma"
);
}
#[tokio::test]
async fn lint_fix_rule_config_enables_cv003_require_core_autofix() {
let state = test_state(default_config(), vec![]);
let app = build_router(state, 3000);
let (status, json) = post_json(
&app,
"/api/lint-fix",
json!({
"sql": "SELECT a FROM t",
"rule_configs": {
"convention.select_trailing_comma": {
"select_clause_trailing_comma": "require"
}
}
}),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(json["changed"], true);
assert_eq!(
json["sql"].as_str().unwrap(),
"SELECT a, FROM t\n",
"expected CV003 require-mode core autofix to insert trailing comma"
);
}
#[tokio::test]
async fn lint_fix_applies_st012_core_autofix_in_patch_mode() {
let state = test_state(default_config(), vec![]);
let app = build_router(state, 3000);
let (status, json) = post_json(
&app,
"/api/lint-fix",
json!({
"sql": "SELECT 1;;"
}),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(json["changed"], true);
assert_eq!(
json["sql"].as_str().unwrap(),
"SELECT 1;",
"expected ST012 core autofix to collapse consecutive semicolons"
);
}
#[tokio::test]
async fn lint_fix_applies_lt012_core_autofix_in_patch_mode() {
let state = test_state(default_config(), vec![]);
let app = build_router(state, 3000);
let (status, json) = post_json(
&app,
"/api/lint-fix",
json!({
"sql": "SELECT 1\nFROM t"
}),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(json["changed"], true);
assert_eq!(
json["sql"].as_str().unwrap(),
"SELECT 1\nFROM t\n",
"expected LT012 core autofix to enforce exactly one trailing newline"
);
}
#[tokio::test]
async fn lint_fix_applies_lt013_core_autofix_in_patch_mode() {
let state = test_state(default_config(), vec![]);
let app = build_router(state, 3000);
let (status, json) = post_json(
&app,
"/api/lint-fix",
json!({
"sql": "\n\nSELECT 1"
}),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(json["changed"], true);
let fixed_sql = json["sql"].as_str().unwrap();
assert!(
fixed_sql.starts_with("SELECT 1"),
"expected LT013 core autofix to remove leading blank lines: {fixed_sql:?}"
);
assert!(
!fixed_sql.starts_with('\n'),
"expected LT013 output to start with SQL text: {fixed_sql:?}"
);
}
#[tokio::test]
async fn lint_fix_applies_lt014_core_autofix_in_patch_mode() {
let state = test_state(default_config(), vec![]);
let app = build_router(state, 3000);
let (status, json) = post_json(
&app,
"/api/lint-fix",
json!({
"sql": "SELECT a FROM t\nWHERE a = 1"
}),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(json["changed"], true);
let fixed_sql = json["sql"].as_str().unwrap();
assert!(
fixed_sql.contains("\nFROM t"),
"expected LT014 core autofix to line-break major clause keyword: {fixed_sql:?}"
);
}
#[tokio::test]
async fn lint_fix_applies_lt010_core_autofix_in_patch_mode() {
let state = test_state(default_config(), vec![]);
let app = build_router(state, 3000);
let (status, json) = post_json(
&app,
"/api/lint-fix",
json!({
"sql": "SELECT\nDISTINCT a\nFROM t"
}),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(json["changed"], true);
assert_eq!(
json["sql"].as_str().unwrap(),
"SELECT DISTINCT a\nFROM t\n",
"expected LT010 core autofix to place DISTINCT on SELECT line"
);
}
#[tokio::test]
async fn lint_fix_applies_lt011_core_autofix_in_patch_mode() {
let state = test_state(default_config(), vec![]);
let app = build_router(state, 3000);
let (status, json) = post_json(
&app,
"/api/lint-fix",
json!({
"sql": "SELECT 1 UNION SELECT 2\nUNION SELECT 3",
"disabled_rules": ["LINT_AM_002"]
}),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(json["changed"], true);
assert_eq!(
json["sql"].as_str().unwrap(),
"SELECT 1\nUNION\nSELECT 2\nUNION\nSELECT 3\n",
"expected LT011 core autofix to put set operators on their own line"
);
}
#[tokio::test]
async fn lint_fix_applies_lt007_core_autofix_in_patch_mode() {
let state = test_state(default_config(), vec![]);
let app = build_router(state, 3000);
let (status, json) = post_json(
&app,
"/api/lint-fix",
json!({
"sql": "WITH cte AS (\n SELECT 1)\nSELECT * FROM cte"
}),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(json["changed"], true);
assert_eq!(
json["sql"].as_str().unwrap(),
"WITH cte AS (\n SELECT 1\n)\n\nSELECT * FROM cte\n",
"expected LT007 core autofix output in patch mode"
);
}
#[tokio::test]
async fn lint_fix_applies_lt009_core_autofix_in_patch_mode() {
let state = test_state(default_config(), vec![]);
let app = build_router(state, 3000);
let (status, json) = post_json(
&app,
"/api/lint-fix",
json!({
"sql": "select\n a,\n b,\n c from x",
"disabled_rules": ["LINT_LT_014"]
}),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(json["changed"], true);
assert_eq!(
json["sql"].as_str().unwrap(),
"select\n a,\n b,\n c\nfrom x\n",
"expected LT009 core autofix to place FROM on a new line after final target"
);
}
#[tokio::test]
async fn lint_fix_applies_lt008_core_autofix_in_patch_mode() {
let state = test_state(default_config(), vec![]);
let app = build_router(state, 3000);
let (status, json) = post_json(
&app,
"/api/lint-fix",
json!({
"sql": "WITH cte AS (SELECT 1) SELECT * FROM cte"
}),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(json["changed"], true);
assert_eq!(
json["sql"].as_str().unwrap(),
"WITH cte AS (SELECT 1)\n\nSELECT * FROM cte\n",
"expected LT008 core autofix to place SELECT on a new line after CTE close"
);
}
#[tokio::test]
async fn lint_fix_applies_lt015_core_autofix_in_patch_mode() {
let state = test_state(default_config(), vec![]);
let app = build_router(state, 3000);
let (status, json) = post_json(
&app,
"/api/lint-fix",
json!({
"sql": "SELECT 1\n\n\nFROM t"
}),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(json["changed"], true);
assert_eq!(
json["sql"].as_str().unwrap(),
"SELECT 1\n\nFROM t\n",
"expected LT015 core autofix to collapse excessive blank lines"
);
}
#[tokio::test]
async fn lint_fix_applies_jj001_core_autofix_only_in_unsafe_mode() {
let state = test_state(default_config(), vec![]);
let app = build_router(state, 3000);
let sql = "SELECT '{{foo}}' AS templated\n";
let (safe_status, safe_json) = post_json(
&app,
"/api/lint-fix",
json!({
"sql": sql,
"unsafe_fixes": false
}),
)
.await;
let (unsafe_status, unsafe_json) = post_json(
&app,
"/api/lint-fix",
json!({
"sql": sql,
"unsafe_fixes": true
}),
)
.await;
assert_eq!(safe_status, StatusCode::OK);
assert_eq!(unsafe_status, StatusCode::OK);
assert_eq!(
safe_json["sql"].as_str().unwrap(),
sql,
"safe mode should keep JJ001 template edits blocked by protected ranges"
);
assert!(
unsafe_json["sql"].as_str().unwrap().contains("{{ foo }}"),
"unsafe mode should apply JJ001 core autofix: {}",
unsafe_json["sql"].as_str().unwrap()
);
}
#[tokio::test]
async fn lint_fix_safe_vs_unsafe_mode_shows_expected_delta() {
let state = test_state(default_config(), vec![]);
let app = build_router(state, 3000);
let sql = SQL_UNSAFE_FIX_REPRESENTATIVE;
let (safe_status, safe_json) = post_json(
&app,
"/api/lint-fix",
json!({
"sql": sql,
"unsafe_fixes": false,
"legacy_ast_fixes": true
}),
)
.await;
let (unsafe_status, unsafe_json) = post_json(
&app,
"/api/lint-fix",
json!({
"sql": sql,
"unsafe_fixes": true,
"legacy_ast_fixes": true
}),
)
.await;
assert_eq!(safe_status, StatusCode::OK);
assert_eq!(unsafe_status, StatusCode::OK);
let safe_sql = safe_json["sql"].as_str().unwrap().to_ascii_uppercase();
let unsafe_sql = unsafe_json["sql"].as_str().unwrap().to_ascii_uppercase();
assert!(
!safe_sql.starts_with("WITH "),
"safe mode should not apply ST_005 rewrite: {safe_sql}"
);
assert!(
unsafe_sql.starts_with("WITH "),
"unsafe mode with legacy AST rewrites should apply ST_005 rewrite: {unsafe_sql}"
);
assert!(
safe_json["skipped_counts"]["unsafe_skipped"]
.as_u64()
.unwrap()
> 0
);
assert_eq!(unsafe_json["skipped_counts"]["unsafe_skipped"], 0);
}
#[tokio::test]
async fn lint_fix_unsafe_without_legacy_ast_rewrites_keeps_st05_shape() {
let state = test_state(default_config(), vec![]);
let app = build_router(state, 3000);
let sql = SQL_UNSAFE_FIX_REPRESENTATIVE;
let (status, json) = post_json(
&app,
"/api/lint-fix",
json!({
"sql": sql,
"unsafe_fixes": true
}),
)
.await;
assert_eq!(status, StatusCode::OK);
let fixed_sql = json["sql"].as_str().unwrap().to_ascii_uppercase();
assert!(
!fixed_sql.contains("WITH SUB AS"),
"unsafe patch mode without legacy AST rewrites should not emit ST_005 CTE rewrite: {fixed_sql}"
);
}
#[tokio::test]
async fn lint_fix_preserves_comments_while_fixing_non_comment_regions() {
let state = test_state(default_config(), vec![]);
let app = build_router(state, 3000);
let (status, json) = post_json(
&app,
"/api/lint-fix",
json!({
"sql": "-- keep this comment\nSELECT COUNT (1) FROM t"
}),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(json["skipped_due_to_comments"], false);
let fixed_sql = json["sql"].as_str().unwrap();
assert!(
fixed_sql.contains("-- keep this comment"),
"comment must be preserved: {fixed_sql}"
);
assert!(
fixed_sql.to_ascii_uppercase().contains("COUNT (*)")
|| fixed_sql.to_ascii_uppercase().contains("COUNT(*)"),
"non-comment fix should still apply: {fixed_sql}"
);
}
#[tokio::test]
async fn lint_fix_respects_disabled_rules() {
let state = test_state(default_config(), vec![]);
let app = build_router(state, 3000);
let (status, json) = post_json(
&app,
"/api/lint-fix",
json!({
"sql": SQL_UNSAFE_FIX_REPRESENTATIVE,
"unsafe_fixes": true,
"legacy_ast_fixes": true,
"disabled_rules": ["LINT_ST_005"]
}),
)
.await;
assert_eq!(status, StatusCode::OK);
assert!(!json["sql"]
.as_str()
.unwrap()
.to_ascii_uppercase()
.contains("WITH SUB AS"));
assert_eq!(json["skipped_counts"]["unsafe_skipped"], 0);
}
#[tokio::test]
async fn lint_fix_rejects_non_object_rule_config_entries() {
let state = test_state(default_config(), vec![]);
let app = build_router(state, 3000);
let response = app
.oneshot(
Request::post("/api/lint-fix")
.header("content-type", "application/json")
.body(Body::from(
json!({
"sql": "SELECT 1",
"rule_configs": {
"structure.subquery": "both"
}
})
.to_string(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let message = String::from_utf8(body.to_vec()).unwrap();
assert!(
message.contains("'rule_configs' entry for 'structure.subquery' must be a JSON object"),
"unexpected error message: {message}"
);
}
#[tokio::test]
async fn files_returns_empty_when_no_files() {
let state = test_state(default_config(), vec![]);
let app = build_router(state, 3000);
let response = app
.oneshot(Request::get("/api/files").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let json: Value = serde_json::from_slice(&body).unwrap();
assert!(json.is_array());
assert!(json.as_array().unwrap().is_empty());
}
#[tokio::test]
async fn files_returns_loaded_files() {
let files = vec![
FileSource {
name: "queries.sql".to_string(),
content: "SELECT * FROM users".to_string(),
},
FileSource {
name: "reports/summary.sql".to_string(),
content: "SELECT COUNT(*) FROM orders".to_string(),
},
];
let state = test_state(default_config(), files);
let app = build_router(state, 3000);
let response = app
.oneshot(Request::get("/api/files").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let json: Value = serde_json::from_slice(&body).unwrap();
assert!(json.is_array());
assert_eq!(json.as_array().unwrap().len(), 2);
let first = &json[0];
assert_eq!(first["name"], "queries.sql");
assert_eq!(first["content"], "SELECT * FROM users");
}
#[tokio::test]
async fn schema_returns_null_when_no_schema() {
let state = test_state(default_config(), vec![]);
let app = build_router(state, 3000);
let response = app
.oneshot(Request::get("/api/schema").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let json: Value = serde_json::from_slice(&body).unwrap();
assert!(json.is_null());
}
#[tokio::test]
async fn config_returns_server_configuration() {
let config = ServerConfig {
dialect: Dialect::Postgres,
watch_dirs: vec![PathBuf::from("/tmp/sql")],
static_files: None,
metadata_url: None,
metadata_schema: None,
port: 8080,
open_browser: false,
schema_path: None,
#[cfg(feature = "templating")]
template_config: None,
};
let state = test_state(config, vec![]);
let app = build_router(state, 3000);
let response = app
.oneshot(Request::get("/api/config").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let json: Value = serde_json::from_slice(&body).unwrap();
assert_eq!(json["dialect"], "Postgres");
assert!(json["watch_dirs"].is_array());
assert_eq!(json["has_schema"], false);
}
#[tokio::test]
async fn export_json_format() {
let state = test_state(default_config(), vec![]);
let app = build_router(state, 3000);
let response = app
.oneshot(
Request::post("/api/export/json")
.header("content-type", "application/json")
.body(Body::from(
json!({
"sql": "SELECT id FROM users"
})
.to_string(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response.headers().get("content-type").unwrap(),
"application/json"
);
}
#[tokio::test]
async fn export_mermaid_format() {
let state = test_state(default_config(), vec![]);
let app = build_router(state, 3000);
let response = app
.oneshot(
Request::post("/api/export/mermaid")
.header("content-type", "application/json")
.body(Body::from(
json!({
"sql": "SELECT id FROM users"
})
.to_string(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response.headers().get("content-type").unwrap(),
"text/plain"
);
}
#[tokio::test]
async fn export_unknown_format_returns_error() {
let state = test_state(default_config(), vec![]);
let app = build_router(state, 3000);
let response = app
.oneshot(
Request::post("/api/export/unknown")
.header("content-type", "application/json")
.body(Body::from(
json!({
"sql": "SELECT id FROM users"
})
.to_string(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}