blob: a0b11ffd8daa7deab42d4d851689c1a8b577c81d [file]
// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The ASF licenses this file
// to you under the Apache License, Version 2.0 (the
// "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
//! Checks the span of every node in every input the test suite parses,
//! against the known failures in `tests/span_baseline.tsv`.
use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::fmt;
use std::io::Write as _;
use std::path::Path;
use std::sync::{LazyLock, Mutex};
use crate::ast::{Spanned, Statement};
use crate::dialect::Dialect;
use crate::parser::{Parser, ParserOptions};
use crate::tokenizer::{Location, Span, Token, TokenWithSpan, Tokenizer};
const BASELINE: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/span_baseline.tsv");
const RECORD_DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/target/span-oracle");
/// Checks the spans of `sql` as parsed by each of `dialects` against the baseline,
/// or appends its findings to the record directory when `SPAN_ORACLE=record`.
pub(super) fn check(
dialects: &[Box<dyn Dialect>],
options: Option<&ParserOptions>,
recursion_limit: Option<usize>,
sql: &str,
) {
let input = Input {
dialects,
options,
recursion_limit,
sql,
};
// Recording must not read the baseline, which may hold merge conflict markers.
if std::env::var_os("SPAN_ORACLE").is_some_and(|mode| mode == "record") {
append_lines(Path::new(RECORD_DIR), &input.recorded_lines());
} else if let Some(report) = BASELINE_FINDINGS
.as_ref()
.and_then(|baseline| input.mismatch_report(baseline))
{
panic!("span oracle mismatch parsing {sql:?}\n{report}\nSee docs/span_oracle.md.");
}
}
type Baseline = HashMap<String, BTreeSet<String>>;
/// One SQL string as a `TestedDialects` set parses it.
struct Input<'a> {
dialects: &'a [Box<dyn Dialect>],
options: Option<&'a ParserOptions>,
recursion_limit: Option<usize>,
sql: &'a str,
}
/// The findings of one dialect, with the options hash and name its baseline lines carry.
struct DialectFindings {
options: String,
name: String,
findings: BTreeSet<String>,
}
impl Input<'_> {
fn hash(&self) -> String {
format!("{:016x}", fnv64(self.sql))
}
fn per_dialect(&self) -> Vec<DialectFindings> {
self.dialects
.iter()
.filter_map(|dialect| {
let options = effective_options(&**dialect, self.options);
let found = findings(&**dialect, &options, self.recursion_limit, self.sql)?;
Some(DialectFindings {
options: format!("{:016x}", fnv64(&format!("{options:?}"))),
name: dialect_name(&**dialect),
findings: found.iter().map(ToString::to_string).collect(),
})
})
.collect()
}
/// Baseline lines, one per finding and options hash, listing the dialects that produce it.
fn recorded_lines(&self) -> Vec<String> {
let mut grouped: BTreeMap<(String, String), Vec<String>> = BTreeMap::new();
for DialectFindings {
options,
name,
findings,
} in self.per_dialect()
{
for finding in findings {
grouped
.entry((options.clone(), finding))
.or_default()
.push(name.clone());
}
}
let input = self.hash();
grouped
.into_iter()
.map(|((options, finding), names)| {
format!("{input}\t{options}\t{}\t{finding}", names.join(","))
})
.collect()
}
/// Every difference from `baseline`, per dialect, or `None` when there is none.
fn mismatch_report(&self, baseline: &Baseline) -> Option<String> {
let input = self.hash();
let mut report = String::new();
for DialectFindings {
options,
name,
findings,
} in self.per_dialect()
{
let expected = baseline
.get(&format!("{input}\t{options}\t{name}"))
.cloned()
.unwrap_or_default();
if let Some(mismatch) = mismatch(&expected, &findings) {
report.push_str(&format!("\n{name}:{mismatch}"));
}
}
(!report.is_empty()).then_some(report)
}
}
/// The dialect's `Debug` rendering, safe to list with `,` in a tab-separated line.
fn dialect_name(dialect: &dyn Dialect) -> String {
format!("{dialect:?}").replace(|c: char| c == ',' || c.is_whitespace(), "_")
}
/// The options [`Parser::new`] would use when a test sets none.
fn effective_options(dialect: &dyn Dialect, options: Option<&ParserOptions>) -> ParserOptions {
options.cloned().unwrap_or_else(|| {
ParserOptions::new().with_trailing_commas(dialect.supports_trailing_commas())
})
}
#[derive(Debug, PartialEq, Eq)]
struct Finding {
check: &'static str,
class: &'static str,
node: String,
occurrence: usize,
text: String,
}
impl fmt::Display for Finding {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"{}\t{}\t{}\t{}\t{}",
self.check,
self.class,
escape(&self.node),
self.occurrence,
escape(&self.text)
)
}
}
/// Counts nodes by label and rendering, so a key survives changes elsewhere in the tree.
#[derive(Default)]
struct Occurrences(HashMap<(String, String), usize>);
impl Occurrences {
fn next(&mut self, node: &str, text: &str) -> usize {
let count = self
.0
.entry((node.to_string(), text.to_string()))
.or_default();
*count += 1;
*count - 1
}
}
fn findings(
dialect: &dyn Dialect,
options: &ParserOptions,
recursion_limit: Option<usize>,
sql: &str,
) -> Option<Vec<Finding>> {
let mut parser = Parser::new(dialect).with_options(options.clone());
if let Some(limit) = recursion_limit {
parser = parser.with_recursion_limit(limit);
}
let statements = parser.try_with_sql(sql).ok()?.parse_statements().ok()?;
let mut found = extent(dialect, options, sql, &statements);
found.extend(nodes::walk(dialect, options, sql, &statements));
Some(found)
}
fn extent(
dialect: &dyn Dialect,
options: &ParserOptions,
sql: &str,
statements: &[Statement],
) -> Vec<Finding> {
let truth = match statements {
[_] => token_extent(dialect, options, sql),
_ => None,
};
let mut occurrences = Occurrences::default();
let mut previous_end = None;
let mut found = Vec::new();
for statement in statements {
let span = statement.span();
let node = format!("Statement::{}", debug_variant(statement));
let text = statement.to_string();
let occurrence = occurrences.next(&node, &text);
let class = if span == Span::empty() {
Some("empty")
} else if slice(sql, span).is_none() {
Some("invalid")
} else if let Some(truth) = truth {
(span != truth).then_some("inexact")
} else {
previous_end
.is_some_and(|end| span.start < end)
.then_some("overlap")
};
if span != Span::empty() {
previous_end = Some(span.end);
}
if let Some(class) = class {
found.push(Finding {
check: "extent",
class,
node,
occurrence,
text,
});
}
}
found
}
/// First to last significant token, not counting trailing `;`.
fn token_extent(dialect: &dyn Dialect, options: &ParserOptions, sql: &str) -> Option<Span> {
let tokens = Tokenizer::new(dialect, sql)
.with_unescape(options.unescape)
.tokenize_with_location()
.ok()?;
let significant = |t: &&TokenWithSpan| !matches!(t.token, Token::Whitespace(_) | Token::EOF);
let first = tokens.iter().find(significant)?.span.start;
let last = tokens
.iter()
.rev()
.filter(significant)
.find(|t| t.token != Token::SemiColon)?
.span
.end;
Some(Span::new(first, last))
}
fn debug_variant(statement: &Statement) -> String {
format!("{statement:?}")
.chars()
.take_while(|c| c.is_alphanumeric() || *c == '_')
.collect()
}
/// Byte offset of a 1-based line and 1-based `char` column.
fn offset(sql: &str, location: Location) -> Option<usize> {
let (mut line, mut column) = (1, 1);
for (index, c) in sql.char_indices() {
if (line, column) == (location.line, location.column) {
return Some(index);
}
if c == '\n' {
line += 1;
column = 1;
} else {
column += 1;
}
}
((line, column) == (location.line, location.column)).then_some(sql.len())
}
/// The source text under `span`, if the span lies inside `sql` and is not inverted.
fn slice(sql: &str, span: Span) -> Option<&str> {
let (start, end) = (offset(sql, span.start)?, offset(sql, span.end)?);
sql.get(start..end)
}
mod nodes {
use core::convert::Infallible;
use core::ops::ControlFlow;
use super::{slice, Finding, Occurrences};
use crate::ast::{
Expr, GroupByExpr, Ident, MergeInsertExpr, MergeUpdateExpr, NodeRef, ObjectName,
OrderByExpr, Query, Select, Statement, TableFactor, ValueWithSpan, Visit, Visitor,
WildcardAdditionalOptions,
};
use crate::dialect::Dialect;
use crate::parser::{Parser, ParserError, ParserOptions};
use crate::tokenizer::{Span, Token, Tokenizer};
pub(super) fn walk(
dialect: &dyn Dialect,
options: &ParserOptions,
sql: &str,
statements: &[Statement],
) -> Vec<Finding> {
let mut oracle = Oracle {
dialect,
options,
sql,
ancestors: Vec::new(),
occurrences: Occurrences::default(),
found: Vec::new(),
};
for statement in statements {
match statement.visit(&mut oracle) {
ControlFlow::Continue(()) => {}
ControlFlow::Break(never) => match never {},
}
}
oracle.found
}
struct Oracle<'a> {
dialect: &'a dyn Dialect,
options: &'a ParserOptions,
sql: &'a str,
/// The valid span of every node on the path from the statement, `None` where there is none.
ancestors: Vec<Option<Span>>,
occurrences: Occurrences,
found: Vec<Finding>,
}
impl Visitor for Oracle<'_> {
type Break = Infallible;
fn pre_visit_node(&mut self, node: NodeRef<'_>) -> ControlFlow<Infallible> {
let span = node
.spanned()
.map(|s| s.span())
.or_else(|| node.downcast_ref::<Ident>().map(|i| i.span));
let valid = span.filter(|s| *s != Span::empty() && slice(self.sql, *s).is_some());
// Top-level statements are covered by the extent check.
if !self.ancestors.is_empty() {
if let Some(span) = span {
self.check(node, span, valid.is_some());
}
}
self.ancestors.push(valid);
ControlFlow::Continue(())
}
fn post_visit_node(&mut self, _node: NodeRef<'_>) -> ControlFlow<Infallible> {
self.ancestors.pop();
ControlFlow::Continue(())
}
}
impl Oracle<'_> {
fn check(&mut self, node: NodeRef<'_>, span: Span, valid: bool) {
let label = label(node);
let text = node.display().map(|d| d.to_string());
let occurrence = self
.occurrences
.next(&label, text.as_deref().unwrap_or_default());
let renders_empty = text.as_deref().map(str::is_empty);
let parent = self.ancestors.iter().rev().flatten().next();
let structure = if span == Span::empty() {
(renders_empty == Some(false) && !stands_for_absent_clause(node)).then_some("empty")
} else if renders_empty == Some(true) && !renders_in_parent(node) {
Some("not-empty")
} else if !valid {
Some("invalid")
} else {
parent
.is_some_and(|p| span.start < p.start || span.end > p.end)
.then_some("outside-parent")
};
// A slice equal to the rendering is exact even where it cannot parse out of context.
let source = slice(self.sql, span).filter(|_| valid);
let exact = source.is_some() && source == text.as_deref();
let reparse = (source.is_some() && !exact && self.reparses(node, span) == Some(false))
.then_some("inexact");
let edges = match (source, text.as_deref()) {
(Some(source), Some(text))
if !exact && parse_entry(node).is_none() && !renders_in_parent(node) =>
{
self.edges(source, text)
}
_ => None,
};
let text = text.unwrap_or_default();
for (check, class) in [
("structure", structure),
("reparse", reparse),
("edges", edges),
] {
if let Some(class) = class {
self.found.push(Finding {
check,
class,
node: label.clone(),
occurrence,
text: text.clone(),
});
}
}
}
/// Which end of `source` starts or ends on a different token than the rendering.
fn edges(&self, source: &str, rendered: &str) -> Option<&'static str> {
let tokens = |sql: &str| -> Option<Vec<Token>> {
let tokens = Tokenizer::new(self.dialect, sql)
.with_unescape(self.options.unescape)
.tokenize()
.ok()?;
Some(
tokens
.into_iter()
.filter(|t| !matches!(t, Token::Whitespace(_) | Token::EOF))
.collect(),
)
};
let (source, mut rendered) = (tokens(source)?, tokens(rendered)?);
// Spans exclude statement separators, which renderings of bodies keep.
while rendered.last() == Some(&Token::SemiColon) {
rendered.pop();
}
let start = same_token(source.first()?, rendered.first()?);
let end = same_token(source.last()?, rendered.last()?);
match (start, end) {
(true, true) => None,
(false, true) => Some("start"),
(true, false) => Some("end"),
(false, false) => Some("both"),
}
}
/// Whether the source under `span` parses back to the node, `None` for types with no parse entry.
fn reparses(&self, node: NodeRef<'_>, span: Span) -> Option<bool> {
let parse = parse_entry(node)?;
let expected = node.display()?.to_string();
let source = slice(self.sql, span)?;
let Ok(mut parser) = Parser::new(self.dialect)
.with_options(self.options.clone())
.try_with_sql(source)
else {
return Some(false);
};
Some(
parse(&mut parser).is_ok_and(|parsed| {
parser.peek_token().token == Token::EOF && parsed == expected
}),
)
}
}
/// Unquoted words compare case-insensitively, because the rendering uppercases keywords.
fn same_token(source: &Token, rendered: &Token) -> bool {
match (source, rendered) {
(Token::Word(a), Token::Word(b))
if a.quote_style.is_none() && b.quote_style.is_none() =>
{
a.value.eq_ignore_ascii_case(&b.value)
}
(Token::Word(a), Token::Word(b)) => {
(&a.value, a.quote_style) == (&b.value, b.quote_style)
}
// Renderings normalize literals, and reparse checks the expressions that hold them.
_ if is_literal(source) && is_literal(rendered) => {
core::mem::discriminant(source) == core::mem::discriminant(rendered)
}
_ => source == rendered,
}
}
fn is_literal(token: &Token) -> bool {
matches!(
token,
Token::Number(..)
| Token::SingleQuotedString(_)
| Token::DoubleQuotedString(_)
| Token::TripleSingleQuotedString(_)
| Token::TripleDoubleQuotedString(_)
| Token::DollarQuotedString(_)
| Token::SingleQuotedByteStringLiteral(_)
| Token::DoubleQuotedByteStringLiteral(_)
| Token::TripleSingleQuotedByteStringLiteral(_)
| Token::TripleDoubleQuotedByteStringLiteral(_)
| Token::SingleQuotedRawStringLiteral(_)
| Token::DoubleQuotedRawStringLiteral(_)
| Token::TripleSingleQuotedRawStringLiteral(_)
| Token::TripleDoubleQuotedRawStringLiteral(_)
| Token::NationalStringLiteral(_)
| Token::QuoteDelimitedStringLiteral(_)
| Token::NationalQuoteDelimitedStringLiteral(_)
| Token::EscapedStringLiteral(_)
| Token::UnicodeStringLiteral(_)
| Token::HexStringLiteral(_)
)
}
type ParseEntry = fn(&mut Parser<'_>) -> Result<String, ParserError>;
fn parse_entry(node: NodeRef<'_>) -> Option<ParseEntry> {
let entries: [(bool, ParseEntry); 9] = [
(node.downcast_ref::<Statement>().is_some(), |p| {
p.parse_statement().map(|v| v.to_string())
}),
(node.downcast_ref::<Query>().is_some(), |p| {
p.parse_query().map(|v| v.to_string())
}),
(node.downcast_ref::<Select>().is_some(), |p| {
p.parse_select().map(|v| v.to_string())
}),
(node.downcast_ref::<TableFactor>().is_some(), |p| {
p.parse_table_factor().map(|v| v.to_string())
}),
(node.downcast_ref::<Expr>().is_some(), |p| {
p.parse_expr().map(|v| v.to_string())
}),
(node.downcast_ref::<OrderByExpr>().is_some(), |p| {
p.parse_order_by_expr().map(|v| v.to_string())
}),
(node.downcast_ref::<ValueWithSpan>().is_some(), |p| {
p.parse_value().map(|v| v.to_string())
}),
(node.downcast_ref::<Ident>().is_some(), |p| {
p.parse_identifier().map(|v| v.to_string())
}),
(node.downcast_ref::<ObjectName>().is_some(), |p| {
p.parse_object_name(false).map(|v| v.to_string())
}),
];
entries
.into_iter()
.find_map(|(matches, entry)| matches.then_some(entry))
}
/// Nodes that consumed no tokens yet render text.
fn stands_for_absent_clause(node: NodeRef<'_>) -> bool {
node.downcast_ref::<GroupByExpr>().is_some_and(
|g| matches!(g, GroupByExpr::Expressions(exprs, modifiers) if exprs.is_empty() && modifiers.is_empty()),
)
}
/// Nodes that own a leading token their parent renders, as `*` in [`WildcardAdditionalOptions`].
fn renders_in_parent(node: NodeRef<'_>) -> bool {
node.downcast_ref::<WildcardAdditionalOptions>().is_some()
|| node.downcast_ref::<MergeInsertExpr>().is_some()
|| node.downcast_ref::<MergeUpdateExpr>().is_some()
}
/// `Type::Variant` with module paths removed, as in `Expr::Cast` or `Parens<Expr>`.
fn label(node: NodeRef<'_>) -> String {
let mut label = String::new();
let mut segment = String::new();
let mut chars = node.type_name().chars().peekable();
while let Some(c) = chars.next() {
if c == ':' && chars.peek() == Some(&':') {
chars.next();
segment.clear();
} else if c.is_alphanumeric() || c == '_' {
segment.push(c);
} else {
label.push_str(&segment);
segment.clear();
label.push(c);
}
}
label.push_str(&segment);
if let Some(variant) = node.variant_name() {
label.push_str("::");
label.push_str(variant);
}
label
}
}
/// Baseline findings keyed by input, options and dialect, `None` outside this repository.
static BASELINE_FINDINGS: LazyLock<Option<Baseline>> =
LazyLock::new(|| load_baseline(Path::new(BASELINE)));
/// `None` when the file does not exist, as in the published crate, which ships without it.
fn load_baseline(path: &Path) -> Option<Baseline> {
let text = match std::fs::read_to_string(path) {
Ok(text) => text,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return None,
Err(e) => panic!("reading {}: {e}", path.display()),
};
let mut findings = Baseline::new();
for line in text.lines() {
let fields: Vec<&str> = line.splitn(4, '\t').collect();
let [input, options, dialects, finding] = fields[..] else {
panic!("malformed line in {}: {line:?}", path.display());
};
for dialect in dialects.split(',') {
findings
.entry(format!("{input}\t{options}\t{dialect}"))
.or_default()
.insert(finding.to_string());
}
}
Some(findings)
}
/// Appends `lines` to this process's file in `dir`, which the recording merge reads.
fn append_lines(dir: &Path, lines: &[String]) {
static WRITE: Mutex<()> = Mutex::new(());
if lines.is_empty() {
return;
}
let _guard = WRITE
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let path = dir.join(format!("{}.tsv", std::process::id()));
let written = std::fs::create_dir_all(dir).and_then(|()| {
let mut file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)?;
lines.iter().try_for_each(|line| writeln!(file, "{line}"))
});
if let Err(e) = written {
panic!("recording span findings to {}: {e}", path.display());
}
}
#[derive(Debug)]
struct Mismatch {
new: Vec<String>,
fixed: Vec<String>,
}
fn mismatch(expected: &BTreeSet<String>, actual: &BTreeSet<String>) -> Option<Mismatch> {
let new: Vec<String> = actual.difference(expected).cloned().collect();
let fixed: Vec<String> = expected.difference(actual).cloned().collect();
(!new.is_empty() || !fixed.is_empty()).then_some(Mismatch { new, fixed })
}
impl fmt::Display for Mismatch {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if !self.new.is_empty() {
writeln!(f, "\nFindings missing from tests/span_baseline.tsv:")?;
self.new.iter().try_for_each(|l| writeln!(f, "{l}"))?;
}
if !self.fixed.is_empty() {
writeln!(f, "\nBaseline findings that no longer fail:")?;
self.fixed.iter().try_for_each(|l| writeln!(f, "{l}"))?;
}
Ok(())
}
}
fn escape(text: &str) -> String {
text.replace('\\', "\\\\")
.replace('\t', "\\t")
.replace('\n', "\\n")
.replace('\r', "\\r")
}
/// FNV-1a, stable across platforms and releases.
fn fnv64(text: &str) -> u64 {
text.bytes().fold(0xcbf2_9ce4_8422_2325, |hash, byte| {
(hash ^ u64::from(byte)).wrapping_mul(0x0000_0100_0000_01b3)
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ast::helpers::attached_token::AttachedToken;
use crate::ast::{Expr, Select, SelectItem, SetExpr};
use crate::dialect::{AnsiDialect, GenericDialect};
use crate::tokenizer::Location;
fn findings_of(sql: &str) -> Vec<Finding> {
let dialect = GenericDialect {};
findings(&dialect, &effective_options(&dialect, None), None, sql).unwrap()
}
fn has(findings: &[Finding], check: &str, class: &str, text: &str) -> bool {
findings
.iter()
.any(|f| f.check == check && f.class == class && f.text == text)
}
#[test]
fn offset_counts_chars_from_one() {
let sql = "é\nab";
assert_eq!(offset(sql, Location::new(1, 1)), Some(0));
assert_eq!(offset(sql, Location::new(1, 2)), Some(2));
assert_eq!(offset(sql, Location::new(2, 2)), Some(4));
assert_eq!(offset(sql, Location::new(2, 3)), Some(5));
assert_eq!(offset(sql, Location::new(2, 4)), None);
assert_eq!(offset(sql, Location::new(0, 0)), None);
}
#[test]
fn correct_spans_produce_no_findings() {
assert_eq!(findings_of("SELECT a FROM t WHERE b = 1"), []);
}
/// Parses `sql` and applies `change` to the first statement's first and last token.
fn statement_with_tokens(
sql: &str,
change: impl FnOnce(&mut AttachedToken, &mut AttachedToken),
) -> Vec<Statement> {
let mut statements = Parser::parse_sql(&GenericDialect {}, sql).unwrap();
let (start_token, end_token) = statements[0].tokens_mut();
change(start_token, end_token);
statements
}
#[test]
fn truncated_statement_fails_extent() {
let sql = "SELECT CAST(a AS INT)";
let statements = statement_with_tokens(sql, |start, end| *end = start.clone());
let dialect = GenericDialect {};
let found = extent(
&dialect,
&effective_options(&dialect, None),
sql,
&statements,
);
assert!(has(&found, "extent", "inexact", sql));
}
#[test]
fn empty_statement_fails_extent() {
let sql = "DROP TABLE t";
let statements = statement_with_tokens(sql, |start, end| {
*start = AttachedToken::empty();
*end = AttachedToken::empty();
});
let dialect = GenericDialect {};
let found = extent(
&dialect,
&effective_options(&dialect, None),
sql,
&statements,
);
assert!(has(&found, "extent", "empty", sql));
}
#[test]
fn span_missing_operator_fails_reparse() {
let found = findings_of("SELECT -x FROM t");
assert!(has(&found, "reparse", "inexact", "-x"));
}
#[test]
fn child_outside_parent_fails_structure() {
let found = findings_of("SELECT a FROM t WINDOW w AS (ORDER BY a)");
assert!(has(&found, "structure", "outside-parent", "a"));
}
#[test]
fn span_missing_leading_keywords_fails_edges() {
let found = findings_of("SELECT * FROM a LEFT JOIN b ON a.x = b.x");
assert!(has(&found, "edges", "start", "LEFT JOIN b ON a.x = b.x"));
}
#[test]
fn edges_ignore_keyword_case() {
let found = findings_of("select null as x from t");
assert!(!found
.iter()
.any(|f| f.check == "edges" && f.text == "NULL AS x"));
}
#[test]
fn missing_baseline_disables_the_check() {
let dir = TempDir::new("missing");
let missing = dir.0.join("missing.tsv");
let empty = dir.0.join("empty.tsv");
std::fs::write(&empty, "").unwrap();
assert!(load_baseline(&missing).is_none());
assert_eq!(load_baseline(&empty), Some(HashMap::new()));
}
fn input<'a>(dialects: &'a [Box<dyn Dialect>], sql: &'a str) -> Input<'a> {
Input {
dialects,
options: None,
recursion_limit: None,
sql,
}
}
fn generic_and_ansi() -> Vec<Box<dyn Dialect>> {
vec![Box::new(GenericDialect {}), Box::new(AnsiDialect {})]
}
/// A scratch directory removed on drop, also when a test panics.
struct TempDir(std::path::PathBuf);
impl TempDir {
fn new(name: &str) -> Self {
let dir =
std::env::temp_dir().join(format!("span-oracle-{name}-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
Self(dir)
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
#[test]
fn recorded_lines_list_every_dialect_with_the_finding() {
let dialects = generic_and_ansi();
let lines = input(&dialects, "SELECT CAST(a AS INT)").recorded_lines();
assert!(lines
.iter()
.any(|l| l.contains("\tGenericDialect,AnsiDialect\treparse\tinexact\tExpr::Cast\t")));
}
#[test]
fn recorded_lines_round_trip_through_the_baseline() {
let dialects = generic_and_ansi();
let input = input(&dialects, "SELECT CAST(a AS INT)");
let lines = input.recorded_lines();
let dir = TempDir::new("round-trip");
let path = dir.0.join("baseline.tsv");
std::fs::write(&path, lines.join("\n")).unwrap();
assert_eq!(input.mismatch_report(&load_baseline(&path).unwrap()), None);
let (dropped, kept) = lines.split_first().unwrap();
std::fs::write(&path, kept.join("\n")).unwrap();
let report = input
.mismatch_report(&load_baseline(&path).unwrap())
.unwrap();
let finding = dropped.splitn(4, '\t').nth(3).unwrap();
assert!(report.contains(finding));
let key: Vec<&str> = dropped.splitn(4, '\t').take(3).collect();
let stale = format!("{}\tstructure\tempty\tFixed\t0\tfixed", key.join("\t"));
std::fs::write(&path, [lines.join("\n"), stale].join("\n")).unwrap();
let report = input
.mismatch_report(&load_baseline(&path).unwrap())
.unwrap();
assert!(report.contains("Fixed\t0\tfixed"));
}
#[test]
fn unparsable_input_has_no_findings() {
let dialects = generic_and_ansi();
let input = input(&dialects, "SELEC a");
assert_eq!(input.recorded_lines(), Vec::<String>::new());
assert_eq!(input.mismatch_report(&Baseline::new()), None);
}
#[test]
fn appended_lines_accumulate_in_one_file_per_process() {
let dir = TempDir::new("append");
append_lines(&dir.0, &["a".to_string()]);
append_lines(&dir.0, &["b".to_string()]);
let written = std::fs::read_to_string(dir.0.join(format!("{}.tsv", std::process::id())));
assert_eq!(written.unwrap(), "a\nb\n");
std::fs::remove_dir_all(&dir.0).unwrap();
append_lines(&dir.0, &[]);
assert!(!dir.0.exists());
}
#[test]
#[should_panic(expected = "malformed line")]
fn malformed_baseline_line_is_rejected() {
let dir = TempDir::new("malformed");
let path = dir.0.join("baseline.tsv");
std::fs::write(&path, "<<<<<<< HEAD\n").unwrap();
load_baseline(&path);
}
fn select_mut(statement: &mut Statement) -> Option<&mut Select> {
match statement {
Statement::Query(query) => match query.body.as_mut() {
SetExpr::Select(select) => Some(select),
_ => None,
},
_ => None,
}
}
fn far_away() -> Span {
Span::new(Location::new(9, 1), Location::new(9, 2))
}
#[test]
fn span_outside_the_input_is_invalid() {
let dialect = GenericDialect {};
let options = effective_options(&dialect, None);
let sql = "SELECT a FROM t";
let mut statements = Parser::parse_sql(&dialect, sql).unwrap();
if let Some(select) = select_mut(&mut statements[0]) {
if let SelectItem::UnnamedExpr(Expr::Identifier(ident)) = &mut select.projection[0] {
ident.span = far_away();
}
}
let found = nodes::walk(&dialect, &options, sql, &statements);
assert!(has(&found, "structure", "invalid", "a"));
statements[0].tokens_mut().1 .0.span = far_away();
let found = extent(&dialect, &options, sql, &statements);
assert!(has(&found, "extent", "invalid", "SELECT a FROM t"));
}
#[test]
fn span_splitting_a_token_fails_reparse() {
let dialect = GenericDialect {};
let options = effective_options(&dialect, None);
let sql = "SELECT 'ab' FROM t";
let mut statements = Parser::parse_sql(&dialect, sql).unwrap();
if let Some(select) = select_mut(&mut statements[0]) {
if let SelectItem::UnnamedExpr(Expr::Value(value)) = &mut select.projection[0] {
value.span = Span::new(Location::new(1, 8), Location::new(1, 10));
}
}
let found = nodes::walk(&dialect, &options, sql, &statements);
assert!(has(&found, "reparse", "inexact", "'ab'"));
}
#[test]
fn edges_ignore_a_rendered_statement_separator() {
let found = findings_of("IF 1 THEN SELECT 1; END IF");
assert!(!found
.iter()
.any(|f| f.check == "edges" && f.node == "ConditionalStatements::Sequence"));
}
#[test]
fn mismatch_reports_both_directions() {
let lines = |l: &[&str]| l.iter().map(|s| s.to_string()).collect::<BTreeSet<_>>();
assert!(mismatch(&lines(&["a"]), &lines(&["a"])).is_none());
let m = mismatch(&lines(&["a"]), &lines(&["a", "b"])).unwrap();
assert_eq!((m.new, m.fixed), (vec!["b".to_string()], vec![]));
let m = mismatch(&lines(&["a", "b"]), &lines(&["a"])).unwrap();
assert_eq!((m.new, m.fixed), (vec![], vec!["b".to_string()]));
}
}