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 @@ -1664,7 +1664,7 @@ fn unresolved_reference_warnings(request: UnresolvedReferenceRequest<'_>) -> Vec
let mut protected = ProtectedRanges::new(&patterns.lexical, sql);
for name in marker_names_in(&patterns.reference, &mut protected) {
let message = format!(
"test '{test_name}': model '{model_name}' references __ref('{name}') which has no mock and is not in the expected chain"
"test '{test_name}': model '{model_name}' references __ref(\"{name}\") which has no mock and is not in the expected chain"
);
warn(REF_FUNCTION, name, message);
}
Expand All @@ -1674,7 +1674,7 @@ fn unresolved_reference_warnings(request: UnresolvedReferenceRequest<'_>) -> Vec
] {
for name in marker_names_in(pattern, &mut protected) {
let message = format!(
"test '{test_name}': model '{model_name}' references {function_name}('{name}') which has no mock"
"test '{test_name}': model '{model_name}' references {function_name}(\"{name}\") which has no mock"
);
warn(function_name, name, message);
}
Expand Down
4 changes: 2 additions & 2 deletions crates/sqlbuild-analysis/src/compiler/tests/helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -337,7 +337,7 @@ pub(crate) fn unresolved_reference_fast_rejection_preserves_warning() -> bool {
json!([{
"modelName": "orders",
"severity": "error",
"message": "test 'orders_case': model 'orders' references __source('missing_orders') which has no mock"
"message": "test 'orders_case': model 'orders' references __source(\"missing_orders\") which has no mock"
}])
);
true
Expand Down Expand Up @@ -519,7 +519,7 @@ pub(crate) fn deep_shared_graph_reports_missing_mock_once() -> bool {
json!([{
"modelName": "orders_00",
"severity": "error",
"message": "test 'orders_totals': model 'orders_00' references __source('raw_orders') which has no mock"
"message": "test 'orders_totals': model 'orders_00' references __source(\"raw_orders\") which has no mock"
}]),
"sql_analysis_enabled={sql_analysis_enabled}"
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -299,7 +299,7 @@ fn given_quoted_commented_and_malformed_fragments_when_scanning_then_every_scann
test_case.description
);
assert_eq!(
extract(&format!("SELECT {fragment} __ref('b')")).is_some(),
extract(&format!("SELECT {fragment} __ref(\"b\")")).is_some(),
test_case.expected_reference_fast_path,
"SQL reference fast path: {}",
test_case.description
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
use crate::compiler::types::StaticReference;
use crate::constants::{DBT_REFERENCE_KIND, TABLE_FUNCTION_REFERENCE_KIND};
use crate::sql_scan::main::non_code_end::non_code_end;
use crate::sql_scan::main::quote_end::quote_end;
use crate::sql_scan::models::QuotePolicy;

const PREFIXES: [(&str, &str); 6] = [
Expand Down Expand Up @@ -39,13 +38,14 @@ pub(crate) fn extract(sql: &str) -> Option<Vec<StaticReference>> {
return None;
}
let (first_name, first_end) = reference_name(sql, index + prefix.len())?;
let mut cursor = ascii_whitespace_end(bytes, first_end);
let mut cursor = first_end;
let mut package = None;
let name;
if *kind == DBT_REFERENCE_KIND && bytes.get(cursor).copied() == Some(b',') {
cursor = ascii_whitespace_end(bytes, cursor + 1);
let (second_name, second_end) = reference_name(sql, cursor)?;
cursor = ascii_whitespace_end(bytes, second_end);
let separator = ascii_whitespace_end(bytes, cursor);
if *kind == DBT_REFERENCE_KIND && bytes.get(separator).copied() == Some(b',') {
let second_start = ascii_whitespace_end(bytes, separator + 1);
let (second_name, second_end) = reference_name(sql, second_start)?;
cursor = second_end;
package = Some(first_name);
name = second_name;
} else {
Expand All @@ -60,27 +60,17 @@ pub(crate) fn extract(sql: &str) -> Option<Vec<StaticReference>> {
Some(references)
}

/// Read one bare `"name"` argument, the only form compile replacement accepts.
fn reference_name(sql: &str, start: usize) -> Option<(String, usize)> {
let bytes = sql.as_bytes();
let start = ascii_whitespace_end(bytes, start);
let first = bytes.get(start).copied()?;
if matches!(first, b'\'' | b'"') {
let Ok(end) = quote_end(bytes, start, QuotePolicy::COMPILER) else {
return None;
};
return Some((sql[start + 1..end - 1].to_owned(), end));
}
if !is_identifier_start(first) {
if bytes.get(start).copied() != Some(b'"') {
return None;
}
let mut end = start + 1;
while bytes.get(end).copied().is_some_and(is_identifier_continue) {
end += 1;
}
if bytes.get(end).is_some_and(|value| !value.is_ascii()) {
let close = start + 1 + bytes[start + 1..].iter().position(|value| *value == b'"')?;
if close == start + 1 {
return None;
}
Some((sql[start..end].to_owned(), end))
Some((sql[start + 1..close].to_owned(), close + 1))
}

fn ascii_whitespace_end(bytes: &[u8], start: usize) -> usize {
Expand All @@ -93,11 +83,3 @@ fn ascii_whitespace_end(bytes: &[u8], start: usize) -> usize {
}
end
}

fn is_identifier_start(value: u8) -> bool {
value.is_ascii_alphabetic()
}

fn is_identifier_continue(value: u8) -> bool {
value.is_ascii_alphanumeric() || value == b'_'
}
13 changes: 10 additions & 3 deletions crates/sqlbuild-sqltext/src/compiler/tests/helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ pub(crate) fn dynamic_or_malformed_sql_requests_fallback() -> bool {

pub(crate) fn simple_references_preserve_authored_order() -> bool {
extract(
"SELECT * FROM __source('orders') UNION ALL SELECT * FROM __dbt_ref(\"shop\", \"customers\")",
"SELECT * FROM __source(\"orders\") UNION ALL SELECT * FROM __dbt_ref(\"shop\" , \"customers\")",
) == Some(vec![
("source".to_owned(), "orders".to_owned(), None, None),
(
Expand All @@ -56,15 +56,15 @@ pub(crate) fn simple_references_preserve_authored_order() -> bool {
}

pub(crate) fn comments_and_quoted_text_hide_references() -> bool {
extract("-- __ref(\"ignored\")\nSELECT '__seed(\"also_ignored\")' FROM __ref(orders)")
extract("-- __ref(\"ignored\")\nSELECT '__seed(\"also_ignored\")' FROM __ref(\"orders\")")
== Some(vec![("ref".to_owned(), "orders".to_owned(), None, None)])
}

pub(crate) fn dollar_quoted_text_hides_references() -> bool {
extract(concat!(
"SELECT $$Customer's order -- __ref(\"ignored\") $5$$ AS label, ",
"$tag$ $$ __seed(\"also_ignored\") $tag$ AS note, price$1$ ",
"FROM __ref(orders)"
"FROM __ref(\"orders\")"
)) == Some(vec![("ref".to_owned(), "orders".to_owned(), None, None)])
}

Expand All @@ -77,6 +77,13 @@ pub(crate) fn complex_or_malformed_sql_requests_fallback() -> bool {
"SELECT * FROM __ref(\"orders\") WHERE note = 'unterminated",
"SELECT * FROM __ref(\"orders\") WHERE note = $$unterminated",
"SELECT * FROM __ref(örders)",
"SELECT * FROM __ref(orders)",
"SELECT * FROM __ref('orders')",
"SELECT * FROM __ref( \"orders\" )",
"SELECT * FROM __ref(/* upstream */ \"orders\")",
"SELECT * FROM __ref(\"\")",
"SELECT * FROM __ref(\"ord\"\"ers\")",
"SELECT * FROM __dbt_ref(\"shop\", \"customers\" )",
]
.into_iter()
.all(|sql| extract(sql).is_none())
Expand Down
135 changes: 58 additions & 77 deletions crates/sqlbuild-sqltext/src/sql_references/_helpers/scan.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
//! The general reference scan of Python `extract_sql_references`, reproduced exactly.

use crate::sql_references::constants::{
DBT_REFERENCE_KIND, NAME_QUOTE_BYTES, NON_CODE_START_BYTES, PAIRED_QUOTE_LENGTH,
PYTHON_ASCII_WHITESPACE, QUOTE_BYTES, REFERENCE_CONTEXT, REFERENCE_PREFIXES,
TABLE_FUNCTION_CALL_CONTEXT, TABLE_FUNCTION_REFERENCE_KIND,
DBT_REFERENCE_KIND, NON_CODE_START_BYTES, PYTHON_ASCII_WHITESPACE, QUOTE_BYTES,
REFERENCE_CONTEXT, REFERENCE_PREFIXES, TABLE_FUNCTION_CALL_CONTEXT,
TABLE_FUNCTION_REFERENCE_KIND,
};
use crate::sql_references::models::SqlReference;
use crate::sql_references::types::ReferencePrefix;
Expand Down Expand Up @@ -54,7 +54,7 @@ pub(crate) fn scan_references(sql: &[u8], syntax: &LexicalSyntax) -> Scan<Vec<Sq
}
let Some(call) = REFERENCE_PREFIXES
.iter()
.find(|(prefix, _, _)| sql[index..].starts_with(prefix))
.find(|(prefix, _)| sql[index..].starts_with(prefix))
else {
index += 1;
continue;
Expand All @@ -66,63 +66,85 @@ pub(crate) fn scan_references(sql: &[u8], syntax: &LexicalSyntax) -> Scan<Vec<Sq
Ok(references)
}

/// Parse the reference call starting at `start`, returning it and the offset after its name list.
/// Parse a bare double-quoted reference call at `start`; other forms defer so Python reports P012.
fn parse_reference(
sql: &[u8],
start: usize,
call: &ReferencePrefix,
syntax: &LexicalSyntax,
) -> Scan<(SqlReference, usize)> {
let (prefix, kind, call_name) = *call;
let (prefix, kind) = *call;
let open = start + prefix.len() - 1;
let close = matching_paren(sql, open, syntax, REFERENCE_CONTEXT)?;
let arguments = split_arguments(sql, open + 1, close, syntax)?;
if kind == DBT_REFERENCE_KIND {
if !matches!(arguments.len(), 1 | 2) {
return Err(failed(format!(
"{call_name} must contain one name argument or package/name arguments"
)));
}
if let [package, name] = arguments.as_slice() {
let package = reference_name(package, kind, call_name)?;
let name = reference_name(name, kind, call_name)?;
return Ok((
SqlReference {
kind,
name,
package: Some(package),
call_argument_count: None,
},
close + 1,
));
}
} else if arguments.len() != 1 {
return Err(failed(format!(
"{call_name} must contain exactly one name argument"
)));
}
let arguments = &sql[open + 1..close];
let (name, package) = if kind == DBT_REFERENCE_KIND {
dbt_reference_names(arguments)?
} else {
(bare_name(arguments)?, None)
};
let mut call_argument_count = None;
if kind == TABLE_FUNCTION_REFERENCE_KIND {
let suffix = python_whitespace_end(sql, close + 1)?;
if sql.get(suffix) != Some(&b'(') {
return Err(failed(format!(
"{call_name} must be followed by an argument list"
)));
return Err(Stop::Deferred);
}
let suffix_close = matching_paren(sql, suffix, syntax, TABLE_FUNCTION_CALL_CONTEXT)?;
call_argument_count = Some(split_arguments(sql, suffix + 1, suffix_close, syntax)?.len());
}
Ok((
SqlReference {
kind,
name: reference_name(&arguments[0], kind, call_name)?,
package: None,
name,
package,
call_argument_count,
},
close + 1,
))
}

/// Python `"([^"]+)"` matched against the whole argument text.
fn bare_name(arguments: &[u8]) -> Scan<String> {
match quoted_name_end(arguments, 0) {
Some(end) if end == arguments.len() => utf8(&arguments[1..end - 1]),
_ => Err(Stop::Deferred),
}
}

/// Python `"([^"]+)"(?:\s*,\s*"([^"]+)")?` matched against the whole argument text.
fn dbt_reference_names(arguments: &[u8]) -> Scan<(String, Option<String>)> {
let Some(first_end) = quoted_name_end(arguments, 0) else {
return Err(Stop::Deferred);
};
if first_end == arguments.len() {
return Ok((utf8(&arguments[1..first_end - 1])?, None));
}
let separator = python_whitespace_end(arguments, first_end)?;
if arguments.get(separator) != Some(&b',') {
return Err(Stop::Deferred);
}
let second_start = python_whitespace_end(arguments, separator + 1)?;
match quoted_name_end(arguments, second_start) {
Some(end) if end == arguments.len() => Ok((
utf8(&arguments[second_start + 1..end - 1])?,
Some(utf8(&arguments[1..first_end - 1])?),
)),
_ => Err(Stop::Deferred),
}
}

/// The offset after a `"name"` with at least one character starting at `start`.
fn quoted_name_end(arguments: &[u8], start: usize) -> Option<usize> {
if arguments.get(start) != Some(&b'"') {
return None;
}
let close = start
+ 1
+ arguments[start + 1..]
.iter()
.position(|byte| *byte == b'"')?;
(close > start + 1).then_some(close + 1)
}

/// Python `_split_top_level_arguments` over `sql[start..end]`: comments read as one space.
fn split_arguments(
sql: &[u8],
Expand Down Expand Up @@ -180,47 +202,6 @@ fn split_arguments(
Ok(arguments)
}

/// Python `_parse_reference_name` for one stripped argument.
fn reference_name(argument: &[u8], kind: &str, call_name: &str) -> Scan<String> {
let paired_quote = |quotes: &[u8]| {
argument.len() >= PAIRED_QUOTE_LENGTH
&& argument[0] == argument[argument.len() - 1]
&& quotes.contains(&argument[0])
};
if kind == TABLE_FUNCTION_REFERENCE_KIND && !paired_quote(b"\"") {
return Err(failed(format!(
"{call_name} name argument must be double quoted"
)));
}
if paired_quote(NAME_QUOTE_BYTES) {
return utf8(&argument[1..argument.len() - 1]);
}
if identifier_validity(argument)? {
return utf8(argument);
}
Err(failed(format!(
"{call_name} name argument must be a quoted string or identifier"
)))
}

/// Python `value.replace("_", "a").isalnum() and value[0].isalpha()` for a non-empty value.
fn identifier_validity(value: &[u8]) -> Scan<bool> {
if value
.iter()
.any(|byte| byte.is_ascii() && !(byte.is_ascii_alphanumeric() || *byte == b'_'))
{
return Ok(false);
}
if value[0].is_ascii() && !value[0].is_ascii_alphabetic() {
return Ok(false);
}
if value.is_ascii() {
Ok(true)
} else {
Err(Stop::Deferred)
}
}

/// Python `str.strip()`; a non-ASCII character at either edge may be Unicode whitespace.
fn python_strip(value: &[u8]) -> Scan<&[u8]> {
let start = value
Expand Down
18 changes: 7 additions & 11 deletions crates/sqlbuild-sqltext/src/sql_references/constants.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,14 @@

use crate::sql_references::types::ReferencePrefix;

/// Call prefixes with their kinds and error names, in Python's matching order.
/// Call prefixes with their kinds, in Python's matching order.
pub(crate) const REFERENCE_PREFIXES: [ReferencePrefix; 6] = [
(b"__dbt_ref(", "dbt_ref", "__dbt_ref"),
(b"__table_fn(", "table_fn", "__table_fn"),
(b"__source(", "source", "__source"),
(b"__seed(", "seed", "__seed"),
(b"__udf(", "udf", "__udf"),
(b"__ref(", "ref", "__ref"),
(b"__dbt_ref(", "dbt_ref"),
(b"__table_fn(", "table_fn"),
(b"__source(", "source"),
(b"__seed(", "seed"),
(b"__udf(", "udf"),
(b"__ref(", "ref"),
];
pub(crate) const DBT_REFERENCE_KIND: &str = "dbt_ref";
pub(crate) const TABLE_FUNCTION_REFERENCE_KIND: &str = "table_fn";
Expand All @@ -21,9 +21,5 @@ pub(crate) const SUPPORTED_LINE_COMMENT_PREFIXES: [&str; 3] = ["--", "//", "#"];
pub(crate) const NON_CODE_START_BYTES: &[u8] = b"-/#'\"`$";
/// Non-code openers whose text an argument keeps; comments become one space.
pub(crate) const QUOTE_BYTES: &[u8] = b"'\"`$";
/// Shortest quoted reference name: the opening and closing quote.
pub(crate) const PAIRED_QUOTE_LENGTH: usize = 2;
/// Quotes that delimit a reference name.
pub(crate) const NAME_QUOTE_BYTES: &[u8] = b"'\"";
/// Python `str.isspace()` for ASCII characters.
pub(crate) const PYTHON_ASCII_WHITESPACE: &[u8] = b" \t\n\x0b\x0c\r\x1c\x1d\x1e\x1f";
Loading
Loading