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
12 changes: 6 additions & 6 deletions corpora/everyday/queries.toml
Original file line number Diff line number Diff line change
Expand Up @@ -318,8 +318,8 @@ snowflake = "rejects 1003"
[[query]]
category = "select_star"
sql = '''SELECT TOP 5 * FROM orders'''
expect = "refused SQ0002 node:select at 0..26"
expect_repository = "refused SQ0002 node:select at 0..26"
expect = "translated"
expect_repository = "translated"

[[query]]
category = "select_star"
Expand Down Expand Up @@ -553,8 +553,8 @@ expect_repository = "translated"
[[query]]
category = "columns"
sql = '''SELECT order_id FROM orders FETCH FIRST 10 ROWS ONLY'''
expect = "refused SQ0002 node:select at 0..52"
expect_repository = "refused SQ0002 node:select at 0..52"
expect = "translated"
expect_repository = "translated"
snowflake = "compiles"

[[query]]
Expand Down Expand Up @@ -2970,8 +2970,8 @@ expect_repository = "parse SQ0001 at 47..49"
[[query]]
category = "invalid_syntax"
sql = '''SELECT order_id FROM orders WHERE status == 'shipped''''
expect = "translated"
expect_repository = "translated"
expect = "check SQ0206 at 41..43"
expect_repository = "check SQ0206 at 41..43"
snowflake = "rejects 2139"

