diff --git a/.github/demos/check-fix.gif b/.github/demos/check-fix.gif index 1cfa45a..c3ce0cc 100644 Binary files a/.github/demos/check-fix.gif and b/.github/demos/check-fix.gif differ diff --git a/README.md b/README.md index 68605fc..79dc572 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ SQLClean in other configurations, on the same files: | SQLClean command | Wall time | |---|---:| -| `sqlclean check --select SQC` (every built-in Rule) | 1.33 s | +| `sqlclean check --select SQLCLEAN` (every built-in Rule) | 1.33 s | | `sqlclean check` plus three custom Python Rules | 2.27 s | | `sqlclean check` rerun with nothing changed (result cache) | 0.23 s | @@ -114,7 +114,7 @@ def lowered(operand: ast.Expression) -> bool: @rule( - code="XSQCTEAM001", + code="XSQLCLEANTEAM001", message="Email comparisons must be case-insensitive", remediation="Compare LOWER(email) with LOWER(...) instead.", enabled_by_default=True, diff --git a/benchmarks/linters/README.md b/benchmarks/linters/README.md index e91e37c..8123145 100644 --- a/benchmarks/linters/README.md +++ b/benchmarks/linters/README.md @@ -34,7 +34,7 @@ and peak memory with `/usr/bin/time`, and every lint command writes JSON finding | Variant | Command | |---|---| | `sqlclean` | `sqlclean check --output-format json` (13 default Rules) | -| `sqlclean_all` | `sqlclean check --select SQC --output-format json` (all 45 Rules) | +| `sqlclean_all` | `sqlclean check --select SQLCLEAN --output-format json` (all 45 Rules) | | `sqlclean_custom` | `sqlclean check --config custom.toml --output-format json` (default Rules and the three Python Rules in `rules/`) | | `sqlclean_format` | `sqlclean format --check` | | `sqruff` | `sqruff lint -f json .` | diff --git a/benchmarks/linters/bench.sh b/benchmarks/linters/bench.sh index 643b744..b468d58 100755 --- a/benchmarks/linters/bench.sh +++ b/benchmarks/linters/bench.sh @@ -34,7 +34,7 @@ trap 'rm -rf "$WORK"' EXIT declare -A CMD CMD[sqlclean]="$SQLCLEAN check --output-format json" -CMD[sqlclean_all]="$SQLCLEAN check --select SQC --output-format json" +CMD[sqlclean_all]="$SQLCLEAN check --select SQLCLEAN --output-format json" # The default Rules plus the three Python Rules in rules/, copied into the work directory. CMD[sqlclean_custom]="$SQLCLEAN check --config custom.toml --output-format json" # sqruff's and SQLFluff's default rules include layout; SQLClean checks layout with its formatter. diff --git a/benchmarks/linters/rules/layers.py b/benchmarks/linters/rules/layers.py index f3b25af..7398b34 100644 --- a/benchmarks/linters/rules/layers.py +++ b/benchmarks/linters/rules/layers.py @@ -8,7 +8,7 @@ @rule( - code="XSQCLAYER001", + code="XSQLCLEANLAYER001", message="Mart reads a raw source", remediation="Read the staging model instead.", enabled_by_default=True, @@ -26,7 +26,7 @@ def marts_read_staging(*, statement: Statement, ctx: RuleContext) -> list[Findin @rule( - code="XSQCLAYER002", + code="XSQLCLEANLAYER002", message="Staging model reads another model", remediation="Staging models read only raw sources and seeds.", enabled_by_default=True, @@ -46,7 +46,7 @@ def staging_reads_sources(*, statement: Statement, ctx: RuleContext) -> list[Fin @rule( - code="XSQCDET001", + code="XSQLCLEANDET001", message="Model calls a nondeterministic function", remediation="Pass the time or seed in as a column so reruns give the same rows.", enabled_by_default=True, diff --git a/benchmarks/linters/summarize.py b/benchmarks/linters/summarize.py index f58f32c..772fca7 100644 --- a/benchmarks/linters/summarize.py +++ b/benchmarks/linters/summarize.py @@ -40,7 +40,7 @@ def count_findings(variant: str, out: str) -> tuple[int, int]: data = json.loads(out) if variant.startswith("sqlclean"): codes = [finding["code"] for finding in data["findings"]] - return len(codes), codes.count("SQC999") + return len(codes), codes.count("SQLCLEAN999") if variant == "sqruff": codes = [finding.get("code") for findings in data.values() for finding in findings] return len(codes), sum(1 for code in codes if code in (None, "PRS")) diff --git a/crates/sqlclean-cli/skill/SKILL.md b/crates/sqlclean-cli/skill/SKILL.md index 69ee72e..1e046f7 100644 --- a/crates/sqlclean-cli/skill/SKILL.md +++ b/crates/sqlclean-cli/skill/SKILL.md @@ -1,6 +1,6 @@ --- name: sqlclean -description: ALWAYS load this skill when linting, fixing or formatting SQL with SQLClean, editing sqlclean.toml, writing or testing custom Rules (Python @rule functions with XSQC codes), recording Rule exceptions, or reading SQLClean findings (SQC codes, SQC999 parse errors) in CI, JSON or SARIF output. +description: ALWAYS load this skill when linting, fixing or formatting SQL with SQLClean, editing sqlclean.toml, writing or testing custom Rules (Python @rule functions with XSQLCLEAN codes), recording Rule exceptions, or reading SQLClean findings (SQLCLEAN codes, SQLCLEAN999 parse errors) in CI, JSON or SARIF output. --- @@ -17,7 +17,7 @@ engine extracts from each statement. `[rules]`). Paths in it are relative to its directory. 2. **Check.** `sqlclean check [PATHS]` exits 0 when clean, 1 with findings, 2 on errors. Use `--output-format json` when you need to read findings programmatically. -3. **Explain.** `sqlclean rules SQC001` shows a Rule's message, fix safety and remediation. +3. **Explain.** `sqlclean rules SQLCLEAN001` shows a Rule's message, fix safety and remediation. 4. **Fix.** `sqlclean fix --diff` previews safe fixes plus formatting; `sqlclean fix` writes them. Fixes marked unsafe can change query results, such as `= NULL` becoming `IS NULL`. Never pass `--unsafe-fixes` without the user's say-so. @@ -31,16 +31,16 @@ engine extracts from each statement. `[[rules.rule_exceptions]]` entry with the Rule, path and a `reason`. Use `[[rules.rule_ignores]]` only for whole paths a Rule does not apply to. - An exception that no longer matches a finding is reported as stale; remove it. -- `SQC999` is a parse error. It cannot be ignored: fix the SQL or set the right `dialect`, +- `SQLCLEAN999` is a parse error. It cannot be ignored: fix the SQL or set the right `dialect`, globally or per path under `[[dialects]]`. -- `--select` and `--ignore` take codes or prefixes such as `SQC001` or `SQC`. +- `--select` and `--ignore` take codes or prefixes such as `SQLCLEAN001` or `SQLCLEAN`. - Results are cached in `.sqlclean/cache/`. Use `--no-cache` only when debugging a custom Rule that imports helpers from outside its `[rules] custom` paths. ## Custom Rules - A custom Rule is a function decorated with `@rule` from `sqlclean`, in a file under a path - listed in `[rules] custom`. Its code is `XSQC`, optional capital letters, then three digits. + listed in `[rules] custom`. Its code is `XSQLCLEAN`, optional capital letters, then three digits. - The first keyword parameter names the subject: `statement: Statement`, `file: SqlFile` or `repository: Repository`. The second is `ctx: RuleContext`. - Return `ctx.finding(...)` values. Fixes are lists of `ctx.replace`, `ctx.insert_before`, diff --git a/crates/sqlclean-cli/skill/references/docs/benchmarks.md b/crates/sqlclean-cli/skill/references/docs/benchmarks.md index 3b48db7..6621d0d 100644 --- a/crates/sqlclean-cli/skill/references/docs/benchmarks.md +++ b/crates/sqlclean-cli/skill/references/docs/benchmarks.md @@ -14,7 +14,7 @@ SQLFluff ran once. | Command | Wall time | CPU time | Peak memory | vs SQLClean defaults | |---|---:|---:|---:|---:| | `sqlclean check` (13 default Rules) | **0.81 s** | 2.5 s | 48 MiB | 1× | -| `sqlclean check --select SQC` (all 45 Rules) | **1.33 s** | 3.8 s | 140 MiB | 1.6× | +| `sqlclean check --select SQLCLEAN` (all 45 Rules) | **1.33 s** | 3.8 s | 140 MiB | 1.6× | | `sqlclean check` with three [custom Rules](#custom-rules) | **2.27 s** | 6.9 s | 182 MiB | 2.8× | | `sqlclean format --check` | **3.03 s** | 11.5 s | 49 MiB | 3.7× | | `sqruff lint` | 41.0 s | 114 s | 235 MiB | 51× | diff --git a/crates/sqlclean-cli/skill/references/docs/cli/check.md b/crates/sqlclean-cli/skill/references/docs/cli/check.md index 8e0ff69..ff4ca72 100644 --- a/crates/sqlclean-cli/skill/references/docs/cli/check.md +++ b/crates/sqlclean-cli/skill/references/docs/cli/check.md @@ -29,7 +29,7 @@ sqlclean check [OPTIONS] [PATHS]... ```bash sqlclean check # every matching file under the configuration root sqlclean check models/orders.sql reports/ # only these files and directories -sqlclean check --select SQC001,SQC004 # only these Rules +sqlclean check --select SQLCLEAN001,SQLCLEAN004 # only these Rules sqlclean check --output-format sarif > sqlclean.sarif ``` @@ -50,20 +50,20 @@ when `include` does not match it, unless `exclude` does. See | `--config ` | Use this configuration file instead of the nearest `sqlclean.toml` | `--select` and `--ignore` take a comma-separated list, or the flag repeated. A value is an exact -code such as `SQC001` or a prefix such as `SQC` or `XSQCNAME`. A value that matches no Rule is an +code such as `SQLCLEAN001` or a prefix such as `SQLCLEAN` or `XSQLCLEANNAME`. A value that matches no Rule is an error. See [Selecting Rules](../rules/overview.md#selecting-rules). ## Text output ```text -error[SQC004]: Row selection is nondeterministic +error[SQLCLEAN004]: Row selection is nondeterministic --> models/customers.sql:6:1 | 6 | LIMIT 10; | ^^^^^ = help: Add ORDER BY with a deterministic tie-breaker before LIMIT or OFFSET. -error[SQC001]: Comparison with NULL is never true +error[SQLCLEAN001]: Comparison with NULL is never true --> models/orders.sql:8:22 | 8 | WHERE o.cancelled_at = NULL @@ -71,7 +71,7 @@ error[SQC001]: Comparison with NULL is never true = help: Use IS NULL or IS NOT NULL when testing for NULL. = fix: available with `sqlclean fix --unsafe-fixes` -error: stale rule exception: SQC004 in models/orders.sql matches no finding; remove it from sqlclean.toml +error: stale rule exception: SQLCLEAN004 in models/orders.sql matches no finding; remove it from sqlclean.toml Found 3 problems (1 fixable with `sqlclean fix --unsafe-fixes`). ``` @@ -92,7 +92,7 @@ and count characters, not bytes. { "findings": [ { - "code": "SQC004", + "code": "SQLCLEAN004", "path": "models/customers.sql", "line": 6, "column": 1, @@ -103,7 +103,7 @@ and count characters, not bytes. "fix": null }, { - "code": "SQC001", + "code": "SQLCLEAN001", "path": "models/orders.sql", "line": 8, "column": 22, @@ -116,7 +116,7 @@ and count characters, not bytes. ], "stale_exceptions": [ { - "rule": "SQC004", + "rule": "SQLCLEAN004", "path": "models/orders.sql", "relation": null } @@ -140,7 +140,7 @@ text: ```json { - "ruleId": "SQC001", + "ruleId": "SQLCLEAN001", "level": "error", "message": { "text": "Comparison with NULL is never true. Use IS NULL or IS NOT NULL when testing for NULL." diff --git a/crates/sqlclean-cli/skill/references/docs/cli/fix.md b/crates/sqlclean-cli/skill/references/docs/cli/fix.md index 9e1e033..ac7e56f 100644 --- a/crates/sqlclean-cli/skill/references/docs/cli/fix.md +++ b/crates/sqlclean-cli/skill/references/docs/cli/fix.md @@ -36,14 +36,14 @@ Files are chosen as for [`sqlclean check`](check.md). | `--no-cache` | Evaluate every file instead of reusing results for unchanged inputs | | `--config ` | Use this configuration file instead of the nearest `sqlclean.toml` | -Only fixes of selected Rules apply. `sqlclean fix --select SQC005` removes unused CTEs and +Only fixes of selected Rules apply. `sqlclean fix --select SQLCLEAN005` removes unused CTEs and formats the files, and leaves every other finding alone. ## Output ```text Fixed 1 problem(s) and changed 1 file(s). -error[SQC001]: Comparison with NULL is never true +error[SQLCLEAN001]: Comparison with NULL is never true --> models/open_orders.sql:7:24 | 7 | WHERE cancelled_at = NULL @@ -88,7 +88,7 @@ findings: formatter cannot format is reported as `: could not format: ` and keeps its fixes. A file that does not parse gets no fixes and is reported as -[`SQC999`](../rules/overview.md#parse-errors-sqc999). +[`SQLCLEAN999`](../rules/overview.md#parse-errors-sqlclean999). ## Exit codes diff --git a/crates/sqlclean-cli/skill/references/docs/cli/rules.md b/crates/sqlclean-cli/skill/references/docs/cli/rules.md index 2b80af8..f8fd032 100644 --- a/crates/sqlclean-cli/skill/references/docs/cli/rules.md +++ b/crates/sqlclean-cli/skill/references/docs/cli/rules.md @@ -26,9 +26,9 @@ sqlclean rules [OPTIONS] [CODE] ```bash sqlclean rules # list every Rule -sqlclean rules SQC001 # show one Rule +sqlclean rules SQLCLEAN001 # show one Rule sqlclean rules --json # every Rule as JSON -sqlclean rules --json SQC001 # one Rule as JSON +sqlclean rules --json SQLCLEAN001 # one Rule as JSON ``` ## Flags @@ -42,14 +42,14 @@ sqlclean rules --json SQC001 # one Rule as JSON ## Listing Rules ```text -SQC001 default Comparison with NULL is never true -SQC002 default Comma-separated sources create an implicit cartesian join -SQC003 default Non-cross join has no meaningful condition -SQC004 default Row selection is nondeterministic +SQLCLEAN001 default Comparison with NULL is never true +SQLCLEAN002 default Comma-separated sources create an implicit cartesian join +SQLCLEAN003 default Non-cross join has no meaningful condition +SQLCLEAN004 default Row selection is nondeterministic ... -SQC045 JSON payload is parsed more than once in one SELECT -XSQCNAME001 default Query reads a temporary table -XSQCSTYLE001 default COUNT(1) should be COUNT(*) +SQLCLEAN045 JSON payload is parsed more than once in one SELECT +XSQLCLEANNAME001 default Query reads a temporary table +XSQLCLEANSTYLE001 default COUNT(1) should be COUNT(*) ``` `default` marks the Rules that run when `[rules] select` is not set. See @@ -59,11 +59,11 @@ XSQCSTYLE001 default COUNT(1) should be COUNT(*) ## Showing one Rule ```bash -sqlclean rules SQC001 +sqlclean rules SQLCLEAN001 ``` ```text -SQC001: Comparison with NULL is never true +SQLCLEAN001: Comparison with NULL is never true Subject: statement Enabled by default: yes Fixable: unsafe @@ -73,7 +73,7 @@ Remediation: Use IS NULL or IS NOT NULL when testing for NULL. A custom Rule also shows the Python module that defines it: ```text -XSQCSTYLE001: COUNT(1) should be COUNT(*) +XSQLCLEANSTYLE001: COUNT(1) should be COUNT(*) Subject: statement Enabled by default: yes Defined in: sql_rules.naming @@ -86,13 +86,13 @@ Remediation: Write COUNT(*). ## JSON output ```bash -sqlclean rules --json XSQCNAME001 +sqlclean rules --json XSQLCLEANNAME001 ``` ```json [ { - "code": "XSQCNAME001", + "code": "XSQLCLEANNAME001", "message": "Query reads a temporary table", "remediation": "Read from the published table instead.", "enabled_by_default": true, diff --git a/crates/sqlclean-cli/skill/references/docs/configuration.md b/crates/sqlclean-cli/skill/references/docs/configuration.md index 5a26208..4e8d21f 100644 --- a/crates/sqlclean-cli/skill/references/docs/configuration.md +++ b/crates/sqlclean-cli/skill/references/docs/configuration.md @@ -31,25 +31,25 @@ line_width = 100 indent = 4 [rules] -select = ["SQC"] # omit to run the Rules enabled by default -ignore = ["SQC027"] +select = ["SQLCLEAN"] # omit to run the Rules enabled by default +ignore = ["SQLCLEAN027"] custom = ["sql_rules"] # Python files or directories that define custom Rules [rules.thresholds] -max_literal_length = 100 # SQC044 -max_ranking_order_by = 5 # SQC043 +max_literal_length = 100 # SQLCLEAN044 +max_ranking_order_by = 5 # SQLCLEAN043 -[rules.rule_options.XSQCNAME001] +[rules.rule_options.XSQLCLEANNAME001] prefixes = ["tmp_", "scratch_"] [[rules.rule_exceptions]] # one reviewed finding that is allowed -rule = "SQC038" +rule = "SQLCLEAN038" path = "models/calendar.sql" relation = "dates" reason = "Every pair of dates is needed." [[rules.rule_ignores]] # Rules that do not apply to some paths -rules = ["SQC034"] +rules = ["SQLCLEAN034"] paths = ["scratch/**"] reason = "Exploratory queries." ``` @@ -76,8 +76,8 @@ directory is the root. | `[rules] select` | Rules enabled by default | Rule codes or prefixes to run | | `[rules] ignore` | `[]` | Rule codes or prefixes to skip | | `[rules] custom` | `[]` | Python files or directories that define [custom Rules](custom-rules/overview.md) | -| `[rules.thresholds] max_literal_length` | `100` | The longest string literal `SQC044` allows | -| `[rules.thresholds] max_ranking_order_by` | `5` | The most `ORDER BY` expressions `SQC043` allows in a ranking window | +| `[rules.thresholds] max_literal_length` | `100` | The longest string literal `SQLCLEAN044` allows | +| `[rules.thresholds] max_ranking_order_by` | `5` | The most `ORDER BY` expressions `SQLCLEAN043` allows in a ranking window | | `[rules.rule_options.]` | none | Option values of one custom Rule; see [Options](custom-rules/fixes-and-options.md#options) | | `[[rules.rule_exceptions]]` | none | One allowed finding; see [Exceptions](rules/overview.md#exceptions) | | `[[rules.rule_ignores]]` | none | Rules that do not apply to some paths; see [Path ignores](rules/overview.md#path-ignores) | @@ -112,7 +112,7 @@ dialect = "tsql" ``` A file that does not parse under its dialect is reported as -[`SQC999`](rules/overview.md#parse-errors-sqc999), with the dialect in the message. Without +[`SQLCLEAN999`](rules/overview.md#parse-errors-sqlclean999), with the dialect in the message. Without the `[[dialects]]` entry above, this T-SQL file under `reports/` is parsed as `duckdb`: ```sql @@ -120,7 +120,7 @@ SELECT [order id] FROM orders ``` ```text -error[SQC999]: 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] +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] ``` Supported dialects, case-insensitive: @@ -178,11 +178,11 @@ report any finding. | `line_width` of `0` | `sqlclean.toml: [format] line_width must be positive` | | `indent` of `0` or more than `8` | `sqlclean.toml: [format] indent must be between 1 and 8` | | A threshold of `0` | `sqlclean.toml: [rules.thresholds] max_literal_length and max_ranking_order_by must be positive` | -| An exception or ignore without a reason | `sqlclean.toml: rule exception or ignore for SQC001 needs a reason` | -| An exception with `cte` but no `relation` | `sqlclean.toml: rule exception for SQC038 at models/calendar.sql sets cte without relation` | +| An exception or ignore without a reason | `sqlclean.toml: rule exception or ignore for SQLCLEAN001 needs a reason` | +| An exception with `cte` but no `relation` | `sqlclean.toml: rule exception for SQLCLEAN038 at models/calendar.sql sets cte without relation` | | A `custom` path that does not exist | `[rules] custom path does not exist: sql_rules` | -| `rule_options` for a built-in Rule | `[rules.rule_options.SQC001]: built-in Rules take no options; set limits in [rules.thresholds]` | -| `rule_options` for a code no custom Rule has | `[rules.rule_options.XSQCNOPE001]: no custom Rule has this code` | -| A selector that matches no Rule | `Rule selector 'SQC9' matches no Rule` | +| `rule_options` for a built-in Rule | `[rules.rule_options.SQLCLEAN001]: built-in Rules take no options; set limits in [rules.thresholds]` | +| `rule_options` for a code no custom Rule has | `[rules.rule_options.XSQLCLEANNOPE001]: no custom Rule has this code` | +| A selector that matches no Rule | `Rule selector 'SQLCLEAN9' matches no Rule` | Errors are printed as `sqlclean: error: `. diff --git a/crates/sqlclean-cli/skill/references/docs/custom-rules/fixes-and-options.md b/crates/sqlclean-cli/skill/references/docs/custom-rules/fixes-and-options.md index e3f6c1f..850834e 100644 --- a/crates/sqlclean-cli/skill/references/docs/custom-rules/fixes-and-options.md +++ b/crates/sqlclean-cli/skill/references/docs/custom-rules/fixes-and-options.md @@ -23,7 +23,7 @@ A fix is a list of edits to the file. Declare `fixable` on the Rule, then pass t from sqlclean import FixSafety, Finding, RuleContext, Statement, rule @rule( - code="XSQCSTYLE001", + code="XSQLCLEANSTYLE001", message="COUNT(1) should be COUNT(*)", remediation="Write COUNT(*).", fixable=FixSafety.SAFE, @@ -42,7 +42,7 @@ A function call is located at its name, so `f.end + 1` is the first character in parentheses. ```bash -sqlclean fix --diff --select XSQCSTYLE +sqlclean fix --diff --select XSQLCLEANSTYLE ``` ```diff @@ -81,7 +81,7 @@ edit must lie inside the finding's file. See [Safe and unsafe fixes](../rules/overview.md#safe-and-unsafe-fixes). A finding without `fix` has no fix, even in a fixable Rule. A Rule that returns a fix without -declaring `fixable` fails with `Rule XSQCSTYLE001 returned a fix but does not declare fixable`. +declaring `fixable` fails with `Rule XSQLCLEANSTYLE001 returned a fix but does not declare fixable`. ### How fixes combine @@ -107,7 +107,7 @@ PREFIXES = RuleOption.string_list( ) @rule( - code="XSQCNAME001", + code="XSQLCLEANNAME001", message="Query reads a temporary table", remediation="Read from the published table instead.", options=(PREFIXES,), @@ -123,7 +123,7 @@ def temporary_table(*, statement: Statement, ctx: RuleContext) -> list[Finding]: ``` ```toml -[rules.rule_options.XSQCNAME001] +[rules.rule_options.XSQLCLEANNAME001] prefixes = ["tmp_", "scratch_"] ``` @@ -147,12 +147,12 @@ exit code `2`: | Problem | Error | |---------|-------| -| A value of the wrong type | `[rules.rule_options.XSQCNAME001] option prefixes expects a list, got 'tmp_'` | -| An integer out of bounds | `[rules.rule_options.XSQCFILE002] option max_statements must be at least 1` | -| A string outside `choices` | `[rules.rule_options.XSQCFILE002] option style must be one of ['strict', 'loose']` | -| A name the Rule does not declare | `Rule XSQCNAME001 has no option prefix` | -| A code no custom Rule has | `[rules.rule_options.XSQCNOPE001]: no custom Rule has this code` | -| A Rule that reads an option it did not declare | `Rule XSQCFILE002 reads undeclared option verbose` | +| A value of the wrong type | `[rules.rule_options.XSQLCLEANNAME001] option prefixes expects a list, got 'tmp_'` | +| An integer out of bounds | `[rules.rule_options.XSQLCLEANFILE002] option max_statements must be at least 1` | +| A string outside `choices` | `[rules.rule_options.XSQLCLEANFILE002] option style must be one of ['strict', 'loose']` | +| A name the Rule does not declare | `Rule XSQLCLEANNAME001 has no option prefix` | +| A code no custom Rule has | `[rules.rule_options.XSQLCLEANNOPE001]: no custom Rule has this code` | +| A Rule that reads an option it did not declare | `Rule XSQLCLEANFILE002 reads undeclared option verbose` | Built-in Rules take no options. Their two limits are set in [`[rules.thresholds]`](../rules/overview.md#thresholds). diff --git a/crates/sqlclean-cli/skill/references/docs/custom-rules/overview.md b/crates/sqlclean-cli/skill/references/docs/custom-rules/overview.md index 7793d4e..de3a62d 100644 --- a/crates/sqlclean-cli/skill/references/docs/custom-rules/overview.md +++ b/crates/sqlclean-cli/skill/references/docs/custom-rules/overview.md @@ -26,7 +26,7 @@ output and cache as the built-in Rules. from sqlclean import Finding, RuleContext, Statement, rule @rule( - code="XSQCNAME001", + code="XSQLCLEANNAME001", message="Query reads a temporary table", remediation="Read from the published table instead.", enabled_by_default=True, @@ -47,7 +47,7 @@ custom = ["sql_rules"] ``` ```text -error[XSQCNAME001]: Query reads a temporary table +error[XSQLCLEANNAME001]: Query reads a temporary table --> models/orders.sql:2:6 | 2 | FROM tmp_orders @@ -66,13 +66,13 @@ functions, constants and classes stay ordinary Python. - Modules are imported by their path from the configuration root: `sql_rules/naming.py` is `sql_rules.naming`. A Rule module can import a helper the same way, for example `from sql_rules.helpers import table_prefixes`. -- Two Rules with the same code are an error: `custom Rule code XSQCNAME001 is defined twice`. +- Two Rules with the same code are an error: `custom Rule code XSQLCLEANNAME001 is defined twice`. ## Declaring a Rule | `@rule` argument | Default | Description | |------------------|---------|-------------| -| `code` | required | `XSQC`, optional capital letters, and three digits, such as `XSQCNAME001` | +| `code` | required | `XSQLCLEAN`, optional capital letters, and three digits, such as `XSQLCLEANNAME001` | | `message` | required | The finding message | | `remediation` | required | What to do about a finding | | `enabled_by_default` | `False` | Run the Rule when `[rules] select` is not set | @@ -81,7 +81,7 @@ functions, constants and classes stay ordinary Python. | `slug` | the code in lower case | A short name for the Rule | A Rule that is not enabled by default runs when `select` names it, by code or by a prefix such as -`XSQC` or `XSQCNAME`. See [Selecting Rules](../rules/overview.md#selecting-rules). Fixes and +`XSQLCLEAN` or `XSQLCLEANNAME`. See [Selecting Rules](../rules/overview.md#selecting-rules). Fixes and options are covered in [Fixes and options](fixes-and-options.md). ## Choosing a subject @@ -101,7 +101,7 @@ from collections import Counter from sqlclean import Finding, Repository, RuleContext, SqlFile, rule @rule( - code="XSQCFILE001", + code="XSQLCLEANFILE001", message="File holds more than one statement", remediation="Split the file so each holds one statement.", ) @@ -109,7 +109,7 @@ def one_statement(*, file: SqlFile, ctx: RuleContext) -> list[Finding]: return [ctx.finding(subject=file)] if len(file.statements) > 1 else [] @rule( - code="XSQCREPO001", + code="XSQLCLEANREPO001", message="Two files define a CTE with the same name", remediation="Give each CTE a name that is unique across files.", ) @@ -146,7 +146,7 @@ A Repository Rule passes the statement or file each finding belongs to as `subje ## How Rules run - Custom Rules run after the built-in Rules, on files that parse. A file with a - [parse error](../rules/overview.md#parse-errors-sqc999) gets no custom Rule findings. + [parse error](../rules/overview.md#parse-errors-sqlclean999) gets no custom Rule findings. - From 500 files, Statement and File Rules split the files across one Python process per CPU. A Rule can't share state between files, so anything that spans files belongs in a Repository Rule, which always runs in one process with every file. @@ -179,12 +179,12 @@ A Rule that cannot load or run stops the command with exit code `2`: | Problem | Error | |---------|-------| -| An invalid code | `custom Rule code must be XSQC, optional capital letters and 3 digits, such as XSQCNAME001: XSQC01` | -| A wrong signature | `custom Rule XSQCBAD001 must accept exactly one typed Statement, SqlFile or Repository subject and one typed RuleContext, both keyword-only` | +| An invalid code | `custom Rule code must be XSQLCLEAN, optional capital letters and 3 digits, such as XSQLCLEANNAME001: XSQLCLEAN01` | +| A wrong signature | `custom Rule XSQLCLEANBAD001 must accept exactly one typed Statement, SqlFile or Repository subject and one typed RuleContext, both keyword-only` | | A module that fails to import | `could not import custom Rule module sql_rules.bad: ` | -| An exception in the Rule | `custom Rule XSQCBAD001 failed on models/a.sql: IndexError: tuple index out of range`, then the traceback | -| A return value that is not a list of findings | `custom Rule XSQCRET001 must return a list of findings from ctx.finding(...)` | -| A fix from a Rule without `fixable` | `Rule XSQCBAD001 returned a fix but does not declare fixable` | +| An exception in the Rule | `custom Rule XSQLCLEANBAD001 failed on models/a.sql: IndexError: tuple index out of range`, then the traceback | +| A return value that is not a list of findings | `custom Rule XSQLCLEANRET001 must return a list of findings from ctx.finding(...)` | +| A fix from a Rule without `fixable` | `Rule XSQLCLEANBAD001 returned a fix but does not declare fixable` | ## Continue diff --git a/crates/sqlclean-cli/skill/references/docs/custom-rules/statement-facts.md b/crates/sqlclean-cli/skill/references/docs/custom-rules/statement-facts.md index 49828ae..b029079 100644 --- a/crates/sqlclean-cli/skill/references/docs/custom-rules/statement-facts.md +++ b/crates/sqlclean-cli/skill/references/docs/custom-rules/statement-facts.md @@ -29,7 +29,7 @@ carries its position, so a finding can point at it and a fix can replace it. from sqlclean import Finding, RuleContext, Statement, rule @rule( - code="XSQCJOIN001", + code="XSQLCLEANJOIN001", message="Join to customers has no condition", remediation="Join customers on customer_id.", ) diff --git a/crates/sqlclean-cli/skill/references/docs/custom-rules/testing.md b/crates/sqlclean-cli/skill/references/docs/custom-rules/testing.md index 2c10112..b32bbcf 100644 --- a/crates/sqlclean-cli/skill/references/docs/custom-rules/testing.md +++ b/crates/sqlclean-cli/skill/references/docs/custom-rules/testing.md @@ -143,7 +143,7 @@ and does not format the result. It shows exactly what the Rule's fix changes. | Problem | Message | |---------|---------| | A different number of findings | `reads orders: expected 1 findings, got 0` | -| Findings that depend on file order | `custom Rule XSQCND001 is not deterministic` | +| Findings that depend on file order | `custom Rule XSQLCLEANND001 is not deterministic` | | Case SQL that does not parse | `sqlclean could not parse the case SQL: sqlclean: error: Parse error at line 1, column 18: ...` | | A function without `@rule` | `evaluate_rule needs a function decorated with @rule` | | No `sqlclean` executable | `the sqlclean executable is not installed; install sqlclean` | @@ -163,7 +163,7 @@ The usual cause is state kept between calls, such as a module-level set of names ```python SEEN: set[str] = set() -@rule(code="XSQCND001", message="Table read in an earlier file", remediation="...") +@rule(code="XSQLCLEANND001", message="Table read in an earlier file", remediation="...") def seen_before(*, statement: Statement, ctx: RuleContext) -> list[Finding]: found = [ctx.finding(subject=statement, node=t) for t in statement.tables if t.name in SEEN] SEEN.update(t.name for t in statement.tables) @@ -171,7 +171,7 @@ def seen_before(*, statement: Statement, ctx: RuleContext) -> list[Finding]: ``` ```text -RuleCaseError: custom Rule XSQCND001 is not deterministic +RuleCaseError: custom Rule XSQLCLEANND001 is not deterministic ``` Compute findings from the subject and `ctx` alone. When a Rule needs to compare files, give it a diff --git a/crates/sqlclean-cli/skill/references/docs/embedding.md b/crates/sqlclean-cli/skill/references/docs/embedding.md index e8e4a90..231da35 100644 --- a/crates/sqlclean-cli/skill/references/docs/embedding.md +++ b/crates/sqlclean-cli/skill/references/docs/embedding.md @@ -11,6 +11,7 @@ Online: https://sqlclean.io/docs/embedding/ - Adding the dependency - Entry points - Lint requests +- Template code - Fixes - Formatting - Statement facts @@ -30,7 +31,7 @@ fn main() -> Result<(), String> { version: LINT_API_VERSION, sql: "SELECT order_id FROM orders WHERE cancelled_at = NULL LIMIT 10".to_owned(), dialect: "duckdb".to_owned(), - enabled_rules: Some(vec!["SQC001".to_owned(), "SQC004".to_owned()]), + enabled_rules: Some(vec!["SQLCLEAN001".to_owned(), "SQLCLEAN004".to_owned()]), ..LintRequest::default() }; let response: LintResponse = lint(request)?; @@ -45,9 +46,9 @@ fn main() -> Result<(), String> { ``` ```text -SQC001 47..48 Comparison with NULL is never true +SQLCLEAN001 47..48 Comparison with NULL is never true fix: replace 47..48 with "IS" -SQC004 54..59 Row selection is nondeterministic +SQLCLEAN004 54..59 Row selection is nondeterministic ``` ## Adding the dependency @@ -87,21 +88,49 @@ the dialect. Offsets in requests, findings, edits and facts count Unicode charac | `dialect` | required | A [dialect name](configuration.md#dialects) | | `enabled_rules` | the default Rules | Rule codes or prefixes to run | | `ignored_rules` | `[]` | Rule codes or prefixes to skip | -| `max_literal_length` | `100` | The `SQC044` limit | -| `max_ranking_order_by` | `5` | The `SQC043` limit | +| `max_literal_length` | `100` | The `SQLCLEAN044` limit | +| `max_ranking_order_by` | `5` | The `SQLCLEAN043` limit | | `config_file` | `sqlclean.toml` | The configuration file name that remediation text refers to | -| `externally_referenced_ctes` | `[]` | CTE names that something outside the SQL reads, so `SQC005` does not report them | -| `relation_keys` | `{}` | Declared unique keys by relation name; an empty key means the relation holds at most one row, so `SQC038` does not report a cross join with it | - -Other fields describe template code the host has replaced before linting. See `LintRequest` in -`sql_lint::models`. - -A selector that matches no Rule is an error: `unknown native lint rule 'SQC9'`. +| `externally_referenced_ctes` | `[]` | CTE names that something outside the SQL reads, so `SQLCLEAN005` does not report them | +| `relation_keys` | `{}` | Unique keys by table name as the SQL writes it, compared without case; an empty key means the relation holds at most one row, so `SQLCLEAN038` does not report a cross join with it and `SQLCLEAN018` can prove a ranking order | +| `relation_names` | `{}` | The relation each placeholder table name stands for, so `SQLCLEAN038` messages and exceptions name it | +| `external_identifiers` | `[]` | Names the host defines outside the SQL, which are not unknown qualifiers | +| `dependency_identifiers` | `[]` | Placeholder table names that stand for other relations | +| `sql_macro_outputs` | `[]` | The character range each macro call's output fills and the relations it passes, so qualification is judged as the macro wrote it | +| `allows_dynamic_output_star` | `false` | The host proved the output columns, so a top-level `SELECT *` passes `SQLCLEAN021` | +| `fixture_harness` | absent | The generated wrapper around a test or macro body; see [Template code](#template-code) | + +A selector that matches no Rule is an error: `unknown native lint rule 'SQLCLEAN9'`. + +## Template code + +A host that lints templated SQL replaces each template expression with a placeholder before +linting, and maps offsets back afterwards. The crate exports the placeholder prefixes in +`constants`: + +| Constant | Stands for | +|----------|------------| +| `INTERPOLATION_PLACEHOLDER_PREFIX` | An interpolated expression, relation or CTE list; the formatter keeps it in place | +| `MACRO_PARAMETER_PLACEHOLDER_PREFIX` | A macro parameter; `SQLCLEAN028` treats `parameter.slot` reads as written by the macro | +| `RELATION_PARAMETER_PLACEHOLDER_PREFIX` | A relation parameter, written `__`; `SELECT *` from it alone passes `SQLCLEAN021` | +| `VALUE_PARAMETER_PLACEHOLDER_PREFIX` | A value parameter | + +Rules never judge a placeholder as an authored name. + +`fixture_harness` describes the query a test runner or macro wraps around a body. When it is +present, a terminal `SELECT 1` is ceremonial: it passes `SQLCLEAN022` and `SQLCLEAN035`, and `SQLCLEAN041` and +`SQLCLEAN042` do not apply. + +| Field | Description | +|-------|-------------| +| `cte_prefixes` | Prefixes of the CTE names the harness generates, which `SQLCLEAN005` treats as reachable | +| `input_cte_prefixes` | Prefixes of the CTEs standing in for the body's inputs | +| `empty_relation` | The table function an input CTE reads with `SELECT * FROM ()` when it has no rows, which passes `SQLCLEAN021` | ## Fixes `LintDiagnostic.fix` holds at most one edit: `start`, `end` and `replacement`. The engine does not -say whether a fix is safe. The `sqlclean` command treats the `SQC001` fix as unsafe, because +say whether a fix is safe. The `sqlclean` command treats the `SQLCLEAN001` fix as unsafe, because turning `= NULL` into `IS NULL` changes which rows match, and every other built-in fix as safe. A host that applies fixes makes the same decision. See [Safe and unsafe fixes](rules/overview.md#safe-and-unsafe-fixes). diff --git a/crates/sqlclean-cli/skill/references/docs/formatting.md b/crates/sqlclean-cli/skill/references/docs/formatting.md index c6d51b1..7f42db6 100644 --- a/crates/sqlclean-cli/skill/references/docs/formatting.md +++ b/crates/sqlclean-cli/skill/references/docs/formatting.md @@ -197,4 +197,4 @@ j.sql: could not format: Parse error at line 1, column 16: Expected Colon, got R other file as usual. Fix the SQL, set the file's dialect with [`[[dialects]]`](configuration.md#dialects), or [`exclude`](configuration.md#choosing-files) the file. `sqlclean check` reports the same file as -[`SQC999`](rules/overview.md#parse-errors-sqc999). +[`SQLCLEAN999`](rules/overview.md#parse-errors-sqlclean999). diff --git a/crates/sqlclean-cli/skill/references/docs/index.md b/crates/sqlclean-cli/skill/references/docs/index.md index 42d7053..e8a82c2 100644 --- a/crates/sqlclean-cli/skill/references/docs/index.md +++ b/crates/sqlclean-cli/skill/references/docs/index.md @@ -16,7 +16,7 @@ sqlclean check ``` ```text -error[SQC001]: Comparison with NULL is never true +error[SQLCLEAN001]: Comparison with NULL is never true --> models/open_orders.sql:1:77 | 1 | with open_orders as (select order_id, amount from orders where cancelled_at = null), @@ -24,7 +24,7 @@ error[SQC001]: Comparison with NULL is never true = help: Use IS NULL or IS NOT NULL when testing for NULL. = fix: available with `sqlclean fix --unsafe-fixes` -error[SQC005]: CTE is unreachable from the final query +error[SQLCLEAN005]: CTE is unreachable from the final query --> models/open_orders.sql:2:1 | 2 | refunds as (select order_id from payments where amount < 0) diff --git a/crates/sqlclean-cli/skill/references/docs/quickstart.md b/crates/sqlclean-cli/skill/references/docs/quickstart.md index d08c635..ee8f369 100644 --- a/crates/sqlclean-cli/skill/references/docs/quickstart.md +++ b/crates/sqlclean-cli/skill/references/docs/quickstart.md @@ -49,7 +49,7 @@ sqlclean check ``` ```text -error[SQC001]: Comparison with NULL is never true +error[SQLCLEAN001]: Comparison with NULL is never true --> models/open_orders.sql:1:90 | 1 | with open_orders as (select order_id, customer_id, amount from orders where cancelled_at = null), @@ -57,7 +57,7 @@ error[SQC001]: Comparison with NULL is never true = help: Use IS NULL or IS NOT NULL when testing for NULL. = fix: available with `sqlclean fix --unsafe-fixes` -error[SQC005]: CTE is unreachable from the final query +error[SQLCLEAN005]: CTE is unreachable from the final query --> models/open_orders.sql:2:1 | 2 | refunds as (select order_id from payments where amount < 0) @@ -65,7 +65,7 @@ error[SQC005]: CTE is unreachable from the final query = help: Reference the CTE from the final query or another reachable CTE, or remove it. = fix: available with `sqlclean fix` -error[SQC004]: Row selection is nondeterministic +error[SQLCLEAN004]: Row selection is nondeterministic --> models/open_orders.sql:3:55 | 3 | select order_id, customer_id, amount from open_orders limit 100 @@ -89,7 +89,7 @@ sqlclean fix ```text Fixed 1 problem(s) and changed 1 file(s). -error[SQC001]: Comparison with NULL is never true +error[SQLCLEAN001]: Comparison with NULL is never true --> models/open_orders.sql:7:24 | 7 | WHERE cancelled_at = NULL @@ -97,7 +97,7 @@ error[SQC001]: Comparison with NULL is never true = help: Use IS NULL or IS NOT NULL when testing for NULL. = fix: available with `sqlclean fix --unsafe-fixes` -error[SQC004]: Row selection is nondeterministic +error[SQLCLEAN004]: Row selection is nondeterministic --> models/open_orders.sql:15:1 | 15 | LIMIT 100 @@ -115,7 +115,7 @@ it: sqlclean fix --unsafe-fixes ``` -`SQC004` has no fix: only you know which column makes the order deterministic. Add +`SQLCLEAN004` has no fix: only you know which column makes the order deterministic. Add `ORDER BY order_id` before `LIMIT 100`, then run `sqlclean fix` once more to format the new clause. The file is now: @@ -169,28 +169,28 @@ every built-in Rule: dialect = "duckdb" [rules] -select = ["SQC"] +select = ["SQLCLEAN"] ``` -`SQC` is a prefix, so it selects all 53 built-in Rules. Three of the opt-in structure Rules now +`SQLCLEAN` is a prefix, so it selects all 53 built-in Rules. Three of the opt-in structure Rules now report the file: ```text -error[SQC041]: Only the last top-level CTE may be named final, and it must use that name +error[SQLCLEAN041]: Only the last top-level CTE may be named final, and it must use that name --> models/open_orders.sql:1:6 | 1 | WITH open_orders AS ( | ^^^^^^^^^^^ - = help: Name the last top-level CTE final and rename any other final CTE; SQC035 checks the terminal SELECT. + = help: Name the last top-level CTE final and rename any other final CTE; SQLCLEAN035 checks the terminal SELECT. -error[SQC034]: Query keeps transformation logic outside top-level CTEs +error[SQLCLEAN034]: Query keeps transformation logic outside top-level CTEs --> models/open_orders.sql:10:1 | 10 | SELECT | ^^^^^^ = help: Move transformation logic into named top-level CTEs before the terminal SELECT. -error[SQC035]: Terminal SELECT does not read plainly from the final top-level CTE +error[SQLCLEAN035]: Terminal SELECT does not read plainly from the final top-level CTE --> models/open_orders.sql:10:1 | 10 | SELECT @@ -206,8 +206,8 @@ Follow them, or skip the ones your team does not want: dialect = "duckdb" [rules] -select = ["SQC"] -ignore = ["SQC034", "SQC035", "SQC041"] +select = ["SQLCLEAN"] +ignore = ["SQLCLEAN034", "SQLCLEAN035", "SQLCLEAN041"] ``` ```text diff --git a/crates/sqlclean-cli/skill/references/docs/rules/overview.md b/crates/sqlclean-cli/skill/references/docs/rules/overview.md index 4794250..8475f9c 100644 --- a/crates/sqlclean-cli/skill/references/docs/rules/overview.md +++ b/crates/sqlclean-cli/skill/references/docs/rules/overview.md @@ -13,14 +13,14 @@ Online: https://sqlclean.io/docs/rules/overview/ - Thresholds - Exceptions - Path ignores -- Parse errors (SQC999) +- Parse errors (SQLCLEAN999) - Safe and unsafe fixes - Caching - Continue A Rule is one check over SQL. It reports a finding at an exact position, with a message, a -remediation, and sometimes a fix. SQLClean has 53 built-in Rules with `SQC###` codes, and runs -[custom Rules](../custom-rules/overview.md) written in Python with `XSQC` codes. +remediation, and sometimes a fix. SQLClean has 53 built-in Rules with `SQLCLEAN###` codes, and runs +[custom Rules](../custom-rules/overview.md) written in Python with `XSQLCLEAN` codes. Consider a query that returns an arbitrary hundred rows: @@ -35,7 +35,7 @@ sqlclean check ``` ```text -error[SQC004]: Row selection is nondeterministic +error[SQLCLEAN004]: Row selection is nondeterministic --> models/recent_orders.sql:3:1 | 3 | LIMIT 100 @@ -62,26 +62,26 @@ Select Rules in `sqlclean.toml` by exact code or by prefix: ```toml [rules] -select = ["SQC"] # every built-in Rule -ignore = ["SQC027", "SQC03"] # except SQC027 and SQC030 to SQC039 +select = ["SQLCLEAN"] # every built-in Rule +ignore = ["SQLCLEAN027", "SQLCLEAN03"] # except SQLCLEAN027 and SQLCLEAN030 to SQLCLEAN039 ``` -- An exact code such as `SQC004` selects one Rule. -- A prefix selects every Rule whose code starts with it: `SQC` selects all built-in Rules, - `SQC00` selects `SQC001` to `SQC009`, and `XSQCNAME` selects every custom Rule in the `NAME` +- An exact code such as `SQLCLEAN004` selects one Rule. +- A prefix selects every Rule whose code starts with it: `SQLCLEAN` selects all built-in Rules, + `SQLCLEAN00` selects `SQLCLEAN001` to `SQLCLEAN009`, and `XSQLCLEANNAME` selects every custom Rule in the `NAME` family. - `ignore` removes Rules from the selection, by code or prefix. -- A selector that matches no Rule is an error: `Rule selector 'SQC9' matches no Rule`. +- A selector that matches no Rule is an error: `Rule selector 'SQLCLEAN9' matches no Rule`. On the command line, `--select` replaces `[rules] select` for one run, and `--ignore` adds to `[rules] ignore`: ```bash -sqlclean check --select SQC001,SQC004 -sqlclean check --ignore SQC039 +sqlclean check --select SQLCLEAN001,SQLCLEAN004 +sqlclean check --ignore SQLCLEAN039 ``` -`select = []` runs no Rules. Files are still parsed, so [parse errors](#parse-errors-sqc999) are +`select = []` runs no Rules. Files are still parsed, so [parse errors](#parse-errors-sqlclean999) are still reported. ## Default Rules @@ -92,19 +92,19 @@ duplicate names and unused CTEs. | Code | Finding | |------|---------| -| `SQC001` | Comparison with NULL is never true | -| `SQC002` | Comma-separated sources create an implicit cartesian join | -| `SQC003` | Non-cross join has no meaningful condition | -| `SQC004` | Row selection is nondeterministic | -| `SQC005` | CTE is unreachable from the final query | -| `SQC006` | DISTINCT is redundant with the grouped output | -| `SQC007` | Positional set operation is vulnerable to column-order drift | -| `SQC009` | Table alias is duplicated in the same query scope | -| `SQC010` | Output alias is duplicated in the same select list | -| `SQC018` | ROW_NUMBER has no deterministic ordering | -| `SQC019` | NOT IN list contains NULL and can reject every row | -| `SQC020` | Set-operation branches project different column counts | -| `SQC039` | Inline query relation obscures data flow | +| `SQLCLEAN001` | Comparison with NULL is never true | +| `SQLCLEAN002` | Comma-separated sources create an implicit cartesian join | +| `SQLCLEAN003` | Non-cross join has no meaningful condition | +| `SQLCLEAN004` | Row selection is nondeterministic | +| `SQLCLEAN005` | CTE is unreachable from the final query | +| `SQLCLEAN006` | DISTINCT is redundant with the grouped output | +| `SQLCLEAN007` | Positional set operation is vulnerable to column-order drift | +| `SQLCLEAN009` | Table alias is duplicated in the same query scope | +| `SQLCLEAN010` | Output alias is duplicated in the same select list | +| `SQLCLEAN018` | ROW_NUMBER has no deterministic ordering | +| `SQLCLEAN019` | NOT IN list contains NULL and can reject every row | +| `SQLCLEAN020` | Set-operation branches project different column counts | +| `SQLCLEAN039` | Inline query relation obscures data flow | The other 32 Rules run only when `select` names them, by code or prefix. A prefix selects every matching Rule, whether it is enabled by default or not. A custom Rule is enabled by default when its @@ -116,8 +116,8 @@ Two built-in Rules compare against a limit, set under `[rules.thresholds]`: ```toml [rules.thresholds] -max_literal_length = 100 # SQC044: the longest string literal allowed -max_ranking_order_by = 5 # SQC043: the most ORDER BY expressions in a ranking window +max_literal_length = 100 # SQLCLEAN044: the longest string literal allowed +max_ranking_order_by = 5 # SQLCLEAN043: the most ORDER BY expressions in a ranking window ``` Both default to the values shown and must be positive. Built-in Rules take no other options. @@ -129,7 +129,7 @@ so a reviewer sees it in the diff, and it needs a reason: ```toml [[rules.rule_exceptions]] -rule = "SQC004" +rule = "SQLCLEAN004" path = "models/order_preview.sql" reason = "A preview of any hundred orders is enough." ``` @@ -137,23 +137,23 @@ reason = "A preview of any hundred orders is enough." `rule` is an exact code, not a prefix. `path` is the file's path relative to the configuration root, as findings print it. The exception covers every finding of that Rule in that file. -SQLClean has no inline suppression comments. A comment such as `-- noqa: SQC001` changes nothing; +SQLClean has no inline suppression comments. A comment such as `-- noqa: SQLCLEAN001` changes nothing; record the exception in `sqlclean.toml` instead. ### Cross join exceptions A file-wide exception for a deliberate `CROSS JOIN` would also hide any accidental cross join added -to the file later. `SQC038` findings name the joined relation and, inside a CTE, that CTE, so an +to the file later. `SQLCLEAN038` findings name the joined relation and, inside a CTE, that CTE, so an exception can cover exactly one join: ```text -error[SQC038]: CROSS JOIN with `regions` in CTE `pairs` creates an explicit cartesian product +error[SQLCLEAN038]: CROSS JOIN with `regions` in CTE `pairs` creates an explicit cartesian product --> models/calendar.sql:11:3 ``` ```toml [[rules.rule_exceptions]] -rule = "SQC038" +rule = "SQLCLEAN038" path = "models/calendar.sql" relation = "regions" cte = "pairs" # optional: only the join inside this CTE @@ -161,10 +161,10 @@ reason = "Every order date is reported for every region." ``` `relation` and `cte` compare without case. Without `cte`, the exception covers joins with that -relation anywhere in the file. `cte` needs `relation`, and `relation` matches only `SQC038` +relation anywhere in the file. `cte` needs `relation`, and `relation` matches only `SQLCLEAN038` findings. -`SQC038` does not report a cross join when one side is provably at most one row, such as a CTE +`SQLCLEAN038` does not report a cross join when one side is provably at most one row, such as a CTE that aggregates without `GROUP BY`, `LIMIT 1` or a one-row `VALUES`, so many intentional cross joins need no exception: @@ -186,11 +186,11 @@ CROSS JOIN latest AS l An exception that matches no finding is stale. `check` and `fix` report it and exit with `1`: ```text -error: stale rule exception: SQC004 in models/orders.sql matches no finding; remove it from sqlclean.toml +error: stale rule exception: SQLCLEAN004 in models/orders.sql matches no finding; remove it from sqlclean.toml ``` Delete the entry. Exceptions are only judged stale when their file was checked and their Rule was -selected, so `sqlclean check models/customers.sql` or `--select SQC001` never reports an exception +selected, so `sqlclean check models/customers.sql` or `--select SQLCLEAN001` never reports an exception for another file or Rule. ## Path ignores @@ -200,7 +200,7 @@ prefixes, glob patterns, and a reason: ```toml [[rules.rule_ignores]] -rules = ["SQC034", "SQC035"] +rules = ["SQLCLEAN034", "SQLCLEAN035"] paths = ["scratch/**"] reason = "Exploratory queries." ``` @@ -208,13 +208,13 @@ reason = "Exploratory queries." Patterns are relative to the configuration root, as in [`include`](../configuration.md#choosing-files). To turn a Rule off everywhere, add it to `[rules] ignore` instead. -## Parse errors (SQC999) +## Parse errors (SQLCLEAN999) -A file that does not parse under its [dialect](../configuration.md#dialects) gets one `SQC999` +A file that does not parse under its [dialect](../configuration.md#dialects) gets one `SQLCLEAN999` finding and no other: ```text -error[SQC999]: SQL could not be parsed as duckdb: Parse error at line 1, column 18: Expected table name or subquery, got Where +error[SQLCLEAN999]: SQL could not be parsed as duckdb: Parse error at line 1, column 18: Expected table name or subquery, got Where --> scratch/broken.sql:1:1 | 1 | SELECT FROM WHERE orders @@ -223,7 +223,7 @@ error[SQC999]: SQL could not be parsed as duckdb: Parse error at line 1, column ``` The finding points at the start of the file; the message gives the line and column of the error. -`SQC999` is not a selectable Rule: selection, `ignore`, path ignores and exceptions never hide it. +`SQLCLEAN999` is not a selectable Rule: selection, `ignore`, path ignores and exceptions never hide it. Fix the SQL, set the right dialect for the file, or [`exclude`](../configuration.md#choosing-files) the file. @@ -231,9 +231,9 @@ the file. Some Rules carry a fix. [`sqlclean fix`](../cli/fix.md) applies it. -- A **safe** fix keeps the query's results. `SQC005` removes an unused CTE; `SQC002` turns +- A **safe** fix keeps the query's results. `SQLCLEAN005` removes an unused CTE; `SQLCLEAN002` turns `FROM orders, customers` into `FROM orders CROSS JOIN customers`. -- An **unsafe** fix can change results. `SQC001` turns `cancelled_at = NULL`, which matches no row, +- An **unsafe** fix can change results. `SQLCLEAN001` turns `cancelled_at = NULL`, which matches no row, into `cancelled_at IS NULL`, which matches rows. That is almost always the intent, but it is a different query. Unsafe fixes apply only with `--unsafe-fixes`. diff --git a/crates/sqlclean-cli/skill/references/docs/rules/reference.md b/crates/sqlclean-cli/skill/references/docs/rules/reference.md index 2dec3ac..8299a30 100644 --- a/crates/sqlclean-cli/skill/references/docs/rules/reference.md +++ b/crates/sqlclean-cli/skill/references/docs/rules/reference.md @@ -8,14 +8,14 @@ Online: https://sqlclean.io/docs/rules/reference/ ## Contents -- SQC001: NULL comparisons -- SQC002 and SQC003: joins without keys -- SQC005: unused CTEs -- SQC018: unstable row numbers -- SQC019: NULL in NOT IN -- SQC046 to SQC053: queries that cannot do what they say -- SQC039: inline subqueries -- SQC034, SQC035 and SQC041: query structure +- SQLCLEAN001: NULL comparisons +- SQLCLEAN002 and SQLCLEAN003: joins without keys +- SQLCLEAN005: unused CTEs +- SQLCLEAN018: unstable row numbers +- SQLCLEAN019: NULL in NOT IN +- SQLCLEAN046 to SQLCLEAN053: queries that cannot do what they say +- SQLCLEAN039: inline subqueries +- SQLCLEAN034, SQLCLEAN035 and SQLCLEAN041: query structure SQLClean has 53 built-in Rules. `Default` Rules run when `[rules] select` is not set; the others run when `select` names them by code or prefix. A Rule with a `Safe` fix is fixed by @@ -27,67 +27,67 @@ Run `sqlclean rules ` for a Rule's remediation. | Code | Finding | Default | Fix | |------|---------|---------|-----| -| `SQC001` | Comparison with NULL is never true | Yes | Unsafe | -| `SQC002` | Comma-separated sources create an implicit cartesian join | Yes | Safe | -| `SQC003` | Non-cross join has no meaningful condition | Yes | Safe | -| `SQC004` | Row selection is nondeterministic | Yes | — | -| `SQC005` | CTE is unreachable from the final query | Yes | Safe | -| `SQC006` | DISTINCT is redundant with the grouped output | Yes | Safe | -| `SQC007` | Positional set operation is vulnerable to column-order drift | Yes | — | -| `SQC008` | Bare UNION leaves duplicate handling implicit | No | Safe | -| `SQC009` | Table alias is duplicated in the same query scope | Yes | — | -| `SQC010` | Output alias is duplicated in the same select list | Yes | — | -| `SQC011` | Clause mixes positional and named references | No | — | -| `SQC012` | ELSE NULL is redundant | No | — | -| `SQC013` | DISTINCT is written as though it were a function | No | Safe | -| `SQC014` | Predicate compares identical constant values | No | — | -| `SQC015` | Empty statement follows another terminator | No | — | -| `SQC016` | Expression is aliased to its existing name | No | — | -| `SQC017` | Row count uses a constant expression | No | Safe | -| `SQC018` | ROW_NUMBER has no deterministic ordering | Yes | — | -| `SQC019` | NOT IN list contains NULL and can reject every row | Yes | — | -| `SQC020` | Set-operation branches project different column counts | Yes | — | -| `SQC021` | Query output has an uncontrolled column shape | No | — | -| `SQC022` | Calculated projection has no stable output name | No | — | -| `SQC023` | Table alias is never referenced | No | — | -| `SQC024` | WHERE predicate rejects NULL rows from a LEFT JOIN | No | — | -| `SQC025` | JOIN type is implicit | No | Safe | -| `SQC026` | ORDER BY mixes explicit and implicit directions | No | — | -| `SQC027` | Column reference is unqualified in a multi-relation query | No | — | -| `SQC028` | Single-relation query mixes qualified and unqualified columns | No | — | -| `SQC029` | Qualified reference uses a relation absent from this query scope | No | — | -| `SQC030` | CASE expression only converts a condition to TRUE or FALSE | No | Safe | -| `SQC031` | CASE expression is nested directly inside ELSE | No | — | -| `SQC032` | Joined relation contributes no referenced values | No | — | -| `SQC033` | Comment is not attached to the syntax node it explains | No | — | -| `SQC034` | Query keeps transformation logic outside top-level CTEs | No | — | -| `SQC035` | Terminal SELECT does not read plainly from the final top-level CTE | No | — | -| `SQC036` | CTE is nested inside another query | No | — | -| `SQC037` | Recursive CTE is not permitted | No | — | -| `SQC038` | CROSS JOIN creates an explicit cartesian product | No | — | -| `SQC039` | Inline query relation obscures data flow | Yes | — | -| `SQC040` | JOIN ON contains a computed expression | No | — | -| `SQC041` | Only the last top-level CTE may be named final, and it must use that name | No | — | -| `SQC042` | CTE output column is never read later in the query | No | — | -| `SQC043` | Ranking window sorts by more expressions than max_ranking_order_by | No | — | -| `SQC044` | String literal is longer than max_literal_length | No | — | -| `SQC045` | JSON payload is parsed more than once in one SELECT | No | — | -| `SQC046` | WITH defines the same CTE name more than once | Yes | — | -| `SQC047` | CASE WHEN NULL or IN (NULL) never matches | Yes | — | -| `SQC048` | Expression divides by a literal zero | Yes | — | -| `SQC049` | UPDATE or DELETE has no limiting predicate and changes every row | No | — | -| `SQC050` | INSERT does not list its target columns | No | — | -| `SQC051` | NATURAL JOIN matches on every column the relations share | Yes | — | -| `SQC052` | BETWEEN lower bound is greater than its upper bound | Yes | — | -| `SQC053` | Aggregate or window function is used in WHERE, ON or GROUP BY | Yes | — | - -`SQC999` is the [parse error](overview.md#parse-errors-sqc999) finding. It is not a +| `SQLCLEAN001` | Comparison with NULL is never true | Yes | Unsafe | +| `SQLCLEAN002` | Comma-separated sources create an implicit cartesian join | Yes | Safe | +| `SQLCLEAN003` | Non-cross join has no meaningful condition | Yes | Safe | +| `SQLCLEAN004` | Row selection is nondeterministic | Yes | — | +| `SQLCLEAN005` | CTE is unreachable from the final query | Yes | Safe | +| `SQLCLEAN006` | DISTINCT is redundant with the grouped output | Yes | Safe | +| `SQLCLEAN007` | Positional set operation is vulnerable to column-order drift | Yes | — | +| `SQLCLEAN008` | Bare UNION leaves duplicate handling implicit | No | Safe | +| `SQLCLEAN009` | Table alias is duplicated in the same query scope | Yes | — | +| `SQLCLEAN010` | Output alias is duplicated in the same select list | Yes | — | +| `SQLCLEAN011` | Clause mixes positional and named references | No | — | +| `SQLCLEAN012` | ELSE NULL is redundant | No | — | +| `SQLCLEAN013` | DISTINCT is written as though it were a function | No | Safe | +| `SQLCLEAN014` | Predicate compares identical constant values | No | — | +| `SQLCLEAN015` | Empty statement follows another terminator | No | — | +| `SQLCLEAN016` | Expression is aliased to its existing name | No | — | +| `SQLCLEAN017` | Row count uses a constant expression | No | Safe | +| `SQLCLEAN018` | ROW_NUMBER has no deterministic ordering | Yes | — | +| `SQLCLEAN019` | NOT IN list contains NULL and can reject every row | Yes | — | +| `SQLCLEAN020` | Set-operation branches project different column counts | Yes | — | +| `SQLCLEAN021` | Query output has an uncontrolled column shape | No | — | +| `SQLCLEAN022` | Calculated projection has no stable output name | No | — | +| `SQLCLEAN023` | Table alias is never referenced | No | — | +| `SQLCLEAN024` | WHERE predicate rejects NULL rows from a LEFT JOIN | No | — | +| `SQLCLEAN025` | JOIN type is implicit | No | Safe | +| `SQLCLEAN026` | ORDER BY mixes explicit and implicit directions | No | — | +| `SQLCLEAN027` | Column reference is unqualified in a multi-relation query | No | — | +| `SQLCLEAN028` | Single-relation query mixes qualified and unqualified columns | No | — | +| `SQLCLEAN029` | Qualified reference uses a relation absent from this query scope | No | — | +| `SQLCLEAN030` | CASE expression only converts a condition to TRUE or FALSE | No | Safe | +| `SQLCLEAN031` | CASE expression is nested directly inside ELSE | No | — | +| `SQLCLEAN032` | Joined relation contributes no referenced values | No | — | +| `SQLCLEAN033` | Comment is not attached to the syntax node it explains | No | — | +| `SQLCLEAN034` | Query keeps transformation logic outside top-level CTEs | No | — | +| `SQLCLEAN035` | Terminal SELECT does not read plainly from the final top-level CTE | No | — | +| `SQLCLEAN036` | CTE is nested inside another query | No | — | +| `SQLCLEAN037` | Recursive CTE is not permitted | No | — | +| `SQLCLEAN038` | CROSS JOIN creates an explicit cartesian product | No | — | +| `SQLCLEAN039` | Inline query relation obscures data flow | Yes | — | +| `SQLCLEAN040` | JOIN ON contains a computed expression | No | — | +| `SQLCLEAN041` | Only the last top-level CTE may be named final, and it must use that name | No | — | +| `SQLCLEAN042` | CTE output column is never read later in the query | No | — | +| `SQLCLEAN043` | Ranking window sorts by more expressions than max_ranking_order_by | No | — | +| `SQLCLEAN044` | String literal is longer than max_literal_length | No | — | +| `SQLCLEAN045` | JSON payload is parsed more than once in one SELECT | No | — | +| `SQLCLEAN046` | WITH defines the same CTE name more than once | Yes | — | +| `SQLCLEAN047` | CASE WHEN NULL or IN (NULL) never matches | Yes | — | +| `SQLCLEAN048` | Expression divides by a literal zero | Yes | — | +| `SQLCLEAN049` | UPDATE or DELETE has no limiting predicate and changes every row | No | — | +| `SQLCLEAN050` | INSERT does not list its target columns | No | — | +| `SQLCLEAN051` | NATURAL JOIN matches on every column the relations share | Yes | — | +| `SQLCLEAN052` | BETWEEN lower bound is greater than its upper bound | Yes | — | +| `SQLCLEAN053` | Aggregate or window function is used in WHERE, ON or GROUP BY | Yes | — | + +`SQLCLEAN999` is the [parse error](overview.md#parse-errors-sqlclean999) finding. It is not a selectable Rule. -`SQC043` and `SQC044` read their limits from +`SQLCLEAN043` and `SQLCLEAN044` read their limits from [`[rules.thresholds]`](overview.md#thresholds). -## SQC001: NULL comparisons +## SQLCLEAN001: NULL comparisons `= NULL` and `<> NULL` are never true, so the filter matches no row: @@ -98,7 +98,7 @@ WHERE cancelled_at = NULL ``` ```text -error[SQC001]: Comparison with NULL is never true +error[SQLCLEAN001]: Comparison with NULL is never true --> models/open_orders.sql:3:20 | 3 | WHERE cancelled_at = NULL @@ -110,7 +110,7 @@ error[SQC001]: Comparison with NULL is never true The fix writes `WHERE cancelled_at IS NULL`. It is unsafe because the query then returns rows it did not return before. -## SQC002 and SQC003: joins without keys +## SQLCLEAN002 and SQLCLEAN003: joins without keys A comma between sources joins every row with every row unless a `WHERE` predicate filters them: @@ -122,7 +122,7 @@ FROM orders AS o, customers AS c WHERE o.customer_id = c.customer_id ``` -The `SQC002` fix keeps the meaning and makes the cartesian product visible: +The `SQLCLEAN002` fix keeps the meaning and makes the cartesian product visible: ```diff -FROM orders AS o, customers AS c @@ -132,10 +132,10 @@ The `SQC002` fix keeps the meaning and makes the cartesian product visible: ``` Move the predicate into `JOIN customers AS c ON o.customer_id = c.customer_id` by hand to make it a -keyed join. `SQC003` reports a `JOIN` whose condition is missing or constant, such as +keyed join. `SQLCLEAN003` reports a `JOIN` whose condition is missing or constant, such as `ON 1 = 1`; its fix turns a `JOIN` without a condition into `CROSS JOIN`. -## SQC005: unused CTEs +## SQLCLEAN005: unused CTEs A CTE that no reachable query reads is dead code: @@ -152,7 +152,7 @@ SELECT order_id FROM paid ``` ```text -error[SQC005]: CTE is unreachable from the final query +error[SQLCLEAN005]: CTE is unreachable from the final query --> models/paid_orders.sql:5:1 | 5 | refunded AS ( @@ -163,7 +163,7 @@ error[SQC005]: CTE is unreachable from the final query The fix removes `refunded`. -## SQC018: unstable row numbers +## SQLCLEAN018: unstable row numbers Without `ORDER BY` in the window, the row numbered `1` can change between runs: @@ -175,7 +175,7 @@ FROM orders ``` ```text -error[SQC018]: ROW_NUMBER has no deterministic ordering +error[SQLCLEAN018]: ROW_NUMBER has no deterministic ordering ``` An `ORDER BY` is not always enough. Ties still rank in any order unless `PARTITION BY` and @@ -184,7 +184,7 @@ from the query, such as the `GROUP BY` columns of a CTE. Ranking straight over a with a second message: ```text -error[SQC018]: Ranking window order is not proven unique within its partition +error[SQLCLEAN018]: Ranking window order is not proven unique within its partition --> models/ranked.sql:3:3 | 3 | ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY ordered_at, order_id) AS order_rank @@ -212,10 +212,10 @@ SELECT FROM customer_orders ``` -`SQC004` reports the same problem for `LIMIT` and `OFFSET`; see the example in +`SQLCLEAN004` reports the same problem for `LIMIT` and `OFFSET`; see the example in [Rules](overview.md). -## SQC019: NULL in NOT IN +## SQLCLEAN019: NULL in NOT IN `x NOT IN (..., NULL)` is never true, so the filter rejects every row: @@ -226,7 +226,7 @@ WHERE status NOT IN ('cancelled', NULL) ``` ```text -error[SQC019]: NOT IN list contains NULL and can reject every row +error[SQLCLEAN019]: NOT IN list contains NULL and can reject every row --> models/active_orders.sql:3:14 | 3 | WHERE status NOT IN ('cancelled', NULL) @@ -234,49 +234,49 @@ error[SQC019]: NOT IN list contains NULL and can reject every row = help: Remove NULL from the list or express the anti-join with a NULL-safe NOT EXISTS. ``` -## SQC046 to SQC053: queries that cannot do what they say +## SQLCLEAN046 to SQLCLEAN053: queries that cannot do what they say These Rules report a query that runs differently from how it reads. None of them has a fix, because only the author knows the intended query. ```sql WITH paid AS ( - SELECT order_id, amount FROM orders WHERE amount BETWEEN 100 AND 10 -- SQC052 + SELECT order_id, amount FROM orders WHERE amount BETWEEN 100 AND 10 -- SQLCLEAN052 ), -paid AS ( -- SQC046 - SELECT order_id, amount / 0 AS ratio FROM orders -- SQC048 +paid AS ( -- SQLCLEAN046 + SELECT order_id, amount / 0 AS ratio FROM orders -- SQLCLEAN048 ) SELECT customer_id, - CASE region WHEN NULL THEN 'unknown' END AS region_label -- SQC047 + CASE region WHEN NULL THEN 'unknown' END AS region_label -- SQLCLEAN047 FROM paid -NATURAL JOIN customers -- SQC051 -WHERE SUM(amount) > 100 -- SQC053 +NATURAL JOIN customers -- SQLCLEAN051 +WHERE SUM(amount) > 100 -- SQLCLEAN053 GROUP BY customer_id ``` -- `SQC046` compares CTE names within one `WITH` the way the dialect resolves them, so quoted +- `SQLCLEAN046` compares CTE names within one `WITH` the way the dialect resolves them, so quoted `"paid"` and `"PAID"` are different names in PostgreSQL but the same name in DuckDB. A nested `WITH` may reuse an outer name. -- `SQC047` reports `CASE x WHEN NULL` and `x IN (NULL)`, which compare with `=` and so never - match. `NOT IN` lists with NULL are `SQC019`. -- `SQC048` reports `/`, `%`, `//` and `MOD` with a literal zero divisor. Depending on the dialect +- `SQLCLEAN047` reports `CASE x WHEN NULL` and `x IN (NULL)`, which compare with `=` and so never + match. `NOT IN` lists with NULL are `SQLCLEAN019`. +- `SQLCLEAN048` reports `/`, `%`, `//` and `MOD` with a literal zero divisor. Depending on the dialect the query fails or returns NULL. -- `SQC052` compares number and `DATE 'YYYY-MM-DD'` bounds exactly and skips `BETWEEN SYMMETRIC`. +- `SQLCLEAN052` compares number and `DATE 'YYYY-MM-DD'` bounds exactly and skips `BETWEEN SYMMETRIC`. A reversed `NOT BETWEEN` is reported too, because it matches every non-NULL value. -- `SQC053` reports aggregates in `WHERE`, `ON` and `GROUP BY`, and window functions anywhere in +- `SQLCLEAN053` reports aggregates in `WHERE`, `ON` and `GROUP BY`, and window functions anywhere in those clauses. Aggregates in `HAVING`, windows in `QUALIFY`, `FILTER (WHERE ...)` and subqueries pass. Two write-safety Rules are opt-in, because changing every row is sometimes intended: -- `SQC049` reports an `UPDATE` or `DELETE` whose rows nothing limits. A `WHERE` without a column +- `SQLCLEAN049` reports an `UPDATE` or `DELETE` whose rows nothing limits. A `WHERE` without a column or subquery, such as `WHERE TRUE`, does not count; a join condition or a `LIMIT` does. -- `SQC050` reports `INSERT` without a column list. `DEFAULT VALUES` and DuckDB's `BY NAME` pass. +- `SQLCLEAN050` reports `INSERT` without a column list. `DEFAULT VALUES` and DuckDB's `BY NAME` pass. -## SQC039: inline subqueries +## SQLCLEAN039: inline subqueries A subquery in `FROM` hides a step of the query. Name it as a CTE instead: @@ -296,7 +296,7 @@ SELECT order_id FROM paid ``` -## SQC034, SQC035 and SQC041: query structure +## SQLCLEAN034, SQLCLEAN035 and SQLCLEAN041: query structure These opt-in Rules ask every query to do its work in top-level CTEs, name the last one `final`, and end with a plain `SELECT` of named columns from it. This query passes every built-in Rule: @@ -323,4 +323,4 @@ SELECT FROM final ``` -Ending with `SELECT * FROM final` is reported by `SQC021`, which asks for named output columns. +Ending with `SELECT * FROM final` is reported by `SQLCLEAN021`, which asks for named output columns. diff --git a/crates/sqlclean-cli/src/checking/_helpers/orchestration.rs b/crates/sqlclean-cli/src/checking/_helpers/orchestration.rs index 6fc3a19..2b708cc 100644 --- a/crates/sqlclean-cli/src/checking/_helpers/orchestration.rs +++ b/crates/sqlclean-cli/src/checking/_helpers/orchestration.rs @@ -100,7 +100,7 @@ pub(crate) fn check_sources( }) } -/// Whether a selection can match custom Rules: an `XSQC` code or a prefix of `XSQC`. +/// Whether a selection can match custom Rules: an `XSQLCLEAN` code or a prefix of `XSQLCLEAN`. fn selects_custom_rules(selectors: &[String]) -> bool { selectors.iter().any(|selector| { selector.starts_with(CUSTOM_RULE_PREFIX) diff --git a/crates/sqlclean-cli/src/checking/constants.rs b/crates/sqlclean-cli/src/checking/constants.rs index d0cb12f..c35f2eb 100644 --- a/crates/sqlclean-cli/src/checking/constants.rs +++ b/crates/sqlclean-cli/src/checking/constants.rs @@ -1,15 +1,23 @@ /// The finding for a file the parser rejects; it cannot be deselected or excepted. -pub const PARSE_ERROR_CODE: &str = "SQC999"; +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."; -pub(crate) const CROSS_JOIN_CODE: &str = "SQC038"; +pub(crate) const CROSS_JOIN_CODE: &str = "SQLCLEAN038"; /// Custom Rule codes start with this prefix. -pub const CUSTOM_RULE_PREFIX: &str = "XSQC"; +pub const CUSTOM_RULE_PREFIX: &str = "XSQLCLEAN"; /// Built-in Rules whose findings can carry a fix. pub(crate) const FIXABLE_BUILTIN_RULES: [&str; 10] = [ - "SQC001", "SQC002", "SQC003", "SQC005", "SQC006", "SQC008", "SQC013", "SQC017", "SQC025", - "SQC030", + "SQLCLEAN001", + "SQLCLEAN002", + "SQLCLEAN003", + "SQLCLEAN005", + "SQLCLEAN006", + "SQLCLEAN008", + "SQLCLEAN013", + "SQLCLEAN017", + "SQLCLEAN025", + "SQLCLEAN030", ]; /// Built-in Rules whose fix can change query results, so `--unsafe-fixes` must opt in. -pub(crate) const UNSAFE_FIX_BUILTIN_RULES: [&str; 1] = ["SQC001"]; +pub(crate) const UNSAFE_FIX_BUILTIN_RULES: [&str; 1] = ["SQLCLEAN001"]; diff --git a/crates/sqlclean-cli/src/config/tests/test_load_config.rs b/crates/sqlclean-cli/src/config/tests/test_load_config.rs index 021acd3..a676b48 100644 --- a/crates/sqlclean-cli/src/config/tests/test_load_config.rs +++ b/crates/sqlclean-cli/src/config/tests/test_load_config.rs @@ -9,11 +9,11 @@ fn given_a_config_file_when_loading_from_below_then_the_nearest_one_applies() -> let test_cases = [ LoadedConfigTestCase { description: "values set in a parent directory apply and unset thresholds keep defaults", - config_text: "dialect = \"duckdb\"\n[format]\nline_width = 80\n[rules]\nselect = [\"SQC004\"]\n[rules.thresholds]\nmax_literal_length = 40\n", + config_text: "dialect = \"duckdb\"\n[format]\nline_width = 80\n[rules]\nselect = [\"SQLCLEAN004\"]\n[rules.thresholds]\nmax_literal_length = 40\n", start: "models/orders", expected_dialect: "duckdb", expected_line_width: 80, - expected_select: Some(&["SQC004"]), + expected_select: Some(&["SQLCLEAN004"]), expected_max_literal_length: 40, expected_max_ranking_order_by: 5, }, @@ -89,7 +89,7 @@ fn given_invalid_values_when_loading_then_each_is_rejected_with_its_reason() -> }, RejectedConfigTestCase { description: "an exception with a blank reason", - config_text: "[[rules.rule_exceptions]]\nrule = \"SQC004\"\npath = \"a.sql\"\nreason = \" \"\n", + config_text: "[[rules.rule_exceptions]]\nrule = \"SQLCLEAN004\"\npath = \"a.sql\"\nreason = \" \"\n", expected_error: "needs a reason", }, RejectedConfigTestCase { diff --git a/crates/sqlclean/src/constants.rs b/crates/sqlclean/src/constants.rs index 2954b0f..068c43d 100644 --- a/crates/sqlclean/src/constants.rs +++ b/crates/sqlclean/src/constants.rs @@ -10,6 +10,16 @@ pub(crate) const SETTING_SNIPPET_INDENT: &str = " "; /// The configuration file remediation text names when the request does not name one. pub const DEFAULT_CONFIG_FILE: &str = "sqlclean.toml"; /// The identifier prefix a host writes in place of each template interpolation. -pub const LINT_SENTINEL_PREFIX: &str = "__sqb_lint_"; +pub const INTERPOLATION_PLACEHOLDER_PREFIX: &str = "__sqlclean_interpolation_"; /// The identifier prefix a host writes in place of a SQL macro parameter. -pub const SQL_MACRO_PARAMETER_SENTINEL_PREFIX: &str = "__sqlbuild_macro_parameter_"; +pub const MACRO_PARAMETER_PLACEHOLDER_PREFIX: &str = "__sqlclean_macro_parameter_"; +/// The prefix of a relation parameter placeholder, written `__`. +pub const RELATION_PARAMETER_PLACEHOLDER_PREFIX: &str = "__sqlclean_relation_parameter_"; +/// The identifier prefix a host writes in place of a value parameter. +pub const VALUE_PARAMETER_PLACEHOLDER_PREFIX: &str = "__sqlclean_value_parameter_"; +/// Placeholders standing for names the host generates, which Rules never judge as authored names. +pub(crate) const GENERATED_PLACEHOLDER_PREFIXES: [&str; 3] = [ + INTERPOLATION_PLACEHOLDER_PREFIX, + RELATION_PARAMETER_PLACEHOLDER_PREFIX, + VALUE_PARAMETER_PLACEHOLDER_PREFIX, +]; diff --git a/crates/sqlclean/src/sql_lint/_helpers/additional.rs b/crates/sqlclean/src/sql_lint/_helpers/additional.rs index 301f421..369923f 100644 --- a/crates/sqlclean/src/sql_lint/_helpers/additional.rs +++ b/crates/sqlclean/src/sql_lint/_helpers/additional.rs @@ -2,26 +2,26 @@ use std::collections::HashSet; use polyglot_sql::tokens::{Span, Token, TokenType}; -use crate::constants::SQL_MACRO_PARAMETER_SENTINEL_PREFIX; use crate::constants::{CLOSE_PAREN, OPEN_PAREN, SQL_WILDCARD}; use crate::constants::{EMPTY_FIXTURE_QUERY_TOKEN_COUNT, ENCLOSING_CTE_TOKEN_COUNT}; +use crate::constants::{ + GENERATED_PLACEHOLDER_PREFIXES, MACRO_PARAMETER_PLACEHOLDER_PREFIX, + RELATION_PARAMETER_PLACEHOLDER_PREFIX, +}; use crate::sql_lint::_helpers::engine::{ direct_indices, is_comment, is_layout, is_query_from, query_end, significant_after, significant_before, token_depths, }; use crate::sql_lint::constants::RELATION_MODIFIER_KEYWORDS; -use crate::sql_lint::models::{AdditionalFactOptions, AdditionalQueryFacts, SqlMacroOutput}; +use crate::sql_lint::models::{ + AdditionalFactOptions, AdditionalQueryFacts, FixtureHarness, SqlMacroOutput, +}; use rustc_hash::{FxHashMap, FxHashSet}; const COUNT_ONE_LITERAL: &str = "1"; const QUALIFIED_REFERENCE_LENGTH: usize = 3; const QUALIFIER_PAIR_LENGTH: usize = 2; const IMPLICIT_ALIAS_MINIMUM_LENGTH: usize = 2; -const GENERATED_IDENTIFIER_PREFIXES: [&str; 3] = [ - "__sqb_lint_", - "__sqlbuild_audit_parameter_", - "__sqlbuild_context_parameter_", -]; struct QuerySlice<'a> { tokens: &'a [Token], @@ -56,7 +56,7 @@ struct SelectFactContext<'a> { dependency_identifiers: &'a HashSet, allows_ceremonial_select: bool, allows_dynamic_output_star: bool, - allows_empty_fixture_star: bool, + fixture_harness: Option<&'a FixtureHarness>, } struct ProjectedStarContext<'a> { @@ -178,7 +178,7 @@ pub(super) fn collect_additional_facts( dependency_identifiers: options.dependency_identifiers, allows_ceremonial_select: options.allows_ceremonial_select, allows_dynamic_output_star: options.allows_dynamic_output_star, - allows_empty_fixture_star: options.allows_empty_fixture_star, + fixture_harness: options.fixture_harness, }); facts.duplicate_output_aliases = select_facts.duplicate_output_aliases; facts.unaliased_calculations = select_facts.unaliased_calculations; @@ -375,9 +375,12 @@ fn is_canonical_empty_input_fixture( select_index: usize, query_end: usize, ) -> bool { - if !context.allows_empty_fixture_star { + let Some(harness) = context.fixture_harness else { return false; - } + }; + let Some(empty_relation) = harness.empty_relation.as_deref() else { + return false; + }; let significant_query: Vec<&str> = context.tokens[select_index..query_end] .iter() .filter(|token| !is_layout(token) && !is_comment(token)) @@ -387,7 +390,7 @@ fn is_canonical_empty_input_fixture( || !significant_query[0].eq_ignore_ascii_case("select") || significant_query[1] != SQL_WILDCARD || !significant_query[2].eq_ignore_ascii_case("from") - || !significant_query[3].eq_ignore_ascii_case("__empty_fixture") + || !significant_query[3].eq_ignore_ascii_case(empty_relation) || significant_query[4] != OPEN_PAREN || significant_query[5] != CLOSE_PAREN { @@ -406,9 +409,10 @@ fn is_canonical_empty_input_fixture( return false; } let cte_name = previous[2].text.trim_matches(['"', '`']); - ["__ref__", "__source__", "__seed__"] + harness + .input_cte_prefixes .iter() - .any(|prefix| cte_name.starts_with(prefix)) + .any(|prefix| cte_name.starts_with(prefix.as_str())) } fn is_compiler_proven_dynamic_output_select( @@ -485,7 +489,7 @@ fn is_lone_audit_parameter_relation(tokens: &[Token], relation: &[usize]) -> boo }; let normalized = tokens[relation_index].text.to_ascii_lowercase(); let Some(index) = normalized - .strip_prefix("__sqlbuild_audit_parameter_") + .strip_prefix(RELATION_PARAMETER_PLACEHOLDER_PREFIX) .and_then(|value| value.strip_suffix("__")) else { return false; @@ -694,7 +698,7 @@ fn collect_reference_facts(context: &ReferenceFactContext<'_>) -> AdditionalQuer facts } -/// The unqualified references of a single-relation scope that also qualifies some, each judged as it was written. In a macro file a plain slot read such as `@orders.amount` lints as a parameter sentinel pair and counts as unqualified. In a call's output, a read qualified by a relation the call passes takes neither style: the slot decides how it expands, not the caller or the macro author. A scope whose every reference a macro call generated is judged once, in the macro file. +/// The unqualified references of a single-relation scope that also qualifies some, each judged as it was written. In a macro body a plain slot read lints as a pair of macro parameter placeholders and counts as unqualified. In a call's output, a read qualified by a relation the call passes takes neither style: the slot decides how it expands, not the caller or the macro author. A scope whose every reference a macro call generated is judged once, in the macro file. fn inconsistent_qualification( tokens: &[Token], (qualified, unqualified): (&[usize], &[usize]), @@ -751,7 +755,7 @@ fn is_plain_slot_read(tokens: &[Token], index: usize) -> bool { token .text .to_ascii_lowercase() - .starts_with(SQL_MACRO_PARAMETER_SENTINEL_PREFIX) + .starts_with(MACRO_PARAMETER_PLACEHOLDER_PREFIX) }; is_parameter(&tokens[index]) && tokens.get(index + 2).is_some_and(is_parameter) } @@ -1068,7 +1072,7 @@ fn is_values_relation(tokens: &[Token], relation_clause: &[usize]) -> bool { fn is_generated_identifier(token: &Token) -> bool { let normalized = token.text.to_ascii_lowercase(); - GENERATED_IDENTIFIER_PREFIXES + GENERATED_PLACEHOLDER_PREFIXES .iter() .any(|prefix| normalized.starts_with(prefix)) } diff --git a/crates/sqlclean/src/sql_lint/_helpers/engine.rs b/crates/sqlclean/src/sql_lint/_helpers/engine.rs index 639a90b..cb3b3d2 100644 --- a/crates/sqlclean/src/sql_lint/_helpers/engine.rs +++ b/crates/sqlclean/src/sql_lint/_helpers/engine.rs @@ -7,8 +7,8 @@ use polyglot_sql::{DialectType, Expression, ExpressionWalk}; use crate::sql_lint::constants::LINT_API_VERSION; use crate::sql_lint::models::{ - AdditionalFactOptions, CrossJoinRequest, LintDiagnostic, LintEdit, LintRequest, LintResponse, - LintRuleMetadata, ParsedSql, QueryFacts, SqlMacroOutput, + AdditionalFactOptions, CrossJoinRequest, FixtureHarness, LintDiagnostic, LintEdit, LintRequest, + LintResponse, LintRuleMetadata, ParsedSql, QueryFacts, SqlMacroOutput, }; use crate::sql_lint::_helpers::additional::collect_additional_facts; @@ -18,224 +18,211 @@ use crate::sql_lint::_helpers::parsing::parse; use crate::sql_lint::_helpers::terminal_shape::collect_terminal_shape_facts; const NULL_COMPARISON: LintRuleMetadata = LintRuleMetadata { - code: "SQC001", + code: "SQLCLEAN001", message: "Comparison with NULL is never true", remediation: "Use IS NULL or IS NOT NULL when testing for NULL.", }; -const CEREMONIAL_CTE_PREFIXES: [&str; 11] = [ - "__expected__", - "__assert__", - "__ref__", - "__source__", - "__seed__", - "__dbt_ref__", - "__macro_actual__", - "__macro_expected__", - "__udf_actual__", - "__udf_expected__", - "__table_fn_", -]; const IMPLICIT_CARTESIAN_JOIN: LintRuleMetadata = LintRuleMetadata { - code: "SQC002", + code: "SQLCLEAN002", message: "Comma-separated sources create an implicit cartesian join", remediation: "Replace comma-separated sources with an explicit keyed join, or use CROSS JOIN when the cartesian product is intentional.", }; const JOIN_WITHOUT_CONDITION: LintRuleMetadata = LintRuleMetadata { - code: "SQC003", + code: "SQLCLEAN003", message: "Non-cross join has no meaningful condition", remediation: "Add a meaningful ON or USING condition, or declare an intentional cartesian product with CROSS JOIN.", }; const UNORDERED_LIMIT: LintRuleMetadata = LintRuleMetadata { - code: "SQC004", + code: "SQLCLEAN004", message: "Row selection is nondeterministic", remediation: "Add ORDER BY with a deterministic tie-breaker before LIMIT or OFFSET.", }; const UNUSED_CTE: LintRuleMetadata = LintRuleMetadata { - code: "SQC005", + code: "SQLCLEAN005", message: "CTE is unreachable from the final query", remediation: "Reference the CTE from the final query or another reachable CTE, or remove it.", }; const REDUNDANT_DISTINCT: LintRuleMetadata = LintRuleMetadata { - code: "SQC006", + code: "SQLCLEAN006", message: "DISTINCT is redundant with the grouped output", remediation: "Remove DISTINCT; the equivalent GROUP BY already determines the output groups.", }; const POSITIONAL_SET_STAR: LintRuleMetadata = LintRuleMetadata { - code: "SQC007", + code: "SQLCLEAN007", message: "Positional set operation is vulnerable to column-order drift", remediation: "Enumerate columns in the same order in every set-operation branch.", }; const EXPLICIT_UNION: LintRuleMetadata = LintRuleMetadata { - code: "SQC008", + code: "SQLCLEAN008", message: "Bare UNION leaves duplicate handling implicit", remediation: "Use UNION DISTINCT or UNION ALL to state duplicate handling explicitly.", }; const DUPLICATE_TABLE_ALIAS: LintRuleMetadata = LintRuleMetadata { - code: "SQC009", + code: "SQLCLEAN009", message: "Table alias is duplicated in the same query scope", remediation: "Give every relation in this query scope a unique alias.", }; const DUPLICATE_OUTPUT_ALIAS: LintRuleMetadata = LintRuleMetadata { - code: "SQC010", + code: "SQLCLEAN010", message: "Output alias is duplicated in the same select list", remediation: "Give every projected expression a unique output alias.", }; const MIXED_GROUP_ORDER_REFERENCES: LintRuleMetadata = LintRuleMetadata { - code: "SQC011", + code: "SQLCLEAN011", message: "Clause mixes positional and named references", remediation: "Use column names or positions consistently throughout this clause.", }; const REDUNDANT_ELSE_NULL: LintRuleMetadata = LintRuleMetadata { - code: "SQC012", + code: "SQLCLEAN012", message: "ELSE NULL is redundant", remediation: "Remove ELSE NULL; CASE expressions return NULL when ELSE is omitted.", }; const PARENTHESIZED_DISTINCT: LintRuleMetadata = LintRuleMetadata { - code: "SQC013", + code: "SQLCLEAN013", message: "DISTINCT is written as though it were a function", remediation: "Write DISTINCT as a SELECT modifier without function-like parentheses.", }; const CONSTANT_PREDICATE: LintRuleMetadata = LintRuleMetadata { - code: "SQC014", + code: "SQLCLEAN014", message: "Predicate compares identical constant values", remediation: "Remove the scaffold predicate or replace it with the intended condition.", }; const CONSECUTIVE_SEMICOLON: LintRuleMetadata = LintRuleMetadata { - code: "SQC015", + code: "SQLCLEAN015", message: "Empty statement follows another terminator", remediation: "Remove the redundant statement terminator.", }; const REDUNDANT_SELF_ALIAS: LintRuleMetadata = LintRuleMetadata { - code: "SQC016", + code: "SQLCLEAN016", message: "Expression is aliased to its existing name", remediation: "Remove the self-alias when the inferred output identity remains stable.", }; const COUNT_ONE: LintRuleMetadata = LintRuleMetadata { - code: "SQC017", + code: "SQLCLEAN017", message: "Row count uses a constant expression", remediation: "Use COUNT(*) to state that rows, rather than a nullable expression, are counted.", }; const UNSTABLE_ROW_NUMBER: LintRuleMetadata = LintRuleMetadata { - code: "SQC018", + code: "SQLCLEAN018", message: "ROW_NUMBER has no deterministic ordering", remediation: "Add ORDER BY with a stable tie-breaker inside the window specification.", }; const NULL_NOT_IN: LintRuleMetadata = LintRuleMetadata { - code: "SQC019", + code: "SQLCLEAN019", message: "NOT IN list contains NULL and can reject every row", remediation: "Remove NULL from the list or express the anti-join with a NULL-safe NOT EXISTS.", }; const SET_ARITY_MISMATCH: LintRuleMetadata = LintRuleMetadata { - code: "SQC020", + code: "SQLCLEAN020", message: "Set-operation branches project different column counts", remediation: "Project the same number of columns in every set-operation branch.", }; const PROJECTED_STAR: LintRuleMetadata = LintRuleMetadata { - code: "SQC021", + code: "SQLCLEAN021", message: "Query output has an uncontrolled column shape", remediation: "Enumerate the intended output columns explicitly.", }; const UNALIASED_CALCULATION: LintRuleMetadata = LintRuleMetadata { - code: "SQC022", + code: "SQLCLEAN022", message: "Calculated projection has no stable output name", remediation: "Add an explicit AS alias that describes the projected value.", }; const UNUSED_TABLE_ALIAS: LintRuleMetadata = LintRuleMetadata { - code: "SQC023", + code: "SQLCLEAN023", message: "Table alias is never referenced", remediation: "Use the alias to qualify references or remove it.", }; const NULL_REJECTED_LEFT_JOIN: LintRuleMetadata = LintRuleMetadata { - code: "SQC024", + code: "SQLCLEAN024", message: "WHERE predicate rejects NULL rows from a LEFT JOIN", remediation: "Move the right-side predicate into ON or use INNER JOIN when row rejection is intentional.", }; const IMPLICIT_INNER_JOIN: LintRuleMetadata = LintRuleMetadata { - code: "SQC025", + code: "SQLCLEAN025", message: "JOIN type is implicit", remediation: "Write INNER JOIN explicitly.", }; const AMBIGUOUS_ORDER_DIRECTIONS: LintRuleMetadata = LintRuleMetadata { - code: "SQC026", + code: "SQLCLEAN026", message: "ORDER BY mixes explicit and implicit directions", remediation: "State ASC or DESC for every ordering expression in this clause.", }; const UNQUALIFIED_MULTI_SOURCE_COLUMN: LintRuleMetadata = LintRuleMetadata { - code: "SQC027", + code: "SQLCLEAN027", message: "Column reference is unqualified in a multi-relation query", remediation: "Qualify the column with its relation alias.", }; const INCONSISTENT_SINGLE_SOURCE_QUALIFICATION: LintRuleMetadata = LintRuleMetadata { - code: "SQC028", + code: "SQLCLEAN028", message: "Single-relation query mixes qualified and unqualified columns", remediation: "Use one consistent qualification style in this query scope.", }; const UNKNOWN_RELATION_QUALIFIER: LintRuleMetadata = LintRuleMetadata { - code: "SQC029", + code: "SQLCLEAN029", message: "Qualified reference uses a relation absent from this query scope", remediation: "Use an available relation alias or add the intended relation to FROM.", }; const SIMPLE_BOOLEAN_CASE: LintRuleMetadata = LintRuleMetadata { - code: "SQC030", + code: "SQLCLEAN030", message: "CASE expression only converts a condition to TRUE or FALSE", remediation: "Use a NULL-safe boolean expression directly.", }; const NESTED_ELSE_CASE: LintRuleMetadata = LintRuleMetadata { - code: "SQC031", + code: "SQLCLEAN031", message: "CASE expression is nested directly inside ELSE", remediation: "Flatten the nested branch into the surrounding CASE expression.", }; const UNUSED_JOINED_RELATION: LintRuleMetadata = LintRuleMetadata { - code: "SQC032", + code: "SQLCLEAN032", message: "Joined relation contributes no referenced values", remediation: "Use the joined relation or remove the join. When the join intentionally filters rows, record it as a reviewed rule exception with a reason in the configuration file.", }; const COMMENT_ATTACHMENT: LintRuleMetadata = LintRuleMetadata { - code: "SQC033", + code: "SQLCLEAN033", message: "Comment is not attached to the syntax node it explains", remediation: "Use a standalone -- comment block immediately above the relevant CTE, JOIN, filter, predicate, selected expression, CASE branch, grouping, or ordering node.", }; const CTE_ONLY_BODY: LintRuleMetadata = LintRuleMetadata { - code: "SQC034", + code: "SQLCLEAN034", message: "Query keeps transformation logic outside top-level CTEs", remediation: "Move transformation logic into named top-level CTEs before the terminal SELECT.", }; const TERMINAL_SELECT: LintRuleMetadata = LintRuleMetadata { - code: "SQC035", + code: "SQLCLEAN035", message: "Terminal SELECT does not read plainly from the final top-level CTE", remediation: "Name the final logical CTE and leave one terminal SELECT from that CTE.", }; const NESTED_CTE: LintRuleMetadata = LintRuleMetadata { - code: "SQC036", + code: "SQLCLEAN036", message: "CTE is nested inside another query", remediation: "Hoist the nested CTE into the statement's top-level WITH list.", }; const RECURSIVE_CTE: LintRuleMetadata = LintRuleMetadata { - code: "SQC037", + code: "SQLCLEAN037", message: "Recursive CTE is not permitted", remediation: "Replace recursion with an explicit upstream relation or a bounded non-recursive query shape.", }; const CROSS_JOIN: LintRuleMetadata = LintRuleMetadata { - code: "SQC038", + code: "SQLCLEAN038", message: "CROSS JOIN creates an explicit cartesian product", remediation: "Use a keyed JOIN, or make one side provably at most one row (LIMIT 1, an aggregate without GROUP BY, or a one-row VALUES). When the cartesian product is intentional, record it as a reviewed [[rules.rule_exceptions]] entry with rule, path, relation and reason in the configuration file.", }; const INLINE_QUERY_RELATION: LintRuleMetadata = LintRuleMetadata { - code: "SQC039", + code: "SQLCLEAN039", message: "Inline query relation obscures data flow", remediation: "Extract the derived relation into a named top-level CTE; use explicit LATERAL only when the relation must remain correlated.", }; const TRIVIAL_EQUALITY_TOKEN_COUNT: usize = 3; const JOIN_EXPRESSION: LintRuleMetadata = LintRuleMetadata { - code: "SQC040", + code: "SQLCLEAN040", message: "JOIN ON contains a computed expression", remediation: "Compute keys, including variant path access, in input CTEs and join on plain columns. Literal-only predicates such as ON TRUE are allowed.", }; const FINAL_CTE_NAME: LintRuleMetadata = LintRuleMetadata { - code: "SQC041", + code: "SQLCLEAN041", message: "Only the last top-level CTE may be named final, and it must use that name", - remediation: "Name the last top-level CTE final and rename any other final CTE; SQC035 checks the terminal SELECT.", + remediation: "Name the last top-level CTE final and rename any other final CTE; SQLCLEAN035 checks the terminal SELECT.", }; pub(crate) const DEFAULT_RULES: [&str; 19] = [ @@ -316,10 +303,12 @@ const ALL_RULE_METADATA: [&LintRuleMetadata; 53] = [ &crate::sql_quality::constants::MISPLACED_AGGREGATE, ]; -fn is_ceremonial_cte_name(name: &str) -> bool { - CEREMONIAL_CTE_PREFIXES +/// Whether the harness generated the CTE `name`. +fn is_harness_cte(harness: &FixtureHarness, name: &str) -> bool { + harness + .cte_prefixes .iter() - .any(|prefix| name.starts_with(prefix)) + .any(|prefix| name.starts_with(prefix.as_str())) } struct QueryTokenContext<'a> { @@ -352,7 +341,7 @@ struct FactBuildOptions<'a> { externally_referenced_ctes: &'a HashSet, allows_ceremonial_select: bool, allows_dynamic_output_star: bool, - allows_empty_fixture_star: bool, + fixture_harness: Option<&'a FixtureHarness>, } pub(crate) fn lint_json_impl(request_json: &str) -> Result { @@ -399,9 +388,9 @@ pub(crate) fn lint(request: LintRequest) -> Result { sql_macro_outputs: &request.sql_macro_outputs, dependency_identifiers: &dependency_identifiers, externally_referenced_ctes: &externally_referenced_ctes, - allows_ceremonial_select: request.allows_ceremonial_select, + allows_ceremonial_select: request.fixture_harness.is_some(), allows_dynamic_output_star: request.allows_dynamic_output_star, - allows_empty_fixture_star: request.allows_empty_fixture_star, + fixture_harness: request.fixture_harness.as_ref(), }; let facts = build_facts(&statements, &tokens, &request.sql, &options); let context = DiagnosticContext { @@ -434,7 +423,7 @@ pub(crate) fn lint(request: LintRequest) -> Result { Some("computing a join key requires an authored CTE rewrite"), )); } - if enabled.contains(FINAL_CTE_NAME.code) && !request.allows_ceremonial_select { + if enabled.contains(FINAL_CTE_NAME.code) && request.fixture_harness.is_none() { diagnostics.extend(diagnostics_for_spans( &FINAL_CTE_NAME, &crate::sql_lint::_helpers::terminal_shape::final_cte_name_spans(&tokens, &depths), @@ -455,7 +444,7 @@ pub(crate) fn lint(request: LintRequest) -> Result { .unwrap_or(crate::sql_quality::constants::DEFAULT_MAX_RANKING_ORDER_BY), relation_keys: &request.relation_keys, externally_referenced_ctes: &externally_referenced_ctes, - fixture_body: request.allows_ceremonial_select, + fixture_body: request.fixture_harness.is_some(), config_file: request .config_file .as_deref() @@ -515,7 +504,7 @@ fn build_facts( dependency_identifiers: options.dependency_identifiers, allows_ceremonial_select: options.allows_ceremonial_select, allows_dynamic_output_star: options.allows_dynamic_output_star, - allows_empty_fixture_star: options.allows_empty_fixture_star, + fixture_harness: options.fixture_harness, }, ); for expression in expressions { @@ -1145,11 +1134,11 @@ fn collect_unused_for_with( .intersection(&names) .cloned(), ); - if options.allows_ceremonial_select { + if let Some(harness) = options.fixture_harness { reachable.extend( names .iter() - .filter(|name| is_ceremonial_cte_name(name)) + .filter(|name| is_harness_cte(harness, name)) .cloned(), ); } diff --git a/crates/sqlclean/src/sql_lint/_helpers/formatter.rs b/crates/sqlclean/src/sql_lint/_helpers/formatter.rs index bd8442b..c881702 100644 --- a/crates/sqlclean/src/sql_lint/_helpers/formatter.rs +++ b/crates/sqlclean/src/sql_lint/_helpers/formatter.rs @@ -5,7 +5,7 @@ use polyglot_sql::tokens::{Token, TokenType}; use polyglot_sql::{ComplexityGuardOptions, Dialect, DialectType, ParseOptions}; use rayon::iter::{IntoParallelIterator, ParallelIterator}; -use crate::constants::LINT_SENTINEL_PREFIX; +use crate::constants::INTERPOLATION_PLACEHOLDER_PREFIX; use crate::sql_lint::_helpers::formatter_syntax::{cte_macro_indices, protect_syntax}; use crate::sql_lint::_helpers::printed_layout::{PrintedSql, WrapOptions, wrap_lines}; use crate::sql_lint::_helpers::token_layout::{ @@ -236,7 +236,7 @@ fn layout_oracle( } fn layout_cte_macros(mut formatted: String, dialect: &Dialect) -> Result { - if !formatted.contains(LINT_SENTINEL_PREFIX) { + if !formatted.contains(INTERPOLATION_PLACEHOLDER_PREFIX) { return Ok(formatted); } let tokens = dialect diff --git a/crates/sqlclean/src/sql_lint/_helpers/formatter_syntax.rs b/crates/sqlclean/src/sql_lint/_helpers/formatter_syntax.rs index c53a540..86071f4 100644 --- a/crates/sqlclean/src/sql_lint/_helpers/formatter_syntax.rs +++ b/crates/sqlclean/src/sql_lint/_helpers/formatter_syntax.rs @@ -1,4 +1,4 @@ -use crate::constants::LINT_SENTINEL_PREFIX; +use crate::constants::INTERPOLATION_PLACEHOLDER_PREFIX; use crate::sql_lint::_helpers::engine::token_depths; use crate::sql_lint::constants::{ CTE_MACRO_PLACEHOLDER_LITERAL, POSITION_ARGUMENT_SEPARATOR, POSTFIX_CAST_PREFIX_TOKEN_COUNT, @@ -116,7 +116,7 @@ pub(super) fn protect_syntax( cast_types: &[(usize, usize)], expressions: bool, ) -> Result { - let mut prefix = "__sqlbuild_format_".to_string(); + let mut prefix = "__sqlclean_format_".to_string(); let lowercase: String = sql.to_ascii_lowercase(); while lowercase.contains(&prefix) { prefix.push('_'); @@ -292,7 +292,7 @@ pub(super) fn cte_macro_indices(tokens: &[Token]) -> HashSet { scopes.insert(depths[index]); } else if token.token_type == TokenType::Select { scopes.remove(&depths[index]); - } else if token.text.starts_with(LINT_SENTINEL_PREFIX) + } else if token.text.starts_with(INTERPOLATION_PLACEHOLDER_PREFIX) && scopes.contains(&depths[index]) && index > 0 && (matches!( @@ -315,7 +315,7 @@ fn starts_cte(tokens: &[Token], index: usize) -> bool { let Some(token) = tokens.get(index) else { return false; }; - if token.text.starts_with(LINT_SENTINEL_PREFIX) { + if token.text.starts_with(INTERPOLATION_PLACEHOLDER_PREFIX) { return true; } let named: bool = matches!( diff --git a/crates/sqlclean/src/sql_lint/_helpers/printed_layout.rs b/crates/sqlclean/src/sql_lint/_helpers/printed_layout.rs index a949f60..a26645f 100644 --- a/crates/sqlclean/src/sql_lint/_helpers/printed_layout.rs +++ b/crates/sqlclean/src/sql_lint/_helpers/printed_layout.rs @@ -16,7 +16,6 @@ const BETWEEN_SEPARATOR: &str = "AND"; const VALUES_KEYWORD: &str = "VALUES"; const DECODE_FUNCTION: &str = "DECODE"; const CASE_KEYWORD: &str = "CASE"; -const SENTINEL_PREFIX: &str = "__SQB_LINT_"; const SINGLE_ITEM_CLAUSES: [&str; 4] = ["SELECT", "WHERE", "QUALIFY", "HAVING"]; const SELECT_MODIFIERS: [&str; 10] = [ "DISTINCT", "ALL", "TOP", "PERCENT", "WITH", "TIES", "ON", "AS", "STRUCT", "VALUE", @@ -551,7 +550,9 @@ impl PrintedSql<'_> { /// The last token of the CTE, or CTE macro placeholder, that starts at `item`. fn common_table_expression_end(&self, item: usize) -> Option { let name = self.tokens.get(item)?; - if name.to_ascii_uppercase().starts_with(SENTINEL_PREFIX) + if name + .to_ascii_lowercase() + .starts_with(crate::constants::INTERPOLATION_PLACEHOLDER_PREFIX) && !self.is(item + 1, "AS") && !self.is(item + 1, OPEN_PARENTHESIS) { diff --git a/crates/sqlclean/src/sql_lint/constants.rs b/crates/sqlclean/src/sql_lint/constants.rs index 4b329c0..eead024 100644 --- a/crates/sqlclean/src/sql_lint/constants.rs +++ b/crates/sqlclean/src/sql_lint/constants.rs @@ -50,11 +50,11 @@ pub(crate) const RELATION_MODIFIER_KEYWORDS: [&str; 43] = [ "WHERE", "WINDOW", ]; -/// The parts of a `SQC038` message that names its joined relation and CTE. +/// The parts of a `SQLCLEAN038` message that names its joined relation and CTE. pub(crate) const CROSS_JOIN_PREFIX: &str = "CROSS JOIN with `"; pub(crate) const CROSS_JOIN_CTE_MARKER: &str = "` in CTE `"; pub(crate) const CROSS_JOIN_SUFFIX: &str = "` creates an explicit cartesian product"; -/// The `SQC038` message for a join whose relation cannot be named. +/// The `SQLCLEAN038` message for a join whose relation cannot be named. pub(crate) const CROSS_JOIN_UNNAMED_MESSAGE: &str = "CROSS JOIN creates an explicit cartesian product with an unnamed relation; only a file-wide exception (rule and path) can cover it"; /// Stands for the finding's project-relative path until the caller that knows it fills it in. pub(crate) const CROSS_JOIN_PATH_PLACEHOLDER: &str = ""; diff --git a/crates/sqlclean/src/sql_lint/main/cross_join_subject.rs b/crates/sqlclean/src/sql_lint/main/cross_join_subject.rs index 524a580..7a6d996 100644 --- a/crates/sqlclean/src/sql_lint/main/cross_join_subject.rs +++ b/crates/sqlclean/src/sql_lint/main/cross_join_subject.rs @@ -1,4 +1,4 @@ -//! The relation and CTE a `SQC038` finding names, for relation-level Rule exceptions. +//! The relation and CTE a `SQLCLEAN038` finding names, for relation-level Rule exceptions. use crate::sql_lint::constants::{CROSS_JOIN_CTE_MARKER, CROSS_JOIN_PREFIX, CROSS_JOIN_SUFFIX}; diff --git a/crates/sqlclean/src/sql_lint/models.rs b/crates/sqlclean/src/sql_lint/models.rs index d6053e4..52cf220 100644 --- a/crates/sqlclean/src/sql_lint/models.rs +++ b/crates/sqlclean/src/sql_lint/models.rs @@ -77,13 +77,12 @@ pub struct LintRequest { pub dependency_identifiers: Vec, #[serde(default)] pub externally_referenced_ctes: Vec, + /// The harness the host wraps this body in; absent for an ordinary query. #[serde(default)] - pub allows_ceremonial_select: bool, + pub fixture_harness: Option, #[serde(default)] pub allows_dynamic_output_star: bool, #[serde(default)] - pub allows_empty_fixture_star: bool, - #[serde(default)] pub max_literal_length: Option, #[serde(default)] pub max_ranking_order_by: Option, @@ -91,7 +90,7 @@ pub struct LintRequest { pub relation_keys: HashMap>>, #[serde(default)] pub relation_names: HashMap, - /// The SQL each `@sql.` call in the query generated, so column qualification is judged as the macro wrote it. + /// The SQL each macro call in the query generated, so column qualification is judged as the macro wrote it. #[serde(default)] pub sql_macro_outputs: Vec, /// The configuration file remediation text names; SQLClean's own when absent. @@ -99,7 +98,22 @@ pub struct LintRequest { pub config_file: Option, } -/// The character range one `@sql.` call's output fills, and the names its arguments pass, which a plain slot read such as `@rel.amount` expands to qualify with. +/// The generated wrapper around a test or macro body: its terminal SELECT is ceremonial, and these names are generated. +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct FixtureHarness { + /// Prefixes of the CTE names the harness generates, which are reachable even when unread. + #[serde(default)] + pub cte_prefixes: Vec, + /// Prefixes of the CTEs standing in for the body's inputs, which may read the empty relation with `SELECT *`. + #[serde(default)] + pub input_cte_prefixes: Vec, + /// The table function an input CTE reads, as `SELECT * FROM ()`, when it has no rows. + #[serde(default)] + pub empty_relation: Option, +} + +/// The character range one macro call's output fills, and the relations its arguments pass, which a plain parameter slot read expands to qualify with. #[derive(Clone, Debug, Default, Deserialize, PartialEq, Eq)] pub struct SqlMacroOutput { pub start: usize, @@ -115,7 +129,7 @@ pub(crate) struct AdditionalFactOptions<'a> { pub sql_macro_outputs: &'a [SqlMacroOutput], pub allows_ceremonial_select: bool, pub allows_dynamic_output_star: bool, - pub allows_empty_fixture_star: bool, + pub fixture_harness: Option<&'a FixtureHarness>, } #[derive(Debug, Serialize)] diff --git a/crates/sqlclean/src/sql_lint/tests/helpers.rs b/crates/sqlclean/src/sql_lint/tests/helpers.rs index bb4c1b9..d3c11d2 100644 --- a/crates/sqlclean/src/sql_lint/tests/helpers.rs +++ b/crates/sqlclean/src/sql_lint/tests/helpers.rs @@ -14,7 +14,7 @@ use crate::sql_tokens::main::canonical_tokens::canonical_tokens; use crate::sql_tokens::main::query_fingerprint::query_fingerprint; /// The dependency sentinel the cross join cases name `order_policy`. -pub(crate) const POLICY_SENTINEL: &str = "__sqb_lint_7__"; +pub(crate) const POLICY_SENTINEL: &str = "__sqlclean_interpolation_7__"; /// Format a JSON array of format requests in parallel, as a JSON array of responses. pub(crate) fn format_batch_json(request_json: &str) -> Result { @@ -266,12 +266,12 @@ fn is_tight_call(name: &str, sql: &str, arguments: &str) -> bool { .contains(&format!("{} (", name.to_ascii_uppercase())) } -/// The `SQC038` messages for one cross join case. +/// The `SQLCLEAN038` messages for one cross join case. pub(crate) fn cross_join_messages(test_case: &CrossJoinTestCase) -> Result, String> { cross_join_texts(test_case, "message") } -/// The `SQC038` remediations for one cross join case. +/// The `SQLCLEAN038` remediations for one cross join case. pub(crate) fn cross_join_remediations( test_case: &CrossJoinTestCase, ) -> Result, String> { @@ -288,7 +288,7 @@ fn cross_join_texts(test_case: &CrossJoinTestCase, field: &str) -> Result Result Value { + json!({ + "cte_prefixes": ["__expected__", "__ref__", "__source__", "__macro_actual__", "__macro_expected__"], + "input_cte_prefixes": ["__ref__", "__source__", "__seed__"], + "empty_relation": "__empty_fixture", + }) +} diff --git a/crates/sqlclean/src/sql_lint/tests/test_cross_joins.rs b/crates/sqlclean/src/sql_lint/tests/test_cross_joins.rs index eb0645d..ed3b8a4 100644 --- a/crates/sqlclean/src/sql_lint/tests/test_cross_joins.rs +++ b/crates/sqlclean/src/sql_lint/tests/test_cross_joins.rs @@ -22,14 +22,14 @@ fn given_cross_joins_when_one_side_is_provably_one_row_then_only_multiplying_joi }, CrossJoinTestCase { description: "a one-row seed on the right", - sql: "SELECT o.order_id FROM orders AS o CROSS JOIN __sqb_lint_7__ AS p", + sql: "SELECT o.order_id FROM orders AS o CROSS JOIN __sqlclean_interpolation_7__ AS p", dialect: "duckdb", one_row_relations: &[POLICY_SENTINEL], expected_messages: &[], }, CrossJoinTestCase { description: "a seed with several rows names its relation", - sql: "SELECT o.order_id FROM orders AS o CROSS JOIN __sqb_lint_7__ AS p", + sql: "SELECT o.order_id FROM orders AS o CROSS JOIN __sqlclean_interpolation_7__ AS p", dialect: "duckdb", one_row_relations: &[], expected_messages: &[ @@ -38,7 +38,7 @@ fn given_cross_joins_when_one_side_is_provably_one_row_then_only_multiplying_joi }, CrossJoinTestCase { description: "a one-row seed on the left of the first join", - sql: "SELECT o.order_id FROM __sqb_lint_7__ AS p CROSS JOIN orders AS o", + sql: "SELECT o.order_id FROM __sqlclean_interpolation_7__ AS p CROSS JOIN orders AS o", dialect: "duckdb", one_row_relations: &[POLICY_SENTINEL], expected_messages: &[], @@ -160,7 +160,7 @@ fn given_cross_joins_when_one_side_is_provably_one_row_then_only_multiplying_joi }, CrossJoinTestCase { description: "a later join still multiplies the rows before it", - sql: "SELECT o.order_id FROM __sqb_lint_7__ AS p CROSS JOIN orders AS o CROSS JOIN customers AS c", + sql: "SELECT o.order_id FROM __sqlclean_interpolation_7__ AS p CROSS JOIN orders AS o CROSS JOIN customers AS c", dialect: "duckdb", one_row_relations: &[POLICY_SENTINEL], expected_messages: &[ @@ -234,7 +234,7 @@ fn given_unnamed_cross_join_when_linting_then_help_prints_a_file_wide_exception( dialect: "duckdb", one_row_relations: &[], expected_messages: &[ - "[[rules.rule_exceptions]]\n rule = \"SQC038\"\n path = \"\"\n reason = \"\"", + "[[rules.rule_exceptions]]\n rule = \"SQLCLEAN038\"\n path = \"\"\n reason = \"\"", ], }]; diff --git a/crates/sqlclean/src/sql_lint/tests/test_empty_fixture_lint.rs b/crates/sqlclean/src/sql_lint/tests/test_empty_fixture_lint.rs index db4988b..11a3aec 100644 --- a/crates/sqlclean/src/sql_lint/tests/test_empty_fixture_lint.rs +++ b/crates/sqlclean/src/sql_lint/tests/test_empty_fixture_lint.rs @@ -1,6 +1,7 @@ use serde_json::{Value, json}; use crate::sql_lint::main::engine::lint_json; +use crate::sql_lint::tests::helpers::fixture_harness; use crate::sql_lint::tests::test_types; #[test] @@ -10,31 +11,31 @@ fn given_empty_fixture_context_when_linting_projected_star_then_only_canonical_i test_types::EmptyFixtureLintTestCase { description: "canonical ref fixture", sql: "WITH __ref__orders AS (SELECT * FROM __empty_fixture()) SELECT 1", - allows_empty_fixture_star: true, + in_fixture_harness: true, expected_diagnostic_count: 0, }, test_types::EmptyFixtureLintTestCase { description: "canonical source fixture", sql: "WITH __source__web__orders AS (SELECT * FROM __empty_fixture()) SELECT 1", - allows_empty_fixture_star: true, + in_fixture_harness: true, expected_diagnostic_count: 0, }, test_types::EmptyFixtureLintTestCase { description: "ordinary SQL has no intrinsic exemption", sql: "SELECT * FROM __empty_fixture()", - allows_empty_fixture_star: false, + in_fixture_harness: false, expected_diagnostic_count: 1, }, test_types::EmptyFixtureLintTestCase { description: "expected fixture remains controlled", sql: "WITH __expected__orders AS (SELECT * FROM __empty_fixture()) SELECT 1", - allows_empty_fixture_star: true, + in_fixture_harness: true, expected_diagnostic_count: 1, }, test_types::EmptyFixtureLintTestCase { description: "additional projection is not canonical", sql: "WITH __ref__orders AS (SELECT *, 1 AS extra FROM __empty_fixture()) SELECT 1", - allows_empty_fixture_star: true, + in_fixture_harness: true, expected_diagnostic_count: 1, }, ]; @@ -44,8 +45,8 @@ fn given_empty_fixture_context_when_linting_projected_star_then_only_canonical_i "version": 1, "sql": test_case.sql, "dialect": "snowflake", - "enabled_rules": ["SQC021"], - "allows_empty_fixture_star": test_case.allows_empty_fixture_star + "enabled_rules": ["SQLCLEAN021"], + "fixture_harness": test_case.in_fixture_harness.then(fixture_harness) }) .to_string(), )?; diff --git a/crates/sqlclean/src/sql_lint/tests/test_layout_rules.rs b/crates/sqlclean/src/sql_lint/tests/test_layout_rules.rs index 15dd495..d8597cd 100644 --- a/crates/sqlclean/src/sql_lint/tests/test_layout_rules.rs +++ b/crates/sqlclean/src/sql_lint/tests/test_layout_rules.rs @@ -172,16 +172,16 @@ fn given_clause_layout_rules_when_formatting_then_each_rule_applies_idempotently test_types::LayoutRuleTestCase { description: "a CTE macro placeholder is separated like a CTE", dialect: "duckdb", - sql: "WITH __sqb_lint_0__, paid AS (SELECT * FROM orders) SELECT * FROM paid", + sql: "WITH __sqlclean_interpolation_0__, paid AS (SELECT * FROM orders) SELECT * FROM paid", line_width: 100, - expected_sql: "WITH\n__sqb_lint_0__,\n\npaid AS (\n SELECT *\n FROM orders\n)\n\nSELECT *\nFROM paid", + expected_sql: "WITH\n__sqlclean_interpolation_0__,\n\npaid AS (\n SELECT *\n FROM orders\n)\n\nSELECT *\nFROM paid", }, test_types::LayoutRuleTestCase { description: "comma-less CTE macro placeholders in a row are separated like CTEs", dialect: "duckdb", - sql: "WITH a AS (SELECT 1 AS x), __sqb_lint_0__ __sqb_lint_1__ paid AS (SELECT * FROM a) SELECT * FROM paid", + sql: "WITH a AS (SELECT 1 AS x), __sqlclean_interpolation_0__ __sqlclean_interpolation_1__ paid AS (SELECT * FROM a) SELECT * FROM paid", line_width: 100, - expected_sql: "WITH a AS (\n SELECT 1 AS x\n),\n\n__sqb_lint_0__\n\n__sqb_lint_1__\npaid AS (\n SELECT *\n FROM a\n)\nSELECT *\nFROM paid", + expected_sql: "WITH a AS (\n SELECT 1 AS x\n),\n\n__sqlclean_interpolation_0__\n\n__sqlclean_interpolation_1__\npaid AS (\n SELECT *\n FROM a\n)\nSELECT *\nFROM paid", }, test_types::LayoutRuleTestCase { description: "an alias and comma that push a call over the width break the call", diff --git a/crates/sqlclean/src/sql_lint/tests/test_line_wrap.rs b/crates/sqlclean/src/sql_lint/tests/test_line_wrap.rs index 3a3d365..7e9693b 100644 --- a/crates/sqlclean/src/sql_lint/tests/test_line_wrap.rs +++ b/crates/sqlclean/src/sql_lint/tests/test_line_wrap.rs @@ -67,10 +67,10 @@ fn given_lines_over_the_width_when_formatting_then_each_wrap_rule_applies_idempo }, test_types::LineWrapTestCase { description: "a sentinel counts with the width of the text it stands for", - sql: "select coalesce(__sqb_lint_0__, b) as amount from orders", + sql: "select coalesce(__sqlclean_interpolation_0__, b) as amount from orders", line_width: 40, - token_widths: &[("__SQB_LINT_0__", 30)], - expected_sql: "SELECT\n COALESCE(\n __sqb_lint_0__,\n b\n ) AS amount\nFROM orders", + token_widths: &[("__SQLCLEAN_INTERPOLATION_0__", 30)], + expected_sql: "SELECT\n COALESCE(\n __sqlclean_interpolation_0__,\n b\n ) AS amount\nFROM orders", }, test_types::LineWrapTestCase { description: "a nested expression breaks only the levels that overflow", diff --git a/crates/sqlclean/src/sql_lint/tests/test_lint_exemptions.rs b/crates/sqlclean/src/sql_lint/tests/test_lint_exemptions.rs index 5ed8b24..94768c3 100644 --- a/crates/sqlclean/src/sql_lint/tests/test_lint_exemptions.rs +++ b/crates/sqlclean/src/sql_lint/tests/test_lint_exemptions.rs @@ -62,7 +62,7 @@ fn given_sql_macro_output_when_linting_qualification_then_each_column_counts_as_ "version": 1, "sql": test_case.sql, "dialect": "duckdb", - "enabled_rules": ["SQC028"], + "enabled_rules": ["SQLCLEAN028"], "sql_macro_outputs": [{ "start": start, "end": start + test_case.output.len(), @@ -103,7 +103,7 @@ unrelated AS (SELECT * FROM pivot_input) SELECT * FROM final", "version": 1, "sql": test_case.sql, "dialect": "snowflake", - "enabled_rules": ["SQC021"], + "enabled_rules": ["SQLCLEAN021"], "allows_dynamic_output_star": true, }) .to_string(), diff --git a/crates/sqlclean/src/sql_lint/tests/test_sql_lint.rs b/crates/sqlclean/src/sql_lint/tests/test_sql_lint.rs index 4171cd0..909602d 100644 --- a/crates/sqlclean/src/sql_lint/tests/test_sql_lint.rs +++ b/crates/sqlclean/src/sql_lint/tests/test_sql_lint.rs @@ -2,7 +2,7 @@ use serde_json::{Value, json}; use crate::sql_lint::main::engine::lint_json; use crate::sql_lint::main::formatter::format_json; -use crate::sql_lint::tests::helpers::format_batch_json; +use crate::sql_lint::tests::helpers::{fixture_harness, format_batch_json}; use crate::sql_lint::tests::{helpers, test_types}; #[test] @@ -57,32 +57,32 @@ fn given_sql_cases_when_linting_then_diagnostics_match() -> Result<(), String> { test_types::LintTestCase { description: "NULL comparison", sql: "SELECT value FROM a WHERE value = NULL", - expected_codes: &["SQC001"], - expected_anchors: &[("SQC001", "=")], + expected_codes: &["SQLCLEAN001"], + expected_anchors: &[("SQLCLEAN001", "=")], }, test_types::LintTestCase { description: "wrapped NULL comparison", sql: "SELECT value FROM a WHERE (NULL) = value", - expected_codes: &["SQC001"], - expected_anchors: &[("SQC001", "=")], + expected_codes: &["SQLCLEAN001"], + expected_anchors: &[("SQLCLEAN001", "=")], }, test_types::LintTestCase { description: "implicit cartesian join", sql: "SELECT a.id FROM a, b", - expected_codes: &["SQC002"], - expected_anchors: &[("SQC002", ",")], + expected_codes: &["SQLCLEAN002"], + expected_anchors: &[("SQLCLEAN002", ",")], }, test_types::LintTestCase { description: "unconditioned join", sql: "SELECT a.id FROM a JOIN b", - expected_codes: &["SQC003"], - expected_anchors: &[("SQC003", "JOIN")], + expected_codes: &["SQLCLEAN003"], + expected_anchors: &[("SQLCLEAN003", "JOIN")], }, test_types::LintTestCase { description: "unordered limit", sql: "SELECT id FROM items LIMIT 1", - expected_codes: &["SQC004"], - expected_anchors: &[("SQC004", "LIMIT")], + expected_codes: &["SQLCLEAN004"], + expected_anchors: &[("SQLCLEAN004", "LIMIT")], }, test_types::LintTestCase { description: "limit zero selects no rows in any order", @@ -105,8 +105,8 @@ fn given_sql_cases_when_linting_then_diagnostics_match() -> Result<(), String> { test_types::LintTestCase { description: "semi-structured limit key does not hide a later row limit clause", sql: "SELECT payload:Limit::VARCHAR AS order_limit FROM orders LIMIT 1", - expected_codes: &["SQC004"], - expected_anchors: &[("SQC004", "LIMIT")], + expected_codes: &["SQLCLEAN004"], + expected_anchors: &[("SQLCLEAN004", "LIMIT")], }, test_types::LintTestCase { description: "semi-structured limit key in a filter is not a row limit clause", @@ -117,26 +117,26 @@ fn given_sql_cases_when_linting_then_diagnostics_match() -> Result<(), String> { test_types::LintTestCase { description: "unused CTE", sql: "WITH unused AS (SELECT 1) SELECT 1", - expected_codes: &["SQC005"], - expected_anchors: &[("SQC005", "unused")], + expected_codes: &["SQLCLEAN005"], + expected_anchors: &[("SQLCLEAN005", "unused")], }, test_types::LintTestCase { description: "unreachable CTE chain", sql: "WITH first AS (SELECT 1), second AS (SELECT * FROM first) SELECT 1", - expected_codes: &["SQC005", "SQC005"], + expected_codes: &["SQLCLEAN005", "SQLCLEAN005"], expected_anchors: &[], }, test_types::LintTestCase { description: "unused CTE on set operation", sql: "WITH unused AS (SELECT 1 AS id) SELECT id FROM left_table UNION ALL SELECT id FROM right_table", - expected_codes: &["SQC005"], - expected_anchors: &[("SQC005", "unused")], + expected_codes: &["SQLCLEAN005"], + expected_anchors: &[("SQLCLEAN005", "unused")], }, test_types::LintTestCase { description: "redundant distinct", sql: "SELECT DISTINCT id FROM items GROUP BY id", - expected_codes: &["SQC006"], - expected_anchors: &[("SQC006", "DISTINCT")], + expected_codes: &["SQLCLEAN006"], + expected_anchors: &[("SQLCLEAN006", "DISTINCT")], }, test_types::LintTestCase { description: "aggregate distinct is not a select modifier", @@ -147,35 +147,35 @@ fn given_sql_cases_when_linting_then_diagnostics_match() -> Result<(), String> { test_types::LintTestCase { description: "positional set star", sql: "SELECT * FROM a UNION ALL SELECT * FROM b", - expected_codes: &["SQC007"], - expected_anchors: &[("SQC007", "UNION")], + expected_codes: &["SQLCLEAN007"], + expected_anchors: &[("SQLCLEAN007", "UNION")], }, test_types::LintTestCase { description: "parenthesized positional set star", sql: "(SELECT * FROM a) UNION ALL (SELECT id FROM b)", - expected_codes: &["SQC007"], - expected_anchors: &[("SQC007", "UNION")], + expected_codes: &["SQLCLEAN007"], + expected_anchors: &[("SQLCLEAN007", "UNION")], }, test_types::LintTestCase { description: "nested set wildcard does not contaminate outer set operation", sql: "SELECT 'actual' WHERE EXISTS (SELECT id FROM actual EXCEPT SELECT * FROM expected) UNION ALL SELECT 'expected'", - expected_codes: &["SQC007"], - expected_anchors: &[("SQC007", "EXCEPT")], + expected_codes: &["SQLCLEAN007"], + expected_anchors: &[("SQLCLEAN007", "EXCEPT")], }, test_types::LintTestCase { description: "multiple statements", sql: "SELECT value FROM first LIMIT 1; SELECT value FROM second OFFSET 1", - expected_codes: &["SQC004", "SQC004"], - expected_anchors: &[("SQC004", "LIMIT"), ("SQC004", "OFFSET")], + expected_codes: &["SQLCLEAN004", "SQLCLEAN004"], + expected_anchors: &[("SQLCLEAN004", "LIMIT"), ("SQLCLEAN004", "OFFSET")], }, test_types::LintTestCase { description: "nested query anchors", sql: "SELECT * FROM (SELECT id FROM inner_items ORDER BY id LIMIT 1) safe JOIN outer_items ON TRUE OFFSET 2", - expected_codes: &["SQC039", "SQC003", "SQC004"], + expected_codes: &["SQLCLEAN039", "SQLCLEAN003", "SQLCLEAN004"], expected_anchors: &[ - ("SQC039", "SELECT"), - ("SQC003", "JOIN"), - ("SQC004", "OFFSET"), + ("SQLCLEAN039", "SELECT"), + ("SQLCLEAN003", "JOIN"), + ("SQLCLEAN004", "OFFSET"), ], }, test_types::LintTestCase { @@ -247,14 +247,14 @@ fn given_sql_cases_when_linting_then_diagnostics_match() -> Result<(), String> { test_types::LintTestCase { description: "nested star outside set arms", sql: "SELECT id, name FROM (SELECT * FROM base) b UNION ALL SELECT id, name FROM other", - expected_codes: &["SQC039"], - expected_anchors: &[("SQC039", "SELECT")], + expected_codes: &["SQLCLEAN039"], + expected_anchors: &[("SQLCLEAN039", "SELECT")], }, test_types::LintTestCase { description: "nested star inside wrapped set arm", sql: "(SELECT id FROM (SELECT * FROM base) b) UNION ALL (SELECT id FROM other)", - expected_codes: &["SQC039"], - expected_anchors: &[("SQC039", "SELECT")], + expected_codes: &["SQLCLEAN039"], + expected_anchors: &[("SQLCLEAN039", "SELECT")], }, ]; @@ -291,361 +291,361 @@ fn given_plain_sql_rules_when_linting_then_project_context_is_not_required() -> test_types::PlainSqlLintTestCase { description: "trailing comment", sql: "SELECT id -- why\nFROM items", - rule: "SQC033", + rule: "SQLCLEAN033", expected_count: 1, }, test_types::PlainSqlLintTestCase { description: "detached comment", sql: "SELECT id\n-- why\n\nFROM items", - rule: "SQC033", + rule: "SQLCLEAN033", expected_count: 1, }, test_types::PlainSqlLintTestCase { description: "attached predicate comment", sql: "SELECT id FROM items WHERE active\n-- retain settled rows\nAND settled", - rule: "SQC033", + rule: "SQLCLEAN033", expected_count: 0, }, test_types::PlainSqlLintTestCase { description: "attached CTE comment", sql: "WITH\n-- normalized rows\nnormalized AS (SELECT id FROM items)\nSELECT id FROM normalized", - rule: "SQC033", + rule: "SQLCLEAN033", expected_count: 0, }, test_types::PlainSqlLintTestCase { description: "attached join comment", sql: "SELECT left_rows.id FROM left_rows\n-- match stable identities\nJOIN right_rows ON left_rows.id = right_rows.id", - rule: "SQC033", + rule: "SQLCLEAN033", expected_count: 0, }, test_types::PlainSqlLintTestCase { description: "attached filter comment", sql: "SELECT id FROM items\n-- retain active rows\nWHERE active", - rule: "SQC033", + rule: "SQLCLEAN033", expected_count: 0, }, test_types::PlainSqlLintTestCase { description: "attached projection comment", sql: "SELECT\n-- stable identity\nid\nFROM items", - rule: "SQC033", + rule: "SQLCLEAN033", expected_count: 0, }, test_types::PlainSqlLintTestCase { description: "attached CASE branch comment", sql: "SELECT CASE\n-- settled outcome\nWHEN settled THEN 1 ELSE 0 END FROM items", - rule: "SQC033", + rule: "SQLCLEAN033", expected_count: 0, }, test_types::PlainSqlLintTestCase { description: "attached ordering comment with Unicode and CRLF", sql: "SELECT id FROM items\r\n-- deterministic café order\r\nORDER BY id", - rule: "SQC033", + rule: "SQLCLEAN033", expected_count: 0, }, test_types::PlainSqlLintTestCase { description: "block comment", sql: "SELECT id\n/* selected identity */\nFROM items", - rule: "SQC033", + rule: "SQLCLEAN033", expected_count: 1, }, test_types::PlainSqlLintTestCase { description: "body without CTE", sql: "SELECT id FROM items", - rule: "SQC034", + rule: "SQLCLEAN034", expected_count: 1, }, test_types::PlainSqlLintTestCase { description: "logic in terminal select", sql: "WITH final_rows AS (SELECT id FROM items) SELECT id FROM final_rows WHERE id > 0", - rule: "SQC034", + rule: "SQLCLEAN034", expected_count: 1, }, test_types::PlainSqlLintTestCase { description: "terminal select reads wrong CTE", sql: "WITH first_rows AS (SELECT 1 AS id), final_rows AS (SELECT id FROM first_rows) SELECT id FROM first_rows", - rule: "SQC035", + rule: "SQLCLEAN035", expected_count: 1, }, test_types::PlainSqlLintTestCase { description: "plain terminal select", sql: "WITH first_rows AS (SELECT 1 AS id), final_rows AS (SELECT id FROM first_rows) SELECT id FROM final_rows", - rule: "SQC035", + rule: "SQLCLEAN035", expected_count: 0, }, test_types::PlainSqlLintTestCase { description: "plain terminal select with keyword-named columns", sql: "WITH final_rows AS (SELECT 1 AS date, 2 AS comment, 3 AS key, 4 AS percent, 5 AS sequence, 6 AS time) SELECT date, comment, key, percent, sequence, time FROM final_rows", - rule: "SQC035", + rule: "SQLCLEAN035", expected_count: 0, }, test_types::PlainSqlLintTestCase { description: "terminal date literal remains an expression", sql: "WITH final_rows AS (SELECT 1 AS id) SELECT DATE '2026-01-01' FROM final_rows", - rule: "SQC035", + rule: "SQLCLEAN035", expected_count: 1, }, test_types::PlainSqlLintTestCase { description: "terminal modulo calculation remains an expression", sql: "WITH final_rows AS (SELECT 10 AS quantity, 3 AS pack_size) SELECT quantity % pack_size AS remainder FROM final_rows", - rule: "SQC035", + rule: "SQLCLEAN035", expected_count: 1, }, test_types::PlainSqlLintTestCase { description: "plain terminal select from CTE with output column list", sql: "WITH final_rows(id) AS (SELECT 1) SELECT id FROM final_rows", - rule: "SQC035", + rule: "SQLCLEAN035", expected_count: 0, }, test_types::PlainSqlLintTestCase { description: "CTE output column list names its calculation", sql: "WITH final_rows(id) AS (SELECT 1) SELECT id FROM final_rows", - rule: "SQC022", + rule: "SQLCLEAN022", expected_count: 0, }, test_types::PlainSqlLintTestCase { description: "terminal union remains outside final CTE", sql: "WITH final_rows AS (SELECT 1 AS id) SELECT id FROM final_rows UNION ALL SELECT id FROM final_rows", - rule: "SQC034", + rule: "SQLCLEAN034", expected_count: 1, }, test_types::PlainSqlLintTestCase { description: "terminal union is not a plain final CTE read", sql: "WITH final_rows AS (SELECT 1 AS id) SELECT id FROM final_rows UNION ALL SELECT id FROM final_rows", - rule: "SQC035", + rule: "SQLCLEAN035", expected_count: 1, }, test_types::PlainSqlLintTestCase { description: "parenthesized terminal union remains outside final CTE", sql: "WITH final_rows AS (SELECT 1 AS id) (SELECT id FROM final_rows) UNION ALL (SELECT id FROM final_rows)", - rule: "SQC034", + rule: "SQLCLEAN034", expected_count: 1, }, test_types::PlainSqlLintTestCase { description: "parenthesized terminal union is not a plain final CTE read", sql: "WITH final_rows AS (SELECT 1 AS id) (SELECT id FROM final_rows) UNION ALL (SELECT id FROM final_rows)", - rule: "SQC035", + rule: "SQLCLEAN035", expected_count: 1, }, test_types::PlainSqlLintTestCase { description: "mixed parenthesized terminal union remains outside final CTE", sql: "WITH final_rows AS (SELECT 1 AS id) (SELECT id FROM final_rows) UNION ALL SELECT id FROM final_rows", - rule: "SQC034", + rule: "SQLCLEAN034", expected_count: 1, }, test_types::PlainSqlLintTestCase { description: "mixed parenthesized terminal union is not a plain final CTE read", sql: "WITH final_rows AS (SELECT 1 AS id) (SELECT id FROM final_rows) UNION ALL SELECT id FROM final_rows", - rule: "SQC035", + rule: "SQLCLEAN035", expected_count: 1, }, test_types::PlainSqlLintTestCase { description: "mixed parenthesized terminal intersect is not a plain final CTE read", sql: "WITH final_rows AS (SELECT 1 AS id) (SELECT id FROM final_rows) INTERSECT SELECT id FROM final_rows", - rule: "SQC035", + rule: "SQLCLEAN035", expected_count: 1, }, test_types::PlainSqlLintTestCase { description: "mixed parenthesized terminal except is not a plain final CTE read", sql: "WITH final_rows AS (SELECT 1 AS id) (SELECT id FROM final_rows) EXCEPT SELECT id FROM final_rows", - rule: "SQC035", + rule: "SQLCLEAN035", expected_count: 1, }, test_types::PlainSqlLintTestCase { description: "nested CTE", sql: "SELECT id FROM (WITH nested_rows AS (SELECT 1 AS id) SELECT id FROM nested_rows)", - rule: "SQC036", + rule: "SQLCLEAN036", expected_count: 1, }, test_types::PlainSqlLintTestCase { description: "Snowflake hierarchy START WITH is not a nested CTE", sql: "WITH entities AS (SELECT id, parent_id FROM source_rows), hierarchy AS (SELECT id FROM entities START WITH parent_id IS NULL CONNECT BY parent_id = PRIOR id) SELECT id FROM hierarchy", - rule: "SQC036", + rule: "SQLCLEAN036", expected_count: 0, }, test_types::PlainSqlLintTestCase { description: "recursive CTE", sql: "WITH RECURSIVE numbers AS (SELECT 1 UNION ALL SELECT 2) SELECT * FROM numbers", - rule: "SQC037", + rule: "SQLCLEAN037", expected_count: 1, }, test_types::PlainSqlLintTestCase { description: "cross join", sql: "SELECT left_rows.id FROM left_rows CROSS JOIN right_rows", - rule: "SQC038", + rule: "SQLCLEAN038", expected_count: 1, }, test_types::PlainSqlLintTestCase { description: "inline query after from", sql: "SELECT derived.order_id FROM (SELECT order_id FROM orders) AS derived", - rule: "SQC039", + rule: "SQLCLEAN039", expected_count: 1, }, test_types::PlainSqlLintTestCase { description: "inline query after join", sql: "SELECT orders.order_id FROM orders INNER JOIN (SELECT order_id FROM shipments) AS shipped_orders ON orders.order_id = shipped_orders.order_id", - rule: "SQC039", + rule: "SQLCLEAN039", expected_count: 1, }, test_types::PlainSqlLintTestCase { description: "inline values query after full outer join", sql: "SELECT COALESCE(actual.order_id, expected.order_id) AS order_id FROM actual FULL OUTER JOIN (SELECT column1 AS order_id FROM VALUES (1), (2)) AS expected ON actual.order_id = expected.order_id", - rule: "SQC039", + rule: "SQLCLEAN039", expected_count: 1, }, test_types::PlainSqlLintTestCase { description: "inline values relation after join", sql: "SELECT orders.order_id FROM orders INNER JOIN (VALUES (1), (2)) AS expected(order_id) ON orders.order_id = expected.order_id", - rule: "SQC039", + rule: "SQLCLEAN039", expected_count: 1, }, test_types::PlainSqlLintTestCase { description: "inline nested with query", sql: "SELECT derived.order_id FROM (WITH imported_orders AS (SELECT order_id FROM orders) SELECT order_id FROM imported_orders) AS derived", - rule: "SQC039", + rule: "SQLCLEAN039", expected_count: 1, }, test_types::PlainSqlLintTestCase { description: "redundantly parenthesized inline query", sql: "SELECT derived.order_id FROM (((SELECT order_id FROM orders))) AS derived", - rule: "SQC039", + rule: "SQLCLEAN039", expected_count: 1, }, test_types::PlainSqlLintTestCase { description: "comma-separated inline query", sql: "SELECT orders.order_id FROM orders, (SELECT order_id FROM shipments) AS shipped_orders", - rule: "SQC039", + rule: "SQLCLEAN039", expected_count: 1, }, test_types::PlainSqlLintTestCase { description: "scalar subquery", sql: "SELECT (SELECT MAX(order_id) FROM orders) AS latest_order_id", - rule: "SQC039", + rule: "SQLCLEAN039", expected_count: 0, }, test_types::PlainSqlLintTestCase { description: "scalar subquery containing a derived relation", sql: "SELECT (SELECT derived.order_id FROM (SELECT nested.order_id FROM (SELECT 1 AS order_id) AS nested) AS derived) AS order_id", - rule: "SQC039", + rule: "SQLCLEAN039", expected_count: 0, }, test_types::PlainSqlLintTestCase { description: "exists subquery containing a derived relation", sql: "SELECT order_id FROM orders WHERE EXISTS (SELECT 1 FROM (SELECT nested.order_id FROM (SELECT order_id FROM shipments) AS nested) AS shipped_orders WHERE shipped_orders.order_id = orders.order_id)", - rule: "SQC039", + rule: "SQLCLEAN039", expected_count: 0, }, test_types::PlainSqlLintTestCase { description: "exists set branch containing a derived relation", sql: "SELECT EXISTS (SELECT 1 UNION ALL SELECT derived.order_id FROM (SELECT 1 AS order_id) AS derived)", - rule: "SQC039", + rule: "SQLCLEAN039", expected_count: 0, }, test_types::PlainSqlLintTestCase { description: "scalar with query containing a derived relation in a CTE", sql: "SELECT (WITH derived AS (SELECT nested.order_id FROM (SELECT 1 AS order_id) AS nested) SELECT order_id FROM derived) AS order_id", - rule: "SQC039", + rule: "SQLCLEAN039", expected_count: 0, }, test_types::PlainSqlLintTestCase { description: "scalar with query containing a derived terminal relation", sql: "SELECT (WITH orders AS (SELECT 1 AS order_id) SELECT derived.order_id FROM (SELECT order_id FROM orders) AS derived) AS order_id", - rule: "SQC039", + rule: "SQLCLEAN039", expected_count: 0, }, test_types::PlainSqlLintTestCase { description: "nested derived relations outside an excluded subquery", sql: "SELECT derived.order_id FROM (SELECT nested.order_id FROM (SELECT 1 AS order_id) AS nested) AS derived", - rule: "SQC039", + rule: "SQLCLEAN039", expected_count: 2, }, test_types::PlainSqlLintTestCase { description: "nested derived relations inside a parenthesized ordinary relation", sql: "SELECT derived.order_id FROM ((SELECT nested.order_id FROM (SELECT 1 AS order_id) AS nested)) AS derived", - rule: "SQC039", + rule: "SQLCLEAN039", expected_count: 2, }, test_types::PlainSqlLintTestCase { description: "scalar subquery after projection comma", sql: "SELECT 1 AS constant_value, (SELECT MAX(order_id) FROM orders) AS latest_order_id", - rule: "SQC039", + rule: "SQLCLEAN039", expected_count: 0, }, test_types::PlainSqlLintTestCase { description: "scalar subquery after expression from", sql: "SELECT EXTRACT(YEAR FROM (SELECT MAX(order_date) FROM orders)) AS latest_order_year", - rule: "SQC039", + rule: "SQLCLEAN039", expected_count: 0, }, test_types::PlainSqlLintTestCase { description: "scalar subquery after sibling projection subquery", sql: "SELECT (SELECT MAX(order_id) FROM orders), COALESCE(NULL, (SELECT MAX(order_id) FROM orders))", - rule: "SQC039", + rule: "SQLCLEAN039", expected_count: 0, }, test_types::PlainSqlLintTestCase { description: "expression from after sibling projection subquery", sql: "SELECT (SELECT MAX(order_id) FROM orders), EXTRACT(YEAR FROM (SELECT MAX(order_date) FROM orders))", - rule: "SQC039", + rule: "SQLCLEAN039", expected_count: 0, }, test_types::PlainSqlLintTestCase { description: "scalar subquery after order by comma", sql: "SELECT order_id FROM orders ORDER BY order_id, (SELECT MAX(status_id) FROM statuses)", - rule: "SQC039", + rule: "SQLCLEAN039", expected_count: 0, }, test_types::PlainSqlLintTestCase { description: "scalar subquery with a derived relation after order by comma", sql: "SELECT order_id FROM orders ORDER BY order_id, (SELECT derived.status_id FROM (SELECT 1 AS status_id) AS derived)", - rule: "SQC039", + rule: "SQLCLEAN039", expected_count: 0, }, test_types::PlainSqlLintTestCase { description: "exists subquery", sql: "SELECT order_id FROM orders WHERE EXISTS (SELECT 1 FROM shipments WHERE shipments.order_id = orders.order_id)", - rule: "SQC039", + rule: "SQLCLEAN039", expected_count: 0, }, test_types::PlainSqlLintTestCase { description: "in subquery", sql: "SELECT order_id FROM orders WHERE order_id IN (SELECT order_id FROM shipments)", - rule: "SQC039", + rule: "SQLCLEAN039", expected_count: 0, }, test_types::PlainSqlLintTestCase { description: "named CTE relation", sql: "WITH shipped_orders AS (SELECT order_id FROM shipments) SELECT orders.order_id FROM orders INNER JOIN shipped_orders ON orders.order_id = shipped_orders.order_id", - rule: "SQC039", + rule: "SQLCLEAN039", expected_count: 0, }, test_types::PlainSqlLintTestCase { description: "materialized CTE containing a derived relation", sql: "WITH orders AS MATERIALIZED (SELECT derived.order_id FROM (SELECT 1 AS order_id) AS derived) SELECT order_id FROM orders", - rule: "SQC039", + rule: "SQLCLEAN039", expected_count: 1, }, test_types::PlainSqlLintTestCase { description: "not materialized CTE containing a derived relation", sql: "WITH orders AS NOT MATERIALIZED (SELECT derived.order_id FROM (SELECT 1 AS order_id) AS derived) SELECT order_id FROM orders", - rule: "SQC039", + rule: "SQLCLEAN039", expected_count: 1, }, test_types::PlainSqlLintTestCase { description: "lateral query relation", sql: "SELECT orders.order_id FROM orders, LATERAL (SELECT orders.order_id) AS derived", - rule: "SQC039", + rule: "SQLCLEAN039", expected_count: 0, }, test_types::PlainSqlLintTestCase { description: "apply query relation", sql: "SELECT orders.order_id FROM orders CROSS APPLY (SELECT orders.order_id) AS derived", - rule: "SQC039", + rule: "SQLCLEAN039", expected_count: 0, }, test_types::PlainSqlLintTestCase { description: "table function relation", sql: "SELECT flattened.value FROM TABLE(FLATTEN(input => PARSE_JSON('[1]'))) AS flattened", - rule: "SQC039", + rule: "SQLCLEAN039", expected_count: 0, }, ]; @@ -676,49 +676,49 @@ fn given_native_rules_when_linting_then_diagnosis_and_remediation_are_actionable test_types::LintMetadataTestCase { description: "NULL comparison guidance", sql: "SELECT value FROM a WHERE value = NULL", - expected_code: "SQC001", + expected_code: "SQLCLEAN001", expected_message: "Comparison with NULL is never true", expected_remediation: "Use IS NULL or IS NOT NULL when testing for NULL.", }, test_types::LintMetadataTestCase { description: "implicit join guidance", sql: "SELECT a.id FROM a, b", - expected_code: "SQC002", + expected_code: "SQLCLEAN002", expected_message: "Comma-separated sources create an implicit cartesian join", expected_remediation: "Replace comma-separated sources with an explicit keyed join, or use CROSS JOIN when the cartesian product is intentional.", }, test_types::LintMetadataTestCase { description: "unconditioned join guidance", sql: "SELECT a.id FROM a JOIN b", - expected_code: "SQC003", + expected_code: "SQLCLEAN003", expected_message: "Non-cross join has no meaningful condition", expected_remediation: "Add a meaningful ON or USING condition, or declare an intentional cartesian product with CROSS JOIN.", }, test_types::LintMetadataTestCase { description: "unordered limit guidance", sql: "SELECT id FROM items LIMIT 1", - expected_code: "SQC004", + expected_code: "SQLCLEAN004", expected_message: "Row selection is nondeterministic", expected_remediation: "Add ORDER BY with a deterministic tie-breaker before LIMIT or OFFSET.", }, test_types::LintMetadataTestCase { description: "unused CTE guidance", sql: "WITH unused AS (SELECT 1) SELECT 1", - expected_code: "SQC005", + expected_code: "SQLCLEAN005", expected_message: "CTE is unreachable from the final query", expected_remediation: "Reference the CTE from the final query or another reachable CTE, or remove it.", }, test_types::LintMetadataTestCase { description: "redundant distinct guidance", sql: "SELECT DISTINCT id FROM items GROUP BY id", - expected_code: "SQC006", + expected_code: "SQLCLEAN006", expected_message: "DISTINCT is redundant with the grouped output", expected_remediation: "Remove DISTINCT; the equivalent GROUP BY already determines the output groups.", }, test_types::LintMetadataTestCase { description: "positional set star guidance", sql: "SELECT * FROM a UNION ALL SELECT * FROM b", - expected_code: "SQC007", + expected_code: "SQLCLEAN007", expected_message: "Positional set operation is vulnerable to column-order drift", expected_remediation: "Enumerate columns in the same order in every set-operation branch.", }, @@ -762,55 +762,55 @@ fn given_fixable_and_ambiguous_findings_when_linting_then_only_proven_edits_are_ test_types::LintFixTestCase { description: "right-hand NULL equality", sql: "SELECT value FROM a WHERE value = NULL", - expected_code: "SQC001", + expected_code: "SQLCLEAN001", expected_replacement: Some("IS"), }, test_types::LintFixTestCase { description: "right-hand NULL inequality", sql: "SELECT value FROM a WHERE value <> NULL", - expected_code: "SQC001", + expected_code: "SQLCLEAN001", expected_replacement: Some("IS NOT"), }, test_types::LintFixTestCase { description: "left-hand NULL comparison requires authored intent", sql: "SELECT value FROM a WHERE NULL = value", - expected_code: "SQC001", + expected_code: "SQLCLEAN001", expected_replacement: None, }, test_types::LintFixTestCase { description: "plain conditionless join", sql: "SELECT a.id FROM a JOIN b", - expected_code: "SQC003", + expected_code: "SQLCLEAN003", expected_replacement: Some("CROSS JOIN"), }, test_types::LintFixTestCase { description: "qualified conditionless join requires intent", sql: "SELECT a.id FROM a LEFT JOIN b", - expected_code: "SQC003", + expected_code: "SQLCLEAN003", expected_replacement: None, }, test_types::LintFixTestCase { description: "conditionless semi join is never rewritten as cross join", sql: "SELECT a.id FROM a SEMI JOIN b", - expected_code: "SQC003", + expected_code: "SQLCLEAN003", expected_replacement: None, }, test_types::LintFixTestCase { description: "redundant distinct", sql: "SELECT DISTINCT id FROM items GROUP BY id", - expected_code: "SQC006", + expected_code: "SQLCLEAN006", expected_replacement: Some(""), }, test_types::LintFixTestCase { description: "single unused select CTE", sql: "WITH unused AS (SELECT 1) SELECT 1", - expected_code: "SQC005", + expected_code: "SQLCLEAN005", expected_replacement: Some(""), }, test_types::LintFixTestCase { description: "reserved framework CTE is never deleted", sql: "WITH __expected__items AS (SELECT 1) SELECT 1", - expected_code: "SQC005", + expected_code: "SQLCLEAN005", expected_replacement: None, }, ]; @@ -837,539 +837,539 @@ fn given_additional_rule_cases_when_linting_then_findings_and_fixes_match() -> R test_types::AdditionalLintRuleTestCase { description: "bare union", sql: "SELECT 1 UNION SELECT 2", - rule: "SQC008", + rule: "SQLCLEAN008", expected_anchor: Some("UNION"), expected_replacement: Some("UNION DISTINCT"), }, test_types::AdditionalLintRuleTestCase { description: "duplicate relation alias", sql: "SELECT x.id FROM alpha AS x JOIN beta AS x ON x.id = x.id", - rule: "SQC009", + rule: "SQLCLEAN009", expected_anchor: Some("x"), expected_replacement: None, }, test_types::AdditionalLintRuleTestCase { description: "duplicate output alias", sql: "SELECT a AS value, b AS value FROM items", - rule: "SQC010", + rule: "SQLCLEAN010", expected_anchor: Some("value"), expected_replacement: None, }, test_types::AdditionalLintRuleTestCase { description: "mixed grouping references", sql: "SELECT a, b FROM items GROUP BY 1, b", - rule: "SQC011", + rule: "SQLCLEAN011", expected_anchor: Some("GROUP"), expected_replacement: None, }, test_types::AdditionalLintRuleTestCase { description: "redundant else null", sql: "SELECT CASE WHEN a THEN b ELSE NULL END FROM items", - rule: "SQC012", + rule: "SQLCLEAN012", expected_anchor: Some("ELSE NULL"), expected_replacement: Some(""), }, test_types::AdditionalLintRuleTestCase { description: "function-like distinct", sql: "SELECT DISTINCT(a) FROM items", - rule: "SQC013", + rule: "SQLCLEAN013", expected_anchor: Some("DISTINCT(a)"), expected_replacement: Some("DISTINCT a"), }, test_types::AdditionalLintRuleTestCase { description: "constant scaffold predicate", sql: "SELECT a FROM items WHERE 1 = 1", - rule: "SQC014", + rule: "SQLCLEAN014", expected_anchor: Some("="), expected_replacement: None, }, test_types::AdditionalLintRuleTestCase { description: "consecutive statement terminator", sql: "SELECT 1;;", - rule: "SQC015", + rule: "SQLCLEAN015", expected_anchor: Some(";"), expected_replacement: Some(""), }, test_types::AdditionalLintRuleTestCase { description: "redundant self alias", sql: "SELECT value AS value FROM items", - rule: "SQC016", + rule: "SQLCLEAN016", expected_anchor: Some(" AS value"), expected_replacement: None, }, test_types::AdditionalLintRuleTestCase { description: "qualified column self alias is redundant", sql: "SELECT items.value AS value FROM items", - rule: "SQC016", + rule: "SQLCLEAN016", expected_anchor: Some(" AS value"), expected_replacement: None, }, test_types::AdditionalLintRuleTestCase { description: "qualified column self alias after an expression is redundant", sql: "SELECT item_id + 1 AS next_item_id, items.value AS value FROM items", - rule: "SQC016", + rule: "SQLCLEAN016", expected_anchor: Some(" AS value"), expected_replacement: None, }, test_types::AdditionalLintRuleTestCase { description: "top-modified column self alias is redundant", sql: "SELECT TOP 5 value AS value FROM items", - rule: "SQC016", + rule: "SQLCLEAN016", expected_anchor: Some(" AS value"), expected_replacement: None, }, test_types::AdditionalLintRuleTestCase { description: "terminal operand name does not make a calculation self-aliased", sql: "SELECT price + value AS value FROM items", - rule: "SQC016", + rule: "SQLCLEAN016", expected_anchor: None, expected_replacement: None, }, test_types::AdditionalLintRuleTestCase { description: "cast type syntax is not an output self alias", sql: "SELECT CAST(order_id::INTEGER AS INTEGER) AS order_id FROM orders", - rule: "SQC016", + rule: "SQLCLEAN016", expected_anchor: None, expected_replacement: None, }, test_types::AdditionalLintRuleTestCase { description: "relation self alias is not an output self alias", sql: "SELECT orders.order_id FROM orders AS orders", - rule: "SQC016", + rule: "SQLCLEAN016", expected_anchor: None, expected_replacement: None, }, test_types::AdditionalLintRuleTestCase { description: "semi-structured path alias is not redundant", sql: "SELECT event.payload:data:order_id AS order_id FROM events AS event", - rule: "SQC016", + rule: "SQLCLEAN016", expected_anchor: None, expected_replacement: None, }, test_types::AdditionalLintRuleTestCase { description: "constant row count", sql: "SELECT COUNT(1) FROM items", - rule: "SQC017", + rule: "SQLCLEAN017", expected_anchor: Some("1"), expected_replacement: Some("*"), }, test_types::AdditionalLintRuleTestCase { description: "unordered row number", sql: "SELECT ROW_NUMBER() OVER (PARTITION BY id) FROM items", - rule: "SQC018", + rule: "SQLCLEAN018", expected_anchor: Some("ROW_NUMBER"), expected_replacement: None, }, test_types::AdditionalLintRuleTestCase { description: "literal null in not-in list", sql: "SELECT id FROM items WHERE id NOT IN (1, NULL)", - rule: "SQC019", + rule: "SQLCLEAN019", expected_anchor: Some("NOT"), expected_replacement: None, }, test_types::AdditionalLintRuleTestCase { description: "set branch arity mismatch", sql: "SELECT a, b FROM first UNION ALL SELECT c FROM second", - rule: "SQC020", + rule: "SQLCLEAN020", expected_anchor: Some("UNION"), expected_replacement: None, }, test_types::AdditionalLintRuleTestCase { description: "uncontrolled projection star", sql: "SELECT * FROM items", - rule: "SQC021", + rule: "SQLCLEAN021", expected_anchor: Some("*"), expected_replacement: None, }, test_types::AdditionalLintRuleTestCase { description: "projection star without from remains uncontrolled", sql: "SELECT *", - rule: "SQC021", + rule: "SQLCLEAN021", expected_anchor: Some("*"), expected_replacement: None, }, test_types::AdditionalLintRuleTestCase { description: "mixed dynamic pivot projection remains uncontrolled", sql: "SELECT *, customer_id FROM orders PIVOT(MAX(amount) FOR category IN (ANY))", - rule: "SQC021", + rule: "SQLCLEAN021", expected_anchor: Some("*"), expected_replacement: None, }, test_types::AdditionalLintRuleTestCase { description: "static pivot star remains uncontrolled", sql: "SELECT * FROM orders PIVOT(MAX(amount) FOR category IN ('books'))", - rule: "SQC021", + rule: "SQLCLEAN021", expected_anchor: Some("*"), expected_replacement: None, }, test_types::AdditionalLintRuleTestCase { description: "static pivot any string remains uncontrolled", sql: "SELECT * FROM orders PIVOT(MAX(amount) FOR category IN ('ANY'))", - rule: "SQC021", + rule: "SQLCLEAN021", expected_anchor: Some("*"), expected_replacement: None, }, test_types::AdditionalLintRuleTestCase { description: "nested dynamic pivot does not exempt outer projection star", sql: "SELECT * FROM orders WHERE EXISTS (SELECT 1 FROM items PIVOT(MAX(amount) FOR category IN (ANY)))", - rule: "SQC021", + rule: "SQLCLEAN021", expected_anchor: Some("*"), expected_replacement: None, }, test_types::AdditionalLintRuleTestCase { description: "unrelated any does not exempt static pivot star", sql: "SELECT * FROM orders PIVOT(MAX(amount) FOR category IN ('books')) WHERE customer_id = ANY (SELECT customer_id FROM customers)", - rule: "SQC021", + rule: "SQLCLEAN021", expected_anchor: Some("*"), expected_replacement: None, }, test_types::AdditionalLintRuleTestCase { description: "comma joined dynamic pivot star remains uncontrolled", sql: "SELECT * FROM orders PIVOT(MAX(amount) FOR category IN (ANY)) AS p, customers AS c", - rule: "SQC021", + rule: "SQLCLEAN021", expected_anchor: Some("*"), expected_replacement: None, }, test_types::AdditionalLintRuleTestCase { description: "joined audit relation parameter star remains uncontrolled", - sql: "SELECT * FROM __sqlbuild_audit_parameter_0__ AS a CROSS JOIN customers AS c", - rule: "SQC021", + sql: "SELECT * FROM __sqlclean_relation_parameter_0__ AS a CROSS JOIN customers AS c", + rule: "SQLCLEAN021", expected_anchor: Some("*"), expected_replacement: None, }, test_types::AdditionalLintRuleTestCase { description: "terminal framework dependency star remains uncontrolled", - sql: "SELECT * FROM __sqb_lint_0__", - rule: "SQC021", + sql: "SELECT * FROM __sqlclean_interpolation_0__", + rule: "SQLCLEAN021", expected_anchor: Some("*"), expected_replacement: None, }, test_types::AdditionalLintRuleTestCase { description: "transformed framework dependency import star remains uncontrolled", - sql: "WITH imported_orders AS (SELECT *, CURRENT_TIMESTAMP AS loaded_at FROM __sqb_lint_0__) SELECT order_id FROM imported_orders", - rule: "SQC021", + sql: "WITH imported_orders AS (SELECT *, CURRENT_TIMESTAMP AS loaded_at FROM __sqlclean_interpolation_0__) SELECT order_id FROM imported_orders", + rule: "SQLCLEAN021", expected_anchor: Some("*"), expected_replacement: None, }, test_types::AdditionalLintRuleTestCase { description: "ordinary relation import star remains uncontrolled", sql: "WITH imported_orders AS (SELECT * FROM orders) SELECT order_id FROM imported_orders", - rule: "SQC021", + rule: "SQLCLEAN021", expected_anchor: Some("*"), expected_replacement: None, }, test_types::AdditionalLintRuleTestCase { description: "nested framework dependency import star remains uncontrolled", - sql: "WITH outer_query AS (WITH imported_orders AS (SELECT * FROM __sqb_lint_0__) SELECT order_id FROM imported_orders) SELECT order_id FROM outer_query", - rule: "SQC021", + sql: "WITH outer_query AS (WITH imported_orders AS (SELECT * FROM __sqlclean_interpolation_0__) SELECT order_id FROM imported_orders) SELECT order_id FROM outer_query", + rule: "SQLCLEAN021", expected_anchor: Some("*"), expected_replacement: None, }, test_types::AdditionalLintRuleTestCase { description: "unaliased calculated projection", sql: "SELECT price * quantity FROM items", - rule: "SQC022", + rule: "SQLCLEAN022", expected_anchor: Some("price"), expected_replacement: None, }, test_types::AdditionalLintRuleTestCase { description: "unused table alias", sql: "SELECT id FROM items AS unused", - rule: "SQC023", + rule: "SQLCLEAN023", expected_anchor: Some("AS unused"), expected_replacement: Some(""), }, test_types::AdditionalLintRuleTestCase { description: "left join rejected in where", sql: "SELECT a.id FROM a LEFT JOIN b AS right_side ON a.id = right_side.id WHERE right_side.active = TRUE", - rule: "SQC024", + rule: "SQLCLEAN024", expected_anchor: Some("LEFT"), expected_replacement: None, }, test_types::AdditionalLintRuleTestCase { description: "implicit inner join", sql: "SELECT a.id FROM a JOIN b ON a.id = b.id", - rule: "SQC025", + rule: "SQLCLEAN025", expected_anchor: Some("JOIN"), expected_replacement: Some("INNER JOIN"), }, test_types::AdditionalLintRuleTestCase { description: "mixed order directions", sql: "SELECT a, b FROM items ORDER BY a, b DESC", - rule: "SQC026", + rule: "SQLCLEAN026", expected_anchor: Some("ORDER"), expected_replacement: None, }, test_types::AdditionalLintRuleTestCase { description: "unqualified multi-source column", sql: "SELECT id FROM a JOIN b ON a.id = b.id", - rule: "SQC027", + rule: "SQLCLEAN027", expected_anchor: Some("id"), expected_replacement: None, }, test_types::AdditionalLintRuleTestCase { description: "mixed single-source qualification", sql: "SELECT items.id, name FROM items", - rule: "SQC028", + rule: "SQLCLEAN028", expected_anchor: Some("name"), expected_replacement: None, }, test_types::AdditionalLintRuleTestCase { description: "plain macro slot reads count as unqualified beside a plain group read", - sql: "SELECT __sqlbuild_macro_parameter_1__, __sqlbuild_macro_parameter_0__.__sqlbuild_macro_parameter_2__ AS orders FROM __sqlbuild_macro_parameter_0__", - rule: "SQC028", + sql: "SELECT __sqlclean_macro_parameter_1__, __sqlclean_macro_parameter_0__.__sqlclean_macro_parameter_2__ AS orders FROM __sqlclean_macro_parameter_0__", + rule: "SQLCLEAN028", expected_anchor: None, expected_replacement: None, }, test_types::AdditionalLintRuleTestCase { description: "a plain macro slot read beside a hand-qualified column is mixed", - sql: "SELECT __sqlbuild_macro_parameter_0__.__sqlbuild_macro_parameter_1__ AS orders, s.refunds FROM __sqlbuild_macro_parameter_0__ AS s", - rule: "SQC028", - expected_anchor: Some("__sqlbuild_macro_parameter_0__"), + sql: "SELECT __sqlclean_macro_parameter_0__.__sqlclean_macro_parameter_1__ AS orders, s.refunds FROM __sqlclean_macro_parameter_0__ AS s", + rule: "SQLCLEAN028", + expected_anchor: Some("__sqlclean_macro_parameter_0__"), expected_replacement: None, }, test_types::AdditionalLintRuleTestCase { description: "unknown relation qualifier", sql: "SELECT missing.id FROM items AS present", - rule: "SQC029", + rule: "SQLCLEAN029", expected_anchor: Some("missing"), expected_replacement: None, }, test_types::AdditionalLintRuleTestCase { description: "Snowflake shorthand cast type is not an unqualified column", sql: "SELECT src.value::STRING AS value FROM items AS src", - rule: "SQC028", + rule: "SQLCLEAN028", expected_anchor: None, expected_replacement: None, }, test_types::AdditionalLintRuleTestCase { description: "Snowflake JSON path and shorthand cast type are not columns", sql: "SELECT src.payload:item::TEXT AS item FROM items AS src", - rule: "SQC028", + rule: "SQLCLEAN028", expected_anchor: None, expected_replacement: None, }, test_types::AdditionalLintRuleTestCase { description: "Snowflake shorthand cast type is not a multi-source column", sql: "SELECT first.value::TEXT AS value FROM first JOIN second ON first.id = second.id", - rule: "SQC027", + rule: "SQLCLEAN027", expected_anchor: None, expected_replacement: None, }, test_types::AdditionalLintRuleTestCase { description: "implicit table alias is known in query scope", sql: "SELECT present.id FROM items present", - rule: "SQC029", + rule: "SQLCLEAN029", expected_anchor: None, expected_replacement: None, }, test_types::AdditionalLintRuleTestCase { description: "implicit joined alias contributes a selected value", sql: "SELECT left_side.id, right_side.name FROM alpha left_side LEFT JOIN beta right_side ON left_side.id = right_side.id", - rule: "SQC032", + rule: "SQLCLEAN032", expected_anchor: None, expected_replacement: None, }, test_types::AdditionalLintRuleTestCase { description: "Snowflake lateral flatten comma is explicit lateral syntax", sql: "SELECT flattened.value FROM items, LATERAL FLATTEN(input => items.payload) flattened", - rule: "SQC002", + rule: "SQLCLEAN002", expected_anchor: None, expected_replacement: None, }, test_types::AdditionalLintRuleTestCase { description: "Snowflake VALUES row separator is not a relation separator", sql: "SELECT column1 FROM VALUES (1), (2), (3)", - rule: "SQC002", + rule: "SQLCLEAN002", expected_anchor: None, expected_replacement: None, }, test_types::AdditionalLintRuleTestCase { description: "arithmetic multiplication is not a projected wildcard", sql: "SELECT price * quantity AS total FROM items", - rule: "SQC021", + rule: "SQLCLEAN021", expected_anchor: None, expected_replacement: None, }, test_types::AdditionalLintRuleTestCase { description: "number-led multiplication is not a projected wildcard", sql: "SELECT 100 * ratio AS percentage FROM items", - rule: "SQC021", + rule: "SQLCLEAN021", expected_anchor: None, expected_replacement: None, }, test_types::AdditionalLintRuleTestCase { description: "TOP projection wildcard remains controlled by star rule", sql: "SELECT TOP 10 * FROM items", - rule: "SQC021", + rule: "SQLCLEAN021", expected_anchor: Some("*"), expected_replacement: None, }, test_types::AdditionalLintRuleTestCase { description: "qualified wildcard modifier does not require an alias", sql: "SELECT src.* EXCLUDE (internal_id) FROM items AS src", - rule: "SQC022", + rule: "SQLCLEAN022", expected_anchor: None, expected_replacement: None, }, test_types::AdditionalLintRuleTestCase { description: "EXISTS literal projection does not require an alias", sql: "SELECT parent.id FROM parent WHERE EXISTS (SELECT 1 FROM child WHERE child.id = parent.id)", - rule: "SQC022", + rule: "SQLCLEAN022", expected_anchor: None, expected_replacement: None, }, test_types::AdditionalLintRuleTestCase { description: "correlated subquery can reference an outer relation", sql: "SELECT parent.id FROM parent WHERE EXISTS (SELECT 1 FROM child WHERE child.id = parent.id)", - rule: "SQC029", + rule: "SQLCLEAN029", expected_anchor: None, expected_replacement: None, }, test_types::AdditionalLintRuleTestCase { description: "Snowflake JSON path key is not a relation qualifier", sql: "SELECT PARSE_JSON(src.payload):item.name::VARCHAR AS item_name FROM items AS src", - rule: "SQC029", + rule: "SQLCLEAN029", expected_anchor: None, expected_replacement: None, }, test_types::AdditionalLintRuleTestCase { description: "Snowflake JSON path starts from a qualified relation value", sql: "SELECT parsed:item::VARCHAR AS item FROM items AS src, LATERAL FLATTEN(input => src.payload) AS parsed", - rule: "SQC027", + rule: "SQLCLEAN027", expected_anchor: None, expected_replacement: None, }, test_types::AdditionalLintRuleTestCase { description: "Snowflake ASOF JOIN alias is known in query scope", sql: "SELECT matched.ts FROM requested request ASOF JOIN available matched MATCH_CONDITION (request.ts >= matched.ts) ON request.id = matched.id", - rule: "SQC029", + rule: "SQLCLEAN029", expected_anchor: None, expected_replacement: None, }, test_types::AdditionalLintRuleTestCase { description: "later set branches inherit the first branch output name", sql: "SELECT 0 AS offset_value UNION ALL SELECT 1 UNION ALL SELECT 2", - rule: "SQC022", + rule: "SQLCLEAN022", expected_anchor: None, expected_replacement: None, }, test_types::AdditionalLintRuleTestCase { description: "scalar subquery output does not require an alias inside its scope", sql: "SELECT (SELECT MAX(child.value) FROM child WHERE child.id = parent.id) AS maximum_value FROM parent", - rule: "SQC022", + rule: "SQLCLEAN022", expected_anchor: None, expected_replacement: None, }, test_types::AdditionalLintRuleTestCase { description: "later scalar function argument does not require an inner alias", sql: "SELECT COALESCE((SELECT MAX(change_id) FROM first_changes), (SELECT MAX(change_id) FROM second_changes), 0) AS latest_change_id FROM support_tickets", - rule: "SQC022", + rule: "SQLCLEAN022", expected_anchor: None, expected_replacement: None, }, test_types::AdditionalLintRuleTestCase { description: "scalar aggregate nested in conditional coalesce does not require an inner alias", sql: "SELECT CASE WHEN batch.layout = 'fallback' THEN COALESCE((SELECT MAX(change.line_id) FROM first_changes AS change WHERE change.batch_id = batch.batch_id AND change.line_id < batch.line_id), (SELECT MAX(archive.line_id) FROM archived_changes AS archive WHERE archive.batch_id = batch.batch_id AND archive.line_id < batch.line_id), header.line_id) + 1 ELSE batch.start_line END AS start_line FROM support_batches AS batch LEFT JOIN support_headers AS header ON header.batch_id = batch.batch_id", - rule: "SQC022", + rule: "SQLCLEAN022", expected_anchor: None, expected_replacement: None, }, test_types::AdditionalLintRuleTestCase { description: "prior nested relation does not contaminate a later scalar function argument", sql: "WITH prior_changes AS (SELECT (SELECT MAX(change_id) FROM support_changes) AS latest_change_id FROM support_tickets), current_changes AS (SELECT COALESCE((SELECT MAX(change_id) FROM first_changes), (SELECT MAX(change_id) FROM second_changes), 0) AS latest_change_id FROM current_tickets) SELECT * FROM current_changes", - rule: "SQC022", + rule: "SQLCLEAN022", expected_anchor: None, expected_replacement: None, }, test_types::AdditionalLintRuleTestCase { description: "scalar subquery after a prior CTE relation remains in projection scope", sql: "WITH products_used AS (SELECT product_id FROM products), ticket_totals AS (SELECT ticket.ticket_id, (SELECT MAX(change_id) FROM support_changes) AS latest_change_id FROM support_tickets AS ticket) SELECT * FROM ticket_totals", - rule: "SQC022", + rule: "SQLCLEAN022", expected_anchor: None, expected_replacement: None, }, test_types::AdditionalLintRuleTestCase { description: "comma-derived table calculation still requires an alias", sql: "SELECT derived.value FROM items, (SELECT amount + tax FROM totals) AS derived", - rule: "SQC022", + rule: "SQLCLEAN022", expected_anchor: Some("amount"), expected_replacement: None, }, test_types::AdditionalLintRuleTestCase { description: "scalar subquery scope does not cross a prior statement", sql: "SELECT order_id FROM orders; SELECT invoice_id, (SELECT MAX(amount + tax) FROM totals) AS maximum_total FROM invoices", - rule: "SQC022", + rule: "SQLCLEAN022", expected_anchor: None, expected_replacement: None, }, test_types::AdditionalLintRuleTestCase { description: "contextual keyword remains a simple qualified projection", sql: "SELECT flattened.index FROM items AS src, LATERAL FLATTEN(input => src.payload) AS flattened", - rule: "SQC022", + rule: "SQLCLEAN022", expected_anchor: None, expected_replacement: None, }, test_types::AdditionalLintRuleTestCase { description: "generated expression sentinel is not an authored column", - sql: "SELECT first.id FROM first JOIN second ON first.id = second.id WHERE __sqb_lint_0__", - rule: "SQC027", + sql: "SELECT first.id FROM first JOIN second ON first.id = second.id WHERE __sqlclean_interpolation_0__", + rule: "SQLCLEAN027", expected_anchor: None, expected_replacement: None, }, test_types::AdditionalLintRuleTestCase { description: "null-safe comparison FROM is not a relation clause", sql: "SELECT CASE WHEN src.actual IS DISTINCT FROM src.expected THEN 1 ELSE 0 END AS differs FROM items AS src", - rule: "SQC023", + rule: "SQLCLEAN023", expected_anchor: None, expected_replacement: None, }, test_types::AdditionalLintRuleTestCase { description: "earlier projection alias is not a relation column", sql: "SELECT flattened.value AS item_index, item_index + 1 AS next_index FROM items AS src, LATERAL FLATTEN(input => src.payload) AS flattened", - rule: "SQC027", + rule: "SQLCLEAN027", expected_anchor: None, expected_replacement: None, }, test_types::AdditionalLintRuleTestCase { description: "current date context value is not a multi-source column", sql: "SELECT first_side.id FROM first_side JOIN second_side ON first_side.id = second_side.id WHERE first_side.created_at >= CURRENT_DATE", - rule: "SQC027", + rule: "SQLCLEAN027", expected_anchor: None, expected_replacement: None, }, test_types::AdditionalLintRuleTestCase { description: "current date context value does not mix qualification styles", sql: "SELECT first_side.id FROM first_side WHERE first_side.created_at >= CURRENT_DATE", - rule: "SQC028", + rule: "SQLCLEAN028", expected_anchor: None, expected_replacement: None, }, test_types::AdditionalLintRuleTestCase { description: "simple boolean case", sql: "SELECT CASE WHEN amount > 0 THEN TRUE ELSE FALSE END AS positive FROM items", - rule: "SQC030", + rule: "SQLCLEAN030", expected_anchor: Some("CASE WHEN amount > 0 THEN TRUE ELSE FALSE END"), expected_replacement: Some("COALESCE(amount > 0, FALSE)"), }, test_types::AdditionalLintRuleTestCase { description: "unicode before simple boolean case preserves replacement text", sql: "SELECT 'é', CASE WHEN amount > 0 THEN TRUE ELSE FALSE END AS positive FROM items", - rule: "SQC030", + rule: "SQLCLEAN030", expected_anchor: Some("CASE WHEN amount > 0 THEN TRUE ELSE FALSE END"), expected_replacement: Some("COALESCE(amount > 0, FALSE)"), }, test_types::AdditionalLintRuleTestCase { description: "commented boolean case is diagnosed without a fix", sql: "SELECT CASE WHEN amount /* reason */ > 0 THEN TRUE ELSE FALSE END FROM items", - rule: "SQC030", + rule: "SQLCLEAN030", expected_anchor: Some("CASE WHEN amount /* reason */ > 0 THEN TRUE ELSE FALSE END"), expected_replacement: None, }, test_types::AdditionalLintRuleTestCase { description: "multi-branch boolean case is diagnosed without an unsafe partial fix", sql: "SELECT CASE WHEN status = 'open' THEN TRUE WHEN priority = 'high' THEN TRUE ELSE FALSE END AS actionable FROM support_tickets", - rule: "SQC030", + rule: "SQLCLEAN030", expected_anchor: Some( "CASE WHEN status = 'open' THEN TRUE WHEN priority = 'high' THEN TRUE ELSE FALSE END", ), @@ -1378,189 +1378,189 @@ fn given_additional_rule_cases_when_linting_then_findings_and_fixes_match() -> R test_types::AdditionalLintRuleTestCase { description: "case nested directly in else", sql: "SELECT CASE WHEN a THEN 1 ELSE CASE WHEN b THEN 2 END END FROM items", - rule: "SQC031", + rule: "SQLCLEAN031", expected_anchor: Some("CASE"), expected_replacement: None, }, test_types::AdditionalLintRuleTestCase { description: "joined relation contributes no values", sql: "SELECT a.id FROM a LEFT JOIN b ON a.id = b.id", - rule: "SQC032", + rule: "SQLCLEAN032", expected_anchor: Some("JOIN"), expected_replacement: None, }, test_types::AdditionalLintRuleTestCase { description: "joined relation contributes through an aggregate expression", sql: "SELECT ARRAY_AGG(b.value) AS values FROM a LEFT JOIN b ON a.id = b.id", - rule: "SQC032", + rule: "SQLCLEAN032", expected_anchor: None, expected_replacement: None, }, test_types::AdditionalLintRuleTestCase { description: "bare projected star consumes every joined relation", sql: "SELECT * FROM orders AS o INNER JOIN customers AS c ON o.customer_id = c.customer_id", - rule: "SQC032", + rule: "SQLCLEAN032", expected_anchor: None, expected_replacement: None, }, test_types::AdditionalLintRuleTestCase { description: "unqualified projection prevents an unsupported unused relation claim", sql: "SELECT customer_name FROM orders AS o INNER JOIN customers AS c ON o.customer_id = c.customer_id", - rule: "SQC032", + rule: "SQLCLEAN032", expected_anchor: None, expected_replacement: None, }, test_types::AdditionalLintRuleTestCase { description: "unqualified aggregate argument prevents an unsupported unused relation claim", sql: "SELECT MAX(customer_name) FROM orders AS o INNER JOIN customers AS c ON o.customer_id = c.customer_id", - rule: "SQC032", + rule: "SQLCLEAN032", expected_anchor: None, expected_replacement: None, }, test_types::AdditionalLintRuleTestCase { description: "left qualified star does not consume joined relation", sql: "SELECT o.* FROM orders AS o INNER JOIN customers AS c ON o.customer_id = c.customer_id", - rule: "SQC032", + rule: "SQLCLEAN032", expected_anchor: Some("JOIN"), expected_replacement: None, }, test_types::AdditionalLintRuleTestCase { description: "cross joined relation contributes through a filter predicate", sql: "SELECT orders.order_id FROM orders CROSS JOIN processing_cutoff AS cutoff WHERE orders.created_at < cutoff.created_at", - rule: "SQC032", + rule: "SQLCLEAN032", expected_anchor: None, expected_replacement: None, }, test_types::AdditionalLintRuleTestCase { description: "lateral relation contributes through a downstream join condition", sql: "SELECT orders.order_id, products.name FROM orders CROSS JOIN LATERAL UNNEST(orders.product_ids) AS item INNER JOIN products ON (products.product_id = item.product_id)", - rule: "SQC032", + rule: "SQLCLEAN032", expected_anchor: None, expected_replacement: None, }, test_types::AdditionalLintRuleTestCase { description: "explicit union is clean", sql: "SELECT 1 UNION ALL SELECT 2", - rule: "SQC008", + rule: "SQLCLEAN008", expected_anchor: None, expected_replacement: None, }, test_types::AdditionalLintRuleTestCase { description: "ordered row number without a declared key is unproven", sql: "SELECT ROW_NUMBER() OVER (PARTITION BY id ORDER BY created_at, id) FROM items", - rule: "SQC018", + rule: "SQLCLEAN018", expected_anchor: Some("ROW_NUMBER"), expected_replacement: None, }, test_types::AdditionalLintRuleTestCase { description: "equal set arity is clean", sql: "SELECT a, b FROM first UNION ALL SELECT c, d FROM second", - rule: "SQC020", + rule: "SQLCLEAN020", expected_anchor: None, expected_replacement: None, }, test_types::AdditionalLintRuleTestCase { description: "dynamic pivot star has data-dependent columns", sql: "SELECT * FROM orders PIVOT(MAX(amount) FOR category IN (ANY ORDER BY category))", - rule: "SQC021", + rule: "SQLCLEAN021", expected_anchor: None, expected_replacement: None, }, test_types::AdditionalLintRuleTestCase { description: "audit relation parameter star has invocation-dependent columns", - sql: "SELECT * FROM __sqlbuild_audit_parameter_0__", - rule: "SQC021", + sql: "SELECT * FROM __sqlclean_relation_parameter_0__", + rule: "SQLCLEAN021", expected_anchor: None, expected_replacement: None, }, test_types::AdditionalLintRuleTestCase { description: "qualified alias is used", sql: "SELECT kept.id FROM items AS kept", - rule: "SQC023", + rule: "SQLCLEAN023", expected_anchor: None, expected_replacement: None, }, test_types::AdditionalLintRuleTestCase { description: "left join predicate retained in on is clean", sql: "SELECT a.id FROM a LEFT JOIN b ON a.id = b.id AND b.active = TRUE", - rule: "SQC024", + rule: "SQLCLEAN024", expected_anchor: None, expected_replacement: None, }, test_types::AdditionalLintRuleTestCase { description: "explicit inner join is clean", sql: "SELECT a.id FROM a INNER JOIN b ON a.id = b.id", - rule: "SQC025", + rule: "SQLCLEAN025", expected_anchor: None, expected_replacement: None, }, test_types::AdditionalLintRuleTestCase { description: "null literal inside not-in subquery expression is not a literal list", sql: "SELECT id FROM items WHERE id NOT IN (SELECT COALESCE(id, NULL) FROM other)", - rule: "SQC019", + rule: "SQLCLEAN019", expected_anchor: None, expected_replacement: None, }, test_types::AdditionalLintRuleTestCase { description: "left join null probe preserves unmatched rows", sql: "SELECT a.id FROM a LEFT JOIN b ON a.id = b.id WHERE b.id IS NULL", - rule: "SQC024", + rule: "SQLCLEAN024", expected_anchor: None, expected_replacement: None, }, test_types::AdditionalLintRuleTestCase { description: "left join predicate guarded by null probe preserves unmatched rows", sql: "SELECT a.id FROM a LEFT JOIN b ON a.id = b.id WHERE b.active = TRUE OR b.id IS NULL", - rule: "SQC024", + rule: "SQLCLEAN024", expected_anchor: None, expected_replacement: None, }, test_types::AdditionalLintRuleTestCase { description: "qualified catalog path is not treated as an unknown relation alias", sql: "SELECT catalog.schema.items.id FROM catalog.schema.items", - rule: "SQC029", + rule: "SQLCLEAN029", expected_anchor: None, expected_replacement: None, }, test_types::AdditionalLintRuleTestCase { description: "correlated subquery keeps outer table alias", sql: "SELECT id FROM items AS kept WHERE EXISTS (SELECT 1 FROM other WHERE other.id = kept.id)", - rule: "SQC023", + rule: "SQLCLEAN023", expected_anchor: None, expected_replacement: None, }, test_types::AdditionalLintRuleTestCase { description: "asof join is not rewritten as an inner join", sql: "SELECT a.id FROM a ASOF JOIN b ON a.id = b.id", - rule: "SQC025", + rule: "SQLCLEAN025", expected_anchor: None, expected_replacement: None, }, test_types::AdditionalLintRuleTestCase { description: "semi join is not rewritten as an inner join", sql: "SELECT a.id FROM a SEMI JOIN b ON a.id = b.id", - rule: "SQC025", + rule: "SQLCLEAN025", expected_anchor: None, expected_replacement: None, }, test_types::AdditionalLintRuleTestCase { description: "table alias carrying a column list is retained", sql: "SELECT a, b FROM items AS t(a, b)", - rule: "SQC023", + rule: "SQLCLEAN023", expected_anchor: None, expected_replacement: None, }, test_types::AdditionalLintRuleTestCase { description: "bracket list commas do not change set projection arity", sql: "SELECT a, [1, 2, 3] AS arr FROM t UNION ALL SELECT a, [4] AS arr FROM t", - rule: "SQC020", + rule: "SQLCLEAN020", expected_anchor: None, expected_replacement: None, }, test_types::AdditionalLintRuleTestCase { description: "quoted output alias is not redundant with an unquoted identifier", sql: "SELECT value AS \"value\" FROM items", - rule: "SQC016", + rule: "SQLCLEAN016", expected_anchor: None, expected_replacement: None, }, @@ -1619,8 +1619,8 @@ fn given_ceremonial_cte_contexts_when_linting_then_only_harness_dependencies_are "version": 1, "sql": test_case.sql, "dialect": "duckdb", - "enabled_rules": ["SQC005"], - "allows_ceremonial_select": true, + "enabled_rules": ["SQLCLEAN005"], + "fixture_harness": fixture_harness(), }) .to_string(), )?; @@ -1653,44 +1653,44 @@ fn given_dependency_identity_when_linting_import_stars_then_only_plain_top_level let test_cases = [ test_types::DependencyImportLintTestCase { description: "plain top-level dependency import", - sql: "WITH imported_orders AS (SELECT * FROM __sqb_lint_0__) SELECT order_id FROM imported_orders", - dependency_identifiers: &["__sqb_lint_0__"], + sql: "WITH imported_orders AS (SELECT * FROM __sqlclean_interpolation_0__) SELECT order_id FROM imported_orders", + dependency_identifiers: &["__sqlclean_interpolation_0__"], expected_count: 0, }, test_types::DependencyImportLintTestCase { description: "star without from remains diagnosable", sql: "WITH imported_orders AS (SELECT *) SELECT order_id FROM imported_orders", - dependency_identifiers: &["__sqb_lint_0__"], + dependency_identifiers: &["__sqlclean_interpolation_0__"], expected_count: 1, }, test_types::DependencyImportLintTestCase { description: "set operation import is transformed", - sql: "WITH imported_orders AS (SELECT * FROM __sqb_lint_0__ UNION ALL SELECT order_id FROM orders) SELECT order_id FROM imported_orders", - dependency_identifiers: &["__sqb_lint_0__"], + sql: "WITH imported_orders AS (SELECT * FROM __sqlclean_interpolation_0__ UNION ALL SELECT order_id FROM orders) SELECT order_id FROM imported_orders", + dependency_identifiers: &["__sqlclean_interpolation_0__"], expected_count: 1, }, test_types::DependencyImportLintTestCase { description: "dynamic relation is not a dependency", - sql: "WITH imported_orders AS (SELECT * FROM __sqb_lint_0__) SELECT order_id FROM imported_orders", + sql: "WITH imported_orders AS (SELECT * FROM __sqlclean_interpolation_0__) SELECT order_id FROM imported_orders", dependency_identifiers: &[], expected_count: 1, }, test_types::DependencyImportLintTestCase { description: "terminal dependency star remains uncontrolled", - sql: "SELECT * FROM __sqb_lint_0__", - dependency_identifiers: &["__sqb_lint_0__"], + sql: "SELECT * FROM __sqlclean_interpolation_0__", + dependency_identifiers: &["__sqlclean_interpolation_0__"], expected_count: 1, }, test_types::DependencyImportLintTestCase { description: "transformed dependency import remains uncontrolled", - sql: "WITH imported_orders AS (SELECT *, CURRENT_TIMESTAMP AS loaded_at FROM __sqb_lint_0__) SELECT order_id FROM imported_orders", - dependency_identifiers: &["__sqb_lint_0__"], + sql: "WITH imported_orders AS (SELECT *, CURRENT_TIMESTAMP AS loaded_at FROM __sqlclean_interpolation_0__) SELECT order_id FROM imported_orders", + dependency_identifiers: &["__sqlclean_interpolation_0__"], expected_count: 1, }, test_types::DependencyImportLintTestCase { description: "nested dependency import remains uncontrolled", - sql: "WITH outer_query AS (WITH imported_orders AS (SELECT * FROM __sqb_lint_0__) SELECT order_id FROM imported_orders) SELECT order_id FROM outer_query", - dependency_identifiers: &["__sqb_lint_0__"], + sql: "WITH outer_query AS (WITH imported_orders AS (SELECT * FROM __sqlclean_interpolation_0__) SELECT order_id FROM imported_orders) SELECT order_id FROM outer_query", + dependency_identifiers: &["__sqlclean_interpolation_0__"], expected_count: 1, }, ]; @@ -1700,7 +1700,7 @@ fn given_dependency_identity_when_linting_import_stars_then_only_plain_top_level "version": 1, "sql": test_case.sql, "dialect": "duckdb", - "enabled_rules": ["SQC021"], + "enabled_rules": ["SQLCLEAN021"], "dependency_identifiers": test_case.dependency_identifiers, }) .to_string(), @@ -1725,7 +1725,7 @@ fn given_ceremonial_select_context_when_linting_terminal_shape_then_rule_035_all let test_cases = [test_types::PlainSqlLintTestCase { description: "direct ceremonial select is valid in harness context", sql: "WITH __source__raw_orders AS (SELECT 1 AS order_id), __expected__orders AS (SELECT 1 AS order_id) SELECT 1", - rule: "SQC035", + rule: "SQLCLEAN035", expected_count: 0, }]; for test_case in test_cases { @@ -1735,7 +1735,7 @@ fn given_ceremonial_select_context_when_linting_terminal_shape_then_rule_035_all "sql": test_case.sql, "dialect": "duckdb", "enabled_rules": [test_case.rule], - "allows_ceremonial_select": true, + "fixture_harness": fixture_harness(), }) .to_string(), )?; @@ -1759,7 +1759,7 @@ fn given_tsql_bare_union_when_linting_then_diagnostic_has_no_invalid_fix() -> Re description: "T-SQL bare UNION is diagnosed without an invalid fix", sql: "SELECT 1 UNION SELECT 2", dialect: "tsql", - rule: "SQC008", + rule: "SQLCLEAN008", expected_count: 1, expected_fix: false, expected_reason: "the active dialect does not accept explicit UNION DISTINCT", @@ -1950,8 +1950,8 @@ fn given_ordered_sql_batch_when_formatting_then_outputs_are_parallel_and_ordered fn given_unknown_rule_when_linting_then_request_is_rejected() -> Result<(), String> { let test_cases = [test_types::InvalidRuleTestCase { description: "unknown native lint rule", - rule: "SQC999", - expected_message: "unknown native lint rule 'SQC999'", + rule: "SQLCLEAN999", + expected_message: "unknown native lint rule 'SQLCLEAN999'", }]; for test_case in &test_cases { diff --git a/crates/sqlclean/src/sql_lint/tests/test_types.rs b/crates/sqlclean/src/sql_lint/tests/test_types.rs index 6a3348b..bbe79a3 100644 --- a/crates/sqlclean/src/sql_lint/tests/test_types.rs +++ b/crates/sqlclean/src/sql_lint/tests/test_types.rs @@ -8,7 +8,7 @@ pub(crate) struct LintTestCase { pub(crate) struct EmptyFixtureLintTestCase { pub(crate) description: &'static str, pub(crate) sql: &'static str, - pub(crate) allows_empty_fixture_star: bool, + pub(crate) in_fixture_harness: bool, pub(crate) expected_diagnostic_count: usize, } diff --git a/crates/sqlclean/src/sql_lint/tests/test_unused_table_alias.rs b/crates/sqlclean/src/sql_lint/tests/test_unused_table_alias.rs index 95a86de..69849d6 100644 --- a/crates/sqlclean/src/sql_lint/tests/test_unused_table_alias.rs +++ b/crates/sqlclean/src/sql_lint/tests/test_unused_table_alias.rs @@ -7,105 +7,105 @@ fn given_implicit_and_explicit_table_aliases_when_linting_then_unused_aliases_ma test_types::AdditionalLintRuleTestCase { description: "unused implicit table alias", sql: "SELECT id FROM items unused", - rule: "SQC023", + rule: "SQLCLEAN023", expected_anchor: Some("unused"), expected_replacement: Some(""), }, test_types::AdditionalLintRuleTestCase { description: "unused implicit joined table alias", sql: "SELECT o.id FROM orders o INNER JOIN customers unused ON o.customer_id = o.id", - rule: "SQC023", + rule: "SQLCLEAN023", expected_anchor: Some("unused"), expected_replacement: Some(""), }, test_types::AdditionalLintRuleTestCase { description: "unused implicit qualified table alias", sql: "SELECT id FROM warehouse.analytics.items unused", - rule: "SQC023", + rule: "SQLCLEAN023", expected_anchor: Some("unused"), expected_replacement: Some(""), }, test_types::AdditionalLintRuleTestCase { description: "unused implicit subquery alias", sql: "SELECT id FROM (SELECT id FROM items) unused", - rule: "SQC023", + rule: "SQLCLEAN023", expected_anchor: Some("unused"), expected_replacement: Some(""), }, test_types::AdditionalLintRuleTestCase { description: "unused implicit lateral table function alias", sql: "SELECT src.id FROM items src, LATERAL FLATTEN(input => src.payload) unused", - rule: "SQC023", + rule: "SQLCLEAN023", expected_anchor: Some("unused"), expected_replacement: Some(""), }, test_types::AdditionalLintRuleTestCase { description: "used implicit table alias", sql: "SELECT kept.id FROM items kept", - rule: "SQC023", + rule: "SQLCLEAN023", expected_anchor: None, expected_replacement: None, }, test_types::AdditionalLintRuleTestCase { description: "used implicit joined table aliases", sql: "SELECT o.id, c.name FROM orders o INNER JOIN customers c ON o.customer_id = c.id", - rule: "SQC023", + rule: "SQLCLEAN023", expected_anchor: None, expected_replacement: None, }, test_types::AdditionalLintRuleTestCase { description: "used implicit qualified table alias", sql: "SELECT kept.id FROM warehouse.analytics.items kept", - rule: "SQC023", + rule: "SQLCLEAN023", expected_anchor: None, expected_replacement: None, }, test_types::AdditionalLintRuleTestCase { description: "used implicit subquery alias", sql: "SELECT sub.id FROM (SELECT id FROM items) sub", - rule: "SQC023", + rule: "SQLCLEAN023", expected_anchor: None, expected_replacement: None, }, test_types::AdditionalLintRuleTestCase { description: "used implicit lateral table function alias", sql: "SELECT flattened.value FROM items src, LATERAL FLATTEN(input => src.payload) flattened", - rule: "SQC023", + rule: "SQLCLEAN023", expected_anchor: None, expected_replacement: None, }, test_types::AdditionalLintRuleTestCase { description: "implicit alias used by correlated subquery", sql: "SELECT id FROM items kept WHERE EXISTS (SELECT 1 FROM other WHERE other.id = kept.id)", - rule: "SQC023", + rule: "SQLCLEAN023", expected_anchor: None, expected_replacement: None, }, test_types::AdditionalLintRuleTestCase { description: "implicit alias carrying a column list is retained", sql: "SELECT a FROM items t(a)", - rule: "SQC023", + rule: "SQLCLEAN023", expected_anchor: None, expected_replacement: None, }, test_types::AdditionalLintRuleTestCase { description: "qualified relation name is not an implicit alias", sql: "SELECT id FROM warehouse.analytics.items", - rule: "SQC023", + rule: "SQLCLEAN023", expected_anchor: None, expected_replacement: None, }, test_types::AdditionalLintRuleTestCase { description: "join modifier keywords are not implicit aliases", sql: "SELECT o.id, c.name FROM orders o POSITIONAL JOIN customers c", - rule: "SQC023", + rule: "SQLCLEAN023", expected_anchor: None, expected_replacement: None, }, test_types::AdditionalLintRuleTestCase { description: "join condition identifiers are not implicit aliases", sql: "SELECT o.id, c.name FROM orders o INNER JOIN customers c USING (customer_id)", - rule: "SQC023", + rule: "SQLCLEAN023", expected_anchor: None, expected_replacement: None, }, @@ -147,140 +147,140 @@ fn given_join_modifiers_and_whole_row_uses_when_linting_duckdb_then_aliases_are_ test_types::AdditionalLintRuleTestCase { description: "unaliased relation before positional join has no alias", sql: "SELECT * FROM orders POSITIONAL JOIN customers", - rule: "SQC023", + rule: "SQLCLEAN023", expected_anchor: None, expected_replacement: None, }, test_types::AdditionalLintRuleTestCase { description: "unaliased relation before asof join has no alias", sql: "SELECT * FROM orders ASOF JOIN prices ON orders.item_id = prices.item_id AND orders.ordered_at >= prices.valid_from", - rule: "SQC023", + rule: "SQLCLEAN023", expected_anchor: None, expected_replacement: None, }, test_types::AdditionalLintRuleTestCase { description: "unaliased relation before natural join has no alias", sql: "SELECT * FROM orders NATURAL JOIN customers", - rule: "SQC023", + rule: "SQLCLEAN023", expected_anchor: None, expected_replacement: None, }, test_types::AdditionalLintRuleTestCase { description: "unaliased relation before anti join has no alias", sql: "SELECT * FROM orders ANTI JOIN refunds USING (order_id)", - rule: "SQC023", + rule: "SQLCLEAN023", expected_anchor: None, expected_replacement: None, }, test_types::AdditionalLintRuleTestCase { description: "unaliased relation before semi join has no alias", sql: "SELECT * FROM orders SEMI JOIN refunds USING (order_id)", - rule: "SQC023", + rule: "SQLCLEAN023", expected_anchor: None, expected_replacement: None, }, test_types::AdditionalLintRuleTestCase { description: "unaliased relation before cross join has no alias", sql: "SELECT * FROM orders CROSS JOIN customers", - rule: "SQC023", + rule: "SQLCLEAN023", expected_anchor: None, expected_replacement: None, }, test_types::AdditionalLintRuleTestCase { description: "implicit alias repeating the relation name is unused", sql: "SELECT id FROM orders orders", - rule: "SQC023", + rule: "SQLCLEAN023", expected_anchor: Some("orders"), expected_replacement: Some(""), }, test_types::AdditionalLintRuleTestCase { description: "explicit alias repeating the relation name is unused", sql: "SELECT id FROM orders AS orders", - rule: "SQC023", + rule: "SQLCLEAN023", expected_anchor: Some("AS orders"), expected_replacement: Some(""), }, test_types::AdditionalLintRuleTestCase { description: "alias repeating the relation name is used when referenced", sql: "SELECT orders.id FROM orders orders", - rule: "SQC023", + rule: "SQLCLEAN023", expected_anchor: None, expected_replacement: None, }, test_types::AdditionalLintRuleTestCase { description: "unaliased relation before window clause has no alias", sql: "SELECT SUM(amount) OVER w FROM orders WINDOW w AS (ORDER BY id)", - rule: "SQC023", + rule: "SQLCLEAN023", expected_anchor: None, expected_replacement: None, }, test_types::AdditionalLintRuleTestCase { description: "unaliased relation before tablesample has no alias", sql: "SELECT * FROM orders TABLESAMPLE BERNOULLI (10)", - rule: "SQC023", + rule: "SQLCLEAN023", expected_anchor: None, expected_replacement: None, }, test_types::AdditionalLintRuleTestCase { description: "unaliased relation before unpivot has no alias", sql: "SELECT * FROM orders UNPIVOT (amount FOR month IN (jan, feb))", - rule: "SQC023", + rule: "SQLCLEAN023", expected_anchor: None, expected_replacement: None, }, test_types::AdditionalLintRuleTestCase { description: "implicit alias used as whole-row function argument", sql: "SELECT row_to_json(o) AS payload FROM orders o", - rule: "SQC023", + rule: "SQLCLEAN023", expected_anchor: None, expected_replacement: None, }, test_types::AdditionalLintRuleTestCase { description: "explicit alias used as whole-row function argument", sql: "SELECT row_to_json(o) AS payload FROM orders AS o", - rule: "SQC023", + rule: "SQLCLEAN023", expected_anchor: None, expected_replacement: None, }, test_types::AdditionalLintRuleTestCase { description: "implicit alias used as bare projection", sql: "SELECT o FROM orders o", - rule: "SQC023", + rule: "SQLCLEAN023", expected_anchor: None, expected_replacement: None, }, test_types::AdditionalLintRuleTestCase { description: "explicit alias used as bare projection", sql: "SELECT o FROM orders AS o", - rule: "SQC023", + rule: "SQLCLEAN023", expected_anchor: None, expected_replacement: None, }, test_types::AdditionalLintRuleTestCase { description: "implicit alias used in whole-row comparison", sql: "SELECT 1 AS present FROM orders o WHERE o IS NOT NULL", - rule: "SQC023", + rule: "SQLCLEAN023", expected_anchor: None, expected_replacement: None, }, test_types::AdditionalLintRuleTestCase { description: "explicit alias used in whole-row comparison", sql: "SELECT 1 AS present FROM orders AS o WHERE o IS NOT NULL", - rule: "SQC023", + rule: "SQLCLEAN023", expected_anchor: None, expected_replacement: None, }, test_types::AdditionalLintRuleTestCase { description: "unused implicit alias beside an unrelated qualified column is reported", sql: "SELECT c.o FROM customers c CROSS JOIN orders o", - rule: "SQC023", + rule: "SQLCLEAN023", expected_anchor: Some("o"), expected_replacement: Some(""), }, test_types::AdditionalLintRuleTestCase { description: "unused explicit alias shadowed by an output alias is reported", sql: "SELECT order_id AS o FROM orders AS o", - rule: "SQC023", + rule: "SQLCLEAN023", expected_anchor: Some("AS o"), expected_replacement: Some(""), }, diff --git a/crates/sqlclean/src/sql_quality/_helpers/cte_columns.rs b/crates/sqlclean/src/sql_quality/_helpers/cte_columns.rs index 8f48d4b..635a244 100644 --- a/crates/sqlclean/src/sql_quality/_helpers/cte_columns.rs +++ b/crates/sqlclean/src/sql_quality/_helpers/cte_columns.rs @@ -1,4 +1,4 @@ -//! SQC042: output columns of non-final CTEs that no later step reads. +//! SQLCLEAN042: output columns of non-final CTEs that no later step reads. use std::borrow::Cow; @@ -9,7 +9,7 @@ use polyglot_sql::{Expression, ExpressionWalk}; use crate::sql_lint::models::{LintDiagnostic, LintEdit}; use crate::sql_quality::constants::{ DISTINCT_UNUSED_REMEDIATION, GROUP_BY_ALL_UNUSED_REMEDIATION, GROUP_KEY_UNUSED_REMEDIATION, - INTERPOLATION_SENTINEL_PREFIXES, UNUSED_CTE_OUTPUT, + UNUSED_CTE_OUTPUT, }; use crate::sql_quality::models::QualityRequest; use crate::sql_quality::removal_safety::{expression_refusal, reads_whole_rows, select_refusal}; @@ -448,10 +448,10 @@ fn unused_outputs( found } -/// Neutralized `@macro` / `@variable` interpolations can stand for any columns. +/// Placeholders for template expressions can stand for any columns. fn is_interpolation(name: &str) -> bool { let name: String = lower(name); - INTERPOLATION_SENTINEL_PREFIXES + crate::constants::GENERATED_PLACEHOLDER_PREFIXES .iter() .any(|prefix| name.starts_with(prefix)) } diff --git a/crates/sqlclean/src/sql_quality/_helpers/declared_keys.rs b/crates/sqlclean/src/sql_quality/_helpers/declared_keys.rs deleted file mode 100644 index 02607a9..0000000 --- a/crates/sqlclean/src/sql_quality/_helpers/declared_keys.rs +++ /dev/null @@ -1,50 +0,0 @@ -//! Unique keys one relation declares through its incremental config and column audits. - -use crate::sql_quality::constants::{ - INCREMENTAL_MATERIALIZATION, KEY_PRESERVING_STRATEGIES, KEYED_DELETE_INSERT_STRATEGY, - MICROBATCH_MODE, NOT_NULL_AUDIT, UNIQUE_AUDIT, -}; -use crate::sql_quality::models::{DeclaredKeyColumn, DeclaredRelation}; - -/// `unique_key` only where the incremental strategy keeps one row per key. -pub(crate) fn trusted_unique_key(relation: &DeclaredRelation) -> Vec> { - let strategy: String = relation.incremental_strategy.to_lowercase(); - let trusted: bool = relation.materialized.to_lowercase() == INCREMENTAL_MATERIALIZATION - && relation.incremental_mode.to_lowercase() != MICROBATCH_MODE - && KEY_PRESERVING_STRATEGIES.contains(&strategy.as_str()) - && !(strategy == KEYED_DELETE_INSERT_STRATEGY && relation.has_cursor); - match &relation.unique_key { - Some(key) if trusted && !key.is_empty() => vec![key.clone()], - _ => Vec::new(), - } -} - -/// Columns with a `unique` audit that are also non-null by audit or `nullable false`. -pub(crate) fn non_null_unique_columns(relation: &DeclaredRelation) -> Vec { - let mut unique: Vec = Vec::new(); - let mut not_null: Vec = Vec::new(); - for column in &relation.columns { - if has_audit(column, UNIQUE_AUDIT) { - unique.push(column.name.clone()); - } - if has_audit(column, NOT_NULL_AUDIT) || column.not_nullable { - not_null.push(str::to_lowercase(&column.name)); - } - } - for (definition, argument) in &relation.audits { - let Some(argument) = argument else { - continue; - }; - if definition == NOT_NULL_AUDIT { - not_null.push(str::to_lowercase(argument)); - } else if definition == UNIQUE_AUDIT { - unique.push(argument.clone()); - } - } - unique.retain(|column| not_null.contains(&str::to_lowercase(column))); - unique -} - -fn has_audit(column: &DeclaredKeyColumn, name: &str) -> bool { - column.audits.iter().any(|audit| audit == name) -} diff --git a/crates/sqlclean/src/sql_quality/_helpers/json_parses.rs b/crates/sqlclean/src/sql_quality/_helpers/json_parses.rs index ef7a3d3..1caaf5d 100644 --- a/crates/sqlclean/src/sql_quality/_helpers/json_parses.rs +++ b/crates/sqlclean/src/sql_quality/_helpers/json_parses.rs @@ -1,4 +1,4 @@ -//! SQC045: the same JSON payload column parsed more than once in one SELECT scope. +//! SQLCLEAN045: the same JSON payload column parsed more than once in one SELECT scope. use std::borrow::Cow; use std::collections::HashMap; diff --git a/crates/sqlclean/src/sql_quality/_helpers/literals.rs b/crates/sqlclean/src/sql_quality/_helpers/literals.rs index 25ca489..cb250d0 100644 --- a/crates/sqlclean/src/sql_quality/_helpers/literals.rs +++ b/crates/sqlclean/src/sql_quality/_helpers/literals.rs @@ -1,4 +1,4 @@ -//! SQC044: string literals longer than the project limit. +//! SQLCLEAN044: string literals longer than the project limit. use std::borrow::Cow; diff --git a/crates/sqlclean/src/sql_quality/_helpers/ranking.rs b/crates/sqlclean/src/sql_quality/_helpers/ranking.rs index b1088df..23dcb2e 100644 --- a/crates/sqlclean/src/sql_quality/_helpers/ranking.rs +++ b/crates/sqlclean/src/sql_quality/_helpers/ranking.rs @@ -1,4 +1,4 @@ -//! SQC043 ranking sort caps and SQC018 ranking determinism proofs. +//! SQLCLEAN043 ranking sort caps and SQLCLEAN018 ranking determinism proofs. use std::borrow::Cow; use std::collections::{HashMap, HashSet}; diff --git a/crates/sqlclean/src/sql_quality/_helpers/removal_safety.rs b/crates/sqlclean/src/sql_quality/_helpers/removal_safety.rs index 92f6025..24b7641 100644 --- a/crates/sqlclean/src/sql_quality/_helpers/removal_safety.rs +++ b/crates/sqlclean/src/sql_quality/_helpers/removal_safety.rs @@ -1,4 +1,4 @@ -//! SQC042 allow-list: when removing an unused CTE output provably keeps every row. +//! SQLCLEAN042 allow-list: when removing an unused CTE output provably keeps every row. use polyglot_sql::expressions::Select; use polyglot_sql::traversal::contains_aggregate; diff --git a/crates/sqlclean/src/sql_quality/_helpers/safety.rs b/crates/sqlclean/src/sql_quality/_helpers/safety.rs index 8a26587..6615bf3 100644 --- a/crates/sqlclean/src/sql_quality/_helpers/safety.rs +++ b/crates/sqlclean/src/sql_quality/_helpers/safety.rs @@ -1,4 +1,4 @@ -//! SQC046 to SQC053: report-only safety Rules for queries that cannot do what they appear to. +//! SQLCLEAN046 to SQLCLEAN053: report-only safety Rules for queries that cannot do what they appear to. use std::borrow::Cow; use std::collections::HashSet; @@ -237,7 +237,7 @@ fn cte_key(identifier: &Identifier, dialect: DialectType) -> String { } } -/// `CASE x WHEN NULL` and a singleton positive `IN (NULL)`; SQC019 owns `NOT IN`. +/// `CASE x WHEN NULL` and a singleton positive `IN (NULL)`; SQLCLEAN019 owns `NOT IN`. fn null_match_spans(significant: &Significant<'_>) -> Vec { let mut spans: Vec = Vec::new(); for index in 0..significant.tokens.len() { diff --git a/crates/sqlclean/src/sql_quality/constants.rs b/crates/sqlclean/src/sql_quality/constants.rs index 2adffd4..fa1cbd2 100644 --- a/crates/sqlclean/src/sql_quality/constants.rs +++ b/crates/sqlclean/src/sql_quality/constants.rs @@ -1,72 +1,72 @@ use crate::sql_lint::models::LintRuleMetadata; pub(crate) const UNUSED_CTE_OUTPUT: LintRuleMetadata = LintRuleMetadata { - code: "SQC042", + code: "SQLCLEAN042", message: "CTE output column is never read later in the query", remediation: "Remove the column from the CTE output, or read it in a later CTE or the final query.", }; pub(crate) const RANKING_SORT_CAP: LintRuleMetadata = LintRuleMetadata { - code: "SQC043", + code: "SQLCLEAN043", message: "Ranking window sorts by more expressions than max_ranking_order_by", remediation: "Order by the one or two columns that decide which row wins, then a unique row key as the final tie-breaker.", }; pub(crate) const LONG_LITERAL: LintRuleMetadata = LintRuleMetadata { - code: "SQC044", + code: "SQLCLEAN044", message: "String literal is longer than max_literal_length", remediation: "Shorten the literal, or move the value into a reference table or a query parameter.", }; pub(crate) const REPEATED_JSON_PARSE: LintRuleMetadata = LintRuleMetadata { - code: "SQC045", + code: "SQLCLEAN045", message: "JSON payload is parsed more than once in one SELECT", remediation: "Parse the payload once in the CTE that reads it, for example `TRY_PARSE_JSON(value) AS payload`, and read every field from that column in a later step.", }; pub(crate) const DUPLICATE_CTE_NAME: LintRuleMetadata = LintRuleMetadata { - code: "SQC046", + code: "SQLCLEAN046", message: "WITH defines the same CTE name more than once", remediation: "Give each CTE in one WITH clause its own name; engines reject the duplicate or silently read only one definition.", }; pub(crate) const NULL_MATCH: LintRuleMetadata = LintRuleMetadata { - code: "SQC047", + code: "SQLCLEAN047", message: "CASE WHEN NULL or IN (NULL) never matches", remediation: "A simple CASE compares with =, and x IN (NULL) means x = NULL, so neither is ever true. Test the value with IS NULL, for example CASE WHEN x IS NULL THEN ... END.", }; pub(crate) const LITERAL_ZERO_DIVISION: LintRuleMetadata = LintRuleMetadata { - code: "SQC048", + code: "SQLCLEAN048", message: "Expression divides by a literal zero", remediation: "Depending on the dialect this raises an error or returns NULL. Remove the division, or divide by NULLIF(divisor, 0) when the divisor can legitimately be zero.", }; pub(crate) const UNFILTERED_WRITE: LintRuleMetadata = LintRuleMetadata { - code: "SQC049", + code: "SQLCLEAN049", message: "UPDATE or DELETE has no limiting predicate and changes every row", remediation: "Add a WHERE condition that reads the target rows; a constant such as WHERE TRUE does not limit them. Use TRUNCATE, or an exception for this file, when changing every row is intended.", }; pub(crate) const INSERT_WITHOUT_COLUMNS: LintRuleMetadata = LintRuleMetadata { - code: "SQC050", + code: "SQLCLEAN050", message: "INSERT does not list its target columns", remediation: "List the target columns, for example INSERT INTO orders (order_id, status) SELECT ..., so values cannot shift into the wrong column when the table changes.", }; pub(crate) const NATURAL_JOIN: LintRuleMetadata = LintRuleMetadata { - code: "SQC051", + code: "SQLCLEAN051", message: "NATURAL JOIN matches on every column the relations share", remediation: "Join with an explicit ON or USING condition; adding a column with a shared name to either relation silently changes a NATURAL JOIN.", }; pub(crate) const REVERSED_BETWEEN: LintRuleMetadata = LintRuleMetadata { - code: "SQC052", + code: "SQLCLEAN052", message: "BETWEEN lower bound is greater than its upper bound", remediation: "BETWEEN low AND high is empty when low > high, so it never matches, and NOT BETWEEN matches every non-NULL value. Swap the bounds, or write BETWEEN SYMMETRIC where the dialect supports it.", }; pub(crate) const MISPLACED_AGGREGATE: LintRuleMetadata = LintRuleMetadata { - code: "SQC053", + code: "SQLCLEAN053", message: "Aggregate or window function is used in WHERE, ON or GROUP BY", remediation: "Rows are filtered and grouped before aggregates and windows are computed. Filter an aggregate in HAVING, filter a window result in QUALIFY or an outer query, and group by the underlying columns.", }; -pub(crate) const UNSTABLE_ROW_NUMBER_CODE: &str = "SQC018"; +pub(crate) const UNSTABLE_ROW_NUMBER_CODE: &str = "SQLCLEAN018"; pub(crate) const UNPROVEN_RANKING_MESSAGE: &str = "Ranking window order is not proven unique within its partition"; pub(crate) const UNPROVEN_RANKING_REMEDIATION: &str = "PARTITION BY and ORDER BY must together cover a unique key of the ranked rows. End the ORDER BY with a unique, non-null tie-breaker column. If the rows have no unique column, build one upstream from the columns that identify a row (for example `HASH(source_partition, source_offset, line_index) AS row_key`) and order by it last."; -/// SQC043 guidance before the `max_ranking_order_by` setting snippet. -pub(crate) const RANKING_SORT_GUIDANCE: &str = "Order by the one or two columns that decide which row wins, then a unique row key as the final tie-breaker. If the rows have no unique column, build one upstream from the columns that identify a row (for example `HASH(source_partition, source_offset, line_index) AS row_key`), so SQC018 can prove the order is deterministic. If the order genuinely needs more keys, raise `max_ranking_order_by`."; -/// SQC044 guidance before the `max_literal_length` setting snippet. +/// SQLCLEAN043 guidance before the `max_ranking_order_by` setting snippet. +pub(crate) const RANKING_SORT_GUIDANCE: &str = "Order by the one or two columns that decide which row wins, then a unique row key as the final tie-breaker. If the rows have no unique column, build one upstream from the columns that identify a row (for example `HASH(source_partition, source_offset, line_index) AS row_key`), so SQLCLEAN018 can prove the order is deterministic. If the order genuinely needs more keys, raise `max_ranking_order_by`."; +/// SQLCLEAN044 guidance before the `max_literal_length` setting snippet. pub(crate) const LONG_LITERAL_GUIDANCE: &str = "Shorten the literal, or move the value into a reference table or a query parameter so it is defined once."; pub(crate) const RULES_THRESHOLDS_SECTION: &str = "rules.thresholds"; pub(crate) const MAX_RANKING_ORDER_BY_KEY: &str = "max_ranking_order_by"; @@ -225,18 +225,4 @@ pub(crate) const DEFAULT_MAX_LITERAL_LENGTH: usize = 100; pub(crate) const DEFAULT_MAX_RANKING_ORDER_BY: usize = 5; pub(crate) const TIE_SENSITIVE_FUNCTIONS: [&str; 3] = ["ROW_NUMBER", "FIRST_VALUE", "LAST_VALUE"]; pub(crate) const MIN_REMOVABLE_OUTPUTS: usize = 2; -pub(crate) const INTERPOLATION_SENTINEL_PREFIXES: [&str; 3] = [ - "__sqb_lint_", - "__sqlbuild_audit_parameter_", - "__sqlbuild_context_parameter_", -]; pub(crate) const FIRST_ROW: &str = "1"; - -pub(crate) const UNIQUE_AUDIT: &str = "unique"; -pub(crate) const NOT_NULL_AUDIT: &str = "not_null"; -pub(crate) const INCREMENTAL_MATERIALIZATION: &str = "incremental"; -pub(crate) const MICROBATCH_MODE: &str = "microbatch"; -pub(crate) const KEYED_DELETE_INSERT_STRATEGY: &str = "delete_insert"; -pub(crate) const KEY_PRESERVING_STRATEGIES: [&str; 2] = ["merge", KEYED_DELETE_INSERT_STRATEGY]; -pub(crate) const RELATION_KIND_REF: &str = "ref"; -pub(crate) const RELATION_KIND_SEED: &str = "seed"; diff --git a/crates/sqlclean/src/sql_quality/main/declared_relation_keys.rs b/crates/sqlclean/src/sql_quality/main/declared_relation_keys.rs deleted file mode 100644 index f999212..0000000 --- a/crates/sqlclean/src/sql_quality/main/declared_relation_keys.rs +++ /dev/null @@ -1,38 +0,0 @@ -use std::collections::BTreeMap; - -use crate::sql_quality::constants::{RELATION_KIND_REF, RELATION_KIND_SEED}; -use crate::sql_quality::declared_keys::{non_null_unique_columns, trusted_unique_key}; -use crate::sql_quality::models::DeclaredRelation; - -/// Keys proving one row per key by `kind:name`; seeds are also refs, one-row relations key `[]`. -pub fn declared_relation_keys( - relations: &[DeclaredRelation], -) -> BTreeMap>> { - let mut keys: BTreeMap>> = BTreeMap::new(); - for relation in relations { - let mut found: Vec> = trusted_unique_key(relation); - for column in non_null_unique_columns(relation) { - found.push(vec![column]); - } - if relation.at_most_one_row { - found.push(Vec::new()); - } - if found.is_empty() { - continue; - } - let mut kinds: Vec<&str> = vec![relation.kind.as_str()]; - if relation.kind == RELATION_KIND_SEED { - kinds.push(RELATION_KIND_REF); - } - for kind in kinds { - let entry: &mut Vec> = - keys.entry(format!("{kind}:{}", relation.name)).or_default(); - for key in &found { - if !entry.contains(key) { - entry.push(key.clone()); - } - } - } - } - keys -} diff --git a/crates/sqlclean/src/sql_quality/main/mod.rs b/crates/sqlclean/src/sql_quality/main/mod.rs index f8c958d..4c63048 100644 --- a/crates/sqlclean/src/sql_quality/main/mod.rs +++ b/crates/sqlclean/src/sql_quality/main/mod.rs @@ -1,2 +1 @@ -pub mod declared_relation_keys; pub mod diagnostics; diff --git a/crates/sqlclean/src/sql_quality/mod.rs b/crates/sqlclean/src/sql_quality/mod.rs index 7834594..1881d4e 100644 --- a/crates/sqlclean/src/sql_quality/mod.rs +++ b/crates/sqlclean/src/sql_quality/mod.rs @@ -3,8 +3,6 @@ pub mod constants; #[path = "_helpers/cte_columns.rs"] mod cte_columns; -#[path = "_helpers/declared_keys.rs"] -mod declared_keys; #[path = "_helpers/json_parses.rs"] mod json_parses; #[path = "_helpers/keys.rs"] diff --git a/crates/sqlclean/src/sql_quality/models.rs b/crates/sqlclean/src/sql_quality/models.rs index 577d6a0..24892b2 100644 --- a/crates/sqlclean/src/sql_quality/models.rs +++ b/crates/sqlclean/src/sql_quality/models.rs @@ -18,30 +18,3 @@ pub(crate) struct QualityRequest<'a> { /// The configuration file remediation text tells the user to edit. pub(crate) config_file: &'a str, } - -/// One project relation's declared key facts: its incremental config and audited columns. -#[derive(Debug, Clone, Default)] -pub struct DeclaredRelation { - /// `ref`, `seed` or `source`. - pub kind: String, - pub name: String, - pub materialized: String, - pub incremental_mode: String, - pub incremental_strategy: String, - pub has_cursor: bool, - /// The configured `unique_key` columns, when it is a name or a list of names. - pub unique_key: Option>, - pub columns: Vec, - /// Table-level audits as `(definition name, its string column argument)`. - pub audits: Vec<(String, Option)>, - /// Whether the relation is known to hold at most one row, which the empty key proves. - pub at_most_one_row: bool, -} - -/// One declared column: its name, its audit definition names and whether it is declared non-null. -#[derive(Debug, Clone, Default)] -pub struct DeclaredKeyColumn { - pub name: String, - pub audits: Vec, - pub not_nullable: bool, -} diff --git a/crates/sqlclean/src/sql_quality/tests/test_json_parses.rs b/crates/sqlclean/src/sql_quality/tests/test_json_parses.rs index 570a272..3586cc6 100644 --- a/crates/sqlclean/src/sql_quality/tests/test_json_parses.rs +++ b/crates/sqlclean/src/sql_quality/tests/test_json_parses.rs @@ -5,7 +5,7 @@ use serde_json::Value; use crate::sql_quality::tests::helpers::{anchors, lint_dialect, wide_json_select}; use crate::sql_quality::tests::test_types::{JsonParseScaleTestCase, JsonParseTestCase}; -const RULE: &str = "SQC045"; +const RULE: &str = "SQLCLEAN045"; #[test] fn given_json_parse_calls_when_linting_then_repeats_in_one_select_are_reported() diff --git a/crates/sqlclean/src/sql_quality/tests/test_quality_rules.rs b/crates/sqlclean/src/sql_quality/tests/test_quality_rules.rs index 7ad2349..132d61b 100644 --- a/crates/sqlclean/src/sql_quality/tests/test_quality_rules.rs +++ b/crates/sqlclean/src/sql_quality/tests/test_quality_rules.rs @@ -9,7 +9,7 @@ fn given_quality_rule_inputs_when_linting_then_findings_and_fixes_match() -> Res QualityRuleTestCase { description: "long literal is reported without a split fix", sql: "SELECT 'pending orders awaiting shipment' AS label", - rule: "SQC044", + rule: "SQLCLEAN044", relation_keys: "{}", expected_anchors: &["'pending orders awaiting shipment'"], expected_fixed_sql: None, @@ -17,7 +17,7 @@ fn given_quality_rule_inputs_when_linting_then_findings_and_fixes_match() -> Res QualityRuleTestCase { description: "short literal passes", sql: "SELECT 'pending' AS label", - rule: "SQC044", + rule: "SQLCLEAN044", relation_keys: "{}", expected_anchors: &[], expected_fixed_sql: None, @@ -25,7 +25,7 @@ fn given_quality_rule_inputs_when_linting_then_findings_and_fixes_match() -> Res QualityRuleTestCase { description: "typed literal is reported without a fix", sql: "SELECT TIMESTAMP '2026-04-01 10:00:00.000000000' AS ordered_at", - rule: "SQC044", + rule: "SQLCLEAN044", relation_keys: "{}", expected_anchors: &["'2026-04-01 10:00:00.000000000'"], expected_fixed_sql: None, @@ -33,7 +33,7 @@ fn given_quality_rule_inputs_when_linting_then_findings_and_fixes_match() -> Res QualityRuleTestCase { description: "ranking sort above the cap", sql: "SELECT ROW_NUMBER() OVER (PARTITION BY o.customer_id ORDER BY o.a, o.b, o.order_id) AS rn FROM orders AS o", - rule: "SQC043", + rule: "SQLCLEAN043", relation_keys: "{}", expected_anchors: &["ROW_NUMBER"], expected_fixed_sql: None, @@ -41,7 +41,7 @@ fn given_quality_rule_inputs_when_linting_then_findings_and_fixes_match() -> Res QualityRuleTestCase { description: "ranking sort within the cap", sql: "SELECT RANK() OVER (ORDER BY o.a, o.order_id) AS rn FROM orders AS o", - rule: "SQC043", + rule: "SQLCLEAN043", relation_keys: "{}", expected_anchors: &[], expected_fixed_sql: None, @@ -49,7 +49,7 @@ fn given_quality_rule_inputs_when_linting_then_findings_and_fixes_match() -> Res QualityRuleTestCase { description: "ordering covers a declared key", sql: "SELECT ROW_NUMBER() OVER (PARTITION BY o.customer_id ORDER BY o.ordered_at, o.order_id) AS rn FROM orders AS o", - rule: "SQC018", + rule: "SQLCLEAN018", relation_keys: r#"{"orders": [["order_id"]]}"#, expected_anchors: &[], expected_fixed_sql: None, @@ -57,7 +57,7 @@ fn given_quality_rule_inputs_when_linting_then_findings_and_fixes_match() -> Res QualityRuleTestCase { description: "ordering without a key is unproven", sql: "SELECT ROW_NUMBER() OVER (PARTITION BY o.customer_id ORDER BY o.ordered_at) AS rn FROM orders AS o", - rule: "SQC018", + rule: "SQLCLEAN018", relation_keys: r#"{"orders": [["order_id"]]}"#, expected_anchors: &["ROW_NUMBER"], expected_fixed_sql: None, @@ -65,7 +65,7 @@ fn given_quality_rule_inputs_when_linting_then_findings_and_fixes_match() -> Res QualityRuleTestCase { description: "key proven through a CTE and a one-to-one join", sql: "WITH lines AS (SELECT l.line_id, l.order_id FROM order_lines AS l), final AS (SELECT l.line_id, o.customer_id, ROW_NUMBER() OVER (PARTITION BY o.customer_id ORDER BY l.line_id) AS rn FROM lines AS l INNER JOIN orders AS o ON l.order_id = o.order_id) SELECT f.line_id, f.customer_id, f.rn FROM final AS f", - rule: "SQC018", + rule: "SQLCLEAN018", relation_keys: r#"{"orders": [["order_id"]], "order_lines": [["line_id"]]}"#, expected_anchors: &[], expected_fixed_sql: None, @@ -73,7 +73,7 @@ fn given_quality_rule_inputs_when_linting_then_findings_and_fixes_match() -> Res QualityRuleTestCase { description: "key lost through a fan-out join", sql: "WITH final AS (SELECT o.order_id, ROW_NUMBER() OVER (ORDER BY o.order_id) AS rn FROM orders AS o INNER JOIN order_lines AS l ON l.order_id = o.order_id) SELECT f.order_id, f.rn FROM final AS f", - rule: "SQC018", + rule: "SQLCLEAN018", relation_keys: r#"{"orders": [["order_id"]], "order_lines": [["line_id"]]}"#, expected_anchors: &["ROW_NUMBER"], expected_fixed_sql: None, @@ -81,7 +81,7 @@ fn given_quality_rule_inputs_when_linting_then_findings_and_fixes_match() -> Res QualityRuleTestCase { description: "GROUP BY grain is a key", sql: "WITH totals AS (SELECT o.customer_id, SUM(o.amount) AS amount FROM orders AS o GROUP BY o.customer_id), final AS (SELECT t.customer_id, ROW_NUMBER() OVER (ORDER BY t.amount, t.customer_id) AS rn FROM totals AS t) SELECT f.customer_id, f.rn FROM final AS f", - rule: "SQC018", + rule: "SQLCLEAN018", relation_keys: "{}", expected_anchors: &[], expected_fixed_sql: None, @@ -89,7 +89,7 @@ fn given_quality_rule_inputs_when_linting_then_findings_and_fixes_match() -> Res QualityRuleTestCase { description: "positional GROUP BY grain is a key", sql: "WITH totals AS (SELECT o.customer_id, SUM(o.amount) AS amount FROM orders AS o GROUP BY 1), final AS (SELECT t.customer_id, ROW_NUMBER() OVER (ORDER BY t.amount, t.customer_id) AS rn FROM totals AS t) SELECT f.customer_id, f.rn FROM final AS f", - rule: "SQC018", + rule: "SQLCLEAN018", relation_keys: "{}", expected_anchors: &[], expected_fixed_sql: None, @@ -97,7 +97,7 @@ fn given_quality_rule_inputs_when_linting_then_findings_and_fixes_match() -> Res QualityRuleTestCase { description: "a later rn = 1 filter makes the partition a key", sql: "WITH ranked AS (SELECT o.order_id, o.customer_id, ROW_NUMBER() OVER (PARTITION BY o.customer_id ORDER BY o.order_id) AS rn FROM orders AS o), latest AS (SELECT r.customer_id, r.order_id FROM ranked AS r WHERE r.rn = 1), final AS (SELECT l.customer_id, ROW_NUMBER() OVER (ORDER BY l.customer_id) AS position FROM latest AS l) SELECT f.customer_id, f.position FROM final AS f", - rule: "SQC018", + rule: "SQLCLEAN018", relation_keys: r#"{"orders": [["order_id"]]}"#, expected_anchors: &[], expected_fixed_sql: None, @@ -105,7 +105,7 @@ fn given_quality_rule_inputs_when_linting_then_findings_and_fixes_match() -> Res QualityRuleTestCase { description: "a QUALIFY window cannot prove itself", sql: "SELECT o.customer_id, o.order_id FROM orders AS o QUALIFY ROW_NUMBER() OVER (PARTITION BY o.customer_id ORDER BY o.amount DESC) = 1", - rule: "SQC018", + rule: "SQLCLEAN018", relation_keys: r#"{"orders": [["order_id"]]}"#, expected_anchors: &["ROW_NUMBER"], expected_fixed_sql: None, @@ -113,7 +113,7 @@ fn given_quality_rule_inputs_when_linting_then_findings_and_fixes_match() -> Res QualityRuleTestCase { description: "a QUALIFY partition is a key for later steps", sql: "WITH latest AS (SELECT o.customer_id, o.order_id FROM orders AS o QUALIFY ROW_NUMBER() OVER (PARTITION BY o.customer_id ORDER BY o.order_id) = 1), final AS (SELECT l.customer_id, ROW_NUMBER() OVER (ORDER BY l.customer_id) AS position FROM latest AS l) SELECT f.customer_id, f.position FROM final AS f", - rule: "SQC018", + rule: "SQLCLEAN018", relation_keys: r#"{"orders": [["order_id"]]}"#, expected_anchors: &[], expected_fixed_sql: None, @@ -121,7 +121,7 @@ fn given_quality_rule_inputs_when_linting_then_findings_and_fixes_match() -> Res QualityRuleTestCase { description: "RANK ties are deterministic", sql: "SELECT RANK() OVER (ORDER BY o.ordered_at) AS rn FROM orders AS o", - rule: "SQC018", + rule: "SQLCLEAN018", relation_keys: "{}", expected_anchors: &[], expected_fixed_sql: None, @@ -129,7 +129,7 @@ fn given_quality_rule_inputs_when_linting_then_findings_and_fixes_match() -> Res QualityRuleTestCase { description: "unused CTE output is removed", sql: "WITH orders AS (SELECT o.order_id, o.status, o.note FROM raw_orders AS o), final AS (SELECT r.order_id, r.status FROM orders AS r) SELECT f.order_id, f.status FROM final AS f", - rule: "SQC042", + rule: "SQLCLEAN042", relation_keys: "{}", expected_anchors: &["o.note"], expected_fixed_sql: Some( @@ -139,7 +139,7 @@ fn given_quality_rule_inputs_when_linting_then_findings_and_fixes_match() -> Res QualityRuleTestCase { description: "star pass-through into the final CTE keeps every column", sql: "WITH orders AS (SELECT o.order_id, o.note FROM raw_orders AS o), final AS (SELECT * FROM orders) SELECT * FROM final", - rule: "SQC042", + rule: "SQLCLEAN042", relation_keys: "{}", expected_anchors: &[], expected_fixed_sql: None, @@ -147,7 +147,7 @@ fn given_quality_rule_inputs_when_linting_then_findings_and_fixes_match() -> Res QualityRuleTestCase { description: "star pass-through follows the reader's own reads", sql: "WITH orders AS (SELECT o.order_id, o.note FROM raw_orders AS o), staged AS (SELECT * FROM orders), final AS (SELECT s.order_id FROM staged AS s) SELECT f.order_id FROM final AS f", - rule: "SQC042", + rule: "SQLCLEAN042", relation_keys: "{}", expected_anchors: &["o.note"], expected_fixed_sql: Some( @@ -157,7 +157,7 @@ fn given_quality_rule_inputs_when_linting_then_findings_and_fixes_match() -> Res QualityRuleTestCase { description: "a same-named cast of a source column is not a lateral read", sql: "WITH orders AS (SELECT o.order_id, CAST(note AS VARCHAR) AS note, o.note || 'x' AS tagged FROM raw_orders AS o), final AS (SELECT r.order_id, r.tagged FROM orders AS r) SELECT f.order_id, f.tagged FROM final AS f", - rule: "SQC042", + rule: "SQLCLEAN042", relation_keys: "{}", expected_anchors: &["CAST(note AS VARCHAR) AS note"], expected_fixed_sql: Some( @@ -166,16 +166,16 @@ fn given_quality_rule_inputs_when_linting_then_findings_and_fixes_match() -> Res }, QualityRuleTestCase { description: "an interpolated expression in a reader may read any column", - sql: "WITH orders AS (SELECT o.order_id, o.status FROM raw_orders AS o), final AS (SELECT r.order_id FROM orders AS r WHERE __sqlbuild_audit_parameter_0__) SELECT f.order_id FROM final AS f", - rule: "SQC042", + sql: "WITH orders AS (SELECT o.order_id, o.status FROM raw_orders AS o), final AS (SELECT r.order_id FROM orders AS r WHERE __sqlclean_relation_parameter_0__) SELECT f.order_id FROM final AS f", + rule: "SQLCLEAN042", relation_keys: "{}", expected_anchors: &[], expected_fixed_sql: None, }, QualityRuleTestCase { description: "an interpolated output is never reported", - sql: "WITH orders AS (SELECT o.order_id, __SQB_LINT_0002 FROM raw_orders AS o), final AS (SELECT r.order_id FROM orders AS r) SELECT f.order_id FROM final AS f", - rule: "SQC042", + sql: "WITH orders AS (SELECT o.order_id, __SQLCLEAN_INTERPOLATION_0002 FROM raw_orders AS o), final AS (SELECT r.order_id FROM orders AS r) SELECT f.order_id FROM final AS f", + rule: "SQLCLEAN042", relation_keys: "{}", expected_anchors: &[], expected_fixed_sql: None, @@ -183,7 +183,7 @@ fn given_quality_rule_inputs_when_linting_then_findings_and_fixes_match() -> Res QualityRuleTestCase { description: "DISTINCT CTE reports without a fix", sql: "WITH orders AS (SELECT DISTINCT o.order_id, o.note FROM raw_orders AS o), final AS (SELECT r.order_id FROM orders AS r) SELECT f.order_id FROM final AS f", - rule: "SQC042", + rule: "SQLCLEAN042", relation_keys: "{}", expected_anchors: &["o.note"], expected_fixed_sql: None, @@ -191,7 +191,7 @@ fn given_quality_rule_inputs_when_linting_then_findings_and_fixes_match() -> Res QualityRuleTestCase { description: "columns read in later join and filter clauses are used", sql: "WITH orders AS (SELECT o.order_id, o.customer_id, o.status FROM raw_orders AS o), final AS (SELECT c.name FROM orders AS r INNER JOIN customers AS c ON r.customer_id = c.customer_id WHERE r.status = 'open' QUALIFY ROW_NUMBER() OVER (PARTITION BY r.order_id ORDER BY c.name) = 1) SELECT f.name FROM final AS f", - rule: "SQC042", + rule: "SQLCLEAN042", relation_keys: "{}", expected_anchors: &[], expected_fixed_sql: None, @@ -199,7 +199,7 @@ fn given_quality_rule_inputs_when_linting_then_findings_and_fixes_match() -> Res QualityRuleTestCase { description: "a column read only through a nested star is used", sql: "WITH g AS (SELECT r.order_id, r.amount FROM raw_orders AS r), final AS (SELECT g.order_id, HASH(g.*) AS row_hash FROM g) SELECT f.order_id, f.row_hash FROM final AS f", - rule: "SQC042", + rule: "SQLCLEAN042", relation_keys: "{}", expected_anchors: &[], expected_fixed_sql: None, @@ -207,7 +207,7 @@ fn given_quality_rule_inputs_when_linting_then_findings_and_fixes_match() -> Res QualityRuleTestCase { description: "a whole-row reference reads every column", sql: "WITH g AS (SELECT r.order_id, r.amount FROM raw_orders AS r), final AS (SELECT g.order_id, HASH(g) AS row_hash FROM g) SELECT f.order_id, f.row_hash FROM final AS f", - rule: "SQC042", + rule: "SQLCLEAN042", relation_keys: "{}", expected_anchors: &[], expected_fixed_sql: None, @@ -215,7 +215,7 @@ fn given_quality_rule_inputs_when_linting_then_findings_and_fixes_match() -> Res QualityRuleTestCase { description: "COUNT(DISTINCT row) reads every column", sql: "WITH g AS (SELECT r.order_id, r.amount FROM raw_orders AS r), final AS (SELECT COUNT(DISTINCT g) AS n FROM g) SELECT f.n FROM final AS f", - rule: "SQC042", + rule: "SQLCLEAN042", relation_keys: "{}", expected_anchors: &[], expected_fixed_sql: None, @@ -223,7 +223,7 @@ fn given_quality_rule_inputs_when_linting_then_findings_and_fixes_match() -> Res QualityRuleTestCase { description: "COLUMNS(*) reads every column", sql: "WITH g AS (SELECT r.order_id, r.amount FROM raw_orders AS r), final AS (SELECT g.order_id FROM g WHERE COLUMNS(*) IS NOT NULL) SELECT f.order_id FROM final AS f", - rule: "SQC042", + rule: "SQLCLEAN042", relation_keys: "{}", expected_anchors: &[], expected_fixed_sql: None, @@ -231,7 +231,7 @@ fn given_quality_rule_inputs_when_linting_then_findings_and_fixes_match() -> Res QualityRuleTestCase { description: "COLUMNS with a pattern reads every column", sql: "WITH g AS (SELECT r.order_id, r.amount FROM raw_orders AS r), final AS (SELECT MAX(COLUMNS('order.*')) FROM g) SELECT * FROM final", - rule: "SQC042", + rule: "SQLCLEAN042", relation_keys: "{}", expected_anchors: &[], expected_fixed_sql: None, @@ -239,7 +239,7 @@ fn given_quality_rule_inputs_when_linting_then_findings_and_fixes_match() -> Res QualityRuleTestCase { description: "DISTINCT star pass-through reads every column", sql: "WITH g AS (SELECT r.order_id, r.amount FROM raw_orders AS r), staged AS (SELECT DISTINCT * FROM g), final AS (SELECT s.order_id FROM staged AS s) SELECT f.order_id FROM final AS f", - rule: "SQC042", + rule: "SQLCLEAN042", relation_keys: "{}", expected_anchors: &[], expected_fixed_sql: None, @@ -247,7 +247,7 @@ fn given_quality_rule_inputs_when_linting_then_findings_and_fixes_match() -> Res QualityRuleTestCase { description: "a positional ORDER BY over a star pass-through reads every column", sql: "WITH g AS (SELECT r.order_id, r.amount FROM raw_orders AS r), staged AS (SELECT * FROM g ORDER BY 2 LIMIT 1), final AS (SELECT s.order_id FROM staged AS s) SELECT f.order_id FROM final AS f", - rule: "SQC042", + rule: "SQLCLEAN042", relation_keys: "{}", expected_anchors: &[], expected_fixed_sql: None, @@ -255,7 +255,7 @@ fn given_quality_rule_inputs_when_linting_then_findings_and_fixes_match() -> Res QualityRuleTestCase { description: "an output alias read in WHERE or GROUP BY is used", sql: "WITH g AS (SELECT r.order_id, r.amount * 2 AS doubled, r.customer_id % 2 AS bucket FROM raw_orders AS r WHERE doubled > 1 GROUP BY r.order_id, doubled, bucket), final AS (SELECT g.order_id FROM g) SELECT f.order_id FROM final AS f", - rule: "SQC042", + rule: "SQLCLEAN042", relation_keys: "{}", expected_anchors: &[], expected_fixed_sql: None, @@ -263,7 +263,7 @@ fn given_quality_rule_inputs_when_linting_then_findings_and_fixes_match() -> Res QualityRuleTestCase { description: "GROUP BY ALL grouping column is reported without a fix", sql: "WITH g AS (SELECT r.customer_id, r.status, SUM(r.amount) AS total FROM raw_orders AS r GROUP BY ALL), final AS (SELECT g.customer_id, g.total FROM g) SELECT f.customer_id, f.total FROM final AS f", - rule: "SQC042", + rule: "SQLCLEAN042", relation_keys: "{}", expected_anchors: &["r.status"], expected_fixed_sql: None, @@ -271,7 +271,7 @@ fn given_quality_rule_inputs_when_linting_then_findings_and_fixes_match() -> Res QualityRuleTestCase { description: "an aggregate in an ungrouped select is reported without a fix", sql: "WITH g AS (SELECT 'all' AS label, COUNT(*) AS n FROM raw_orders AS r), final AS (SELECT g.label FROM g) SELECT f.label FROM final AS f", - rule: "SQC042", + rule: "SQLCLEAN042", relation_keys: "{}", expected_anchors: &["COUNT(*) AS n"], expected_fixed_sql: None, @@ -279,7 +279,7 @@ fn given_quality_rule_inputs_when_linting_then_findings_and_fixes_match() -> Res QualityRuleTestCase { description: "a set-returning projection is reported without a fix", sql: "WITH g AS (SELECT r.order_id, UNNEST(r.tags) AS tag FROM raw_orders AS r), final AS (SELECT g.order_id FROM g) SELECT f.order_id FROM final AS f", - rule: "SQC042", + rule: "SQLCLEAN042", relation_keys: "{}", expected_anchors: &["UNNEST(r.tags) AS tag"], expected_fixed_sql: None, @@ -287,7 +287,7 @@ fn given_quality_rule_inputs_when_linting_then_findings_and_fixes_match() -> Res QualityRuleTestCase { description: "ORDER BY ALL with LIMIT is reported without a fix", sql: "WITH g AS (SELECT r.order_id, r.customer_id FROM raw_orders AS r ORDER BY ALL LIMIT 1), final AS (SELECT g.order_id FROM g) SELECT f.order_id FROM final AS f", - rule: "SQC042", + rule: "SQLCLEAN042", relation_keys: "{}", expected_anchors: &["r.customer_id"], expected_fixed_sql: None, @@ -295,7 +295,7 @@ fn given_quality_rule_inputs_when_linting_then_findings_and_fixes_match() -> Res QualityRuleTestCase { description: "DISTINCT ON is reported without a fix", sql: "WITH g AS (SELECT DISTINCT ON (r.customer_id) r.order_id, r.amount FROM raw_orders AS r ORDER BY r.customer_id, r.order_id), final AS (SELECT g.order_id FROM g) SELECT f.order_id FROM final AS f", - rule: "SQC042", + rule: "SQLCLEAN042", relation_keys: "{}", expected_anchors: &["r.amount"], expected_fixed_sql: None, @@ -303,7 +303,7 @@ fn given_quality_rule_inputs_when_linting_then_findings_and_fixes_match() -> Res QualityRuleTestCase { description: "a volatile projection is reported without a fix", sql: "WITH g AS (SELECT r.order_id, RANDOM() AS draw FROM raw_orders AS r), final AS (SELECT g.order_id FROM g) SELECT f.order_id FROM final AS f", - rule: "SQC042", + rule: "SQLCLEAN042", relation_keys: "{}", expected_anchors: &["RANDOM() AS draw"], expected_fixed_sql: None, @@ -311,7 +311,7 @@ fn given_quality_rule_inputs_when_linting_then_findings_and_fixes_match() -> Res QualityRuleTestCase { description: "a deterministic scalar projection is removed", sql: "WITH g AS (SELECT r.order_id, COALESCE(UPPER(r.status), 'none') AS label FROM raw_orders AS r), final AS (SELECT g.order_id FROM g) SELECT f.order_id FROM final AS f", - rule: "SQC042", + rule: "SQLCLEAN042", relation_keys: "{}", expected_anchors: &["COALESCE(UPPER(r.status), 'none') AS label"], expected_fixed_sql: Some( @@ -344,7 +344,7 @@ fn given_threshold_findings_when_linting_then_remediation_shows_the_exact_settin QualityRemediationTestCase { description: "long literal points at shortening it and the length setting", sql: "SELECT 'pending orders awaiting shipment' AS label", - rule: "SQC044", + rule: "SQLCLEAN044", expected_fragments: &[ "Shorten the literal", "set this in sqlclean.toml:", @@ -355,7 +355,7 @@ fn given_threshold_findings_when_linting_then_remediation_shows_the_exact_settin QualityRemediationTestCase { description: "long ranking sort points at a row key and the sort setting", sql: "SELECT ROW_NUMBER() OVER (PARTITION BY o.customer_id ORDER BY o.a, o.b, o.order_id) AS rn FROM orders AS o", - rule: "SQC043", + rule: "SQLCLEAN043", expected_fragments: &[ "unique row key as the final tie-breaker", "HASH(source_partition, source_offset, line_index) AS row_key", @@ -366,7 +366,7 @@ fn given_threshold_findings_when_linting_then_remediation_shows_the_exact_settin QualityRemediationTestCase { description: "unproven ranking points at a unique tie-breaker and a built row key", sql: "SELECT ROW_NUMBER() OVER (PARTITION BY o.customer_id ORDER BY o.a) AS rn FROM orders AS o", - rule: "SQC018", + rule: "SQLCLEAN018", expected_fragments: &[ "cover a unique key of the ranked rows", "a unique, non-null tie-breaker column", diff --git a/crates/sqlclean/src/sql_quality/tests/test_quality_scale.rs b/crates/sqlclean/src/sql_quality/tests/test_quality_scale.rs index 46f8ad3..512e814 100644 --- a/crates/sqlclean/src/sql_quality/tests/test_quality_scale.rs +++ b/crates/sqlclean/src/sql_quality/tests/test_quality_scale.rs @@ -21,7 +21,7 @@ fn given_many_wide_ctes_with_findings_when_linting_then_quality_rules_stay_linea "version": 1, "sql": wide_cte_chain(test_case.cte_count, test_case.column_count), "dialect": "duckdb", - "enabled_rules": ["SQC018", "SQC042", "SQC043", "SQC044"], + "enabled_rules": ["SQLCLEAN018", "SQLCLEAN042", "SQLCLEAN043", "SQLCLEAN044"], }); let started: Instant = Instant::now(); let response: Value = serde_json::from_str(&lint_json(&request.to_string())?) diff --git a/crates/sqlclean/src/sql_quality/tests/test_safety_rules.rs b/crates/sqlclean/src/sql_quality/tests/test_safety_rules.rs index 517e6bc..74b5156 100644 --- a/crates/sqlclean/src/sql_quality/tests/test_safety_rules.rs +++ b/crates/sqlclean/src/sql_quality/tests/test_safety_rules.rs @@ -11,126 +11,126 @@ fn given_safety_rule_inputs_when_linting_then_exactly_the_mistakes_are_reported( description: "unquoted CTE names collide regardless of case", dialect: "duckdb", sql: "WITH paid AS (SELECT 1 AS x), PAID AS (SELECT 2 AS x) SELECT x FROM paid", - rule: "SQC046", + rule: "SQLCLEAN046", expected_anchors: &["PAID"], }, SafetyRuleTestCase { description: "quoted names that differ in case are distinct in PostgreSQL", dialect: "postgres", sql: r#"WITH "paid" AS (SELECT 1 AS x), "PAID" AS (SELECT 2 AS x) SELECT x FROM "paid""#, - rule: "SQC046", + rule: "SQLCLEAN046", expected_anchors: &[], }, SafetyRuleTestCase { description: "a quoted name equals the folded unquoted name in Snowflake", dialect: "snowflake", sql: r#"WITH "PAID" AS (SELECT 1 AS x), paid AS (SELECT 2 AS x) SELECT x FROM paid"#, - rule: "SQC046", + rule: "SQLCLEAN046", expected_anchors: &["paid"], }, SafetyRuleTestCase { description: "a nested WITH may reuse an outer CTE name", dialect: "duckdb", sql: "WITH paid AS (WITH paid AS (SELECT 1 AS x) SELECT x FROM paid) SELECT x FROM paid", - rule: "SQC046", + rule: "SQLCLEAN046", expected_anchors: &[], }, SafetyRuleTestCase { description: "simple CASE compares with NULL, and a singleton IN list holds only NULL", dialect: "duckdb", sql: "SELECT CASE status WHEN 'open' THEN 1 WHEN NULL THEN 2 END AS s FROM orders WHERE region IN (NULL)", - rule: "SQC047", + rule: "SQLCLEAN047", expected_anchors: &["WHEN NULL", "IN (NULL)"], }, SafetyRuleTestCase { description: "searched CASE, IS NULL, NOT IN and a longer IN list pass", dialect: "duckdb", sql: "SELECT CASE WHEN status IS NULL THEN 1 END AS s FROM orders WHERE region NOT IN (NULL) AND kind IN (1, NULL)", - rule: "SQC047", + rule: "SQLCLEAN047", expected_anchors: &[], }, SafetyRuleTestCase { description: "only the CASE whose operand compares with NULL is reported", dialect: "duckdb", sql: "SELECT CASE status WHEN 'open' THEN CASE WHEN NULL THEN 1 END END AS a, CASE WHEN amount > 0 THEN CASE region WHEN NULL THEN 2 END END AS b FROM orders", - rule: "SQC047", + rule: "SQLCLEAN047", expected_anchors: &["WHEN NULL"], }, SafetyRuleTestCase { description: "division and modulo by literal zero in operator and function form", dialect: "duckdb", sql: "SELECT amount / 0 AS a, amount % 0.0 AS b, MOD(amount, 0) AS c, amount / (-0) AS d FROM orders", - rule: "SQC048", + rule: "SQLCLEAN048", expected_anchors: &["/ 0", "% 0.0", "MOD(amount, 0)", "/ (-0)"], }, SafetyRuleTestCase { description: "a non-zero or computed divisor passes", dialect: "duckdb", sql: "SELECT amount / 0.5 AS a, amount / 1e-400 AS e, amount / 0 ** 2 AS b, MOD(amount, 0 + 1) AS c, amount / NULLIF(quantity, 0) AS d FROM orders", - rule: "SQC048", + rule: "SQLCLEAN048", expected_anchors: &[], }, SafetyRuleTestCase { description: "a missing or constant WHERE does not limit an UPDATE or DELETE", dialect: "duckdb", sql: "UPDATE orders SET status = 'closed'; DELETE FROM orders WHERE TRUE; DELETE FROM orders WHERE 1 = 1", - rule: "SQC049", + rule: "SQLCLEAN049", expected_anchors: &["UPDATE", "DELETE", "DELETE"], }, SafetyRuleTestCase { description: "a row predicate or a subquery limits the write", dialect: "duckdb", sql: "UPDATE orders SET status = 'closed' WHERE closed_at IS NOT NULL; DELETE FROM orders WHERE EXISTS (SELECT 1 FROM refunds)", - rule: "SQC049", + rule: "SQLCLEAN049", expected_anchors: &[], }, SafetyRuleTestCase { description: "a joined DELETE and a LIMIT bound the rows in MySQL", dialect: "mysql", sql: "DELETE o FROM orders o JOIN customers c ON c.id = o.customer_id; DELETE FROM orders LIMIT 10", - rule: "SQC049", + rule: "SQLCLEAN049", expected_anchors: &[], }, SafetyRuleTestCase { description: "UPDATE FROM without WHERE, and DELETE after a WITH clause", dialect: "postgres", sql: "UPDATE orders SET status = 'closed' FROM customers; WITH stale AS (SELECT 1 AS id) DELETE FROM orders", - rule: "SQC049", + rule: "SQLCLEAN049", expected_anchors: &["UPDATE", "DELETE"], }, SafetyRuleTestCase { description: "an INSERT without a column list in query and VALUES form", dialect: "duckdb", sql: "INSERT INTO orders SELECT * FROM staged_orders; INSERT INTO orders VALUES (1, 'open')", - rule: "SQC050", + rule: "SQLCLEAN050", expected_anchors: &["INSERT", "INSERT"], }, SafetyRuleTestCase { description: "listed columns and BY NAME pass", dialect: "duckdb", sql: "INSERT INTO orders (order_id) SELECT order_id FROM staged_orders; INSERT INTO orders BY NAME SELECT 1 AS order_id", - rule: "SQC050", + rule: "SQLCLEAN050", expected_anchors: &[], }, SafetyRuleTestCase { description: "DEFAULT VALUES needs no column list", dialect: "postgres", sql: "INSERT INTO orders DEFAULT VALUES", - rule: "SQC050", + rule: "SQLCLEAN050", expected_anchors: &[], }, SafetyRuleTestCase { description: "NATURAL joins are reported with their join keywords", dialect: "duckdb", sql: "SELECT * FROM orders NATURAL JOIN customers NATURAL LEFT OUTER JOIN regions JOIN items USING (order_id)", - rule: "SQC051", + rule: "SQLCLEAN051", expected_anchors: &["NATURAL JOIN", "NATURAL LEFT OUTER JOIN"], }, SafetyRuleTestCase { description: "reversed number, negative, decimal and date bounds, with or without NOT", dialect: "duckdb", sql: "SELECT * FROM orders WHERE a BETWEEN 10 AND 1 OR b NOT BETWEEN -1 AND -5 OR c BETWEEN 1.5 AND 1.25 OR d BETWEEN DATE '2026-02-01' AND DATE '2026-01-31'", - rule: "SQC052", + rule: "SQLCLEAN052", expected_anchors: &[ "BETWEEN 10 AND 1", "BETWEEN -1 AND -5", @@ -142,21 +142,21 @@ fn given_safety_rule_inputs_when_linting_then_exactly_the_mistakes_are_reported( description: "ASYMMETRIC keeps the ordered meaning in PostgreSQL", dialect: "postgres", sql: "SELECT * FROM orders WHERE a BETWEEN ASYMMETRIC 10 AND 1", - rule: "SQC052", + rule: "SQLCLEAN052", expected_anchors: &["BETWEEN ASYMMETRIC 10 AND 1"], }, SafetyRuleTestCase { description: "ordered, equal, SYMMETRIC, computed, text and frame bounds pass", dialect: "duckdb", sql: "SELECT SUM(a) OVER (ORDER BY b ROWS BETWEEN 2 PRECEDING AND CURRENT ROW) AS s FROM orders WHERE a BETWEEN 9 AND 10 OR b BETWEEN -5 AND -1 OR c BETWEEN 5 AND 5 OR d BETWEEN SYMMETRIC 10 AND 1 OR e BETWEEN 10 AND 1 + 20 OR f BETWEEN 'b' AND 'a'", - rule: "SQC052", + rule: "SQLCLEAN052", expected_anchors: &[], }, SafetyRuleTestCase { description: "aggregates and windows in WHERE, ON and after a GROUP BY comma", dialect: "duckdb", sql: "SELECT o.customer_id FROM orders AS o JOIN customers AS c ON c.id = o.customer_id AND COUNT(*) > 1 WHERE SUM(o.amount) > 100 AND ROW_NUMBER() OVER (PARTITION BY o.customer_id ORDER BY o.ordered_at) = 1 GROUP BY o.customer_id, MAX(o.amount)", - rule: "SQC053", + rule: "SQLCLEAN053", expected_anchors: &[ "COUNT(*)", "SUM(o.amount)", @@ -168,14 +168,14 @@ fn given_safety_rule_inputs_when_linting_then_exactly_the_mistakes_are_reported( description: "HAVING, QUALIFY, FILTER, subqueries and unknown functions pass", dialect: "duckdb", sql: "SELECT customer_id, COUNT(*) FILTER (WHERE amount > 0) AS paid FROM orders WHERE amount > (SELECT AVG(amount) FROM orders) AND order_total(amount) > 0 GROUP BY customer_id HAVING COUNT(*) > 1 QUALIFY ROW_NUMBER() OVER (ORDER BY customer_id) = 1", - rule: "SQC053", + rule: "SQLCLEAN053", expected_anchors: &[], }, SafetyRuleTestCase { description: "multi-argument MAX is the scalar function in SQLite", dialect: "sqlite", sql: "SELECT order_id FROM orders WHERE max(amount, refunded) > 100", - rule: "SQC053", + rule: "SQLCLEAN053", expected_anchors: &[], }, ]; diff --git a/fensu.toml b/fensu.toml index 7443fe2..7f53731 100644 --- a/fensu.toml +++ b/fensu.toml @@ -33,11 +33,6 @@ rule = "FPRSL105" path = "crates/sqlclean/src/sql_lint/main/formatter.rs" reason = "Integration surface: SQLBuild's Python bindings format through `format_json`." -[[targets.rust.rule_exceptions]] -rule = "FPRSL105" -path = "crates/sqlclean/src/sql_quality/main/declared_relation_keys.rs" -reason = "Integration surface: SQLBuild's compiled views derive the relation keys its models declare through this entry." - [[targets.rust.rule_exceptions]] rule = "FPRSL105" path = "crates/sqlclean/src/sql_tokens/main/builtin_function_names.rs" diff --git a/src/sqlclean/rules/_helpers/definition.py b/src/sqlclean/rules/_helpers/definition.py index 22774d2..405f43d 100644 --- a/src/sqlclean/rules/_helpers/definition.py +++ b/src/sqlclean/rules/_helpers/definition.py @@ -41,7 +41,7 @@ def define_rule( if not is_custom_code(code): raise RuleDefinitionError( f"custom Rule code must be {CUSTOM_CODE_PREFIX}, optional capital letters and " - f"{RULE_CODE_DIGIT_COUNT} digits, such as XSQCNAME001: {code}" + f"{RULE_CODE_DIGIT_COUNT} digits, such as XSQLCLEANNAME001: {code}" ) names: list[str] = [option.name for option in options] if len(names) != len(set(names)): @@ -70,7 +70,7 @@ def decorate(check: RuleCheck) -> RuleCheck: def is_custom_code(code: str) -> bool: - """Return whether code is XSQC, optional capital letters and three digits.""" + """Return whether code is XSQLCLEAN, optional capital letters and three digits.""" family: str = code[:-RULE_CODE_DIGIT_COUNT] number: str = code[-RULE_CODE_DIGIT_COUNT:] diff --git a/src/sqlclean/rules/constants.py b/src/sqlclean/rules/constants.py index 878101b..c1bd270 100644 --- a/src/sqlclean/rules/constants.py +++ b/src/sqlclean/rules/constants.py @@ -1,7 +1,7 @@ """Constants of the custom Rule authoring API.""" RULE_ATTRIBUTE: str = "__sqlclean_rule__" -CUSTOM_CODE_PREFIX: str = "XSQC" +CUSTOM_CODE_PREFIX: str = "XSQLCLEAN" RULE_CODE_DIGIT_COUNT: int = 3 RULE_PARAMETER_COUNT: int = 2 SIGNATURE_REQUIREMENT: str = ( diff --git a/tests/e2e/src/sqlclean/hosting/test_sqlclean_command.py b/tests/e2e/src/sqlclean/hosting/test_sqlclean_command.py index 279a38a..80e1c89 100644 --- a/tests/e2e/src/sqlclean/hosting/test_sqlclean_command.py +++ b/tests/e2e/src/sqlclean/hosting/test_sqlclean_command.py @@ -22,16 +22,20 @@ from tests.integration.src.sqlclean.rule_testing.helpers import SAMPLE_RULES DUCKDB: str = 'dialect = "duckdb"\n' -CROSS_JOIN_ONLY: str = DUCKDB + '[rules]\nselect = ["SQC038"]\n' -CUSTOM: str = DUCKDB + '[rules]\nselect = ["XSQCNAME", "XSQCSTYLE"]\ncustom = ["sql_rules"]\n' +CROSS_JOIN_ONLY: str = DUCKDB + '[rules]\nselect = ["SQLCLEAN038"]\n' +CUSTOM: str = ( + DUCKDB + '[rules]\nselect = ["XSQLCLEANNAME", "XSQLCLEANSTYLE"]\ncustom = ["sql_rules"]\n' +) CROSS_JOIN_EXCEPTIONS: str = ( CROSS_JOIN_ONLY - + '[[rules.rule_exceptions]]\nrule = "SQC038"\npath = "models/q.sql"\nrelation = "b"\n' + + '[[rules.rule_exceptions]]\nrule = "SQLCLEAN038"\npath = "models/q.sql"\nrelation = "b"\n' + 'reason = "Every pair is needed."\n' - + '[[rules.rule_exceptions]]\nrule = "SQC038"\npath = "models/q.sql"\nrelation = "z"\n' + + '[[rules.rule_exceptions]]\nrule = "SQLCLEAN038"\npath = "models/q.sql"\nrelation = "z"\n' + 'reason = "Removed join."\n' ) NULL_AND_COMMA: str = "select a.id from a, b where a.x = null\n" +# SQLCLEAN001 is a default Rule; SQLCLEAN034 and SQLCLEAN050 are opt-in. +DEFAULT_AND_OPT_IN: str = "insert into orders select a.id from a where a.x = null\n" @pytest.mark.parametrize( @@ -49,17 +53,41 @@ description="selected Rules report in file order", config=DUCKDB, files={"models/q.sql": NULL_AND_COMMA}, - arguments=("--select", "SQC001,SQC002"), + arguments=("--select", "SQLCLEAN001,SQLCLEAN002"), + expected_exit=1, + expected_codes=("SQLCLEAN002", "SQLCLEAN001"), + ), + CheckTestCase( + description="without a selection only the default Rules run", + config=DUCKDB, + files={"models/q.sql": DEFAULT_AND_OPT_IN}, + arguments=(), + expected_exit=1, + expected_codes=("SQLCLEAN001",), + ), + CheckTestCase( + description="the SQLCLEAN family selects every built-in Rule, opt-in ones too", + config=DUCKDB, + files={"models/q.sql": DEFAULT_AND_OPT_IN}, + arguments=("--select", "SQLCLEAN"), + expected_exit=1, + expected_codes=("SQLCLEAN050", "SQLCLEAN034", "SQLCLEAN001"), + ), + CheckTestCase( + description="an exact code selects only that Rule", + config=DUCKDB, + files={"models/q.sql": DEFAULT_AND_OPT_IN}, + arguments=("--select", "SQLCLEAN050"), expected_exit=1, - expected_codes=("SQC002", "SQC001"), + expected_codes=("SQLCLEAN050",), ), CheckTestCase( description="an ignored Rule is skipped", config=DUCKDB, files={"models/q.sql": NULL_AND_COMMA}, - arguments=("--select", "SQC001,SQC002", "--ignore", "SQC002"), + arguments=("--select", "SQLCLEAN001,SQLCLEAN002", "--ignore", "SQLCLEAN002"), expected_exit=1, - expected_codes=("SQC001",), + expected_codes=("SQLCLEAN001",), ), CheckTestCase( description="an exception hides its finding and an unmatched one is stale", @@ -79,7 +107,7 @@ }, arguments=("--config", "strict.toml"), expected_exit=1, - expected_codes=("SQC038",), + expected_codes=("SQLCLEAN038",), ), CheckTestCase( description="lint is check by another name", @@ -88,7 +116,7 @@ arguments=(), command="lint", expected_exit=1, - expected_codes=("SQC038",), + expected_codes=("SQLCLEAN038",), ), CheckTestCase( description="unparseable SQL is reported as a finding", @@ -96,18 +124,18 @@ files={"models/q.sql": "SELECT FROM WHERE (\n"}, arguments=(), expected_exit=1, - expected_codes=("SQC999",), + expected_codes=("SQLCLEAN999",), ), CheckTestCase( description="custom Rules run with their configured options", - config=CUSTOM + '[rules.rule_options.XSQCNAME001]\nprefixes = ["stg_"]\n', + config=CUSTOM + '[rules.rule_options.XSQLCLEANNAME001]\nprefixes = ["stg_"]\n', files={ "sql_rules/naming.py": SAMPLE_RULES, "models/q.sql": "SELECT COUNT(1) AS n FROM stg_orders JOIN tmp_x ON TRUE\n", }, arguments=(), expected_exit=1, - expected_codes=("XSQCSTYLE001", "XSQCNAME001"), + expected_codes=("XSQLCLEANSTYLE001", "XSQLCLEANNAME001"), ), ], ids=lambda case: case.description, @@ -134,7 +162,7 @@ def test_given_project_when_checking_then_reports_findings_and_exit_status( [ RewriteTestCase( description="fix applies safe fixes, leaves unsafe ones and formats", - config=DUCKDB + '[rules]\nselect = ["SQC001", "SQC002"]\n', + config=DUCKDB + '[rules]\nselect = ["SQLCLEAN001", "SQLCLEAN002"]\n', files={"models/q.sql": NULL_AND_COMMA}, arguments=("fix",), expected_exit=1, @@ -144,7 +172,7 @@ def test_given_project_when_checking_then_reports_findings_and_exit_status( ), RewriteTestCase( description="fix with unsafe fixes applies them too and then passes", - config=DUCKDB + '[rules]\nselect = ["SQC001", "SQC002"]\n', + config=DUCKDB + '[rules]\nselect = ["SQLCLEAN001", "SQLCLEAN002"]\n', files={"models/q.sql": NULL_AND_COMMA}, arguments=("fix", "--unsafe-fixes"), expected_exit=0, @@ -165,7 +193,7 @@ def test_given_project_when_checking_then_reports_findings_and_exit_status( ), RewriteTestCase( description="fix --diff writes nothing", - config=DUCKDB + '[rules]\nselect = ["SQC002"]\n', + config=DUCKDB + '[rules]\nselect = ["SQLCLEAN002"]\n', files={"models/q.sql": "select a.id from a, b\n"}, arguments=("fix", "--diff"), expected_exit=1, @@ -239,19 +267,19 @@ def test_given_project_when_rewriting_then_files_and_exit_status_match( [ SummaryTestCase( description="a safe fix is fixable with plain fix", - config=DUCKDB + '[rules]\nselect = ["SQC002"]\n', + config=DUCKDB + '[rules]\nselect = ["SQLCLEAN002"]\n', source="select a.id from a, b\n", expected_summary="Found 1 problem (1 fixable with `sqlclean fix`).", ), SummaryTestCase( description="an unsafe fix needs --unsafe-fixes", - config=DUCKDB + '[rules]\nselect = ["SQC001"]\n', + config=DUCKDB + '[rules]\nselect = ["SQLCLEAN001"]\n', source="select a.id from a where a.x = null\n", expected_summary="Found 1 problem (1 fixable with `sqlclean fix --unsafe-fixes`).", ), SummaryTestCase( description="safe and unsafe fixes are counted apart", - config=DUCKDB + '[rules]\nselect = ["SQC001", "SQC002"]\n', + config=DUCKDB + '[rules]\nselect = ["SQLCLEAN001", "SQLCLEAN002"]\n', source=NULL_AND_COMMA, expected_summary=( "Found 2 problems (1 fixable with `sqlclean fix`, 1 more with `--unsafe-fixes`)." @@ -275,7 +303,7 @@ def test_given_fixable_findings_when_checking_then_summary_counts_safe_and_unsaf [ SarifTestCase( description="the message and remediation read as two sentences", - config=DUCKDB + '[rules]\nselect = ["SQC025"]\n', + config=DUCKDB + '[rules]\nselect = ["SQLCLEAN025"]\n', source="select a.id from a join b on a.id = b.id\n", expected_message="JOIN type is implicit. Write INNER JOIN explicitly.", ), @@ -330,7 +358,7 @@ def test_given_formatted_file_when_formatting_again_then_nothing_changes( }, edited={"models/q.sql": "SELECT * FROM tmp_customers\n"}, expected_exits=(1, 1, 2), - expected_cached_code="XSQCNAME001", + expected_cached_code="XSQLCLEANNAME001", expected_error="missing-python", ), ], @@ -375,7 +403,7 @@ def test_given_many_files_when_checking_custom_rules_then_every_host_share_repor write_project(tmp_path, config=CUSTOM, files={"sql_rules/naming.py": SAMPLE_RULES, **files}) completed: subprocess.CompletedProcess[str] = run( - tmp_path, "check", "--output-format", "json", "--select", "XSQCNAME001", environment={} + tmp_path, "check", "--output-format", "json", "--select", "XSQLCLEANNAME001", environment={} ) report: dict[str, list[dict[str, object]]] = json.loads(completed.stdout) diff --git a/tests/integration/src/sqlclean/rule_testing/helpers.py b/tests/integration/src/sqlclean/rule_testing/helpers.py index 9659135..2f8ae80 100644 --- a/tests/integration/src/sqlclean/rule_testing/helpers.py +++ b/tests/integration/src/sqlclean/rule_testing/helpers.py @@ -21,7 +21,7 @@ @rule( - code="XSQCSHAPE001", + code="XSQLCLEANSHAPE001", message="Statement joins too many relations", remediation="Split the statement into CTEs.", options=(MAX_JOINS,), @@ -32,7 +32,7 @@ def too_many_joins(*, statement: Statement, ctx: RuleContext) -> list: @rule( - code="XSQCNAME001", + code="XSQLCLEANNAME001", message="Table name uses a forbidden prefix", remediation="Read from the published table.", options=(PREFIXES,), @@ -48,7 +48,7 @@ def forbidden_prefix(*, statement: Statement, ctx: RuleContext) -> list: @rule( - code="XSQCSTYLE001", + code="XSQLCLEANSTYLE001", message="COUNT(1) should be COUNT(*)", remediation="Write COUNT(*).", fixable=FixSafety.SAFE, @@ -65,7 +65,7 @@ def count_one(*, statement: Statement, ctx: RuleContext) -> list: @rule( - code="XSQCFILE001", + code="XSQLCLEANFILE001", message="File holds more than one statement", remediation="Keep one statement per file.", ) @@ -74,7 +74,7 @@ def one_statement(*, file: SqlFile, ctx: RuleContext) -> list: @rule( - code="XSQCREPO001", + code="XSQLCLEANREPO001", message="Table is read by only one file", remediation="Inline the table or share it.", ) diff --git a/tests/unit/src/sqlclean/rules/test_rule_definition.py b/tests/unit/src/sqlclean/rules/test_rule_definition.py index c10173f..e9273c8 100644 --- a/tests/unit/src/sqlclean/rules/test_rule_definition.py +++ b/tests/unit/src/sqlclean/rules/test_rule_definition.py @@ -17,12 +17,12 @@ [ RuleCodeTestCase( description="a bare custom prefix with three digits", - code="XSQC001", + code="XSQLCLEAN001", expected_error=None, ), RuleCodeTestCase( description="a family of capital letters", - code="XSQCNAME001", + code="XSQLCLEANNAME001", expected_error=None, ), ], @@ -44,18 +44,18 @@ def test_given_custom_code_when_declaring_then_the_definition_is_attached( [ RuleCodeTestCase( description="a built-in code is reserved", - code="SQC001", - expected_error="custom Rule code must be XSQC", + code="SQLCLEAN001", + expected_error="custom Rule code must be XSQLCLEAN", ), RuleCodeTestCase( description="a lower-case family", - code="XSQCname001", - expected_error="custom Rule code must be XSQC", + code="XSQLCLEANname001", + expected_error="custom Rule code must be XSQLCLEAN", ), RuleCodeTestCase( description="two digits", - code="XSQCNAME01", - expected_error="custom Rule code must be XSQC", + code="XSQLCLEANNAME01", + expected_error="custom Rule code must be XSQLCLEAN", ), ], ids=lambda case: case.description, diff --git a/website/public/index.html b/website/public/index.html index 3f84666..c1c925f 100644 --- a/website/public/index.html +++ b/website/public/index.html @@ -143,14 +143,14 @@

Flag SQL that runs but is easy to get wrong

How Rules work →

sqlclean check
-error[SQC018]: ROW_NUMBER has no deterministic ordering
+error[SQLCLEAN018]: ROW_NUMBER has no deterministic ordering
   --> models/open_orders.sql:2:3
    |
  2 |   ROW_NUMBER() OVER (PARTITION BY o.customer_id) AS order_rank
    |   ^^^^^^^^^^
   = help: Add ORDER BY with a stable tie-breaker inside the window specification.
 
-error[SQC001]: Comparison with NULL is never true
+error[SQLCLEAN001]: Comparison with NULL is never true
   --> models/open_orders.sql:4:22
    |
  4 | WHERE o.cancelled_at = NULL
@@ -158,7 +158,7 @@ 

Flag SQL that runs but is easy to get wrong

= help: Use IS NULL or IS NOT NULL when testing for NULL. = fix: available with `sqlclean fix --unsafe-fixes` -error[SQC019]: NOT IN list contains NULL and can reject every row +error[SQLCLEAN019]: NOT IN list contains NULL and can reject every row --> models/open_orders.sql:5:16 | 5 | AND o.status NOT IN ('refunded', NULL) @@ -250,7 +250,7 @@

Turn review comments into Rules

# sql_rules/layers.py
 @rule(
-    code="XSQCLAYER001",
+    code="XSQLCLEANLAYER001",
     message="Marts must not read raw tables",
     remediation="Read the staging model instead.",
     enabled_by_default=True,
@@ -265,7 +265,7 @@ 

Turn review comments into Rules

] sqlclean check -error[XSQCLAYER001]: Marts must not read raw tables +error[XSQLCLEANLAYER001]: Marts must not read raw tables --> models/marts/revenue.sql:5:12 | 5 | INNER JOIN raw.payments p @@ -317,20 +317,20 @@

Allow a finding once, with a reason

# sqlclean.toml
 [[rules.rule_exceptions]]
-rule = "SQC038"
+rule = "SQLCLEAN038"
 path = "models/marts/calendar.sql"
 relation = "regions"
 reason = "Every date is reported for every region."
 
 sqlclean check
-error[SQC038]: CROSS JOIN with `markets` creates an explicit cartesian product
+error[SQLCLEAN038]: CROSS JOIN with `markets` creates an explicit cartesian product
   --> models/marts/calendar.sql:5:1
    |
  5 | CROSS JOIN markets r
    | ^^^^^
   = help: Use a keyed JOIN, or make one side provably at most one row …
 
-error: stale rule exception: SQC038 in models/marts/calendar.sql for relation regions
+error: stale rule exception: SQLCLEAN038 in models/marts/calendar.sql for relation regions
   matches no finding; remove it from sqlclean.toml
@@ -358,7 +358,7 @@

Before every commit and in CI

"runs": [{ "tool": { "driver": { "name": "sqlclean", … } }, "results": [{ - "ruleId": "XSQCLAYER001", + "ruleId": "XSQLCLEANLAYER001", "message": { "text": "Marts must not read raw tables. Read the staging model instead." }, …
diff --git a/website/recordings/prepare.sh b/website/recordings/prepare.sh index 9d50da1..0d00ece 100755 --- a/website/recordings/prepare.sh +++ b/website/recordings/prepare.sh @@ -49,7 +49,7 @@ def lowered(operand: ast.Expression) -> bool: @rule( - code="XSQCTEAM001", + code="XSQLCLEANTEAM001", message="Email comparisons must be case-insensitive", remediation="Compare LOWER(email) with LOWER(...) instead.", enabled_by_default=True, diff --git a/website/src/content/docs/docs/benchmarks.mdx b/website/src/content/docs/docs/benchmarks.mdx index d75f38e..d0e4bdf 100644 --- a/website/src/content/docs/docs/benchmarks.mdx +++ b/website/src/content/docs/docs/benchmarks.mdx @@ -11,7 +11,7 @@ SQLFluff ran once. | Command | Wall time | CPU time | Peak memory | vs SQLClean defaults | |---|---:|---:|---:|---:| | `sqlclean check` (13 default Rules) | **0.81 s** | 2.5 s | 48 MiB | 1× | -| `sqlclean check --select SQC` (all 45 Rules) | **1.33 s** | 3.8 s | 140 MiB | 1.6× | +| `sqlclean check --select SQLCLEAN` (all 45 Rules) | **1.33 s** | 3.8 s | 140 MiB | 1.6× | | `sqlclean check` with three [custom Rules](#custom-rules) | **2.27 s** | 6.9 s | 182 MiB | 2.8× | | `sqlclean format --check` | **3.03 s** | 11.5 s | 49 MiB | 3.7× | | `sqruff lint` | 41.0 s | 114 s | 235 MiB | 51× | diff --git a/website/src/content/docs/docs/cli/check.mdx b/website/src/content/docs/docs/cli/check.mdx index a288612..b3a4c2c 100644 --- a/website/src/content/docs/docs/cli/check.mdx +++ b/website/src/content/docs/docs/cli/check.mdx @@ -19,7 +19,7 @@ sqlclean check [OPTIONS] [PATHS]... ```bash sqlclean check # every matching file under the configuration root sqlclean check models/orders.sql reports/ # only these files and directories -sqlclean check --select SQC001,SQC004 # only these Rules +sqlclean check --select SQLCLEAN001,SQLCLEAN004 # only these Rules sqlclean check --output-format sarif > sqlclean.sarif ``` @@ -40,20 +40,20 @@ when `include` does not match it, unless `exclude` does. See | `--config ` | Use this configuration file instead of the nearest `sqlclean.toml` | `--select` and `--ignore` take a comma-separated list, or the flag repeated. A value is an exact -code such as `SQC001` or a prefix such as `SQC` or `XSQCNAME`. A value that matches no Rule is an +code such as `SQLCLEAN001` or a prefix such as `SQLCLEAN` or `XSQLCLEANNAME`. A value that matches no Rule is an error. See [Selecting Rules](/docs/rules/overview/#selecting-rules). ## Text output ```text -error[SQC004]: Row selection is nondeterministic +error[SQLCLEAN004]: Row selection is nondeterministic --> models/customers.sql:6:1 | 6 | LIMIT 10; | ^^^^^ = help: Add ORDER BY with a deterministic tie-breaker before LIMIT or OFFSET. -error[SQC001]: Comparison with NULL is never true +error[SQLCLEAN001]: Comparison with NULL is never true --> models/orders.sql:8:22 | 8 | WHERE o.cancelled_at = NULL @@ -61,7 +61,7 @@ error[SQC001]: Comparison with NULL is never true = help: Use IS NULL or IS NOT NULL when testing for NULL. = fix: available with `sqlclean fix --unsafe-fixes` -error: stale rule exception: SQC004 in models/orders.sql matches no finding; remove it from sqlclean.toml +error: stale rule exception: SQLCLEAN004 in models/orders.sql matches no finding; remove it from sqlclean.toml Found 3 problems (1 fixable with `sqlclean fix --unsafe-fixes`). ``` @@ -82,7 +82,7 @@ and count characters, not bytes. { "findings": [ { - "code": "SQC004", + "code": "SQLCLEAN004", "path": "models/customers.sql", "line": 6, "column": 1, @@ -93,7 +93,7 @@ and count characters, not bytes. "fix": null }, { - "code": "SQC001", + "code": "SQLCLEAN001", "path": "models/orders.sql", "line": 8, "column": 22, @@ -106,7 +106,7 @@ and count characters, not bytes. ], "stale_exceptions": [ { - "rule": "SQC004", + "rule": "SQLCLEAN004", "path": "models/orders.sql", "relation": null } @@ -130,7 +130,7 @@ text: ```json { - "ruleId": "SQC001", + "ruleId": "SQLCLEAN001", "level": "error", "message": { "text": "Comparison with NULL is never true. Use IS NULL or IS NOT NULL when testing for NULL." diff --git a/website/src/content/docs/docs/cli/fix.mdx b/website/src/content/docs/docs/cli/fix.mdx index d054888..2e814a4 100644 --- a/website/src/content/docs/docs/cli/fix.mdx +++ b/website/src/content/docs/docs/cli/fix.mdx @@ -35,14 +35,14 @@ Files are chosen as for [`sqlclean check`](/docs/cli/check/). | `--no-cache` | Evaluate every file instead of reusing results for unchanged inputs | | `--config ` | Use this configuration file instead of the nearest `sqlclean.toml` | -Only fixes of selected Rules apply. `sqlclean fix --select SQC005` removes unused CTEs and +Only fixes of selected Rules apply. `sqlclean fix --select SQLCLEAN005` removes unused CTEs and formats the files, and leaves every other finding alone. ## Output ```text Fixed 1 problem(s) and changed 1 file(s). -error[SQC001]: Comparison with NULL is never true +error[SQLCLEAN001]: Comparison with NULL is never true --> models/open_orders.sql:7:24 | 7 | WHERE cancelled_at = NULL @@ -87,7 +87,7 @@ findings: formatter cannot format is reported as `: could not format: ` and keeps its fixes. A file that does not parse gets no fixes and is reported as -[`SQC999`](/docs/rules/overview/#parse-errors-sqc999). +[`SQLCLEAN999`](/docs/rules/overview/#parse-errors-sqlclean999). ## Exit codes diff --git a/website/src/content/docs/docs/cli/rules.mdx b/website/src/content/docs/docs/cli/rules.mdx index a9fd64b..97d0bbe 100644 --- a/website/src/content/docs/docs/cli/rules.mdx +++ b/website/src/content/docs/docs/cli/rules.mdx @@ -16,9 +16,9 @@ sqlclean rules [OPTIONS] [CODE] ```bash sqlclean rules # list every Rule -sqlclean rules SQC001 # show one Rule +sqlclean rules SQLCLEAN001 # show one Rule sqlclean rules --json # every Rule as JSON -sqlclean rules --json SQC001 # one Rule as JSON +sqlclean rules --json SQLCLEAN001 # one Rule as JSON ``` ## Flags @@ -32,14 +32,14 @@ sqlclean rules --json SQC001 # one Rule as JSON ## Listing Rules ```text -SQC001 default Comparison with NULL is never true -SQC002 default Comma-separated sources create an implicit cartesian join -SQC003 default Non-cross join has no meaningful condition -SQC004 default Row selection is nondeterministic +SQLCLEAN001 default Comparison with NULL is never true +SQLCLEAN002 default Comma-separated sources create an implicit cartesian join +SQLCLEAN003 default Non-cross join has no meaningful condition +SQLCLEAN004 default Row selection is nondeterministic ... -SQC045 JSON payload is parsed more than once in one SELECT -XSQCNAME001 default Query reads a temporary table -XSQCSTYLE001 default COUNT(1) should be COUNT(*) +SQLCLEAN045 JSON payload is parsed more than once in one SELECT +XSQLCLEANNAME001 default Query reads a temporary table +XSQLCLEANSTYLE001 default COUNT(1) should be COUNT(*) ``` `default` marks the Rules that run when `[rules] select` is not set. See @@ -49,11 +49,11 @@ XSQCSTYLE001 default COUNT(1) should be COUNT(*) ## Showing one Rule ```bash -sqlclean rules SQC001 +sqlclean rules SQLCLEAN001 ``` ```text -SQC001: Comparison with NULL is never true +SQLCLEAN001: Comparison with NULL is never true Subject: statement Enabled by default: yes Fixable: unsafe @@ -63,7 +63,7 @@ Remediation: Use IS NULL or IS NOT NULL when testing for NULL. A custom Rule also shows the Python module that defines it: ```text -XSQCSTYLE001: COUNT(1) should be COUNT(*) +XSQLCLEANSTYLE001: COUNT(1) should be COUNT(*) Subject: statement Enabled by default: yes Defined in: sql_rules.naming @@ -76,13 +76,13 @@ Remediation: Write COUNT(*). ## JSON output ```bash -sqlclean rules --json XSQCNAME001 +sqlclean rules --json XSQLCLEANNAME001 ``` ```json [ { - "code": "XSQCNAME001", + "code": "XSQLCLEANNAME001", "message": "Query reads a temporary table", "remediation": "Read from the published table instead.", "enabled_by_default": true, diff --git a/website/src/content/docs/docs/configuration.mdx b/website/src/content/docs/docs/configuration.mdx index cc0a41f..b95d1c9 100644 --- a/website/src/content/docs/docs/configuration.mdx +++ b/website/src/content/docs/docs/configuration.mdx @@ -20,25 +20,25 @@ line_width = 100 indent = 4 [rules] -select = ["SQC"] # omit to run the Rules enabled by default -ignore = ["SQC027"] +select = ["SQLCLEAN"] # omit to run the Rules enabled by default +ignore = ["SQLCLEAN027"] custom = ["sql_rules"] # Python files or directories that define custom Rules [rules.thresholds] -max_literal_length = 100 # SQC044 -max_ranking_order_by = 5 # SQC043 +max_literal_length = 100 # SQLCLEAN044 +max_ranking_order_by = 5 # SQLCLEAN043 -[rules.rule_options.XSQCNAME001] +[rules.rule_options.XSQLCLEANNAME001] prefixes = ["tmp_", "scratch_"] [[rules.rule_exceptions]] # one reviewed finding that is allowed -rule = "SQC038" +rule = "SQLCLEAN038" path = "models/calendar.sql" relation = "dates" reason = "Every pair of dates is needed." [[rules.rule_ignores]] # Rules that do not apply to some paths -rules = ["SQC034"] +rules = ["SQLCLEAN034"] paths = ["scratch/**"] reason = "Exploratory queries." ``` @@ -65,8 +65,8 @@ directory is the root. | `[rules] select` | Rules enabled by default | Rule codes or prefixes to run | | `[rules] ignore` | `[]` | Rule codes or prefixes to skip | | `[rules] custom` | `[]` | Python files or directories that define [custom Rules](/docs/custom-rules/overview/) | -| `[rules.thresholds] max_literal_length` | `100` | The longest string literal `SQC044` allows | -| `[rules.thresholds] max_ranking_order_by` | `5` | The most `ORDER BY` expressions `SQC043` allows in a ranking window | +| `[rules.thresholds] max_literal_length` | `100` | The longest string literal `SQLCLEAN044` allows | +| `[rules.thresholds] max_ranking_order_by` | `5` | The most `ORDER BY` expressions `SQLCLEAN043` allows in a ranking window | | `[rules.rule_options.]` | none | Option values of one custom Rule; see [Options](/docs/custom-rules/fixes-and-options/#options) | | `[[rules.rule_exceptions]]` | none | One allowed finding; see [Exceptions](/docs/rules/overview/#exceptions) | | `[[rules.rule_ignores]]` | none | Rules that do not apply to some paths; see [Path ignores](/docs/rules/overview/#path-ignores) | @@ -101,7 +101,7 @@ dialect = "tsql" ``` A file that does not parse under its dialect is reported as -[`SQC999`](/docs/rules/overview/#parse-errors-sqc999), with the dialect in the message. Without +[`SQLCLEAN999`](/docs/rules/overview/#parse-errors-sqlclean999), with the dialect in the message. Without the `[[dialects]]` entry above, this T-SQL file under `reports/` is parsed as `duckdb`: ```sql @@ -109,7 +109,7 @@ SELECT [order id] FROM orders ``` ```text -error[SQC999]: 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] +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] ``` Supported dialects, case-insensitive: @@ -167,11 +167,11 @@ report any finding. | `line_width` of `0` | `sqlclean.toml: [format] line_width must be positive` | | `indent` of `0` or more than `8` | `sqlclean.toml: [format] indent must be between 1 and 8` | | A threshold of `0` | `sqlclean.toml: [rules.thresholds] max_literal_length and max_ranking_order_by must be positive` | -| An exception or ignore without a reason | `sqlclean.toml: rule exception or ignore for SQC001 needs a reason` | -| An exception with `cte` but no `relation` | `sqlclean.toml: rule exception for SQC038 at models/calendar.sql sets cte without relation` | +| An exception or ignore without a reason | `sqlclean.toml: rule exception or ignore for SQLCLEAN001 needs a reason` | +| An exception with `cte` but no `relation` | `sqlclean.toml: rule exception for SQLCLEAN038 at models/calendar.sql sets cte without relation` | | A `custom` path that does not exist | `[rules] custom path does not exist: sql_rules` | -| `rule_options` for a built-in Rule | `[rules.rule_options.SQC001]: built-in Rules take no options; set limits in [rules.thresholds]` | -| `rule_options` for a code no custom Rule has | `[rules.rule_options.XSQCNOPE001]: no custom Rule has this code` | -| A selector that matches no Rule | `Rule selector 'SQC9' matches no Rule` | +| `rule_options` for a built-in Rule | `[rules.rule_options.SQLCLEAN001]: built-in Rules take no options; set limits in [rules.thresholds]` | +| `rule_options` for a code no custom Rule has | `[rules.rule_options.XSQLCLEANNOPE001]: no custom Rule has this code` | +| A selector that matches no Rule | `Rule selector 'SQLCLEAN9' matches no Rule` | Errors are printed as `sqlclean: error: `. diff --git a/website/src/content/docs/docs/custom-rules/fixes-and-options.mdx b/website/src/content/docs/docs/custom-rules/fixes-and-options.mdx index 118f69d..88e476e 100644 --- a/website/src/content/docs/docs/custom-rules/fixes-and-options.mdx +++ b/website/src/content/docs/docs/custom-rules/fixes-and-options.mdx @@ -16,7 +16,7 @@ from sqlclean import FixSafety, Finding, RuleContext, Statement, rule @rule( - code="XSQCSTYLE001", + code="XSQLCLEANSTYLE001", message="COUNT(1) should be COUNT(*)", remediation="Write COUNT(*).", fixable=FixSafety.SAFE, @@ -35,7 +35,7 @@ A function call is located at its name, so `f.end + 1` is the first character in parentheses. ```bash -sqlclean fix --diff --select XSQCSTYLE +sqlclean fix --diff --select XSQLCLEANSTYLE ``` ```diff @@ -74,7 +74,7 @@ edit must lie inside the finding's file. See [Safe and unsafe fixes](/docs/rules/overview/#safe-and-unsafe-fixes). A finding without `fix` has no fix, even in a fixable Rule. A Rule that returns a fix without -declaring `fixable` fails with `Rule XSQCSTYLE001 returned a fix but does not declare fixable`. +declaring `fixable` fails with `Rule XSQLCLEANSTYLE001 returned a fix but does not declare fixable`. ### How fixes combine @@ -101,7 +101,7 @@ PREFIXES = RuleOption.string_list( @rule( - code="XSQCNAME001", + code="XSQLCLEANNAME001", message="Query reads a temporary table", remediation="Read from the published table instead.", options=(PREFIXES,), @@ -117,7 +117,7 @@ def temporary_table(*, statement: Statement, ctx: RuleContext) -> list[Finding]: ``` ```toml -[rules.rule_options.XSQCNAME001] +[rules.rule_options.XSQLCLEANNAME001] prefixes = ["tmp_", "scratch_"] ``` @@ -141,12 +141,12 @@ exit code `2`: | Problem | Error | |---------|-------| -| A value of the wrong type | `[rules.rule_options.XSQCNAME001] option prefixes expects a list, got 'tmp_'` | -| An integer out of bounds | `[rules.rule_options.XSQCFILE002] option max_statements must be at least 1` | -| A string outside `choices` | `[rules.rule_options.XSQCFILE002] option style must be one of ['strict', 'loose']` | -| A name the Rule does not declare | `Rule XSQCNAME001 has no option prefix` | -| A code no custom Rule has | `[rules.rule_options.XSQCNOPE001]: no custom Rule has this code` | -| A Rule that reads an option it did not declare | `Rule XSQCFILE002 reads undeclared option verbose` | +| A value of the wrong type | `[rules.rule_options.XSQLCLEANNAME001] option prefixes expects a list, got 'tmp_'` | +| An integer out of bounds | `[rules.rule_options.XSQLCLEANFILE002] option max_statements must be at least 1` | +| A string outside `choices` | `[rules.rule_options.XSQLCLEANFILE002] option style must be one of ['strict', 'loose']` | +| A name the Rule does not declare | `Rule XSQLCLEANNAME001 has no option prefix` | +| A code no custom Rule has | `[rules.rule_options.XSQLCLEANNOPE001]: no custom Rule has this code` | +| A Rule that reads an option it did not declare | `Rule XSQLCLEANFILE002 reads undeclared option verbose` | Built-in Rules take no options. Their two limits are set in [`[rules.thresholds]`](/docs/rules/overview/#thresholds). diff --git a/website/src/content/docs/docs/custom-rules/overview.mdx b/website/src/content/docs/docs/custom-rules/overview.mdx index bc04aef..5087a6f 100644 --- a/website/src/content/docs/docs/custom-rules/overview.mdx +++ b/website/src/content/docs/docs/custom-rules/overview.mdx @@ -16,7 +16,7 @@ from sqlclean import Finding, RuleContext, Statement, rule @rule( - code="XSQCNAME001", + code="XSQLCLEANNAME001", message="Query reads a temporary table", remediation="Read from the published table instead.", enabled_by_default=True, @@ -37,7 +37,7 @@ custom = ["sql_rules"] ``` ```text -error[XSQCNAME001]: Query reads a temporary table +error[XSQLCLEANNAME001]: Query reads a temporary table --> models/orders.sql:2:6 | 2 | FROM tmp_orders @@ -56,13 +56,13 @@ functions, constants and classes stay ordinary Python. - Modules are imported by their path from the configuration root: `sql_rules/naming.py` is `sql_rules.naming`. A Rule module can import a helper the same way, for example `from sql_rules.helpers import table_prefixes`. -- Two Rules with the same code are an error: `custom Rule code XSQCNAME001 is defined twice`. +- Two Rules with the same code are an error: `custom Rule code XSQLCLEANNAME001 is defined twice`. ## Declaring a Rule | `@rule` argument | Default | Description | |------------------|---------|-------------| -| `code` | required | `XSQC`, optional capital letters, and three digits, such as `XSQCNAME001` | +| `code` | required | `XSQLCLEAN`, optional capital letters, and three digits, such as `XSQLCLEANNAME001` | | `message` | required | The finding message | | `remediation` | required | What to do about a finding | | `enabled_by_default` | `False` | Run the Rule when `[rules] select` is not set | @@ -71,7 +71,7 @@ functions, constants and classes stay ordinary Python. | `slug` | the code in lower case | A short name for the Rule | A Rule that is not enabled by default runs when `select` names it, by code or by a prefix such as -`XSQC` or `XSQCNAME`. See [Selecting Rules](/docs/rules/overview/#selecting-rules). Fixes and +`XSQLCLEAN` or `XSQLCLEANNAME`. See [Selecting Rules](/docs/rules/overview/#selecting-rules). Fixes and options are covered in [Fixes and options](/docs/custom-rules/fixes-and-options/). ## Choosing a subject @@ -92,7 +92,7 @@ from sqlclean import Finding, Repository, RuleContext, SqlFile, rule @rule( - code="XSQCFILE001", + code="XSQLCLEANFILE001", message="File holds more than one statement", remediation="Split the file so each holds one statement.", ) @@ -101,7 +101,7 @@ def one_statement(*, file: SqlFile, ctx: RuleContext) -> list[Finding]: @rule( - code="XSQCREPO001", + code="XSQLCLEANREPO001", message="Two files define a CTE with the same name", remediation="Give each CTE a name that is unique across files.", ) @@ -138,7 +138,7 @@ A Repository Rule passes the statement or file each finding belongs to as `subje ## How Rules run - Custom Rules run after the built-in Rules, on files that parse. A file with a - [parse error](/docs/rules/overview/#parse-errors-sqc999) gets no custom Rule findings. + [parse error](/docs/rules/overview/#parse-errors-sqlclean999) gets no custom Rule findings. - From 500 files, Statement and File Rules split the files across one Python process per CPU. A Rule can't share state between files, so anything that spans files belongs in a Repository Rule, which always runs in one process with every file. @@ -171,12 +171,12 @@ A Rule that cannot load or run stops the command with exit code `2`: | Problem | Error | |---------|-------| -| An invalid code | `custom Rule code must be XSQC, optional capital letters and 3 digits, such as XSQCNAME001: XSQC01` | -| A wrong signature | `custom Rule XSQCBAD001 must accept exactly one typed Statement, SqlFile or Repository subject and one typed RuleContext, both keyword-only` | +| An invalid code | `custom Rule code must be XSQLCLEAN, optional capital letters and 3 digits, such as XSQLCLEANNAME001: XSQLCLEAN01` | +| A wrong signature | `custom Rule XSQLCLEANBAD001 must accept exactly one typed Statement, SqlFile or Repository subject and one typed RuleContext, both keyword-only` | | A module that fails to import | `could not import custom Rule module sql_rules.bad: ` | -| An exception in the Rule | `custom Rule XSQCBAD001 failed on models/a.sql: IndexError: tuple index out of range`, then the traceback | -| A return value that is not a list of findings | `custom Rule XSQCRET001 must return a list of findings from ctx.finding(...)` | -| A fix from a Rule without `fixable` | `Rule XSQCBAD001 returned a fix but does not declare fixable` | +| An exception in the Rule | `custom Rule XSQLCLEANBAD001 failed on models/a.sql: IndexError: tuple index out of range`, then the traceback | +| A return value that is not a list of findings | `custom Rule XSQLCLEANRET001 must return a list of findings from ctx.finding(...)` | +| A fix from a Rule without `fixable` | `Rule XSQLCLEANBAD001 returned a fix but does not declare fixable` | ## Continue diff --git a/website/src/content/docs/docs/custom-rules/statement-facts.mdx b/website/src/content/docs/docs/custom-rules/statement-facts.mdx index 3284593..50138a1 100644 --- a/website/src/content/docs/docs/custom-rules/statement-facts.mdx +++ b/website/src/content/docs/docs/custom-rules/statement-facts.mdx @@ -12,7 +12,7 @@ from sqlclean import Finding, RuleContext, Statement, rule @rule( - code="XSQCJOIN001", + code="XSQLCLEANJOIN001", message="Join to customers has no condition", remediation="Join customers on customer_id.", ) diff --git a/website/src/content/docs/docs/custom-rules/testing.mdx b/website/src/content/docs/docs/custom-rules/testing.mdx index f1c30ee..f3beaca 100644 --- a/website/src/content/docs/docs/custom-rules/testing.mdx +++ b/website/src/content/docs/docs/custom-rules/testing.mdx @@ -136,7 +136,7 @@ and does not format the result. It shows exactly what the Rule's fix changes. | Problem | Message | |---------|---------| | A different number of findings | `reads orders: expected 1 findings, got 0` | -| Findings that depend on file order | `custom Rule XSQCND001 is not deterministic` | +| Findings that depend on file order | `custom Rule XSQLCLEANND001 is not deterministic` | | Case SQL that does not parse | `sqlclean could not parse the case SQL: sqlclean: error: Parse error at line 1, column 18: ...` | | A function without `@rule` | `evaluate_rule needs a function decorated with @rule` | | No `sqlclean` executable | `the sqlclean executable is not installed; install sqlclean` | @@ -157,7 +157,7 @@ The usual cause is state kept between calls, such as a module-level set of names SEEN: set[str] = set() -@rule(code="XSQCND001", message="Table read in an earlier file", remediation="...") +@rule(code="XSQLCLEANND001", message="Table read in an earlier file", remediation="...") def seen_before(*, statement: Statement, ctx: RuleContext) -> list[Finding]: found = [ctx.finding(subject=statement, node=t) for t in statement.tables if t.name in SEEN] SEEN.update(t.name for t in statement.tables) @@ -165,7 +165,7 @@ def seen_before(*, statement: Statement, ctx: RuleContext) -> list[Finding]: ``` ```text -RuleCaseError: custom Rule XSQCND001 is not deterministic +RuleCaseError: custom Rule XSQLCLEANND001 is not deterministic ``` Compute findings from the subject and `ctx` alone. When a Rule needs to compare files, give it a diff --git a/website/src/content/docs/docs/embedding.mdx b/website/src/content/docs/docs/embedding.mdx index 3675c8c..6129294 100644 --- a/website/src/content/docs/docs/embedding.mdx +++ b/website/src/content/docs/docs/embedding.mdx @@ -17,7 +17,7 @@ fn main() -> Result<(), String> { version: LINT_API_VERSION, sql: "SELECT order_id FROM orders WHERE cancelled_at = NULL LIMIT 10".to_owned(), dialect: "duckdb".to_owned(), - enabled_rules: Some(vec!["SQC001".to_owned(), "SQC004".to_owned()]), + enabled_rules: Some(vec!["SQLCLEAN001".to_owned(), "SQLCLEAN004".to_owned()]), ..LintRequest::default() }; let response: LintResponse = lint(request)?; @@ -32,9 +32,9 @@ fn main() -> Result<(), String> { ``` ```text -SQC001 47..48 Comparison with NULL is never true +SQLCLEAN001 47..48 Comparison with NULL is never true fix: replace 47..48 with "IS" -SQC004 54..59 Row selection is nondeterministic +SQLCLEAN004 54..59 Row selection is nondeterministic ``` ## Adding the dependency @@ -74,21 +74,49 @@ the dialect. Offsets in requests, findings, edits and facts count Unicode charac | `dialect` | required | A [dialect name](/docs/configuration/#dialects) | | `enabled_rules` | the default Rules | Rule codes or prefixes to run | | `ignored_rules` | `[]` | Rule codes or prefixes to skip | -| `max_literal_length` | `100` | The `SQC044` limit | -| `max_ranking_order_by` | `5` | The `SQC043` limit | +| `max_literal_length` | `100` | The `SQLCLEAN044` limit | +| `max_ranking_order_by` | `5` | The `SQLCLEAN043` limit | | `config_file` | `sqlclean.toml` | The configuration file name that remediation text refers to | -| `externally_referenced_ctes` | `[]` | CTE names that something outside the SQL reads, so `SQC005` does not report them | -| `relation_keys` | `{}` | Declared unique keys by relation name; an empty key means the relation holds at most one row, so `SQC038` does not report a cross join with it | - -Other fields describe template code the host has replaced before linting. See `LintRequest` in -`sql_lint::models`. - -A selector that matches no Rule is an error: `unknown native lint rule 'SQC9'`. +| `externally_referenced_ctes` | `[]` | CTE names that something outside the SQL reads, so `SQLCLEAN005` does not report them | +| `relation_keys` | `{}` | Unique keys by table name as the SQL writes it, compared without case; an empty key means the relation holds at most one row, so `SQLCLEAN038` does not report a cross join with it and `SQLCLEAN018` can prove a ranking order | +| `relation_names` | `{}` | The relation each placeholder table name stands for, so `SQLCLEAN038` messages and exceptions name it | +| `external_identifiers` | `[]` | Names the host defines outside the SQL, which are not unknown qualifiers | +| `dependency_identifiers` | `[]` | Placeholder table names that stand for other relations | +| `sql_macro_outputs` | `[]` | The character range each macro call's output fills and the relations it passes, so qualification is judged as the macro wrote it | +| `allows_dynamic_output_star` | `false` | The host proved the output columns, so a top-level `SELECT *` passes `SQLCLEAN021` | +| `fixture_harness` | absent | The generated wrapper around a test or macro body; see [Template code](#template-code) | + +A selector that matches no Rule is an error: `unknown native lint rule 'SQLCLEAN9'`. + +## Template code + +A host that lints templated SQL replaces each template expression with a placeholder before +linting, and maps offsets back afterwards. The crate exports the placeholder prefixes in +`constants`: + +| Constant | Stands for | +|----------|------------| +| `INTERPOLATION_PLACEHOLDER_PREFIX` | An interpolated expression, relation or CTE list; the formatter keeps it in place | +| `MACRO_PARAMETER_PLACEHOLDER_PREFIX` | A macro parameter; `SQLCLEAN028` treats `parameter.slot` reads as written by the macro | +| `RELATION_PARAMETER_PLACEHOLDER_PREFIX` | A relation parameter, written `__`; `SELECT *` from it alone passes `SQLCLEAN021` | +| `VALUE_PARAMETER_PLACEHOLDER_PREFIX` | A value parameter | + +Rules never judge a placeholder as an authored name. + +`fixture_harness` describes the query a test runner or macro wraps around a body. When it is +present, a terminal `SELECT 1` is ceremonial: it passes `SQLCLEAN022` and `SQLCLEAN035`, and `SQLCLEAN041` and +`SQLCLEAN042` do not apply. + +| Field | Description | +|-------|-------------| +| `cte_prefixes` | Prefixes of the CTE names the harness generates, which `SQLCLEAN005` treats as reachable | +| `input_cte_prefixes` | Prefixes of the CTEs standing in for the body's inputs | +| `empty_relation` | The table function an input CTE reads with `SELECT * FROM ()` when it has no rows, which passes `SQLCLEAN021` | ## Fixes `LintDiagnostic.fix` holds at most one edit: `start`, `end` and `replacement`. The engine does not -say whether a fix is safe. The `sqlclean` command treats the `SQC001` fix as unsafe, because +say whether a fix is safe. The `sqlclean` command treats the `SQLCLEAN001` fix as unsafe, because turning `= NULL` into `IS NULL` changes which rows match, and every other built-in fix as safe. A host that applies fixes makes the same decision. See [Safe and unsafe fixes](/docs/rules/overview/#safe-and-unsafe-fixes). diff --git a/website/src/content/docs/docs/formatting.mdx b/website/src/content/docs/docs/formatting.mdx index d45b31b..72f0316 100644 --- a/website/src/content/docs/docs/formatting.mdx +++ b/website/src/content/docs/docs/formatting.mdx @@ -183,4 +183,4 @@ j.sql: could not format: Parse error at line 1, column 16: Expected Colon, got R other file as usual. Fix the SQL, set the file's dialect with [`[[dialects]]`](/docs/configuration/#dialects), or [`exclude`](/docs/configuration/#choosing-files) the file. `sqlclean check` reports the same file as -[`SQC999`](/docs/rules/overview/#parse-errors-sqc999). +[`SQLCLEAN999`](/docs/rules/overview/#parse-errors-sqlclean999). diff --git a/website/src/content/docs/docs/index.mdx b/website/src/content/docs/docs/index.mdx index c824b63..280f32d 100644 --- a/website/src/content/docs/docs/index.mdx +++ b/website/src/content/docs/docs/index.mdx @@ -16,7 +16,7 @@ sqlclean check ``` ```text -error[SQC001]: Comparison with NULL is never true +error[SQLCLEAN001]: Comparison with NULL is never true --> models/open_orders.sql:1:77 | 1 | with open_orders as (select order_id, amount from orders where cancelled_at = null), @@ -24,7 +24,7 @@ error[SQC001]: Comparison with NULL is never true = help: Use IS NULL or IS NOT NULL when testing for NULL. = fix: available with `sqlclean fix --unsafe-fixes` -error[SQC005]: CTE is unreachable from the final query +error[SQLCLEAN005]: CTE is unreachable from the final query --> models/open_orders.sql:2:1 | 2 | refunds as (select order_id from payments where amount < 0) diff --git a/website/src/content/docs/docs/quickstart.mdx b/website/src/content/docs/docs/quickstart.mdx index 973920c..ecf15da 100644 --- a/website/src/content/docs/docs/quickstart.mdx +++ b/website/src/content/docs/docs/quickstart.mdx @@ -37,7 +37,7 @@ sqlclean check ``` ```text -error[SQC001]: Comparison with NULL is never true +error[SQLCLEAN001]: Comparison with NULL is never true --> models/open_orders.sql:1:90 | 1 | with open_orders as (select order_id, customer_id, amount from orders where cancelled_at = null), @@ -45,7 +45,7 @@ error[SQC001]: Comparison with NULL is never true = help: Use IS NULL or IS NOT NULL when testing for NULL. = fix: available with `sqlclean fix --unsafe-fixes` -error[SQC005]: CTE is unreachable from the final query +error[SQLCLEAN005]: CTE is unreachable from the final query --> models/open_orders.sql:2:1 | 2 | refunds as (select order_id from payments where amount < 0) @@ -53,7 +53,7 @@ error[SQC005]: CTE is unreachable from the final query = help: Reference the CTE from the final query or another reachable CTE, or remove it. = fix: available with `sqlclean fix` -error[SQC004]: Row selection is nondeterministic +error[SQLCLEAN004]: Row selection is nondeterministic --> models/open_orders.sql:3:55 | 3 | select order_id, customer_id, amount from open_orders limit 100 @@ -77,7 +77,7 @@ sqlclean fix ```text Fixed 1 problem(s) and changed 1 file(s). -error[SQC001]: Comparison with NULL is never true +error[SQLCLEAN001]: Comparison with NULL is never true --> models/open_orders.sql:7:24 | 7 | WHERE cancelled_at = NULL @@ -85,7 +85,7 @@ error[SQC001]: Comparison with NULL is never true = help: Use IS NULL or IS NOT NULL when testing for NULL. = fix: available with `sqlclean fix --unsafe-fixes` -error[SQC004]: Row selection is nondeterministic +error[SQLCLEAN004]: Row selection is nondeterministic --> models/open_orders.sql:15:1 | 15 | LIMIT 100 @@ -103,7 +103,7 @@ it: sqlclean fix --unsafe-fixes ``` -`SQC004` has no fix: only you know which column makes the order deterministic. Add +`SQLCLEAN004` has no fix: only you know which column makes the order deterministic. Add `ORDER BY order_id` before `LIMIT 100`, then run `sqlclean fix` once more to format the new clause. The file is now: @@ -157,28 +157,28 @@ every built-in Rule: dialect = "duckdb" [rules] -select = ["SQC"] +select = ["SQLCLEAN"] ``` -`SQC` is a prefix, so it selects all 53 built-in Rules. Three of the opt-in structure Rules now +`SQLCLEAN` is a prefix, so it selects all 53 built-in Rules. Three of the opt-in structure Rules now report the file: ```text -error[SQC041]: Only the last top-level CTE may be named final, and it must use that name +error[SQLCLEAN041]: Only the last top-level CTE may be named final, and it must use that name --> models/open_orders.sql:1:6 | 1 | WITH open_orders AS ( | ^^^^^^^^^^^ - = help: Name the last top-level CTE final and rename any other final CTE; SQC035 checks the terminal SELECT. + = help: Name the last top-level CTE final and rename any other final CTE; SQLCLEAN035 checks the terminal SELECT. -error[SQC034]: Query keeps transformation logic outside top-level CTEs +error[SQLCLEAN034]: Query keeps transformation logic outside top-level CTEs --> models/open_orders.sql:10:1 | 10 | SELECT | ^^^^^^ = help: Move transformation logic into named top-level CTEs before the terminal SELECT. -error[SQC035]: Terminal SELECT does not read plainly from the final top-level CTE +error[SQLCLEAN035]: Terminal SELECT does not read plainly from the final top-level CTE --> models/open_orders.sql:10:1 | 10 | SELECT @@ -194,8 +194,8 @@ Follow them, or skip the ones your team does not want: dialect = "duckdb" [rules] -select = ["SQC"] -ignore = ["SQC034", "SQC035", "SQC041"] +select = ["SQLCLEAN"] +ignore = ["SQLCLEAN034", "SQLCLEAN035", "SQLCLEAN041"] ``` ```text diff --git a/website/src/content/docs/docs/rules/overview.mdx b/website/src/content/docs/docs/rules/overview.mdx index 5dd2b7e..5669b51 100644 --- a/website/src/content/docs/docs/rules/overview.mdx +++ b/website/src/content/docs/docs/rules/overview.mdx @@ -7,8 +7,8 @@ import Card from '../../../../components/docs/Card.astro'; import CardGroup from '../../../../components/docs/CardGroup.astro'; A Rule is one check over SQL. It reports a finding at an exact position, with a message, a -remediation, and sometimes a fix. SQLClean has 53 built-in Rules with `SQC###` codes, and runs -[custom Rules](/docs/custom-rules/overview/) written in Python with `XSQC` codes. +remediation, and sometimes a fix. SQLClean has 53 built-in Rules with `SQLCLEAN###` codes, and runs +[custom Rules](/docs/custom-rules/overview/) written in Python with `XSQLCLEAN` codes. Consider a query that returns an arbitrary hundred rows: @@ -23,7 +23,7 @@ sqlclean check ``` ```text -error[SQC004]: Row selection is nondeterministic +error[SQLCLEAN004]: Row selection is nondeterministic --> models/recent_orders.sql:3:1 | 3 | LIMIT 100 @@ -50,26 +50,26 @@ Select Rules in `sqlclean.toml` by exact code or by prefix: ```toml [rules] -select = ["SQC"] # every built-in Rule -ignore = ["SQC027", "SQC03"] # except SQC027 and SQC030 to SQC039 +select = ["SQLCLEAN"] # every built-in Rule +ignore = ["SQLCLEAN027", "SQLCLEAN03"] # except SQLCLEAN027 and SQLCLEAN030 to SQLCLEAN039 ``` -- An exact code such as `SQC004` selects one Rule. -- A prefix selects every Rule whose code starts with it: `SQC` selects all built-in Rules, - `SQC00` selects `SQC001` to `SQC009`, and `XSQCNAME` selects every custom Rule in the `NAME` +- An exact code such as `SQLCLEAN004` selects one Rule. +- A prefix selects every Rule whose code starts with it: `SQLCLEAN` selects all built-in Rules, + `SQLCLEAN00` selects `SQLCLEAN001` to `SQLCLEAN009`, and `XSQLCLEANNAME` selects every custom Rule in the `NAME` family. - `ignore` removes Rules from the selection, by code or prefix. -- A selector that matches no Rule is an error: `Rule selector 'SQC9' matches no Rule`. +- A selector that matches no Rule is an error: `Rule selector 'SQLCLEAN9' matches no Rule`. On the command line, `--select` replaces `[rules] select` for one run, and `--ignore` adds to `[rules] ignore`: ```bash -sqlclean check --select SQC001,SQC004 -sqlclean check --ignore SQC039 +sqlclean check --select SQLCLEAN001,SQLCLEAN004 +sqlclean check --ignore SQLCLEAN039 ``` -`select = []` runs no Rules. Files are still parsed, so [parse errors](#parse-errors-sqc999) are +`select = []` runs no Rules. Files are still parsed, so [parse errors](#parse-errors-sqlclean999) are still reported. ## Default Rules @@ -80,19 +80,19 @@ duplicate names and unused CTEs. | Code | Finding | |------|---------| -| `SQC001` | Comparison with NULL is never true | -| `SQC002` | Comma-separated sources create an implicit cartesian join | -| `SQC003` | Non-cross join has no meaningful condition | -| `SQC004` | Row selection is nondeterministic | -| `SQC005` | CTE is unreachable from the final query | -| `SQC006` | DISTINCT is redundant with the grouped output | -| `SQC007` | Positional set operation is vulnerable to column-order drift | -| `SQC009` | Table alias is duplicated in the same query scope | -| `SQC010` | Output alias is duplicated in the same select list | -| `SQC018` | ROW_NUMBER has no deterministic ordering | -| `SQC019` | NOT IN list contains NULL and can reject every row | -| `SQC020` | Set-operation branches project different column counts | -| `SQC039` | Inline query relation obscures data flow | +| `SQLCLEAN001` | Comparison with NULL is never true | +| `SQLCLEAN002` | Comma-separated sources create an implicit cartesian join | +| `SQLCLEAN003` | Non-cross join has no meaningful condition | +| `SQLCLEAN004` | Row selection is nondeterministic | +| `SQLCLEAN005` | CTE is unreachable from the final query | +| `SQLCLEAN006` | DISTINCT is redundant with the grouped output | +| `SQLCLEAN007` | Positional set operation is vulnerable to column-order drift | +| `SQLCLEAN009` | Table alias is duplicated in the same query scope | +| `SQLCLEAN010` | Output alias is duplicated in the same select list | +| `SQLCLEAN018` | ROW_NUMBER has no deterministic ordering | +| `SQLCLEAN019` | NOT IN list contains NULL and can reject every row | +| `SQLCLEAN020` | Set-operation branches project different column counts | +| `SQLCLEAN039` | Inline query relation obscures data flow | The other 32 Rules run only when `select` names them, by code or prefix. A prefix selects every matching Rule, whether it is enabled by default or not. A custom Rule is enabled by default when its @@ -104,8 +104,8 @@ Two built-in Rules compare against a limit, set under `[rules.thresholds]`: ```toml [rules.thresholds] -max_literal_length = 100 # SQC044: the longest string literal allowed -max_ranking_order_by = 5 # SQC043: the most ORDER BY expressions in a ranking window +max_literal_length = 100 # SQLCLEAN044: the longest string literal allowed +max_ranking_order_by = 5 # SQLCLEAN043: the most ORDER BY expressions in a ranking window ``` Both default to the values shown and must be positive. Built-in Rules take no other options. @@ -117,7 +117,7 @@ so a reviewer sees it in the diff, and it needs a reason: ```toml [[rules.rule_exceptions]] -rule = "SQC004" +rule = "SQLCLEAN004" path = "models/order_preview.sql" reason = "A preview of any hundred orders is enough." ``` @@ -125,23 +125,23 @@ reason = "A preview of any hundred orders is enough." `rule` is an exact code, not a prefix. `path` is the file's path relative to the configuration root, as findings print it. The exception covers every finding of that Rule in that file. -SQLClean has no inline suppression comments. A comment such as `-- noqa: SQC001` changes nothing; +SQLClean has no inline suppression comments. A comment such as `-- noqa: SQLCLEAN001` changes nothing; record the exception in `sqlclean.toml` instead. ### Cross join exceptions A file-wide exception for a deliberate `CROSS JOIN` would also hide any accidental cross join added -to the file later. `SQC038` findings name the joined relation and, inside a CTE, that CTE, so an +to the file later. `SQLCLEAN038` findings name the joined relation and, inside a CTE, that CTE, so an exception can cover exactly one join: ```text -error[SQC038]: CROSS JOIN with `regions` in CTE `pairs` creates an explicit cartesian product +error[SQLCLEAN038]: CROSS JOIN with `regions` in CTE `pairs` creates an explicit cartesian product --> models/calendar.sql:11:3 ``` ```toml [[rules.rule_exceptions]] -rule = "SQC038" +rule = "SQLCLEAN038" path = "models/calendar.sql" relation = "regions" cte = "pairs" # optional: only the join inside this CTE @@ -149,10 +149,10 @@ reason = "Every order date is reported for every region." ``` `relation` and `cte` compare without case. Without `cte`, the exception covers joins with that -relation anywhere in the file. `cte` needs `relation`, and `relation` matches only `SQC038` +relation anywhere in the file. `cte` needs `relation`, and `relation` matches only `SQLCLEAN038` findings. -`SQC038` does not report a cross join when one side is provably at most one row, such as a CTE +`SQLCLEAN038` does not report a cross join when one side is provably at most one row, such as a CTE that aggregates without `GROUP BY`, `LIMIT 1` or a one-row `VALUES`, so many intentional cross joins need no exception: @@ -174,11 +174,11 @@ CROSS JOIN latest AS l An exception that matches no finding is stale. `check` and `fix` report it and exit with `1`: ```text -error: stale rule exception: SQC004 in models/orders.sql matches no finding; remove it from sqlclean.toml +error: stale rule exception: SQLCLEAN004 in models/orders.sql matches no finding; remove it from sqlclean.toml ``` Delete the entry. Exceptions are only judged stale when their file was checked and their Rule was -selected, so `sqlclean check models/customers.sql` or `--select SQC001` never reports an exception +selected, so `sqlclean check models/customers.sql` or `--select SQLCLEAN001` never reports an exception for another file or Rule. ## Path ignores @@ -188,7 +188,7 @@ prefixes, glob patterns, and a reason: ```toml [[rules.rule_ignores]] -rules = ["SQC034", "SQC035"] +rules = ["SQLCLEAN034", "SQLCLEAN035"] paths = ["scratch/**"] reason = "Exploratory queries." ``` @@ -196,13 +196,13 @@ reason = "Exploratory queries." Patterns are relative to the configuration root, as in [`include`](/docs/configuration/#choosing-files). To turn a Rule off everywhere, add it to `[rules] ignore` instead. -## Parse errors (SQC999) +## Parse errors (SQLCLEAN999) -A file that does not parse under its [dialect](/docs/configuration/#dialects) gets one `SQC999` +A file that does not parse under its [dialect](/docs/configuration/#dialects) gets one `SQLCLEAN999` finding and no other: ```text -error[SQC999]: SQL could not be parsed as duckdb: Parse error at line 1, column 18: Expected table name or subquery, got Where +error[SQLCLEAN999]: SQL could not be parsed as duckdb: Parse error at line 1, column 18: Expected table name or subquery, got Where --> scratch/broken.sql:1:1 | 1 | SELECT FROM WHERE orders @@ -211,7 +211,7 @@ error[SQC999]: SQL could not be parsed as duckdb: Parse error at line 1, column ``` The finding points at the start of the file; the message gives the line and column of the error. -`SQC999` is not a selectable Rule: selection, `ignore`, path ignores and exceptions never hide it. +`SQLCLEAN999` is not a selectable Rule: selection, `ignore`, path ignores and exceptions never hide it. Fix the SQL, set the right dialect for the file, or [`exclude`](/docs/configuration/#choosing-files) the file. @@ -219,9 +219,9 @@ the file. Some Rules carry a fix. [`sqlclean fix`](/docs/cli/fix/) applies it. -- A **safe** fix keeps the query's results. `SQC005` removes an unused CTE; `SQC002` turns +- A **safe** fix keeps the query's results. `SQLCLEAN005` removes an unused CTE; `SQLCLEAN002` turns `FROM orders, customers` into `FROM orders CROSS JOIN customers`. -- An **unsafe** fix can change results. `SQC001` turns `cancelled_at = NULL`, which matches no row, +- An **unsafe** fix can change results. `SQLCLEAN001` turns `cancelled_at = NULL`, which matches no row, into `cancelled_at IS NULL`, which matches rows. That is almost always the intent, but it is a different query. Unsafe fixes apply only with `--unsafe-fixes`. diff --git a/website/src/content/docs/docs/rules/reference.mdx b/website/src/content/docs/docs/rules/reference.mdx index 103091c..97149a1 100644 --- a/website/src/content/docs/docs/rules/reference.mdx +++ b/website/src/content/docs/docs/rules/reference.mdx @@ -13,67 +13,67 @@ Run `sqlclean rules ` for a Rule's remediation. | Code | Finding | Default | Fix | |------|---------|---------|-----| -| `SQC001` | Comparison with NULL is never true | Yes | Unsafe | -| `SQC002` | Comma-separated sources create an implicit cartesian join | Yes | Safe | -| `SQC003` | Non-cross join has no meaningful condition | Yes | Safe | -| `SQC004` | Row selection is nondeterministic | Yes | — | -| `SQC005` | CTE is unreachable from the final query | Yes | Safe | -| `SQC006` | DISTINCT is redundant with the grouped output | Yes | Safe | -| `SQC007` | Positional set operation is vulnerable to column-order drift | Yes | — | -| `SQC008` | Bare UNION leaves duplicate handling implicit | No | Safe | -| `SQC009` | Table alias is duplicated in the same query scope | Yes | — | -| `SQC010` | Output alias is duplicated in the same select list | Yes | — | -| `SQC011` | Clause mixes positional and named references | No | — | -| `SQC012` | ELSE NULL is redundant | No | — | -| `SQC013` | DISTINCT is written as though it were a function | No | Safe | -| `SQC014` | Predicate compares identical constant values | No | — | -| `SQC015` | Empty statement follows another terminator | No | — | -| `SQC016` | Expression is aliased to its existing name | No | — | -| `SQC017` | Row count uses a constant expression | No | Safe | -| `SQC018` | ROW_NUMBER has no deterministic ordering | Yes | — | -| `SQC019` | NOT IN list contains NULL and can reject every row | Yes | — | -| `SQC020` | Set-operation branches project different column counts | Yes | — | -| `SQC021` | Query output has an uncontrolled column shape | No | — | -| `SQC022` | Calculated projection has no stable output name | No | — | -| `SQC023` | Table alias is never referenced | No | — | -| `SQC024` | WHERE predicate rejects NULL rows from a LEFT JOIN | No | — | -| `SQC025` | JOIN type is implicit | No | Safe | -| `SQC026` | ORDER BY mixes explicit and implicit directions | No | — | -| `SQC027` | Column reference is unqualified in a multi-relation query | No | — | -| `SQC028` | Single-relation query mixes qualified and unqualified columns | No | — | -| `SQC029` | Qualified reference uses a relation absent from this query scope | No | — | -| `SQC030` | CASE expression only converts a condition to TRUE or FALSE | No | Safe | -| `SQC031` | CASE expression is nested directly inside ELSE | No | — | -| `SQC032` | Joined relation contributes no referenced values | No | — | -| `SQC033` | Comment is not attached to the syntax node it explains | No | — | -| `SQC034` | Query keeps transformation logic outside top-level CTEs | No | — | -| `SQC035` | Terminal SELECT does not read plainly from the final top-level CTE | No | — | -| `SQC036` | CTE is nested inside another query | No | — | -| `SQC037` | Recursive CTE is not permitted | No | — | -| `SQC038` | CROSS JOIN creates an explicit cartesian product | No | — | -| `SQC039` | Inline query relation obscures data flow | Yes | — | -| `SQC040` | JOIN ON contains a computed expression | No | — | -| `SQC041` | Only the last top-level CTE may be named final, and it must use that name | No | — | -| `SQC042` | CTE output column is never read later in the query | No | — | -| `SQC043` | Ranking window sorts by more expressions than max_ranking_order_by | No | — | -| `SQC044` | String literal is longer than max_literal_length | No | — | -| `SQC045` | JSON payload is parsed more than once in one SELECT | No | — | -| `SQC046` | WITH defines the same CTE name more than once | Yes | — | -| `SQC047` | CASE WHEN NULL or IN (NULL) never matches | Yes | — | -| `SQC048` | Expression divides by a literal zero | Yes | — | -| `SQC049` | UPDATE or DELETE has no limiting predicate and changes every row | No | — | -| `SQC050` | INSERT does not list its target columns | No | — | -| `SQC051` | NATURAL JOIN matches on every column the relations share | Yes | — | -| `SQC052` | BETWEEN lower bound is greater than its upper bound | Yes | — | -| `SQC053` | Aggregate or window function is used in WHERE, ON or GROUP BY | Yes | — | - -`SQC999` is the [parse error](/docs/rules/overview/#parse-errors-sqc999) finding. It is not a +| `SQLCLEAN001` | Comparison with NULL is never true | Yes | Unsafe | +| `SQLCLEAN002` | Comma-separated sources create an implicit cartesian join | Yes | Safe | +| `SQLCLEAN003` | Non-cross join has no meaningful condition | Yes | Safe | +| `SQLCLEAN004` | Row selection is nondeterministic | Yes | — | +| `SQLCLEAN005` | CTE is unreachable from the final query | Yes | Safe | +| `SQLCLEAN006` | DISTINCT is redundant with the grouped output | Yes | Safe | +| `SQLCLEAN007` | Positional set operation is vulnerable to column-order drift | Yes | — | +| `SQLCLEAN008` | Bare UNION leaves duplicate handling implicit | No | Safe | +| `SQLCLEAN009` | Table alias is duplicated in the same query scope | Yes | — | +| `SQLCLEAN010` | Output alias is duplicated in the same select list | Yes | — | +| `SQLCLEAN011` | Clause mixes positional and named references | No | — | +| `SQLCLEAN012` | ELSE NULL is redundant | No | — | +| `SQLCLEAN013` | DISTINCT is written as though it were a function | No | Safe | +| `SQLCLEAN014` | Predicate compares identical constant values | No | — | +| `SQLCLEAN015` | Empty statement follows another terminator | No | — | +| `SQLCLEAN016` | Expression is aliased to its existing name | No | — | +| `SQLCLEAN017` | Row count uses a constant expression | No | Safe | +| `SQLCLEAN018` | ROW_NUMBER has no deterministic ordering | Yes | — | +| `SQLCLEAN019` | NOT IN list contains NULL and can reject every row | Yes | — | +| `SQLCLEAN020` | Set-operation branches project different column counts | Yes | — | +| `SQLCLEAN021` | Query output has an uncontrolled column shape | No | — | +| `SQLCLEAN022` | Calculated projection has no stable output name | No | — | +| `SQLCLEAN023` | Table alias is never referenced | No | — | +| `SQLCLEAN024` | WHERE predicate rejects NULL rows from a LEFT JOIN | No | — | +| `SQLCLEAN025` | JOIN type is implicit | No | Safe | +| `SQLCLEAN026` | ORDER BY mixes explicit and implicit directions | No | — | +| `SQLCLEAN027` | Column reference is unqualified in a multi-relation query | No | — | +| `SQLCLEAN028` | Single-relation query mixes qualified and unqualified columns | No | — | +| `SQLCLEAN029` | Qualified reference uses a relation absent from this query scope | No | — | +| `SQLCLEAN030` | CASE expression only converts a condition to TRUE or FALSE | No | Safe | +| `SQLCLEAN031` | CASE expression is nested directly inside ELSE | No | — | +| `SQLCLEAN032` | Joined relation contributes no referenced values | No | — | +| `SQLCLEAN033` | Comment is not attached to the syntax node it explains | No | — | +| `SQLCLEAN034` | Query keeps transformation logic outside top-level CTEs | No | — | +| `SQLCLEAN035` | Terminal SELECT does not read plainly from the final top-level CTE | No | — | +| `SQLCLEAN036` | CTE is nested inside another query | No | — | +| `SQLCLEAN037` | Recursive CTE is not permitted | No | — | +| `SQLCLEAN038` | CROSS JOIN creates an explicit cartesian product | No | — | +| `SQLCLEAN039` | Inline query relation obscures data flow | Yes | — | +| `SQLCLEAN040` | JOIN ON contains a computed expression | No | — | +| `SQLCLEAN041` | Only the last top-level CTE may be named final, and it must use that name | No | — | +| `SQLCLEAN042` | CTE output column is never read later in the query | No | — | +| `SQLCLEAN043` | Ranking window sorts by more expressions than max_ranking_order_by | No | — | +| `SQLCLEAN044` | String literal is longer than max_literal_length | No | — | +| `SQLCLEAN045` | JSON payload is parsed more than once in one SELECT | No | — | +| `SQLCLEAN046` | WITH defines the same CTE name more than once | Yes | — | +| `SQLCLEAN047` | CASE WHEN NULL or IN (NULL) never matches | Yes | — | +| `SQLCLEAN048` | Expression divides by a literal zero | Yes | — | +| `SQLCLEAN049` | UPDATE or DELETE has no limiting predicate and changes every row | No | — | +| `SQLCLEAN050` | INSERT does not list its target columns | No | — | +| `SQLCLEAN051` | NATURAL JOIN matches on every column the relations share | Yes | — | +| `SQLCLEAN052` | BETWEEN lower bound is greater than its upper bound | Yes | — | +| `SQLCLEAN053` | Aggregate or window function is used in WHERE, ON or GROUP BY | Yes | — | + +`SQLCLEAN999` is the [parse error](/docs/rules/overview/#parse-errors-sqlclean999) finding. It is not a selectable Rule. -`SQC043` and `SQC044` read their limits from +`SQLCLEAN043` and `SQLCLEAN044` read their limits from [`[rules.thresholds]`](/docs/rules/overview/#thresholds). -## SQC001: NULL comparisons +## SQLCLEAN001: NULL comparisons `= NULL` and `<> NULL` are never true, so the filter matches no row: @@ -84,7 +84,7 @@ WHERE cancelled_at = NULL ``` ```text -error[SQC001]: Comparison with NULL is never true +error[SQLCLEAN001]: Comparison with NULL is never true --> models/open_orders.sql:3:20 | 3 | WHERE cancelled_at = NULL @@ -96,7 +96,7 @@ error[SQC001]: Comparison with NULL is never true The fix writes `WHERE cancelled_at IS NULL`. It is unsafe because the query then returns rows it did not return before. -## SQC002 and SQC003: joins without keys +## SQLCLEAN002 and SQLCLEAN003: joins without keys A comma between sources joins every row with every row unless a `WHERE` predicate filters them: @@ -108,7 +108,7 @@ FROM orders AS o, customers AS c WHERE o.customer_id = c.customer_id ``` -The `SQC002` fix keeps the meaning and makes the cartesian product visible: +The `SQLCLEAN002` fix keeps the meaning and makes the cartesian product visible: ```diff -FROM orders AS o, customers AS c @@ -118,10 +118,10 @@ The `SQC002` fix keeps the meaning and makes the cartesian product visible: ``` Move the predicate into `JOIN customers AS c ON o.customer_id = c.customer_id` by hand to make it a -keyed join. `SQC003` reports a `JOIN` whose condition is missing or constant, such as +keyed join. `SQLCLEAN003` reports a `JOIN` whose condition is missing or constant, such as `ON 1 = 1`; its fix turns a `JOIN` without a condition into `CROSS JOIN`. -## SQC005: unused CTEs +## SQLCLEAN005: unused CTEs A CTE that no reachable query reads is dead code: @@ -138,7 +138,7 @@ SELECT order_id FROM paid ``` ```text -error[SQC005]: CTE is unreachable from the final query +error[SQLCLEAN005]: CTE is unreachable from the final query --> models/paid_orders.sql:5:1 | 5 | refunded AS ( @@ -149,7 +149,7 @@ error[SQC005]: CTE is unreachable from the final query The fix removes `refunded`. -## SQC018: unstable row numbers +## SQLCLEAN018: unstable row numbers Without `ORDER BY` in the window, the row numbered `1` can change between runs: @@ -161,7 +161,7 @@ FROM orders ``` ```text -error[SQC018]: ROW_NUMBER has no deterministic ordering +error[SQLCLEAN018]: ROW_NUMBER has no deterministic ordering ``` An `ORDER BY` is not always enough. Ties still rank in any order unless `PARTITION BY` and @@ -170,7 +170,7 @@ from the query, such as the `GROUP BY` columns of a CTE. Ranking straight over a with a second message: ```text -error[SQC018]: Ranking window order is not proven unique within its partition +error[SQLCLEAN018]: Ranking window order is not proven unique within its partition --> models/ranked.sql:3:3 | 3 | ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY ordered_at, order_id) AS order_rank @@ -198,10 +198,10 @@ SELECT FROM customer_orders ``` -`SQC004` reports the same problem for `LIMIT` and `OFFSET`; see the example in +`SQLCLEAN004` reports the same problem for `LIMIT` and `OFFSET`; see the example in [Rules](/docs/rules/overview/). -## SQC019: NULL in NOT IN +## SQLCLEAN019: NULL in NOT IN `x NOT IN (..., NULL)` is never true, so the filter rejects every row: @@ -212,7 +212,7 @@ WHERE status NOT IN ('cancelled', NULL) ``` ```text -error[SQC019]: NOT IN list contains NULL and can reject every row +error[SQLCLEAN019]: NOT IN list contains NULL and can reject every row --> models/active_orders.sql:3:14 | 3 | WHERE status NOT IN ('cancelled', NULL) @@ -220,49 +220,49 @@ error[SQC019]: NOT IN list contains NULL and can reject every row = help: Remove NULL from the list or express the anti-join with a NULL-safe NOT EXISTS. ``` -## SQC046 to SQC053: queries that cannot do what they say +## SQLCLEAN046 to SQLCLEAN053: queries that cannot do what they say These Rules report a query that runs differently from how it reads. None of them has a fix, because only the author knows the intended query. ```sql WITH paid AS ( - SELECT order_id, amount FROM orders WHERE amount BETWEEN 100 AND 10 -- SQC052 + SELECT order_id, amount FROM orders WHERE amount BETWEEN 100 AND 10 -- SQLCLEAN052 ), -paid AS ( -- SQC046 - SELECT order_id, amount / 0 AS ratio FROM orders -- SQC048 +paid AS ( -- SQLCLEAN046 + SELECT order_id, amount / 0 AS ratio FROM orders -- SQLCLEAN048 ) SELECT customer_id, - CASE region WHEN NULL THEN 'unknown' END AS region_label -- SQC047 + CASE region WHEN NULL THEN 'unknown' END AS region_label -- SQLCLEAN047 FROM paid -NATURAL JOIN customers -- SQC051 -WHERE SUM(amount) > 100 -- SQC053 +NATURAL JOIN customers -- SQLCLEAN051 +WHERE SUM(amount) > 100 -- SQLCLEAN053 GROUP BY customer_id ``` -- `SQC046` compares CTE names within one `WITH` the way the dialect resolves them, so quoted +- `SQLCLEAN046` compares CTE names within one `WITH` the way the dialect resolves them, so quoted `"paid"` and `"PAID"` are different names in PostgreSQL but the same name in DuckDB. A nested `WITH` may reuse an outer name. -- `SQC047` reports `CASE x WHEN NULL` and `x IN (NULL)`, which compare with `=` and so never - match. `NOT IN` lists with NULL are `SQC019`. -- `SQC048` reports `/`, `%`, `//` and `MOD` with a literal zero divisor. Depending on the dialect +- `SQLCLEAN047` reports `CASE x WHEN NULL` and `x IN (NULL)`, which compare with `=` and so never + match. `NOT IN` lists with NULL are `SQLCLEAN019`. +- `SQLCLEAN048` reports `/`, `%`, `//` and `MOD` with a literal zero divisor. Depending on the dialect the query fails or returns NULL. -- `SQC052` compares number and `DATE 'YYYY-MM-DD'` bounds exactly and skips `BETWEEN SYMMETRIC`. +- `SQLCLEAN052` compares number and `DATE 'YYYY-MM-DD'` bounds exactly and skips `BETWEEN SYMMETRIC`. A reversed `NOT BETWEEN` is reported too, because it matches every non-NULL value. -- `SQC053` reports aggregates in `WHERE`, `ON` and `GROUP BY`, and window functions anywhere in +- `SQLCLEAN053` reports aggregates in `WHERE`, `ON` and `GROUP BY`, and window functions anywhere in those clauses. Aggregates in `HAVING`, windows in `QUALIFY`, `FILTER (WHERE ...)` and subqueries pass. Two write-safety Rules are opt-in, because changing every row is sometimes intended: -- `SQC049` reports an `UPDATE` or `DELETE` whose rows nothing limits. A `WHERE` without a column +- `SQLCLEAN049` reports an `UPDATE` or `DELETE` whose rows nothing limits. A `WHERE` without a column or subquery, such as `WHERE TRUE`, does not count; a join condition or a `LIMIT` does. -- `SQC050` reports `INSERT` without a column list. `DEFAULT VALUES` and DuckDB's `BY NAME` pass. +- `SQLCLEAN050` reports `INSERT` without a column list. `DEFAULT VALUES` and DuckDB's `BY NAME` pass. -## SQC039: inline subqueries +## SQLCLEAN039: inline subqueries A subquery in `FROM` hides a step of the query. Name it as a CTE instead: @@ -282,7 +282,7 @@ SELECT order_id FROM paid ``` -## SQC034, SQC035 and SQC041: query structure +## SQLCLEAN034, SQLCLEAN035 and SQLCLEAN041: query structure These opt-in Rules ask every query to do its work in top-level CTEs, name the last one `final`, and end with a plain `SELECT` of named columns from it. This query passes every built-in Rule: @@ -309,4 +309,4 @@ SELECT FROM final ``` -Ending with `SELECT * FROM final` is reported by `SQC021`, which asks for named output columns. +Ending with `SELECT * FROM final` is reported by `SQLCLEAN021`, which asks for named output columns.