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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ csv = "1.4"
# table turns that off itself (force_no_tty).
comfy-table = "8.0"
unicode-width = "0.2"
unicode-normalization = "0.1.25"
# No zlib feature: it pulls libz-sys, and the pure-Rust deflate keeps
# the build free of another C dependency. Constant memory lets a sheet
# spill its rows to a temporary file instead of holding them.
Expand Down
6 changes: 6 additions & 0 deletions clippy.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,10 @@ disallowed-types = [
disallowed-methods = [
{ path = "serde_json::Value::as_object", reason = "key order is not preserved; keep JSON as JsonText" },
{ path = "serde_json::Value::as_object_mut", reason = "key order is not preserved; keep JSON as JsonText" },
# bson decodes a document by calling itself once per level, so a
# document the server nests deep enough overflows the reading
# thread's stack. A cursor's document is taken out after its depth
# is checked.
{ path = "mongodb::SessionCursor::deserialize_current", reason = "decodes a document of any depth; use nested_document::current_document" },
{ path = "mongodb::Cursor::deserialize_current", reason = "decodes a document of any depth; check it as nested_document::current_document does" },
]
75 changes: 74 additions & 1 deletion core/crates/core/src/sql_syntax/history_recording.rs
Original file line number Diff line number Diff line change
Expand Up @@ -105,17 +105,42 @@ fn carries_credentials(
/// MariaDB `EXECUTE IMMEDIATE`, PL/pgSQL `EXECUTE`. The text can reach
/// them through a variable or a concatenation, so every string of such a
/// script is read for the statements it holds.
///
/// A T-SQL or MySQL script that names an `@` variable counts too, since
/// the `EXEC` or `PREPARE` that runs the variable can come in a later run.
fn runs_text_as_sql(grammar: SqlGrammar, statements: &[Vec<StatementToken<'_>>]) -> bool {
let runners: &[&str] = match grammar {
SqlGrammar::MsSql => &["EXEC", "EXECUTE", "sp_executesql"],
SqlGrammar::MySql => &["PREPARE", "EXECUTE"],
SqlGrammar::PostgreSql => &["EXECUTE"],
_ => return false,
};
statements.iter().flatten().any(|token| {
let names_runner = statements.iter().flatten().any(|token| {
token
.name()
.is_some_and(|name| runners.iter().any(|runner| runner.eq_ignore_ascii_case(name)))
});
names_runner || statements.iter().any(|tokens| names_a_variable(grammar, tokens))
}

/// A T-SQL `@s` word, or MySQL's `@` before a name or a quoted name. A
/// `@@` system variable holds no text the server runs later.
fn names_a_variable(grammar: SqlGrammar, tokens: &[StatementToken<'_>]) -> bool {
tokens.iter().enumerate().any(|(at, token)| match (grammar, token) {
(SqlGrammar::MsSql, StatementToken::Word(word)) => word.starts_with('@') && !word.starts_with("@@"),
(SqlGrammar::MySql, StatementToken::Punct('@')) => {
let system = |index: Option<usize>| {
index
.and_then(|index| tokens.get(index))
.is_some_and(|token| token.is_punct('@'))
};
!system(at.checked_sub(1))
&& !system(Some(at + 1))
&& tokens
.get(at + 1)
.is_some_and(|next| next.name().is_some() || matches!(next, StatementToken::StringLiteral(_)))
}
_ => false,
})
}

Expand Down Expand Up @@ -574,4 +599,52 @@ mod tests {
assert_eq!(classify(grammar, sql), HistoryRecording::Record, "{sql}");
}
}

#[test]
fn text_stored_in_a_variable_is_read_for_statements() {
for (grammar, sql) in [
(
SqlGrammar::MySql,
"SET @s = 'CREATE USER app IDENTIFIED BY ''Hunter2!'''",
),
(
SqlGrammar::MySql,
"SELECT @s := 'CREATE USER app IDENTIFIED BY ''Hunter2!'''",
),
(
SqlGrammar::MySql,
"SET @'s' = 'CREATE USER app IDENTIFIED BY ''Hunter2!'''",
),
(
SqlGrammar::MySql,
"SELECT 'CREATE USER app IDENTIFIED BY ''Hunter2!''' INTO @s",
),
(
SqlGrammar::MsSql,
"DECLARE @s nvarchar(max) = N'CREATE USER app WITH PASSWORD = ''Hunter2!'''",
),
(
SqlGrammar::MsSql,
"SELECT @s = N'CREATE USER app WITH PASSWORD = ''Hunter2!'''",
),
(
SqlGrammar::MsSql,
"SET @s += N'CREATE USER app WITH PASSWORD = ''Hunter2!'''",
),
] {
assert_eq!(classify(grammar, sql), WITHHELD, "{sql}");
}
for grammar in [SqlGrammar::MsSql, SqlGrammar::MySql] {
for sql in ["SET @n = 5", "SELECT @n = 1"] {
assert_eq!(classify(grammar, sql), HistoryRecording::Record, "{grammar:?}: {sql}");
}
}
for (grammar, sql) in [
(SqlGrammar::MySql, "SET @s = 'SELECT 1'"),
(SqlGrammar::MsSql, "DECLARE @s nvarchar(max) = N'SELECT 1'"),
(SqlGrammar::MySql, "SET @@session.sql_mode = 'CREATE USER u'"),
] {
assert_eq!(classify(grammar, sql), HistoryRecording::Record, "{sql}");
}
}
}
19 changes: 11 additions & 8 deletions core/crates/core/src/sql_syntax/script/lexer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -275,16 +275,20 @@ impl<'a> Lexer<'a> {
None
}

/// An `E'...'` string, with every `'...'` that continues it: the
/// server reads those by the rules of the first piece, so a backslash
/// escapes in them whatever `standard_conforming_strings` says.
/// An `E'...'` string, and on PostgreSQL every `'...'` that continues
/// it: the server reads those by the rules of the first piece, so a
/// backslash escapes in them whatever `standard_conforming_strings` says.
fn escape_string(&self, start: usize) -> LexedToken {
let mut from = start + 2;
loop {
let Some(end) = self.quote_end(from, b'\'', true) else {
return self.unterminated(start, OpenConstruct::StringLiteral);
};
match self.continuing_quote(end) {
let continued = match self.syntax.escape_string_continues {
true => self.continuing_quote(end),
false => None,
};
match continued {
Some(quote) => from = quote + 1,
None => return self.plain(ScriptTokenKind::StringLiteral, end),
}
Expand All @@ -293,8 +297,8 @@ impl<'a> Lexer<'a> {

/// The quote that carries on the string that closed just before
/// `from`, as PostgreSQL's `quotecontinue` rule finds it: only blanks
/// and `--` comments in between, a line break among them. Before the
/// first line break only a space, tab or form feed counts as a blank.
/// and `--` comments in between, a line break among them. PostgreSQL
/// 16 takes a vertical tab for a blank; older servers refuse the text.
fn continuing_quote(&self, from: usize) -> Option<usize> {
let mut index = from;
let mut line_broke = false;
Expand All @@ -304,8 +308,7 @@ impl<'a> Lexer<'a> {
line_broke = true;
index += 1;
}
b' ' | b'\t' | 0x0c => index += 1,
0x0b if line_broke => index += 1,
b' ' | b'\t' | 0x0b | 0x0c => index += 1,
b'-' if self.bytes.get(index + 1) == Some(&b'-') => {
index += self.bytes[index..].iter().position(|&b| b == b'\n' || b == b'\r')?;
}
Expand Down
18 changes: 14 additions & 4 deletions core/crates/core/src/sql_syntax/script/script_rules.rs
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@ pub(crate) struct ScriptSyntax {
pub backtick: Option<QuoteRule>,
pub bracket_identifier: BracketIdentifier,
pub escape_string_prefix: bool,
/// A string after a line break carries on an `E'...'` string with its
/// backslash escapes, as PostgreSQL's `quotecontinue` rule reads it.
pub escape_string_continues: bool,
pub dash_comment_needs_space: bool,
/// A line comment ends at a carriage return as well as a line feed,
/// as PostgreSQL's scanner and SQL Server read it. Elsewhere only a
Expand Down Expand Up @@ -118,6 +121,7 @@ const POSTGRESQL: ScriptRules = ScriptRules {
backtick: None,
bracket_identifier: BracketIdentifier::None,
escape_string_prefix: true,
escape_string_continues: true,
dash_comment_needs_space: false,
line_comment_ends_at_cr: true,
hash_comment: HashComment::None,
Expand All @@ -144,6 +148,7 @@ const MYSQL: ScriptRules = ScriptRules {
backtick: Some(quote(QuoteKind::QuotedIdentifier, BackslashPolicy::Never)),
bracket_identifier: BracketIdentifier::None,
escape_string_prefix: false,
escape_string_continues: false,
dash_comment_needs_space: true,
line_comment_ends_at_cr: false,
hash_comment: HashComment::Always,
Expand All @@ -170,6 +175,7 @@ const SQLITE: ScriptRules = ScriptRules {
backtick: Some(quote(QuoteKind::QuotedIdentifier, BackslashPolicy::Never)),
bracket_identifier: BracketIdentifier::NoDoubling,
escape_string_prefix: false,
escape_string_continues: false,
dash_comment_needs_space: false,
line_comment_ends_at_cr: false,
hash_comment: HashComment::None,
Expand All @@ -196,6 +202,7 @@ const MSSQL: ScriptRules = ScriptRules {
backtick: None,
bracket_identifier: BracketIdentifier::Doubling,
escape_string_prefix: false,
escape_string_continues: false,
dash_comment_needs_space: false,
line_comment_ends_at_cr: true,
hash_comment: HashComment::None,
Expand All @@ -222,6 +229,7 @@ const CLICKHOUSE: ScriptRules = ScriptRules {
backtick: Some(quote(QuoteKind::QuotedIdentifier, BackslashPolicy::Always)),
bracket_identifier: BracketIdentifier::None,
escape_string_prefix: false,
escape_string_continues: false,
dash_comment_needs_space: false,
line_comment_ends_at_cr: false,
hash_comment: HashComment::SpaceOrBang,
Expand All @@ -248,6 +256,7 @@ const CQL: ScriptRules = ScriptRules {
backtick: None,
bracket_identifier: BracketIdentifier::None,
escape_string_prefix: false,
escape_string_continues: false,
dash_comment_needs_space: false,
line_comment_ends_at_cr: true,
hash_comment: HashComment::None,
Expand All @@ -263,9 +272,9 @@ const CQL: ScriptRules = ScriptRules {
},
};

/// DuckDB's parser is PostgreSQL's scanner without its procedural
/// bodies: a string escapes only with an `E` prefix, block comments
/// nest, `$tag$` quotes, and a backtick quotes nothing.
/// DuckDB 2.0's PEG tokenizer: a string escapes only with an `E` prefix
/// and ends at its quote, with no continuation on the next line; block
/// comments nest, `$tag$` quotes, and a backtick quotes a name as `"` does.
const DUCKDB: ScriptRules = ScriptRules {
boundary: Boundary::Semicolon,
compound: CompoundRule::None,
Expand All @@ -274,9 +283,10 @@ const DUCKDB: ScriptRules = ScriptRules {
syntax: ScriptSyntax {
single_quote: quote(QuoteKind::StringLiteral, BackslashPolicy::Never),
double_quote: quote(QuoteKind::QuotedIdentifier, BackslashPolicy::Never),
backtick: None,
backtick: Some(quote(QuoteKind::QuotedIdentifier, BackslashPolicy::Never)),
bracket_identifier: BracketIdentifier::None,
escape_string_prefix: true,
escape_string_continues: false,
dash_comment_needs_space: false,
line_comment_ends_at_cr: true,
hash_comment: HashComment::None,
Expand Down
Loading
Loading