blob: fdc3c193ce02bc1844a648c4078660b1d5de5fbd [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.
//! Reports planning statistics alongside runtime metrics for benchmark queries.
use std::collections::{BTreeMap, HashMap, VecDeque};
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::{Arc, LazyLock};
use clap::Args;
use datafusion::error::{DataFusionError, Result};
use datafusion::logical_expr::LogicalPlan;
use datafusion::physical_plan::metrics::MetricValue;
use datafusion::physical_plan::operator_statistics::StatisticsRegistry;
use datafusion::physical_plan::statistics::{StatisticsArgs, StatisticsContext};
use datafusion::physical_plan::{ExecutionPlan, collect};
use datafusion::prelude::{ParquetReadOptions, SessionConfig, SessionContext};
use datafusion::sql::parser::{DFParserBuilder, Statement};
use datafusion::sql::sqlparser::dialect::dialect_from_str;
use datafusion_common::config::{Dialect, SqlParserOptions};
use datafusion_common::config_err;
use datafusion_common::stats::Precision;
use regex::Regex;
use serde::{Deserialize, Serialize};
/// Generate reports that compare planning statistics with runtime metrics.
///
/// Parser options are refreshed before each query file is parsed, so `SET`
/// statements affect later files. Because each file is parsed before any of its
/// statements are executed, parser-setting changes do not affect later
/// statements in the same file.
#[derive(Debug, Args)]
#[command(verbatim_doc_comment)]
pub struct RunOpt {
/// Query filename stem. If not specified, runs every `.sql` file.
#[arg(short, long)]
query: Option<String>,
/// Branch whose results should be compared. Defaults to the previous run on this branch.
#[arg(long)]
compare: Option<String>,
/// Path to Parquet data. Top-level files and directories are registered as tables.
#[arg(required = true, short = 'p', long)]
path: PathBuf,
/// Path to a SQL file or directory of SQL query files.
#[arg(required = true, short = 'Q', long = "query_path")]
query_path: PathBuf,
}
impl RunOpt {
pub async fn run(self) -> Result<()> {
let mut config = SessionConfig::from_env()?.with_collect_statistics(true);
config.options_mut().optimizer.prefer_hash_join = true;
let ctx = SessionContext::new_with_config(config);
register_parquet_files(&ctx, &self.path).await?;
let branch = current_branch_name();
let result_path = self.report_path(&branch);
let comparison_branch = self
.compare
.as_deref()
.map_or(branch.as_str(), |branch| branch);
let comparison_path = self.report_path(comparison_branch);
let previous = load_comparison_report(&comparison_path)?;
backup_previous_report(&result_path)?;
let comparison_description = self.compare.as_ref().map_or_else(
|| format!("previous run on branch '{branch}'"),
|branch| format!("branch '{branch}'"),
);
let mut reports = vec![];
for query_path in query_files(&self.query_path, self.query.as_deref())? {
self.report_query_file(&ctx, &query_path, previous.as_deref(), &mut reports)
.await?;
}
store_report(&result_path, &reports)?;
print_q_error_summary(
&reports,
previous.as_deref(),
&branch,
&comparison_description,
);
Ok(())
}
async fn report_query_file(
&self,
ctx: &SessionContext,
query_path: &Path,
previous: Option<&[QueryReport]>,
reports: &mut Vec<QueryReport>,
) -> Result<()> {
let query = query_path
.file_stem()
.expect("query file has a filename")
.to_string_lossy()
.to_string();
let sql = fs::read_to_string(query_path)?;
let sql_parser_options = ctx.state().config_options().sql_parser.clone();
let statements = match sql_statements(&sql, &sql_parser_options) {
Ok(statements) => statements,
Err(error) => {
let report = QueryReport {
query,
statement: 1,
operators: vec![],
success: false,
error: Some(error.to_string()),
};
print_query_report(&report, previous);
reports.push(report);
return Ok(());
}
};
for (statement, sql) in statements.into_iter().enumerate() {
let statement = statement + 1;
let report = match self.report_statement(ctx, sql).await {
Ok(operators) => QueryReport {
query: query.clone(),
statement,
operators,
success: true,
error: None,
},
Err(error) => QueryReport {
query: query.clone(),
statement,
operators: vec![],
success: false,
error: Some(error.to_string()),
},
};
print_query_report(&report, previous);
reports.push(report);
}
Ok(())
}
fn report_path(&self, branch: &str) -> PathBuf {
let report_name = self.query.as_ref().map_or_else(
|| "statistics.json".to_string(),
|query| format!("statistics-{query}.json"),
);
PathBuf::from("target/dfbench/statistics")
.join(normalize_branch_name(branch))
.join(report_name)
}
async fn report_statement(
&self,
ctx: &SessionContext,
statement: Statement,
) -> Result<Vec<OperatorReport>> {
let state = ctx.state();
let logical_plan = state.statement_to_plan(statement).await?;
if matches!(
logical_plan,
LogicalPlan::Ddl(_) | LogicalPlan::Statement(_)
) {
ctx.execute_logical_plan(logical_plan)
.await?
.collect()
.await?;
return Ok(vec![]);
}
let logical_plan = state.optimize(&logical_plan)?;
let physical_plan = state.create_physical_plan(&logical_plan).await?;
let statistics = capture_statistics(
physical_plan.as_ref(),
&state.statistics_registry().cloned().unwrap_or_default(),
)?;
collect(Arc::clone(&physical_plan), state.task_ctx()).await?;
let mut report = Vec::with_capacity(statistics.len());
append_runtime_metrics(physical_plan.as_ref(), &statistics, &mut report);
Ok(report)
}
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
struct QueryReport {
query: String,
statement: usize,
operators: Vec<OperatorReport>,
#[serde(default = "default_success")]
success: bool,
#[serde(default)]
error: Option<String>,
}
fn default_success() -> bool {
true
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
struct OperatorReport {
path: String,
name: String,
estimated_rows: StatisticValue,
estimated_bytes: StatisticValue,
runtime_output_rows: Option<usize>,
runtime_output_bytes: Option<usize>,
q_error: Option<QError>,
}
#[derive(Debug, Serialize)]
struct CapturedStatistics {
path: String,
name: String,
estimated_rows: StatisticValue,
estimated_bytes: StatisticValue,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)]
#[serde(tag = "precision", content = "value", rename_all = "snake_case")]
enum StatisticValue {
Exact(usize),
Inexact(usize),
Absent,
}
/// Symmetric multiplicative error between an estimated and actual row count.
///
/// The q-error metric is infinite when exactly one value is zero. A zero
/// estimate for an actual zero result is reported separately as an exact
/// estimate and is excluded from q-error percentiles.
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
#[serde(tag = "kind", content = "value", rename_all = "snake_case")]
enum QError {
Finite(f64),
Infinite,
ExactZero,
}
fn capture_statistics(
plan: &dyn ExecutionPlan,
statistics_context: &StatisticsRegistry,
) -> Result<Vec<CapturedStatistics>> {
let mut result = vec![];
capture_statistics_inner(plan, statistics_context, "0", &mut result)?;
Ok(result)
}
fn capture_statistics_inner(
plan: &dyn ExecutionPlan,
statistics_context: &StatisticsRegistry,
path: &str,
result: &mut Vec<CapturedStatistics>,
) -> Result<()> {
let statistics = StatisticsContext::new_with_registry(statistics_context.clone())
.compute(plan, &StatisticsArgs::new())?;
result.push(CapturedStatistics {
path: path.to_string(),
name: plan.name().to_string(),
estimated_rows: statistic_value(statistics.num_rows),
estimated_bytes: statistic_value(statistics.total_byte_size),
});
for (child_index, child) in plan.children().iter().enumerate() {
capture_statistics_inner(
child.as_ref(),
statistics_context,
&format!("{path}.{child_index}"),
result,
)?;
}
Ok(())
}
fn append_runtime_metrics(
plan: &dyn ExecutionPlan,
statistics: &[CapturedStatistics],
report: &mut Vec<OperatorReport>,
) {
let statistics_entry = &statistics[report.len()];
let (runtime_output_rows, runtime_output_bytes) =
plan.metrics().map_or((None, None), |m| {
(
m.output_rows(),
m.sum(|metric| matches!(metric.value(), MetricValue::OutputBytes(_)))
.map(|metric| metric.as_usize()),
)
});
report.push(OperatorReport {
path: statistics_entry.path.clone(),
name: statistics_entry.name.clone(),
estimated_rows: statistics_entry.estimated_rows.clone(),
estimated_bytes: statistics_entry.estimated_bytes.clone(),
runtime_output_rows,
runtime_output_bytes,
q_error: q_error(&statistics_entry.estimated_rows, runtime_output_rows),
});
for child in plan.children() {
append_runtime_metrics(child.as_ref(), statistics, report);
}
}
fn q_error(estimated: &StatisticValue, actual: Option<usize>) -> Option<QError> {
let estimated = match estimated {
StatisticValue::Exact(value) | StatisticValue::Inexact(value) => *value,
StatisticValue::Absent => return None,
};
let actual = actual?;
if estimated == 0 && actual == 0 {
Some(QError::ExactZero)
} else if estimated == 0 || actual == 0 {
Some(QError::Infinite)
} else {
Some(QError::Finite(
estimated.max(actual) as f64 / estimated.min(actual) as f64,
))
}
}
fn statistic_value(value: Precision<usize>) -> StatisticValue {
match value {
Precision::Exact(value) => StatisticValue::Exact(value),
Precision::Inexact(value) => StatisticValue::Inexact(value),
Precision::Absent => StatisticValue::Absent,
}
}
fn load_comparison_report(comparison_path: &Path) -> Result<Option<Vec<QueryReport>>> {
if comparison_path.exists() {
let previous = fs::read_to_string(comparison_path)?;
let previous: Vec<QueryReport> = serde_json::from_str(&previous)
.map_err(|error| DataFusionError::External(Box::new(error)))?;
Ok(Some(previous))
} else {
eprintln!(
"No comparison report found at {}",
comparison_path.display()
);
Ok(None)
}
}
fn backup_previous_report(result_path: &Path) -> Result<()> {
if result_path.exists() {
let previous_path = result_path.with_extension("previous.json");
fs::copy(result_path, previous_path)?;
}
Ok(())
}
fn store_report(result_path: &Path, report: &[QueryReport]) -> Result<()> {
let parent = result_path.parent().expect("result path has a parent");
fs::create_dir_all(parent)?;
let temporary_path = result_path.with_extension("tmp");
fs::write(&temporary_path, serialize_report(report)?)?;
fs::rename(temporary_path, result_path)?;
Ok(())
}
fn current_branch_name() -> String {
Command::new("git")
.args(["rev-parse", "--abbrev-ref", "HEAD"])
.output()
.ok()
.filter(|output| output.status.success())
.and_then(|output| String::from_utf8(output.stdout).ok())
.map(|branch| branch.trim().to_string())
.filter(|branch| branch != "HEAD")
.unwrap_or_else(|| "detached".to_string())
}
fn normalize_branch_name(branch: &str) -> String {
branch
.chars()
.map(|character| match character {
'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '_' | '.' => character,
_ => '_',
})
.collect()
}
fn print_query_report(query: &QueryReport, previous: Option<&[QueryReport]>) {
if !query.success {
println!(
"=== {} (statement {}) FAILED ===\n{}",
query.query,
query.statement,
query.error.as_deref().unwrap_or("unknown error")
);
return;
}
let previous = previous.map(operator_reports);
println!("=== {} ===", query.query);
for operator in &query.operators {
let identifier = OperatorIdentifier::new(query, operator);
let previous_q_error = previous
.as_ref()
.and_then(|reports| reports.get(&identifier))
.and_then(|operator| operator.q_error.as_ref());
let depth = operator.path.matches('.').count();
println!(
"{:indent$}{}: rows={} vs {}, q-error: previous={}, current={}, change={}",
"",
operator.name,
display_statistic(&operator.estimated_rows),
display_option(operator.runtime_output_rows),
display_q_error(previous_q_error),
display_q_error(operator.q_error.as_ref()),
display_improvement(previous_q_error, operator.q_error.as_ref()),
indent = depth * 2,
);
}
}
fn display_option(value: Option<usize>) -> String {
value.map_or_else(|| "?".to_string(), |value| value.to_string())
}
fn display_statistic(value: &StatisticValue) -> String {
match value {
StatisticValue::Exact(value) => format!("Exact({value})"),
StatisticValue::Inexact(value) => format!("Inexact({value})"),
StatisticValue::Absent => "Absent".to_string(),
}
}
fn display_q_error(value: Option<&QError>) -> String {
match value {
Some(QError::Finite(value)) => format!("{value:.2}x"),
Some(QError::Infinite) => "infinite".to_string(),
Some(QError::ExactZero) => "exact zero".to_string(),
None => "?".to_string(),
}
}
fn display_improvement(previous: Option<&QError>, current: Option<&QError>) -> String {
match (previous, current) {
(Some(QError::Finite(previous)), Some(QError::Finite(current))) => {
let improvement = (previous - current) / previous * 100.0;
if improvement >= 20.0 {
format!("✅ {improvement:.1}%")
} else if improvement <= -20.0 {
format!("❌ {improvement:.1}%")
} else {
format!("{improvement:.1}%")
}
}
(Some(QError::Infinite), Some(QError::Infinite)) => "0.0%".to_string(),
(Some(QError::ExactZero), Some(QError::ExactZero)) => "0.0%".to_string(),
(Some(QError::Infinite | QError::Finite(_)), Some(QError::ExactZero)) => {
"✅ exact zero".to_string()
}
(Some(QError::ExactZero), Some(QError::Infinite | QError::Finite(_))) => {
"❌ no longer exact zero".to_string()
}
(Some(QError::Infinite), Some(QError::Finite(_))) => "✅ resolved".to_string(),
(Some(QError::Finite(_)), Some(QError::Infinite)) => "❌ infinite".to_string(),
_ => "?".to_string(),
}
}
fn print_q_error_summary(
reports: &[QueryReport],
previous: Option<&[QueryReport]>,
branch: &str,
comparison_description: &str,
) {
let q_errors = sorted_finite_q_errors(reports);
let previous_q_errors = previous.map(sorted_finite_q_errors);
let q_error_counts = q_error_counts(reports);
let total = reports
.iter()
.map(|query| query.operators.len())
.sum::<usize>();
println!("=== q-error summary ===");
println!("current: branch '{branch}'");
println!("comparison: {comparison_description}");
let failed = reports.iter().filter(|query| !query.success).count();
println!(
"queries: {} succeeded, {failed} failed",
reports.len() - failed
);
println!(
"evaluated operators: {}/{}",
q_error_counts.evaluated, total
);
println!(
"finite q-errors: {}, exact-zero estimates: {}, infinite q-errors: {}",
q_error_counts.finite, q_error_counts.exact_zero, q_error_counts.infinite
);
for percentile in [50, 75, 95, 99] {
let current = percentile_q_error(&q_errors, percentile);
let previous = previous_q_errors
.as_ref()
.and_then(|q_errors| percentile_q_error(q_errors, percentile));
println!(
"p{percentile}: previous={}, current={}, change={}",
display_q_error(previous),
display_q_error(current),
display_improvement(previous, current),
);
}
}
struct QErrorCounts {
evaluated: usize,
finite: usize,
exact_zero: usize,
infinite: usize,
}
fn q_error_counts(reports: &[QueryReport]) -> QErrorCounts {
let mut counts = QErrorCounts {
evaluated: 0,
finite: 0,
exact_zero: 0,
infinite: 0,
};
for q_error in reports
.iter()
.flat_map(|query| &query.operators)
.filter_map(|operator| operator.q_error.as_ref())
{
counts.evaluated += 1;
match q_error {
QError::Finite(_) => counts.finite += 1,
QError::ExactZero => counts.exact_zero += 1,
QError::Infinite => counts.infinite += 1,
}
}
counts
}
fn sorted_finite_q_errors(reports: &[QueryReport]) -> Vec<&QError> {
let mut q_errors = reports
.iter()
.flat_map(|query| &query.operators)
.filter_map(|operator| match operator.q_error.as_ref() {
Some(QError::Finite(_)) => operator.q_error.as_ref(),
Some(QError::ExactZero | QError::Infinite) | None => None,
})
.collect::<Vec<_>>();
q_errors.sort_by(|left, right| q_error_value(left).total_cmp(&q_error_value(right)));
q_errors
}
fn percentile_q_error<'a>(
q_errors: &[&'a QError],
percentile: usize,
) -> Option<&'a QError> {
if q_errors.is_empty() {
return None;
}
let index = (percentile * q_errors.len())
.div_ceil(100)
.saturating_sub(1);
q_errors.get(index).copied()
}
fn q_error_value(q_error: &QError) -> f64 {
match q_error {
QError::Finite(value) => *value,
QError::Infinite => f64::INFINITY,
QError::ExactZero => 1.0,
}
}
fn operator_reports(
reports: &[QueryReport],
) -> HashMap<OperatorIdentifier<'_>, &OperatorReport> {
reports
.iter()
.flat_map(|query| {
query
.operators
.iter()
.map(move |operator| (OperatorIdentifier::new(query, operator), operator))
})
.collect()
}
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
struct OperatorIdentifier<'a> {
query: &'a str,
statement: usize,
path: &'a str,
name: &'a str,
}
impl<'a> OperatorIdentifier<'a> {
fn new(query: &'a QueryReport, operator: &'a OperatorReport) -> Self {
Self {
query: query.query.as_str(),
statement: query.statement,
path: operator.path.as_str(),
name: operator.name.as_str(),
}
}
}
fn serialize_report(report: &[QueryReport]) -> Result<String> {
serde_json::to_string_pretty(report)
.map_err(|error| DataFusionError::External(Box::new(error)))
}
fn sql_statements(sql: &str, options: &SqlParserOptions) -> Result<VecDeque<Statement>> {
let dialect = dialect_from_str(options.dialect).ok_or_else(|| {
DataFusionError::Plan(format!(
"Unsupported SQL dialect: {}. Available dialects: {}.",
options.dialect,
Dialect::available()
))
})?;
DFParserBuilder::new(sql)
.with_dialect(dialect.as_ref())
.with_recursion_limit(options.recursion_limit.get())
.build()?
.parse_statements()
}
fn query_files(path: &Path, query: Option<&str>) -> Result<Vec<PathBuf>> {
if path.is_file() {
return Ok(vec![path.to_path_buf()]);
}
let mut files = fs::read_dir(path)?
.filter_map(|entry| entry.ok().map(|entry| entry.path()))
.filter(|path| path.extension().is_some_and(|extension| extension == "sql"))
.filter(|path| {
query.is_none_or(|query| path.file_stem().is_some_and(|name| name == query))
})
.collect::<Vec<_>>();
files.sort_by(|left, right| {
query_number(left)
.cmp(&query_number(right))
.then_with(|| left.cmp(right))
});
Ok(files)
}
fn query_number(path: &Path) -> Option<usize> {
static QUERY_NUMBER: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\d+").unwrap());
let filename = path.file_stem()?.to_str()?;
QUERY_NUMBER.find(filename)?.as_str().parse().ok()
}
async fn register_parquet_files(ctx: &SessionContext, path: &Path) -> Result<()> {
let mut files = vec![];
collect_parquet_files(path, &mut files)?;
let mut tables = BTreeMap::<String, PathBuf>::new();
for file in files {
let parent = file.parent().expect("Parquet file has a parent directory");
let relative_parent = parent
.strip_prefix(path)
.expect("Parquet file is within the data path");
let (table, table_path) = if relative_parent.as_os_str().is_empty() {
(
file.file_stem()
.expect("Parquet file has a filename")
.to_string_lossy()
.to_string(),
file,
)
} else {
let table = relative_parent
.components()
.next()
.expect("relative table directory has a component")
.as_os_str()
.to_string_lossy()
.to_string();
(table.clone(), path.join(table))
};
if let Some(existing_path) = tables.get(&table) {
if existing_path != &table_path {
return config_err!(
"Tried to register duplicate table {table} from '{}' and '{}'",
existing_path.display(),
table_path.display()
);
}
} else {
tables.insert(table, table_path);
}
}
for (table, table_path) in tables {
ctx.register_parquet(
table,
table_path.to_string_lossy(),
ParquetReadOptions::default(),
)
.await?;
}
Ok(())
}
fn collect_parquet_files(path: &Path, files: &mut Vec<PathBuf>) -> Result<()> {
for entry in fs::read_dir(path)? {
let path = entry?.path();
if path.is_dir() {
collect_parquet_files(&path, files)?;
} else if path
.extension()
.is_some_and(|extension| extension == "parquet")
{
files.push(path);
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use datafusion::execution::session_state::SessionStateBuilder;
use datafusion::physical_plan::Statistics;
use datafusion::physical_plan::operator_statistics::{
ClosureStatisticsProvider, StatisticsResult,
};
use tempfile::tempdir;
#[test]
fn parses_statements_without_losing_external_table_details() {
let statements = sql_statements(
"CREATE EXTERNAL TABLE t(c1 int) STORED AS CSV \
PARTITIONED BY (p1, p2) LOCATION 'foo.csv' \
OPTIONS (format.delimiter '|'); SELECT ';'",
&SessionConfig::new().options().sql_parser,
)
.unwrap();
assert_eq!(statements.len(), 2);
let Statement::CreateExternalTable(table) = &statements[0] else {
panic!("expected CREATE EXTERNAL TABLE");
};
assert_eq!(table.columns.len(), 1);
assert_eq!(table.table_partition_cols, ["p1", "p2"]);
assert_eq!(table.options.len(), 1);
}
#[test]
fn parses_statements_with_session_sql_dialect() {
let sql = "# MySQL comment\nSELECT 1";
assert!(sql_statements(sql, &SessionConfig::new().options().sql_parser).is_err());
let mut config = SessionConfig::new();
config.options_mut().sql_parser.dialect = Dialect::MySQL;
let statements = sql_statements(sql, &config.options().sql_parser).unwrap();
assert_eq!(statements.len(), 1);
}
#[tokio::test]
async fn applies_session_changing_statements_before_reporting_queries() {
let directory = tempdir().unwrap();
let options = RunOpt {
query: None,
compare: None,
path: directory.path().to_path_buf(),
query_path: directory.path().to_path_buf(),
};
let ctx = SessionContext::new();
let mut statements = sql_statements(
"CREATE TABLE x AS VALUES (1); SELECT * FROM x",
&SessionConfig::new().options().sql_parser,
)
.unwrap();
let reports = options
.report_statement(&ctx, statements.pop_front().unwrap())
.await
.unwrap();
assert!(reports.is_empty());
let reports = options
.report_statement(&ctx, statements.pop_front().unwrap())
.await
.unwrap();
assert!(!reports.is_empty());
}
#[tokio::test]
async fn reports_estimates_from_session_statistics_registry() {
let directory = tempdir().unwrap();
let options = RunOpt {
query: None,
compare: None,
path: directory.path().to_path_buf(),
query_path: directory.path().to_path_buf(),
};
let mut registry = StatisticsRegistry::new();
registry.register(Arc::new(ClosureStatisticsProvider::new(
|plan, _child_stats| {
Ok(StatisticsResult::Computed(
Statistics {
num_rows: Precision::Inexact(42),
..Statistics::new_unknown(plan.schema().as_ref())
}
.into(),
))
},
)));
let ctx = SessionContext::from(
SessionStateBuilder::new()
.with_default_features()
.with_statistics_registry(registry)
.build(),
);
let statement =
sql_statements("SELECT 1", &ctx.state().config_options().sql_parser)
.unwrap()
.pop_front()
.unwrap();
let reports = options.report_statement(&ctx, statement).await.unwrap();
assert_eq!(reports[0].estimated_rows, StatisticValue::Inexact(42));
}
async fn report_query_files(
options: &RunOpt,
ctx: &SessionContext,
) -> Vec<QueryReport> {
let mut reports = vec![];
for path in query_files(&options.query_path, options.query.as_deref()).unwrap() {
options
.report_query_file(ctx, &path, None, &mut reports)
.await
.unwrap();
}
reports
}
#[tokio::test]
async fn refreshes_sql_dialect_between_query_files() {
let directory = tempdir().unwrap();
fs::write(
directory.path().join("01.sql"),
"SET datafusion.sql_parser.dialect = 'MySQL'",
)
.unwrap();
fs::write(directory.path().join("02.sql"), "# MySQL comment\nSELECT 1").unwrap();
fs::write(
directory.path().join("03.sql"),
"RESET datafusion.sql_parser.dialect",
)
.unwrap();
fs::write(directory.path().join("04.sql"), "SELECT 1,").unwrap();
let options = RunOpt {
query: None,
compare: None,
path: directory.path().to_path_buf(),
query_path: directory.path().to_path_buf(),
};
let reports = report_query_files(&options, &SessionContext::new()).await;
assert_eq!(reports.len(), 4);
assert!(reports.iter().all(|report| report.success));
assert_eq!(reports[1].query, "02");
assert!(!reports[1].operators.is_empty());
assert_eq!(reports[3].query, "04");
assert!(!reports[3].operators.is_empty());
}
#[tokio::test]
async fn refreshes_parser_recursion_limit_between_query_files() {
let directory = tempdir().unwrap();
fs::write(
directory.path().join("01.sql"),
"SET datafusion.sql_parser.recursion_limit = 2",
)
.unwrap();
fs::write(
directory.path().join("02.sql"),
"SELECT (((((((((((1)))))))))))",
)
.unwrap();
let options = RunOpt {
query: None,
compare: None,
path: directory.path().to_path_buf(),
query_path: directory.path().to_path_buf(),
};
let reports = report_query_files(&options, &SessionContext::new()).await;
assert_eq!(reports.len(), 2);
assert!(reports[0].success);
assert!(!reports[1].success);
assert_eq!(reports[1].query, "02");
assert!(
reports[1]
.error
.as_deref()
.is_some_and(|error| error.contains("RecursionLimitExceeded")),
"unexpected report: {:?}",
reports[1]
);
}
#[test]
fn persists_failed_reports() {
let directory = tempdir().unwrap();
let path = directory.path().join("statistics.json");
let reports = vec![QueryReport {
query: "q1".to_string(),
statement: 1,
operators: vec![],
success: false,
error: Some("expected failure".to_string()),
}];
store_report(&path, &reports).unwrap();
assert_eq!(load_comparison_report(&path).unwrap(), Some(reports));
}
#[tokio::test]
async fn rejects_duplicate_table_names() {
let directory = tempdir().unwrap();
let path = directory.path();
fs::write(path.join("foo.parquet"), b"").unwrap();
fs::create_dir(path.join("foo")).unwrap();
fs::write(path.join("foo/part.parquet"), b"").unwrap();
let error = register_parquet_files(&SessionContext::new(), path)
.await
.unwrap_err();
assert!(error.to_string().contains("duplicate table foo"));
}
}