Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
0ab5dc6
wip(format): lay out Snowflake and DuckDB with SQLQuack's formatter
kvlonge Nov 7, 2026
63c4b4a
wip(format): gate Snowflake and DuckDB on SQLQuack's layout, and drop…
kvlonge Nov 7, 2026
7c7abfc
wip(lint): lint and collect statement facts with SQLQuack instead of …
kvlonge Nov 7, 2026
6c77914
wip(format): lay out around CTE macro placeholders, and fingerprint w…
kvlonge Nov 7, 2026
9d10096
wip(facts): keep Snowflake date parts out of the column facts
kvlonge Nov 7, 2026
868b6ef
Merge the SQLQuack lint port into the Polyglot-free formatter
kvlonge Nov 7, 2026
66af5d5
wip(facts): report facts as unavailable for a dialect SQLQuack does n…
kvlonge Nov 7, 2026
3800b96
wip(facts): read Postgres facts as written, and pin SQLQuack with the…
kvlonge Nov 7, 2026
65b28ba
wip(tokens): fingerprint with sqlquack-fingerprint, as SQLBuild does
kvlonge Nov 7, 2026
3bb3282
Merge the lint port's Postgres slice and unavailable facts, pinning S…
kvlonge Nov 7, 2026
4c54330
wip(format): report formatting unavailable where SQLQuack has no form…
kvlonge Nov 7, 2026
8d6499c
wip(tokens): fold SQL tokens with SQLQuack's rules instead of Polyglo…
kvlonge Nov 7, 2026
3c3b182
wip(cli): report files in a dialect SQLQuack does not parse instead o…
kvlonge Nov 8, 2026
89073b6
wip(format): name every fingerprint and unavailable-corpus test case,…
kvlonge Nov 8, 2026
e5ca8cb
wip(facts): name utility and DROP statement kinds from the native tree
kvlonge Nov 8, 2026
791c93b
wip(cli): read SQL without a leading byte order mark and write it back
kvlonge Nov 8, 2026
b325490
wip(facts): leave the SQLQuack pin to the release head
kvlonge Nov 8, 2026
69e40bf
Merge the native statement kinds of sql_facts
kvlonge Nov 8, 2026
9f160bc
wip(deps): trial pin of SQLQuack at the train tip 19c51fa2
kvlonge Nov 8, 2026
1c7d915
wip(format): pass SQLQuack its formatter dialect
kvlonge Nov 8, 2026
338e82d
wip(format): lay out T-SQL with SQLQuack's SQL Server formatter
kvlonge Nov 8, 2026
44e0a9e
fix(facts): report comma and ON-nested joins as Polyglot did
kvlonge Nov 8, 2026
1226f5d
Merge the native facts' join parity
kvlonge Nov 8, 2026
f3e56a7
fix(facts): collect joins in plain loops and assert the expected join…
kvlonge Nov 8, 2026
2905aae
Merge the fensu fixes of the facts' join parity
kvlonge Nov 8, 2026
3020a07
build(deps): pin SQLQuack at main edfa30b2
kvlonge Nov 8, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 78 additions & 44 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

16 changes: 5 additions & 11 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,17 +21,6 @@ include_dir = "0.7"
ignore = "0.4"
tempfile = "3"
toml = "0.9"
# The same Polyglot source and revision as SQLBuild, so a build that contains both compiles it once.
polyglot-sql = { git = "https://github.com/sqlstack-io/polyglot.git", rev = "8767d688c5b504ad609645e27ab77a260d91c5a1", default-features = false, features = [
"ast-tools",
"builder",
"all-dialects",
"semantic",
"stacker",
] }
polyglot-sql-function-catalogs = { git = "https://github.com/sqlstack-io/polyglot.git", rev = "8767d688c5b504ad609645e27ab77a260d91c5a1", default-features = false, features = [
"dialect-duckdb",
] }
rayon = "1"
regex = "1"
rustc-hash = "2"
Expand All @@ -40,6 +29,11 @@ serde_json = { version = "1", features = ["preserve_order"] }
sha2 = "0.10"
similar = "2"
sqlclean = { path = "crates/sqlclean" }
# SQLQuack's formatter and lint engine, and the tokens and trees they read, which replace Polyglot.
sqlquack-fingerprint = { git = "https://github.com/sqlstack-io/sqlquack.git", rev = "edfa30b26a0bab86661f96490b86a4f24b92db0a" }
sqlquack-format = { git = "https://github.com/sqlstack-io/sqlquack.git", rev = "edfa30b26a0bab86661f96490b86a4f24b92db0a" }
sqlquack-lint = { git = "https://github.com/sqlstack-io/sqlquack.git", rev = "edfa30b26a0bab86661f96490b86a4f24b92db0a" }
sqlquack-syntax = { git = "https://github.com/sqlstack-io/sqlquack.git", rev = "edfa30b26a0bab86661f96490b86a4f24b92db0a" }