[[query]]
Expand Down
8 changes: 8 additions & 0 deletions coverage/sql-size/baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -9262,6 +9262,10 @@
"translation/query/join.join.toml#t004_null_key": 1487,
"translation/query/join.join.toml#t005_self_join": 1082,
"translation/query/join.join.toml#t006_null_key": 1102,
"translation/query/limit.fetch.toml#t001_offset_fetch": 887,
"translation/query/limit.fetch.toml#t002_first_row": 880,
"translation/query/limit.fetch.toml#t003_fetch_zero": 878,
"translation/query/limit.fetch.toml#t004_null_key": 927,
"translation/query/limit.limit.toml#t001_offset": 887,
"translation/query/limit.limit.toml#t002_zero": 878,
"translation/query/operators-query.except.toml#t001_rows": 1206,
Expand Down Expand Up @@ -9299,6 +9303,10 @@
"translation/query/select.select.toml#t003_exclude_two": 999,
"translation/query/select.select.toml#t004_alias_star": 1535,
"translation/query/select.select.toml#t005_null_values": 1039,
"translation/query/top-n.toml#t001_top": 927,
"translation/query/top-n.toml#t002_top_zero": 878,
"translation/query/top-n.toml#t003_top_distinct": 869,
"translation/query/top-n.toml#t004_null_key": 928,
"translation/query/values.toml#t001_aliases": 1082,
"translation/query/values.toml#t002_common_type": 1094,
"translation/query/values.toml#t003_null_value": 1114,
Expand Down
34 changes: 17 additions & 17 deletions coverage/translation/results-baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -12042,10 +12042,10 @@
"translation/query/join.join.toml#t004_null_key": "matching",
"translation/query/join.join.toml#t005_self_join": "matching",
"translation/query/join.join.toml#t006_null_key": "matching",
"translation/query/limit.fetch.toml#t001_offset_fetch": "refused",
"translation/query/limit.fetch.toml#t002_first_row": "refused",
"translation/query/limit.fetch.toml#t003_fetch_zero": "refused",
"translation/query/limit.fetch.toml#t004_null_key": "refused",
"translation/query/limit.fetch.toml#t001_offset_fetch": "matching",
"translation/query/limit.fetch.toml#t002_first_row": "matching",
"translation/query/limit.fetch.toml#t003_fetch_zero": "matching",
"translation/query/limit.fetch.toml#t004_null_key": "matching",
"translation/query/limit.limit.toml#t001_offset": "matching",
"translation/query/limit.limit.toml#t002_zero": "matching",
"translation/query/limit.limit.toml#t003_null_count": "refused",
Expand Down Expand Up @@ -12146,10 +12146,10 @@
"translation/query/select.select.toml#t017_ilike_replace_rename_null_values": "refused",
"translation/query/select.select.toml#t018_replace_rename": "refused",
"translation/query/select.select.toml#t019_replace_rename_null_values": "refused",
"translation/query/top-n.toml#t001_top": "refused",
"translation/query/top-n.toml#t002_top_zero": "refused",
"translation/query/top-n.toml#t003_top_distinct": "refused",
"translation/query/top-n.toml#t004_null_key": "refused",
"translation/query/top-n.toml#t001_top": "matching",
"translation/query/top-n.toml#t002_top_zero": "matching",
"translation/query/top-n.toml#t003_top_distinct": "matching",
"translation/query/top-n.toml#t004_null_key": "matching",
"translation/query/unpivot.toml#t001_basic": "refused",
"translation/query/unpivot.toml#t002_include_nulls": "refused",
"translation/query/values.toml#t001_aliases": "matching",
Expand Down Expand Up @@ -14890,7 +14890,7 @@
"sf.query.select.select": "refused",
"sf.query.select.select.selecting-specific-columns": "matching",
"sf.query.semantic-view": "unmeasured",
"sf.query.top-n": "refused",
"sf.query.top-n": "matching",
"sf.query.unpivot": "refused",
"sf.query.values": "refused",
"sf.query.where": "refused",
Expand Down Expand Up @@ -19031,10 +19031,10 @@
"sf.query.join.join.t004": "met",
"sf.query.join.join.t005": "met",
"sf.query.join.join.t006": "met",
"sf.query.limit.fetch.t001": "unmet",
"sf.query.limit.fetch.t002": "unmet",
"sf.query.limit.fetch.t003": "unmet",
"sf.query.limit.fetch.t004": "unmet",
"sf.query.limit.fetch.t001": "met",
"sf.query.limit.fetch.t002": "met",
"sf.query.limit.fetch.t003": "met",
"sf.query.limit.fetch.t004": "met",
"sf.query.limit.limit.t001": "met",
"sf.query.limit.limit.t002": "met",
"sf.query.limit.limit.t003": "unmet",
Expand Down Expand Up @@ -19144,10 +19144,10 @@
"sf.query.select.select.t017": "unmet",
"sf.query.select.select.t018": "unmet",
"sf.query.select.select.t019": "unmet",
"sf.query.top-n.t001": "unmet",
"sf.query.top-n.t002": "unmet",
"sf.query.top-n.t003": "unmet",
"sf.query.top-n.t004": "unmet",
"sf.query.top-n.t001": "met",
"sf.query.top-n.t002": "met",
"sf.query.top-n.t003": "met",
"sf.query.top-n.t004": "met",
"sf.query.unpivot.t001": "unmet",
"sf.query.unpivot.t002": "unmet",
"sf.query.values.t001": "met",
Expand Down
4 changes: 2 additions & 2 deletions crates/sqlquack-convert/src/snowflake/catalogue/constants.rs
Original file line number Diff line number Diff line change
Expand Up @@ -178,8 +178,8 @@ pub const RULES: &[RuleSpec] = &[
},
RuleSpec {
id: LIMIT,
constructs: &["clause:limit"],
conditions: "Integer literals.",
constructs: &["clause:limit", "clause:top", "clause:fetch"],
conditions: "Integer literals. TOP <n> and FETCH { FIRST | NEXT } <n> { ROW | ROWS } [ ONLY ] keep the first n rows as LIMIT <n> does, after ORDER BY and DISTINCT, as recorded; with PERCENT or WITH TIES, or with more than one of LIMIT, TOP and FETCH, they are refused.",
failures: &[],
},
RuleSpec {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ use crate::snowflake::conversion::constants::{
TYPE_SUFFIX_UNREMOVED, VARIANT_HELD_UNREAD,
};

/// The clauses that keep a query's first rows, by field.
const ROW_LIMIT_CLAUSES: [&str; 3] = ["limit", "top", "fetch"];

/// A clause's condition node, such as WHERE's.
pub(crate) fn clause_condition<'a>(select: &'a Payload, clause: &str) -> Option<&'a Value> {
select
Expand Down Expand Up @@ -264,21 +267,59 @@ pub(crate) fn order_and_limit(
}
sql.push_str(&format!(" ORDER BY {}", items.join(", ")));
}
for (clause, keyword) in [("limit", "LIMIT"), ("offset", "OFFSET")] {
if let Some(value) = query.get(clause).filter(|value| !value.is_null()) {
let _rule = site.context.log.rule(LIMIT);
let count = value.get("this").and_then(integer_literal).ok_or_else(|| {
outside_conditions(
node_construct(clause),
format!("{keyword} must be an integer literal"),
)
})?;
sql.push_str(&format!(" {keyword} {count}"));
}
if let Some(count) = row_limit(query, site)? {
sql.push_str(&format!(" LIMIT {count}"));
}
if let Some(value) = query.get("offset").filter(|value| !value.is_null()) {
let _rule = site.context.log.rule(LIMIT);
let count = value.get("this").and_then(integer_literal).ok_or_else(|| {
outside_conditions(
node_construct("offset"),
"OFFSET must be an integer literal".to_owned(),
)
})?;
sql.push_str(&format!(" OFFSET {count}"));
}
Ok(sql)
}

