Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 23 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,18 +182,39 @@ physical line width in `sqlbuild_project.toml` (the default is `100`):
line_width = 100
```

Select rules in `sqlbuild_project.toml` by exact code or derived family prefix:
Prefer family selection in `sqlbuild_project.toml` over hand-maintained lists of exact codes:

```toml
[rules]
select = ["SQBRSQL", "XSQBRARCH"]
select = ["SQBRSQL", "SQBRMODEL", "SQBRGRAPH", "XSQBRARCH"]
ignore = ["SQBRSQL004"]
```

Built-in codes use `SQBR<FAMILY><three digits>`, such as `SQBRSQL001` and
`SQBRGRAPH101`. Custom codes use `XSQBR<optional family><three digits>`, such as
`XSQBRARCH001`. A family is always the code with its final three digits removed.

Family selectors automatically include new built-in rules on upgrade. Exact-code lists retain
their current membership and must be updated manually. In particular, selecting `SQBRSQL` enables
`SQBRSQL040` (plain JOIN predicates) and `SQBRSQL041` (terminal CTE naming); existing projects may
need to extract computed join keys into input CTEs and rename their last CTE to `final`.
See the [SQL rule conventions](docs/native-lint-rule-matrix.md#join-keys-and-final-cte-names)
for their exact scope and relationship to `SQBRSQL035`.

`sqb format` reports a file-specific `format-unsafe` fault whenever a SQL body cannot be safely
formatted, including parser, comment-attachment, interpolation-restoration, and idempotence
failures. The entire original file is retained (including its headers and fixtures), the reason
appears in human and JSON output, and both
formatting and `sqb format --check` exit nonzero. A declined body is never counted as canonical.

Formatting preserves authored cast types, postfix casts, quoted literals, variant paths, typed
lambda parameters, and supported SQL function spellings while applying canonical layout. It uses
the compiler's trusted-SQL function-depth budget and does not impose the separate browser-oriented
UNION-chain limit from Polyglot's convenience formatting API. SQLBuild calls retain their authored
spelling, including zero-argument cursor and empty-fixture intrinsics. CTE-producing macros remain
authored calls rather than expanded project SQL, with each call on its own CTE-list line and
leading comments attached to the node they describe.

Custom rules are ordinary Python beneath `rules/**/*.py`. Only `@rule` functions register; helper
functions, constants, dataclasses, classes, and nested packages remain ordinary Python. Typed,
keyword-only parameters determine whether a rule runs once per model or once per project:
Expand Down
39 changes: 36 additions & 3 deletions crates/sqlbuild-rules-native/src/sql_lint/_helpers/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,16 @@ const INLINE_QUERY_RELATION: LintRuleMetadata = LintRuleMetadata {
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: "SQBRSQL040",
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: "SQBRSQL041",
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; SQBRSQL035 checks the terminal SELECT.",
};

const DEFAULT_RULES: [&str; 13] = [
NULL_COMPARISON.code,
Expand All @@ -246,7 +256,7 @@ const DEFAULT_RULES: [&str; 13] = [
INLINE_QUERY_RELATION.code,
];

const ALL_RULE_METADATA: [&LintRuleMetadata; 39] = [
const ALL_RULE_METADATA: [&LintRuleMetadata; 41] = [
&NULL_COMPARISON,
&IMPLICIT_CARTESIAN_JOIN,
&JOIN_WITHOUT_CONDITION,
Expand Down Expand Up @@ -286,6 +296,8 @@ const ALL_RULE_METADATA: [&LintRuleMetadata; 39] = [
&RECURSIVE_CTE,
&CROSS_JOIN,
&INLINE_QUERY_RELATION,
&JOIN_EXPRESSION,
&FINAL_CTE_NAME,
];

fn is_ceremonial_cte_name(name: &str) -> bool {
Expand Down Expand Up @@ -395,6 +407,25 @@ pub(crate) fn lint(request: LintRequest) -> Result<LintResponse, String> {
enabled: &enabled,
};
let mut diagnostics = diagnostics(&context);
let depths = token_depths(&tokens);
if enabled.contains(JOIN_EXPRESSION.code) {
diagnostics.extend(diagnostics_for_spans(
&JOIN_EXPRESSION,
&crate::sql_lint::_helpers::join_predicates::join_expression_spans(
&tokens,
&depths,
dialect_type,
),
Some("computing a join key requires an authored CTE rewrite"),
));
}
if enabled.contains(FINAL_CTE_NAME.code) && !request.allows_ceremonial_select {
diagnostics.extend(diagnostics_for_spans(
&FINAL_CTE_NAME,
&crate::sql_lint::_helpers::terminal_shape::final_cte_name_spans(&tokens, &depths),
Some("renaming a CTE requires updating its references"),
));
}
diagnostics.sort_by_key(|diagnostic| (diagnostic.start, diagnostic.end, diagnostic.code));
Ok(LintResponse {
version: LINT_API_VERSION,
Expand Down Expand Up @@ -508,6 +539,8 @@ fn collect_rule_migration_facts(
.filter(|&index| !is_layout(&tokens[index]) && !is_comment(&tokens[index]))
.collect();

let root_with = crate::sql_lint::_helpers::terminal_shape::root_with_indices(tokens, depths);

for (position, &index) in significant.iter().enumerate() {
let text = tokens[index].text.as_str();
if text.eq_ignore_ascii_case("cross")
Expand All @@ -517,11 +550,11 @@ fn collect_rule_migration_facts(
{
facts.cross_joins.push(tokens[index].span);
}
if text.eq_ignore_ascii_case("with") {
if tokens[index].token_type == TokenType::With {
let starts_hierarchy = position.checked_sub(1).is_some_and(|previous| {
tokens[significant[previous]].token_type == TokenType::Start
});
if depths[index] > 0 && !starts_hierarchy {
if !root_with.contains(&index) && !starts_hierarchy {
facts.nested_ctes.push(tokens[index].span);
}
if !starts_hierarchy
Expand Down
Loading
Loading