[workspace.lints.rust]
unsafe_code = "forbid"
Expand Down
9 changes: 9 additions & 0 deletions crates/sqlclean-cli/skill/references/docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,15 @@ SELECT [order id] FROM orders
error[SQLCLEAN999]: SQL could not be parsed as duckdb: Parse error at line 1, column 17: Expected RBracket, got Var ('id') near [SELECT [order id] FROM orders]
```

SQLClean reads SQL with SQLQuack's parsers. A file in a dialect SQLQuack does not parse yet, such
as `mysql`, is not checked, fixed or formatted: `check` reports it once as a warning (in JSON, under
`unavailable`), and it never fails a command or counts as a problem.

```text
warning: SQL lint is unavailable for the mysql dialect: SQLQuack does not parse it yet
--> reports/q.sql
```

Supported dialects, case-insensitive:

| Dialect | Also accepted |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,10 +79,6 @@ One statement of a file, without its terminating semicolon.

Each tuple is in source order and includes nodes inside CTEs and subqueries.

`statement.polyglot_ast()` returns the full Polyglot syntax tree of the statement, parsed on first
use. It needs the `ast` extra: `pip install "sqlclean[ast]"`. Without it, the call fails with
`polyglot_ast() needs the Polyglot SQL package: install the ast extra: pip install 'sqlclean[ast]'`.

## Cte

A common table expression, located at its name.
Expand Down
17 changes: 14 additions & 3 deletions crates/sqlclean-cli/src/checking/_helpers/builtin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,19 @@ use std::collections::BTreeSet;
use sqlclean::sql_lint::constants::LINT_API_VERSION;
use sqlclean::sql_lint::main::fill_finding_path::fill_finding_path;
use sqlclean::sql_lint::main::lint::lint;
use sqlclean::sql_lint::models::{LintDiagnostic, LintRequest};
use sqlclean::sql_lint::types::{LintDiagnostic, LintRequest};

use crate::checking::constants::{
PARSE_ERROR_CODE, PARSE_ERROR_MESSAGE, PARSE_ERROR_REMEDIATION, UNSAFE_FIX_BUILTIN_RULES,
PARSE_ERROR_CODE, PARSE_ERROR_MESSAGE, PARSE_ERROR_REMEDIATION, UNAVAILABLE_CODE,
UNSAFE_FIX_BUILTIN_RULES,
};
use crate::checking::models::{Finding, FixSafety};
use crate::config::constants::CONFIG_FILE_NAME;
use crate::config::models::Thresholds;
use crate::discovery::models::SourceFile;
use crate::positions::models::Edit;

/// The built-in findings of `file`, or one parse-error finding when the engine rejects it.
/// The built-in findings of `file`, or its one parse-error finding or unavailable-dialect marker.
pub(crate) fn builtin_findings(
file: &SourceFile,
codes: &BTreeSet<String>,
Expand All @@ -33,6 +34,16 @@ pub(crate) fn builtin_findings(
..LintRequest::default()
};
match lint(request) {
Ok(response) if response.unavailable.is_some() => vec![Finding {
code: UNAVAILABLE_CODE.to_owned(),
path: file.path.clone(),
start: 0,
end: 0,
message: response.unavailable.unwrap_or_default(),
remediation: String::new(),
edits: Vec::new(),
fix: None,
}],
Ok(response) => response
.diagnostics
.into_iter()
Expand Down
6 changes: 5 additions & 1 deletion crates/sqlclean-cli/src/checking/_helpers/custom.rs
Original file line number Diff line number Diff line change
Expand Up @@ -189,11 +189,15 @@ fn file_key(
}

fn host_file(file: &SourceFile, rules: Vec<String>) -> Result<HostFile, String> {
let facts = file_facts(&file.text, &file.dialect)?;
if let Some(reason) = facts.unavailable {
return Err(reason);
}
Ok(HostFile {
path: file.path.clone(),
source: file.text.clone(),
dialect: file.dialect.clone(),
facts: file_facts(&file.text, &file.dialect)?,
facts,
rules,
})
}
Expand Down
18 changes: 15 additions & 3 deletions crates/sqlclean-cli/src/checking/_helpers/orchestration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ use crate::checking::_helpers::catalogue::{builtin_rules, custom_rules, custom_s
use crate::checking::_helpers::custom::{CustomEvaluation, custom_findings};
use crate::checking::_helpers::exemptions::apply_exemptions;
use crate::checking::_helpers::selection::selected_codes;
use crate::checking::constants::{CUSTOM_RULE_PREFIX, PARSE_ERROR_CODE};
use crate::checking::models::{CheckOptions, CheckOutcome, Finding, RuleInfo};
use crate::checking::constants::{CUSTOM_RULE_PREFIX, PARSE_ERROR_CODE, UNAVAILABLE_CODE};
use crate::checking::models::{CheckOptions, CheckOutcome, Finding, RuleInfo, UnavailableFile};
use crate::config::models::Config;
use crate::discovery::models::SourceFile;

Expand Down Expand Up @@ -49,7 +49,7 @@ pub(crate) fn check_sources(
.cloned()
.collect();
let selected: BTreeSet<String> = selected_codes(&all, select, &ignore)?;
let (mut findings, updated) = run_builtins(
let (builtin, updated) = run_builtins(
&BuiltinRun {
config,
files,
Expand All @@ -59,10 +59,21 @@ pub(crate) fn check_sources(
cache,
);
let mut cache: ResultCache = updated;
let (markers, mut findings): (Vec<Finding>, Vec<Finding>) = builtin
.into_iter()
.partition(|finding| finding.code == UNAVAILABLE_CODE);
let unavailable: Vec<UnavailableFile> = markers
.into_iter()
.map(|marker| UnavailableFile {
path: marker.path,
reason: marker.message,
})
.collect();
let unparsed: HashSet<&str> = findings
.iter()
.filter(|finding| finding.code == PARSE_ERROR_CODE)
.map(|finding| finding.path.as_str())
.chain(unavailable.iter().map(|file| file.path.as_str()))
.collect();
let parsed: Vec<&SourceFile> = files
.iter()
Expand Down Expand Up @@ -97,6 +108,7 @@ pub(crate) fn check_sources(
Ok(CheckOutcome {
findings: kept,
stale_exceptions,
unavailable,
})
}

Expand Down
2 changes: 2 additions & 0 deletions crates/sqlclean-cli/src/checking/constants.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ pub const PARSE_ERROR_CODE: &str = "SQLCLEAN999";
pub(crate) const PARSE_ERROR_MESSAGE: &str = "SQL could not be parsed";
pub(crate) const PARSE_ERROR_REMEDIATION: &str =
"Fix the syntax error, or set this file's dialect in sqlclean.toml.";
/// Marks, in the built-in results and their cache, a file whose dialect SQLQuack does not parse.
pub(crate) const UNAVAILABLE_CODE: &str = "SQLCLEAN998";
pub(crate) const CROSS_JOIN_CODE: &str = "SQLCLEAN038";
/// Custom Rule codes start with this prefix.
pub const CUSTOM_RULE_PREFIX: &str = "XSQLCLEAN";
Expand Down
9 changes: 9 additions & 0 deletions crates/sqlclean-cli/src/checking/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,11 +59,20 @@ pub struct StaleException {
pub relation: Option<String>,
}

/// A file not checked because SQLQuack does not parse its dialect yet; a notice, not a problem.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct UnavailableFile {
pub path: String,
pub reason: String,
}

/// Every finding of one check, sorted by path, position and code.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
pub struct CheckOutcome {
pub findings: Vec<Finding>,
pub stale_exceptions: Vec<StaleException>,
/// Files whose dialect has no SQLQuack parser, by path; they never fail the check.
pub unavailable: Vec<UnavailableFile>,
}

/// What a check evaluates beyond the configuration.
Expand Down
8 changes: 7 additions & 1 deletion crates/sqlclean-cli/src/command/_helpers/diffs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ use std::io::Write;

use similar::TextDiff;

use crate::discovery::constants::UTF8_BOM;
use crate::discovery::models::SourceFile;

const CONTEXT_LINES: usize = 3;
Expand All @@ -19,7 +20,12 @@ pub(crate) fn print_diff(file: &SourceFile, changed: &str) {
let _ = std::io::stdout().write_all(rendered.as_bytes());
}

/// Write `changed` over `file`, with the byte order mark it was read with.
pub(crate) fn write_file(file: &SourceFile, changed: &str) -> Result<(), String> {
fs::write(&file.absolute, changed)
let text: String = match file.bom {
true => format!("{UTF8_BOM}{changed}"),
false => changed.to_string(),
};
fs::write(&file.absolute, text)
.map_err(|error| format!("could not write {}: {error}", file.path))
}
Loading
Loading