/// The count of rows a query keeps from LIMIT, TOP or FETCH, which Snowflake reads alike, as recorded; None without one.
fn row_limit(query: &Payload, site: Site<'_>) -> Result<Option<u64>, Refusal> {
let mut written = ROW_LIMIT_CLAUSES
.into_iter()
.filter(|clause| query.get(*clause).is_some_and(|value| !value.is_null()));
let Some(clause) = written.next() else {
return Ok(None);
};
let construct = node_construct(clause);
if written.next().is_some() {
return Err(outside_conditions(
construct,
"more than one of LIMIT, TOP and FETCH, which no recording shows".to_owned(),
));
}
let _rule = site.context.log.rule(LIMIT);
let keyword = clause.to_ascii_uppercase();
let object = payload_object(&query[clause], &construct)?;
if flag(object, "percent") || flag(object, "with_ties") {
return Err(outside_conditions(
construct,
format!("{keyword} with PERCENT or WITH TIES, which LIMIT does not express"),
));
}
let count_field = match clause {
"fetch" => "count",
_ => "this",
};
let count = object
.get(count_field)
.and_then(integer_literal)
.ok_or_else(|| {
outside_conditions(construct, format!("{keyword} must be an integer literal"))
})?;
Ok(Some(count))
}

/// Whether an ORDER BY only keeps the input fixture's rows in order, by its ordering column.
fn fixture_ordering(order: &Value, site: Site<'_>) -> bool {
let Some(fixture) = &site.context.options.fixture else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,8 @@ const SELECT_FIELDS: &[&str] = &[
"qualify",
"order_by",
"limit",
"top",
"fetch",
"offset",
"distinct",
"with",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -553,7 +553,7 @@ pub const HAVING: &str = "rule.having";
pub const QUALIFY: &str = "rule.qualify";
/// ORDER BY with Snowflake's NULL placement.
pub const ORDER_BY: &str = "rule.order_by";
/// LIMIT and OFFSET.
/// LIMIT, TOP, FETCH and OFFSET.
pub const LIMIT: &str = "rule.limit";
/// `*` and `t.*`.
pub const STAR: &str = "rule.star";
Expand Down
1 change: 1 addition & 0 deletions crates/sqlquack-diagnostics/src/diagnostics/codes.lock
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,4 @@ SQ0203 qualify_without_window
SQ0204 star_filter_drops_named_column
SQ0211 unsupported_type_literal
SQ0205 window_without_over
SQ0206 double_equals
13 changes: 13 additions & 0 deletions crates/sqlquack-diagnostics/src/diagnostics/constants.rs
Original file line number Diff line number Diff line change
Expand Up @@ -375,6 +375,19 @@ pub const CODES: &[CodeEntry] = &[
there. Add the OVER clause that says which rows the function ranks or reads.",
example: r#"echo "SELECT CUME_DIST() AS d FROM orders" | sqlquack check"#,
},
CodeEntry {
code: "SQ0206",
name: "double_equals",
alias: None,
title: "Unknown function ==",
help: Some("compare with `=`"),
explanation: "Snowflake has no `==` operator. The statement parses, but Snowflake reads \
`==` as a call to a function of that name and rejects the query when compiling it \
(Snowflake error 2139, unknown function), so the statement never runs there. Other \
dialects read `==` as `=`; SQLQuack does not, because that would convert SQL \
Snowflake rejects. Write `=` instead.",
example: r#"echo "SELECT order_id FROM orders WHERE status == 'shipped'" | sqlquack check"#,
},
CodeEntry {
code: "SQ0211",
name: "unsupported_type_literal",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,53 @@ use sqlquack_syntax::expressions::types::constants::{
SNOWFLAKE_UNPARAMETERIZED_LITERAL_TYPES, SNOWFLAKE_UNTYPED_LITERAL_TYPES,
};
use sqlquack_syntax::expressions::types::models::DataType;
use sqlquack_syntax::tokens::main::node_location::node_location;
use sqlquack_syntax::tokens::models::SourceLines;
use sqlquack_syntax::{Expression, ExpressionWalk};

/// Window functions Snowflake refuses without OVER (error 2064, missing window specification).
const WINDOW_ONLY_CALLS: [&str; 3] = ["CUME_DIST", "PERCENT_RANK", "NTH_VALUE"];

/// The equality operator of other dialects, which Snowflake reads as a call to an unknown function.
pub(crate) const DOUBLE_EQUALS: &str = "==";

/// The range of `==` in an equality (Snowflake error 2139), skipping unranged literals' quotes.
pub(crate) fn double_equals(
node: &Expression,
text: &[char],
lines: &SourceLines,
) -> Option<(usize, usize)> {
let Expression::Eq(operation) = node else {
return None;
};
let whole = node_location(node, lines)?;
let from = node_location(&operation.left, lines).map_or(whole.start, |left| left.end);
let to = node_location(&operation.right, lines)
.map_or(whole.end, |right| right.start)
.min(text.len());
let mut offset = from;
while offset < to {
let next = text.get(offset + 1).copied();
offset = match (text[offset], next) {
('=', Some('=')) => return Some((offset, offset + 2)),
('=', _) => return None,
('/', Some('*')) => after(text, offset + 2, &['*', '/']),
('-', Some('-')) => after(text, offset + 2, &['\n']),
('$', Some('$')) => after(text, offset + 2, &['$', '$']),
(quote @ ('\'' | '"'), _) => after(text, offset + 1, &[quote]),
_ => offset + 1,
};
}
None
}

/// The offset just past the first `close` at or after `from`, or the end of `text`.
fn after(text: &[char], from: usize, close: &[char]) -> usize {
(from..text.len())
.find(|&offset| text[offset..].starts_with(close))
.map_or(text.len(), |offset| offset + close.len())
}

/// The rejection a node causes, if any, given the calls an OVER clause applies to (`windowed`).
pub(crate) fn rejection(
node: &Expression,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ use sqlquack_syntax::{Expression, ExpressionWalk};
use crate::classification::models::{Construct, ConstructFamily};
use crate::refusals::models::{Refusal, RefusalCategory};
use crate::snowflake::refusals::_helpers::clause_location::clause_location;
use crate::snowflake::refusals::_helpers::rejections::rejection;
use crate::snowflake::refusals::_helpers::rejections::{DOUBLE_EQUALS, double_equals, rejection};
use sqlquack_syntax::tokens::main::node_location::node_location;
use sqlquack_syntax::tokens::models::SourceLines;

Expand All @@ -24,9 +24,24 @@ pub fn snowflake_rejections(
_ => None,
})
.collect();
let written_double = source.contains(DOUBLE_EQUALS);
statement
.dfs()
.filter_map(|node| {
if written_double
&& let Some((start, end)) = double_equals(
node,
characters.get_or_init(|| source.chars().collect()),
lines,
)
{
return Some(Refusal {
construct: Some(Construct::new(ConstructFamily::Syntax, "double_equals")),
location: Some(lines.source_location(start, end)),
category: RefusalCategory::SnowflakeRejects,
reason: "Snowflake reads == as a call to an unknown function".to_owned(),
});
}
let (name, reason) = rejection(node, &windowed)?;
let text = characters.get_or_init(|| source.chars().collect());
Some(Refusal {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,29 @@ fn given_sql_snowflake_parses_then_rejects_when_refusing_then_each_rejection_is_
expected_rejections: vec!["window_without_over"],
expected_codes: vec!["SQ0205"],
},
RejectionTestCase {
description: "== is rejected wherever it compares, after a comment or around parentheses",
sql: "SELECT order_id FROM orders WHERE status == 'shipped'; \
SELECT 1 == 1 AS same; \
SELECT 'a=b' == status AS same FROM orders; \
SELECT (price) /* note */ == (2) AS two FROM orders",
expected_rejections: vec![
"double_equals",
"double_equals",
"double_equals",
"double_equals",
],
expected_codes: vec!["SQ0206", "SQ0206", "SQ0206", "SQ0206"],
},
RejectionTestCase {
description: "a single =, == inside a string or a comment, and >= or != are left to conversion",
sql: "SELECT order_id FROM orders WHERE status = 'a == b'; \
SELECT price /* == */ = 2 AS two FROM orders; \
SELECT 'x==' = status AS same FROM orders; \
SELECT order_id FROM orders WHERE price >= 2 AND price != 3",
expected_rejections: vec![],
expected_codes: vec![],
},
RejectionTestCase {
description: "a star without ILIKE, or ILIKE without REPLACE or RENAME, is left to conversion",
sql: "SELECT * REPLACE (price * 2 AS price) FROM orders; \
Expand Down
Loading
Loading