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
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ fn validate_raw(value: &toml::Value) -> Result<(), String> {
"rule_exceptions",
"graph_edge_exceptions",
"rule_ignores",
"allow_exceptions",
"select_star_allow",
"domains",
"approved_source_tokens",
Expand Down
2 changes: 2 additions & 0 deletions crates/sqlbuild-rules-native/src/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ pub(crate) struct RulesConfig {
pub rule_exceptions: Vec<RuleException>,
pub graph_edge_exceptions: Vec<GraphEdgeException>,
pub rule_ignores: Vec<RuleIgnore>,
pub allow_exceptions: bool,
pub select_star_allow: Vec<SelectStarAllow>,
pub domains: Vec<String>,
pub approved_source_tokens: Vec<String>,
Expand All @@ -165,6 +166,7 @@ impl Default for RulesConfig {
rule_exceptions: vec![],
graph_edge_exceptions: vec![],
rule_ignores: vec![],
allow_exceptions: true,
select_star_allow: vec![],
domains: vec![],
approved_source_tokens: vec![],
Expand Down
4 changes: 3 additions & 1 deletion crates/sqlbuild-rules-native/src/sql_lint/_helpers/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -257,7 +257,7 @@ const DEFAULT_RULES: [&str; 13] = [
INLINE_QUERY_RELATION.code,
];

const ALL_RULE_METADATA: [&LintRuleMetadata; 44] = [
const ALL_RULE_METADATA: [&LintRuleMetadata; 45] = [
&NULL_COMPARISON,
&IMPLICIT_CARTESIAN_JOIN,
&JOIN_WITHOUT_CONDITION,
Expand Down Expand Up @@ -302,6 +302,7 @@ const ALL_RULE_METADATA: [&LintRuleMetadata; 44] = [
&crate::sql_quality::constants::UNUSED_CTE_OUTPUT,
&crate::sql_quality::constants::RANKING_SORT_CAP,
&crate::sql_quality::constants::LONG_LITERAL,
&crate::sql_quality::constants::REPEATED_JSON_PARSE,
];

fn is_ceremonial_cte_name(name: &str) -> bool {
Expand Down Expand Up @@ -442,6 +443,7 @@ pub(crate) fn lint(request: LintRequest) -> Result<LintResponse, String> {
&crate::sql_quality::models::QualityRequest {
statements: &statements,
tokens: &tokens,
dialect: dialect_type,
enabled: &enabled,
max_literal_length: request
.max_literal_length
Expand Down
201 changes: 201 additions & 0 deletions crates/sqlbuild-rules-native/src/sql_quality/_helpers/json_parses.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
//! SQBRSQL045: the same JSON payload column parsed more than once in one SELECT scope.

use std::borrow::Cow;
use std::collections::HashMap;

use polyglot_sql::expressions::{Column, Identifier};
use polyglot_sql::scope::walk_in_scope;
use polyglot_sql::tokens::Token;
use polyglot_sql::{DialectType, Expression, ExpressionWalk};

use crate::sql_lint::models::LintDiagnostic;
use crate::sql_quality::constants::REPEATED_JSON_PARSE;
use crate::sql_quality::models::QualityRequest;
use crate::sql_quality::syntax::{is_trivia, lower};

/// Significant tokens searched backwards from the column for the function name.
const MAX_FUNCTION_NAME_DISTANCE: usize = 32;
const PARSE_ONCE_FIX_REFUSAL: &str =
"Parsing the payload once requires naming a column in the CTE that reads it";

/// An identifier normalised the way the dialect resolves it.
type IdentifierKey = String;

/// One JSON parse call whose argument resolves to a plain column.
struct ParseCall<'a> {
function: &'static str,
column: &'a Column,
column_start: usize,
}

pub(crate) fn diagnostics(request: &QualityRequest<'_>) -> Vec<LintDiagnostic> {
let functions: &[&'static str] = parse_functions(request.dialect);
if functions.is_empty() {
return Vec::new();
}
let mut found: Vec<LintDiagnostic> = Vec::new();
for statement in request.statements {
for node in statement.dfs() {
if matches!(node, Expression::Select(_)) {
found.extend(scope_diagnostics(
request.tokens,
node,
functions,
request.dialect,
));
}
}
}
found
}

/// JSON parse functions that take one string argument, by dialect, in upper case.
fn parse_functions(dialect: DialectType) -> &'static [&'static str] {
match dialect {
DialectType::Snowflake | DialectType::Databricks => &["PARSE_JSON", "TRY_PARSE_JSON"],
DialectType::BigQuery => &["PARSE_JSON", "SAFE.PARSE_JSON"],
DialectType::DuckDB => &["JSON"],
_ => &[],
}
}

fn scope_diagnostics(
tokens: &[Token],
select: &Expression,
functions: &[&'static str],
dialect: DialectType,
) -> Vec<LintDiagnostic> {
let quote: char = identifier_quote(dialect);
let mut calls: Vec<ParseCall<'_>> = walk_in_scope(select, false)
.filter_map(|node| parse_call(node, functions))
.collect();
calls.sort_by_key(|call| call.column_start);
let mut groups: HashMap<
(&'static str, Option<IdentifierKey>, IdentifierKey),
Vec<&ParseCall<'_>>,
> = HashMap::new();
for call in &calls {
let qualifier: Option<IdentifierKey> = call
.column
.table
.as_ref()
.map(|table| identifier_key(table, dialect));
groups
.entry((
call.function,
qualifier,
identifier_key(&call.column.name, dialect),
))
.or_default()
.push(call);
}
let mut found: Vec<LintDiagnostic> = Vec::new();
for group in groups.values().filter(|group| group.len() > 1) {
let second: &ParseCall<'_> = group[1];
let (start, end) = function_span(tokens, second);
found.push(LintDiagnostic {
code: REPEATED_JSON_PARSE.code,
message: REPEATED_JSON_PARSE.message,
remediation: Cow::Owned(remediation(second, group.len(), quote)),
start,
end,
fix: None,
fix_unavailable_reason: Some(PARSE_ONCE_FIX_REFUSAL),
});
}
found
}

/// Normalises a name the way the dialect resolves it (Snowflake folds unquoted names upward).
fn identifier_key(identifier: &Identifier, dialect: DialectType) -> IdentifierKey {
match dialect {
DialectType::Snowflake if identifier.quoted => identifier.name.clone(),
DialectType::Snowflake => identifier.name.to_uppercase(),
_ => lower(&identifier.name),
}
}

fn parse_call<'a>(node: &'a Expression, functions: &[&'static str]) -> Option<ParseCall<'a>> {
let (name, argument): (&str, &Expression) = match node {
Expression::ParseJson(function) => ("PARSE_JSON", &function.this),
Expression::Function(function) if function.args.len() == 1 => {
(function.name.as_str(), &function.args[0])
}
_ => return None,
};
let function: &'static str = functions
.iter()
.copied()
.find(|candidate| candidate.eq_ignore_ascii_case(name))?;
let column: &Column = unwrap_column(argument)?;
let column_start: usize = column.span.or(column.name.span)?.start;
Some(ParseCall {
function,
column,
column_start,
})
}

/// The column under wrappers that keep the parsed value tied to that column.
fn unwrap_column(expression: &Expression) -> Option<&Column> {
let mut current: &Expression = expression;
loop {
current = match current {
Expression::Column(column) => return Some(column),
Expression::Paren(paren) => &paren.this,
Expression::Lower(function) | Expression::Upper(function) => &function.this,
Expression::Trim(trim) if trim.characters.is_none() => &trim.this,
Expression::Cast(cast) | Expression::TryCast(cast) | Expression::SafeCast(cast) => {
&cast.this
}
Expression::Coalesce(coalesce) if coalesce.expressions.len() == 1 => {
&coalesce.expressions[0]
}
_ => return None,
};
}
}

/// Span of the function-name token that opens `call`, or of its column when not found.
fn function_span(tokens: &[Token], call: &ParseCall<'_>) -> (usize, usize) {
let bare: &str = call.function.rsplit('.').next().unwrap_or(call.function);
let before: usize = tokens.partition_point(|token| token.span.start < call.column_start);
tokens[..before]
.iter()
.rev()
.filter(|token| !is_trivia(token))
.take(MAX_FUNCTION_NAME_DISTANCE)
.find(|token| token.text.eq_ignore_ascii_case(bare))
.map_or((call.column_start, call.column_start), |token| {
(token.span.start, token.span.end)
})
}

fn remediation(call: &ParseCall<'_>, count: usize, quote: char) -> String {
let name: String = written(&call.column.name, quote);
let column: String = call.column.table.as_ref().map_or_else(
|| name.clone(),
|table| format!("{}.{name}", written(table, quote)),
);
let function: &str = call.function;
format!(
"{function}({column}) is parsed {count} times in one SELECT. Parse it once in the CTE that reads it, for example `{function}({name}) AS payload`, and read every field from that column in a later step."
)
}

/// The identifier as authored, re-quoted with the dialect's identifier quote when it was quoted.
fn written(identifier: &Identifier, quote: char) -> String {
if identifier.quoted {
let escaped: String = identifier.name.replace(quote, &format!("{quote}{quote}"));
format!("{quote}{escaped}{quote}")
} else {
identifier.name.clone()
}
}

fn identifier_quote(dialect: DialectType) -> char {
match dialect {
DialectType::BigQuery | DialectType::Databricks => '`',
_ => '"',
}
}
5 changes: 5 additions & 0 deletions crates/sqlbuild-rules-native/src/sql_quality/constants.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ pub(crate) const LONG_LITERAL: LintRuleMetadata = LintRuleMetadata {
message: "String literal is longer than max_literal_length",
remediation: "Define the value once as a constant and reference it with `@const`, or shorten it.",
};
pub(crate) const REPEATED_JSON_PARSE: LintRuleMetadata = LintRuleMetadata {
code: "SQBRSQL045",
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 UNSTABLE_ROW_NUMBER_CODE: &str = "SQBRSQL018";
pub(crate) const UNPROVEN_RANKING_MESSAGE: &str =
"Ranking window order is not proven unique within its partition";
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
use crate::sql_lint::models::LintDiagnostic;
use crate::sql_quality::constants::{
LONG_LITERAL, RANKING_SORT_CAP, UNSTABLE_ROW_NUMBER_CODE, UNUSED_CTE_OUTPUT,
LONG_LITERAL, RANKING_SORT_CAP, REPEATED_JSON_PARSE, UNSTABLE_ROW_NUMBER_CODE,
UNUSED_CTE_OUTPUT,
};
use crate::sql_quality::keys::{KeyEnvironment, key_environment};
use crate::sql_quality::models::QualityRequest;
use crate::sql_quality::{cte_columns, literals, ranking};
use crate::sql_quality::{cte_columns, json_parses, literals, ranking};

/// Run every enabled SQL quality Rule over one parsed body.
pub(crate) fn diagnostics(request: &QualityRequest<'_>) -> Result<Vec<LintDiagnostic>, String> {
Expand All @@ -15,6 +16,9 @@ pub(crate) fn diagnostics(request: &QualityRequest<'_>) -> Result<Vec<LintDiagno
if request.enabled.contains(LONG_LITERAL.code) {
found.extend(literals::diagnostics(request));
}
if request.enabled.contains(REPEATED_JSON_PARSE.code) {
found.extend(json_parses::diagnostics(request));
}
if request.enabled.contains(UNUSED_CTE_OUTPUT.code) {
found.extend(cte_columns::diagnostics(request));
}
Expand Down
2 changes: 2 additions & 0 deletions crates/sqlbuild-rules-native/src/sql_quality/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
pub(crate) mod constants;
#[path = "_helpers/cte_columns.rs"]
mod cte_columns;
#[path = "_helpers/json_parses.rs"]
mod json_parses;
#[path = "_helpers/keys.rs"]
mod keys;
#[path = "_helpers/literals.rs"]
Expand Down
3 changes: 2 additions & 1 deletion crates/sqlbuild-rules-native/src/sql_quality/models.rs
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
use std::collections::{HashMap, HashSet};

use polyglot_sql::Expression;
use polyglot_sql::tokens::Token;
use polyglot_sql::{DialectType, Expression};

/// Inputs for one body's quality Rules, borrowed from the native lint request.
#[derive(Debug)]
pub(crate) struct QualityRequest<'a> {
pub(crate) statements: &'a [Expression],
pub(crate) tokens: &'a [Token],
pub(crate) dialect: DialectType,
pub(crate) enabled: &'a HashSet<String>,
pub(crate) max_literal_length: usize,
pub(crate) max_ranking_order_by: usize,
Expand Down
2 changes: 2 additions & 0 deletions crates/sqlbuild-rules-native/src/sql_quality/tests.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
#[path = "tests/helpers.rs"]
mod helpers;
#[path = "tests/test_json_parses.rs"]
mod json_parses;
#[path = "tests/test_quality_rules.rs"]
mod quality_rules;
#[path = "tests/test_quality_scale.rs"]
Expand Down
24 changes: 24 additions & 0 deletions crates/sqlbuild-rules-native/src/sql_quality/tests/helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,30 @@ pub(crate) fn lint(test_case: &QualityRuleTestCase) -> Result<Vec<Value>, String
.unwrap_or_default())
}

/// Lint `sql` in `dialect` with only `rule` enabled.
pub(crate) fn lint_dialect(sql: &str, dialect: &str, rule: &str) -> Result<Vec<Value>, String> {
let request: Value = json!({
"version": 1,
"sql": sql,
"dialect": dialect,
"enabled_rules": [rule],
});
let response: Value = serde_json::from_str(&lint_json(&request.to_string())?)
.map_err(|error| error.to_string())?;
Ok(response["diagnostics"]
.as_array()
.cloned()
.unwrap_or_default())
}

/// One DuckDB SELECT that parses each of `column_count` payload columns twice.
pub(crate) fn wide_json_select(column_count: usize) -> String {
let projections: Vec<String> = (0..column_count * 2)
.map(|index| format!("json(e.c{})->>'f{index}' AS f{index}", index % column_count))
.collect();
format!("SELECT {} FROM events AS e", projections.join(", "))
}

pub(crate) fn anchors(sql: &str, diagnostics: &[Value]) -> Vec<String> {
diagnostics
.iter()
Expand Down
Loading
Loading