blob: fc6da24b8a9b2e27a72fcc63dfa39edb19bd7a8f [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.
use arrow::array::{Array, RecordBatch};
use arrow::datatypes::*;
use arrow::error::ArrowError;
use arrow::util::display::{ArrayFormatter, FormatOptions};
use datafusion::dataframe::DataFrameWriteOptions;
use datafusion::datasource::MemTable;
use datafusion::physical_plan::execute_stream;
use datafusion::prelude::{CsvReadOptions, DataFrame, SessionContext};
use datafusion_common::config::CsvOptions;
use datafusion_common::{DataFusionError, Result, exec_datafusion_err};
use futures::StreamExt;
use log::{debug, info, trace, warn};
use regex::Regex;
use std::collections::HashMap;
use std::fmt::Debug;
use std::fs::{self, File, OpenOptions};
use std::io::{BufRead, BufReader};
use std::path::{Path, PathBuf};
use std::sync::{Arc, LazyLock};
/// A collection of benchmark configurations and state used by the DataFusion
/// sql test harness. Each benchmark is defined by a file that can contain
/// directives such as `load`, `run`, `assert`, `result`, etc. The
/// `SqlBenchmark` struct holds the parsed data from that file and
/// the impl provides methods to run, assert, persist, verify and cleanup benchmark
/// results.
#[derive(Debug, Clone)]
pub struct SqlBenchmark {
/// Human‑readable name of the benchmark.
name: String,
/// Top‑level group name (derived from the file path or defined in a benchmark).
group: String,
/// Subgroup name, often a logical grouping.
subgroup: String,
/// Full path to the benchmark file.
benchmark_path: PathBuf,
/// Mapping of placeholder keys to concrete values (e.g. `"BENCHMARK_DIR"`).
replacement_mapping: HashMap<String, String>,
/// Expected string that must appear in the physical plan of the queries.
expect: Vec<String>,
/// All SQL queries grouped by directive (`load`, `run`, etc.).
queries: HashMap<QueryDirective, Vec<String>>,
/// Queries whose results are persisted to disk for later comparison.
result_queries: Vec<BenchmarkQuery>,
/// Queries whose results are asserted against an expected table.
assert_queries: Vec<BenchmarkQuery>,
/// Flag indicating whether the benchmark has been fully loaded
is_loaded: bool,
/// Stores the last run results if needed so they can be compared or persisted.
last_results: Option<Vec<RecordBatch>>,
/// echo statements
echo: Vec<String>,
}
impl SqlBenchmark {
pub async fn new(
ctx: &SessionContext,
full_path: impl AsRef<Path>,
benchmark_directory: impl AsRef<Path>,
) -> Result<Self> {
let full_path = full_path.as_ref();
let benchmark_directory = benchmark_directory.as_ref();
let group_name = parse_group_from_path(full_path, benchmark_directory);
let mut bm = Self {
name: String::new(),
group: group_name,
subgroup: String::new(),
benchmark_path: full_path.to_path_buf(),
replacement_mapping: HashMap::new(),
expect: vec![],
queries: HashMap::new(),
result_queries: vec![],
assert_queries: vec![],
is_loaded: false,
last_results: None,
echo: vec![],
};
insert_replacement(
&mut bm.replacement_mapping,
"BENCHMARK_DIR",
benchmark_directory.to_string_lossy().into_owned(),
);
let path = bm.benchmark_path.clone();
bm.process_file(ctx, &path).await?;
Ok(bm)
}
/// Initializes the benchmark by executing `load` and `init` queries.
///
/// Registers any required tables or sets up state in the provided
/// `SessionContext` before running queries. This method is idempotent:
/// calling it multiple times on the same instance returns
/// immediately after the first successful initialization.
///
/// # Errors
/// Returns an error if any `load` or `init` query fails, or if the
/// benchmark file does not contain a `run` query.
pub async fn initialize(&mut self, ctx: &SessionContext) -> Result<()> {
if self.is_loaded {
return Ok(());
}
let path = self.benchmark_path.to_string_lossy().into_owned();
// validate there was a run query
if !self.queries.contains_key(&QueryDirective::Run) {
return Err(exec_datafusion_err!(
"Invalid benchmark file: no \"run\" query specified: {path}"
));
}
// display any echo's
self.echo.iter().for_each(|txt| println!("{txt}"));
let load_queries = self.queries.get(&QueryDirective::Load);
if let Some(queries) = load_queries {
for query in queries {
debug!("Executing load query {query}");
ctx.sql(query).await?.collect().await?;
}
}
let init_queries = self.queries.get(&QueryDirective::Init);
if let Some(queries) = init_queries {
for query in queries {
debug!("Executing init query {query}");
ctx.sql(query).await?.collect().await?;
}
}
self.is_loaded = true;
Ok(())
}
/// Executes the `assert` queries and compares actual results against
/// expected values.
///
/// Each `assert` query must be followed by a result table (separated by
/// `----`) in the benchmark file. The assertion passes only if the
/// returned record batches exactly match the expected rows.
///
/// # Errors
/// Returns an error if any `assert` query fails, or if the actual and
/// expected results differ in row count or cell values.
pub async fn assert(&mut self, ctx: &SessionContext) -> Result<()> {
info!("Running assertions...");
for assert_query in &self.assert_queries {
let query = &assert_query.query;
info!("Executing assert query {query}");
let result = ctx.sql(query).await?.collect().await?;
let formatted_actual_results = format_record_batches(&result)?;
Self::compare_results(
assert_query,
&formatted_actual_results,
&assert_query.expected_result,
)?;
}
Ok(())
}
/// Executes the `run` queries, optionally saving results for later
/// verification. If there are multiple queries only the results for
/// the last query are saved.
///
/// When `save_results` is `true`, it collects `SELECT`/`WITH` query
/// results and stores them in `last_results`.
///
/// When `save_results` is `false`, it streams results and counts rows
/// without buffering them.
///
/// If an 'expect' string is defined this method also validates that
/// the physical plan contains that string.
///
/// # Errors
/// Returns an error if a `run` query fails or if expected plan strings
/// are not found.
pub async fn run(&mut self, ctx: &SessionContext, save_results: bool) -> Result<()> {
let run_queries = self
.queries
.get(&QueryDirective::Run)
.ok_or_else(|| exec_datafusion_err!("Run query should be loaded by now"))?;
let mut result_count = 0;
let result: Vec<RecordBatch> = {
let mut local_result = vec![];
for query in run_queries {
match save_results {
true => {
debug!(
"Running query (saving results) {}-{}: {query}",
self.group, self.subgroup
);
let df = ctx.sql(query).await?;
if !self.expect.is_empty() {
let physical_plan = df.create_physical_plan().await?;
self.validate_expected_plan(&physical_plan)?;
}
let result_schema = Arc::new(df.schema().as_arrow().clone());
let mut batches = df.collect().await?;
let trimmed = query.trim_start();
// save the output for select/with queries
if starts_with_ignore_ascii_case(trimmed, "select")
|| starts_with_ignore_ascii_case(trimmed, "with")
{
if batches.is_empty() {
batches.push(RecordBatch::new_empty(result_schema));
}
let row_count_for_query =
batches.iter().map(RecordBatch::num_rows).sum::<usize>();
debug!(
"Persisting {} batches ({} rows)...",
batches.len(),
row_count_for_query
);
result_count = row_count_for_query;
local_result = batches;
}
}
false => {
debug!(
"Running query (ignoring results) {}-{}: {query}",
self.group, self.subgroup
);
result_count = self
.execute_sql_without_result_buffering(query, ctx)
.await?;
}
}
}
Ok::<Vec<RecordBatch>, DataFusionError>(local_result)
}?;
debug!("Results have {result_count} rows");
// Store results for verification
self.last_results = Some(result);
Ok(())
}
/// Calls run and persists results to disk as a CSV file.
///
/// Requires that the benchmark defines a `result` or `result_query`.
/// Registers the results in a memory table and writes them to disk with
/// pipe delimiters and a header row.
///
/// # Errors
/// Returns an error if no results are available or if writing to the
/// target path fails.
pub async fn persist(&mut self, ctx: &SessionContext) -> Result<()> {
self.run(ctx, true).await?;
// Check if we have result queries to persist for
if self.result_queries.is_empty() {
info!("No result paths to persist");
return Ok(());
}
let results = self
.last_results
.as_ref()
.expect("run should store last_results after successful execution");
let query = &self.result_queries[0];
let path = query.path.as_ref().ok_or_else(|| {
exec_datafusion_err!(
"Unable to persist results from query '{}', no result specified",
query.query
)
})?;
info!("Persisting results for query to {path}");
let first_batch = results
.first()
.ok_or_else(|| exec_datafusion_err!("Results should be loaded"))?;
let schema = first_batch.schema();
let provider = MemTable::try_new(schema, vec![results.clone()])?;
ctx.register_table("persist_data", Arc::new(provider))?;
let df = ctx.table("persist_data").await?;
df.write_csv(
path,
DataFrameWriteOptions::new(),
Some(
CsvOptions::default()
.with_delimiter(b'|')
.with_has_header(true),
),
)
.await?;
ctx.deregister_table("persist_data")?;
Ok(())
}
/// Verifies persisted results against expected values.
///
/// Executes the `result_query` or uses the stored last run results, then
/// compares actual output rows to the expected values defined in the
/// benchmark file.
///
/// # Errors
/// Returns an error if no results are available or if the actual and
/// expected results differ in count or content.
pub async fn verify(&mut self, ctx: &SessionContext) -> Result<()> {
// Check if we have result queries to verify
if self.result_queries.is_empty() {
return Ok(());
}
if self.last_results.is_none() {
return Err(exec_datafusion_err!(
"No results available for verification. Run the benchmark first."
));
}
info!("Verifying results...");
self.load_expected_result_files(ctx).await?;
// Get the first result query (assuming only one for now)
let query = &self.result_queries[0];
let formatted_actual_results = if !query.query.trim().is_empty() {
let results = ctx.sql(&query.query).await?.collect().await?;
format_record_batches(&results)
} else {
let actual_results = self
.last_results
.as_ref()
.expect("last_results should be present after successful run");
format_record_batches(actual_results)
}?;
Self::compare_results(query, &formatted_actual_results, &query.expected_result)
}
/// Runs `cleanup` queries to reset state after the benchmark run.
pub async fn cleanup(&mut self, ctx: &SessionContext) -> Result<()> {
info!("Running cleanup...");
let cleanup_queries = self.queries.get(&QueryDirective::Cleanup);
if let Some(queries) = cleanup_queries {
for query in queries {
let _ = ctx.sql(query).await?.collect().await?;
}
}
Ok(())
}
async fn load_expected_result_files(&mut self, ctx: &SessionContext) -> Result<()> {
for query in &mut self.result_queries {
if query.query.trim().is_empty() {
let Some(path) = query.path.clone() else {
continue;
};
let loaded_query =
read_query_from_file(ctx, path, &HashMap::new()).await?;
query.column_count = loaded_query.column_count;
query.expected_result = loaded_query.expected_result;
}
}
Ok(())
}
fn compare_results(
query: &BenchmarkQuery,
actual_results: &[Vec<String>],
expected_results: &[Vec<String>],
) -> Result<()> {
if actual_results.is_empty() && expected_results.is_empty() {
return Ok(());
}
// Compare row count
if actual_results.len() != expected_results.len() {
return Err(exec_datafusion_err!(
"Error in result: expected {} rows but got {} for query {}",
expected_results.len(),
actual_results.len(),
query.query
));
}
// Compare values
let zipped = actual_results
.iter()
.enumerate()
.zip(expected_results.iter());
for ((row_idx, actual), expected) in zipped {
trace!(
"row {}\nactual: {actual:?}\nexpected: {expected:?}",
row_idx + 1
);
// Compare column count
if actual.len() != expected.len() {
return Err(exec_datafusion_err!(
"Error in result: expected {} columns but got {} for query {}",
expected.len(),
actual.len(),
query.query
));
}
for (col_idx, expected_val) in
expected.iter().enumerate().take(query.column_count)
{
// The row-width check above guarantees this index exists.
let actual_val = &actual[col_idx];
trace!("actual_val = {actual_val:?}\nexpected_val = {expected_val:?}");
if (expected_val == "NULL" && actual_val.is_empty())
|| (expected_val == actual_val)
|| (expected_val == "(empty)"
&& (actual_val.is_empty() || actual_val == "NULL"))
{
continue;
}
return Err(exec_datafusion_err!(
"Error in result on row {}, column {} running query \"{}\": expected value \
\"{expected_val}\" but got value \"{actual_val}\" in row: {actual:?}",
row_idx + 1,
col_idx + 1,
query.query
));
}
}
Ok(())
}
async fn process_file(&mut self, ctx: &SessionContext, path: &Path) -> Result<()> {
debug!("Processing file {}", path.display());
let mut replacement_mapping = self.replacement_mapping.clone();
insert_replacement(
&mut replacement_mapping,
"FILE_PATH",
path.to_string_lossy().into_owned(),
);
let mut reader = BenchmarkFileReader::new(path, replacement_mapping)?;
let mut line = String::with_capacity(1024);
let mut reader_result = reader.read_line(&mut line);
while let Some(result) = reader_result {
match result {
Ok(_) => {
if !is_blank_or_comment_line(&line) {
// boxing required because of recursion
Box::pin(self.process_line(ctx, &mut reader, &mut line)).await?;
}
}
Err(e) => return Err(e),
}
// Clear the line buffer for the next iteration.
line.clear();
reader_result = reader.read_line(&mut line);
}
Ok(())
}
async fn process_line(
&mut self,
ctx: &SessionContext,
reader: &mut BenchmarkFileReader,
line: &mut String,
) -> Result<()> {
// Split the line into directive and arguments.
let cloned_line = line.trim_start().to_string();
let splits: Vec<&str> = cloned_line.split_whitespace().collect();
BenchmarkDirective::select(reader, splits[0])?
.process(ctx, self, reader, line, &splits)
.await
}
fn process_query(&mut self, splits: &[&str], mut query: String) -> Result<()> {
debug!("Processing query {query}");
// Trim and validate.
query = query.trim().to_string();
if query.is_empty() {
return Ok(());
}
// remove comments
query = query
.lines()
.filter(|line| !is_comment_line(line))
.collect::<Vec<_>>()
.join("\n");
if query.trim().is_empty() {
return Ok(());
}
query = process_replacements(&query, self.replacement_mapping())?;
let directive = QueryDirective::parse(splits[0]).ok_or_else(|| {
exec_datafusion_err!("Invalid query directive: {}", splits[0])
})?;
self.queries.entry(directive).or_default().push(query);
Ok(())
}
fn validate_expected_plan(&self, physical_plan: &impl Debug) -> Result<()> {
if self.expect.is_empty() {
return Ok(());
}
let plan_string = format!("{physical_plan:#?}");
for exp_str in &self.expect {
if !plan_string.contains(exp_str) {
return Err(exec_datafusion_err!(
"The query physical plan does not contain the expected string '{exp_str}'. Physical plan: {plan_string}"
));
}
}
Ok(())
}
async fn execute_sql_without_result_buffering(
&self,
sql: &str,
ctx: &SessionContext,
) -> Result<usize> {
let mut row_count = 0;
let df = ctx.sql(sql).await?;
let physical_plan = df.create_physical_plan().await?;
self.validate_expected_plan(&physical_plan)?;
let mut stream = execute_stream(physical_plan, ctx.task_ctx())?;
while let Some(batch) = stream.next().await {
row_count += batch?.num_rows();
// Evaluate the result and do nothing, the result will be dropped
// to reduce memory pressure
}
Ok(row_count)
}
pub fn name(&self) -> &str {
&self.name
}
pub fn group(&self) -> &str {
&self.group
}
pub fn subgroup(&self) -> &str {
&self.subgroup
}
pub fn benchmark_path(&self) -> &Path {
&self.benchmark_path
}
pub fn replacement_mapping(&self) -> &HashMap<String, String> {
&self.replacement_mapping
}
pub fn queries(&self) -> &HashMap<QueryDirective, Vec<String>> {
&self.queries
}
pub fn result_queries(&self) -> &[BenchmarkQuery] {
&self.result_queries
}
pub fn assert_queries(&self) -> &[BenchmarkQuery] {
&self.assert_queries
}
pub fn is_loaded(&self) -> bool {
self.is_loaded
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum QueryDirective {
Load,
Run,
Init,
Cleanup,
}
impl QueryDirective {
fn parse(value: &str) -> Option<Self> {
if value.eq_ignore_ascii_case("load") {
Some(Self::Load)
} else if value.eq_ignore_ascii_case("init") {
Some(Self::Init)
} else if value.eq_ignore_ascii_case("run") {
Some(Self::Run)
} else if value.eq_ignore_ascii_case("cleanup") {
Some(Self::Cleanup)
} else {
None
}
}
fn as_str(self) -> &'static str {
match self {
Self::Load => "load",
Self::Run => "run",
Self::Init => "init",
Self::Cleanup => "cleanup",
}
}
}
enum BenchmarkDirective {
Load,
Run,
Init,
Cleanup,
Name,
Group,
Subgroup,
Expect,
Assert,
ResultQuery,
Results,
Template,
Include,
Echo,
}
impl BenchmarkDirective {
fn select(
reader: &BenchmarkFileReader,
directive: &str,
) -> Result<BenchmarkDirective> {
if directive.eq_ignore_ascii_case("load") {
Ok(BenchmarkDirective::Load)
} else if directive.eq_ignore_ascii_case("run") {
Ok(BenchmarkDirective::Run)
} else if directive.eq_ignore_ascii_case("init") {
Ok(BenchmarkDirective::Init)
} else if directive.eq_ignore_ascii_case("cleanup") {
Ok(BenchmarkDirective::Cleanup)
} else if directive.eq_ignore_ascii_case("name") {
Ok(BenchmarkDirective::Name)
} else if directive.eq_ignore_ascii_case("group") {
Ok(BenchmarkDirective::Group)
} else if directive.eq_ignore_ascii_case("subgroup") {
Ok(BenchmarkDirective::Subgroup)
} else if directive.eq_ignore_ascii_case("expect_plan") {
Ok(BenchmarkDirective::Expect)
} else if directive.eq_ignore_ascii_case("assert") {
Ok(BenchmarkDirective::Assert)
} else if directive.eq_ignore_ascii_case("result_query") {
Ok(BenchmarkDirective::ResultQuery)
} else if directive.eq_ignore_ascii_case("result") {
Ok(BenchmarkDirective::Results)
} else if directive.eq_ignore_ascii_case("template") {
Ok(BenchmarkDirective::Template)
} else if directive.eq_ignore_ascii_case("include") {
Ok(BenchmarkDirective::Include)
} else if directive.eq_ignore_ascii_case("echo") {
Ok(BenchmarkDirective::Echo)
} else {
Err(exec_datafusion_err!(
"{}",
reader.format_exception(&format!("Unrecognized command: {directive}"))
))
}
}
async fn process(
&self,
ctx: &SessionContext,
bench: &mut SqlBenchmark,
reader: &mut BenchmarkFileReader,
line: &mut String,
splits: &[&str],
) -> Result<()> {
trace!("-- handling {}", splits[0]);
match self {
BenchmarkDirective::Load
| BenchmarkDirective::Run
| BenchmarkDirective::Init
| BenchmarkDirective::Cleanup => {
Self::process_query_directive(bench, reader, line, splits)
}
BenchmarkDirective::Name => Self::process_metadata_value(
bench,
reader,
line,
"name",
"BENCH_NAME",
"name must be followed by a value",
),
BenchmarkDirective::Group => Self::process_metadata_value(
bench,
reader,
line,
"group",
"BENCH_GROUP",
"group must be followed by a value",
),
BenchmarkDirective::Subgroup => Self::process_metadata_value(
bench,
reader,
line,
"subgroup",
"BENCH_SUBGROUP",
"subgroup must be followed by a value",
),
BenchmarkDirective::Expect => Self::process_expect(bench, reader, splits),
BenchmarkDirective::Assert => {
Self::process_assert(bench, reader, line, splits)
}
BenchmarkDirective::ResultQuery => {
Self::process_result_query(bench, reader, line, splits)
}
BenchmarkDirective::Results => Self::process_results(bench, reader, splits),
BenchmarkDirective::Template => {
Self::process_template(ctx, bench, reader, line, splits).await
}
BenchmarkDirective::Include => {
Self::process_include(ctx, bench, reader, splits).await
}
BenchmarkDirective::Echo => Self::process_echo(bench, reader, splits),
}
}
fn process_query_directive(
bench: &mut SqlBenchmark,
reader: &mut BenchmarkFileReader,
line: &mut String,
splits: &[&str],
) -> Result<()> {
let directive = QueryDirective::parse(splits[0]).ok_or_else(|| {
exec_datafusion_err!("Invalid query directive: {}", splits[0])
})?;
if directive == QueryDirective::Run && bench.queries.contains_key(&directive) {
return Err(exec_datafusion_err!(
"Multiple calls to run in the same benchmark file"
));
}
line.clear();
// Read the query body until a blank line or EOF.
let mut query = String::new();
let mut reader_result = reader.read_line(line);
loop {
match reader_result {
Some(Ok(_)) => {
if is_comment_line(line) {
// comment, ignore
} else if is_blank_line(line) {
break;
} else {
query.push_str(line);
query.push('\n');
}
}
Some(Err(e)) => return Err(e),
None => break,
}
// Clear the line buffer for the next iteration.
line.clear();
reader_result = reader.read_line(line);
}
// Optional file parameter.
if splits.len() > 1 && !splits[1].is_empty() {
if !query.trim().is_empty() {
return Err(exec_datafusion_err!(
"{}",
reader.format_exception(&format!(
"{} directive must use either a query file or inline SQL, not both",
directive.as_str()
))
));
}
debug!("Processing {} file: {}", splits[0], splits[1]);
let query_file = fs::read_to_string(splits[1]).map_err(|e| {
exec_datafusion_err!("Failed to read query file {}: {e}", splits[1])
})?;
let query_file = query_file.replace("\r\n", "\n");
// some files have multiple queries, split apart
for query in split_query_statements(&query_file) {
bench.process_query(splits, query.to_string())?;
}
} else if directive == QueryDirective::Run {
for query in split_query_statements(&query) {
bench.process_query(splits, query.to_string())?;
}
} else {
bench.process_query(splits, query)?;
}
Ok(())
}
fn process_metadata_value(
bench: &mut SqlBenchmark,
reader: &mut BenchmarkFileReader,
line: &str,
directive: &str,
replacement_key: &str,
message: &str,
) -> Result<()> {
let value =
directive_value(reader, line.trim_start(), directive, message)?.to_string();
match directive {
"name" => bench.name.clone_from(&value),
"group" => bench.group.clone_from(&value),
"subgroup" => bench.subgroup.clone_from(&value),
_ => unreachable!("unsupported metadata directive: {directive}"),
}
insert_replacement(
&mut bench.replacement_mapping,
replacement_key,
value.clone(),
);
insert_replacement(&mut reader.replacements, replacement_key, value);
Ok(())
}
fn process_expect(
bench: &mut SqlBenchmark,
reader: &BenchmarkFileReader,
splits: &[&str],
) -> Result<()> {
trace!("-- handling {}", splits[0]);
if splits.len() <= 1 || splits[1].is_empty() {
return Err(exec_datafusion_err!(
"{}",
reader.format_exception(
"expect_plan must be followed by a string to search in the physical plan"
)
));
}
bench.expect.push(splits[1..].join(" ").to_string());
Ok(())
}
fn process_assert(
bench: &mut SqlBenchmark,
reader: &mut BenchmarkFileReader,
line: &mut String,
splits: &[&str],
) -> Result<()> {
// count the amount of columns based on character count. The actual
// character used is irrelevant.
if splits.len() <= 1 || splits[1].is_empty() {
return Err(exec_datafusion_err!(
"{}",
reader.format_exception(
"assert must be followed by a column count (e.g. assert III)"
)
));
}
line.clear();
// read the actual query
let mut found_break = false;
let mut sql = String::new();
let mut reader_result = reader.read_line(line);
loop {
match reader_result {
Some(Ok(_)) => {
if line.trim() == "----" {
found_break = true;
break;
}
sql.push('\n');
sql.push_str(line);
}
Some(Err(e)) => return Err(e),
None => break,
}
// Clear the line buffer for the next iteration.
line.clear();
reader_result = reader.read_line(line);
}
if !found_break {
return Err(exec_datafusion_err!(
"{}",
reader.format_exception(
"assert must be followed by a query and a result (separated by ----)"
)
));
}
bench
.assert_queries
.push(read_query_from_reader(reader, &sql, splits[1])?);
Ok(())
}
fn process_results(
bench: &mut SqlBenchmark,
reader: &BenchmarkFileReader,
splits: &[&str],
) -> Result<()> {
if splits.len() <= 1 || splits[1].is_empty() {
return Err(exec_datafusion_err!(
"{}",
reader.format_exception(
"result must be followed by a path to a result file"
)
));
}
if !bench.result_queries.is_empty() {
return Err(exec_datafusion_err!(
"{}",
reader.format_exception("multiple results found")
));
}
let path = process_replacements(splits[1], &bench.replacement_mapping)?;
bench.result_queries.push(BenchmarkQuery {
path: Some(path),
query: String::new(),
column_count: 0,
expected_result: vec![],
});
Ok(())
}
fn process_result_query(
bench: &mut SqlBenchmark,
reader: &mut BenchmarkFileReader,
line: &mut String,
splits: &[&str],
) -> Result<()> {
if splits.len() <= 1 || splits[1].is_empty() {
return Err(exec_datafusion_err!(
"{}",
reader.format_exception(
"result_query must be followed by a column count (e.g. result_query III)"
)
));
}
line.clear();
let mut sql = String::new();
let mut found_break = false;
let mut reader_result = reader.read_line(line);
loop {
match reader_result {
Some(Ok(_)) => {
if line.trim() == "----" {
found_break = true;
break;
}
sql.push_str(line);
sql.push('\n');
}
Some(Err(e)) => return Err(e),
None => break,
}
// Clear the line buffer for the next iteration.
line.clear();
reader_result = reader.read_line(line);
}
if !found_break {
return Err(exec_datafusion_err!(
"{}",
reader.format_exception(
"result_query must be followed by a query and a result (separated by ----)"
)
));
}
let result_check = read_query_from_reader(reader, &sql, splits[1])?;
if !bench.result_queries.is_empty() {
return Err(exec_datafusion_err!(
"{}",
reader.format_exception("multiple results found")
));
}
bench.result_queries.push(result_check);
Ok(())
}
async fn process_template(
ctx: &SessionContext,
bench: &mut SqlBenchmark,
reader: &mut BenchmarkFileReader,
line: &mut String,
splits: &[&str],
) -> Result<()> {
if splits.len() != 2 || splits[1].is_empty() {
return Err(exec_datafusion_err!(
"{}",
reader.format_exception("template requires a single template path")
));
}
// template: update the path to read
bench.benchmark_path = PathBuf::from(splits[1]);
line.clear();
// now read parameters
let mut reader_result = reader.read_line(line);
loop {
match reader_result {
Some(Ok(_)) => {
if is_comment_line(line) {
// Clear the line buffer for the next iteration.
line.clear();
reader_result = reader.read_line(line);
continue;
}
if is_blank_line(line) {
break;
}
let Some((key, value)) = line.trim_start().split_once('=') else {
return Err(exec_datafusion_err!(
"{}",
reader.format_exception(
"Expected a template parameter in the form of X=Y"
)
));
};
insert_replacement(
&mut bench.replacement_mapping,
key.trim(),
value.trim().to_string(),
);
}
Some(Err(e)) => return Err(e),
None => break,
}
// Clear the line buffer for the next iteration.
line.clear();
reader_result = reader.read_line(line);
}
// restart the load from the template file
Box::pin(bench.process_file(ctx, Path::new(splits[1]))).await
}
async fn process_include(
ctx: &SessionContext,
bench: &mut SqlBenchmark,
reader: &BenchmarkFileReader,
splits: &[&str],
) -> Result<()> {
if splits.len() != 2 || splits[1].is_empty() {
return Err(exec_datafusion_err!(
"{}",
reader.format_exception("include requires a single argument")
));
}
Box::pin(bench.process_file(ctx, Path::new(splits[1]))).await
}
fn process_echo(
bench: &mut SqlBenchmark,
reader: &BenchmarkFileReader,
splits: &[&str],
) -> Result<()> {
if splits.len() < 2 {
return Err(exec_datafusion_err!(
"{}",
reader.format_exception("Echo requires an argument")
));
}
bench.echo.push(splits[1..].join(" "));
Ok(())
}
}
struct BenchmarkFileReader {
path: PathBuf,
reader: BufReader<File>,
line_nr: usize,
replacements: HashMap<String, String>,
}
impl BenchmarkFileReader {
fn new<P: Into<PathBuf>>(
path: P,
replacements: HashMap<String, String>,
) -> Result<Self> {
let path = path.into();
let file = OpenOptions::new().read(true).open(&path)?;
Ok(Self {
path,
reader: BufReader::new(file),
line_nr: 0,
replacements,
})
}
/// Read the next line, applying replacements and removing line terminators.
fn read_line(&mut self, line: &mut String) -> Option<Result<()>> {
match self.reader.read_line(line) {
Ok(0) => None,
Ok(_) => {
self.line_nr += 1;
// Trim newline and carriage return without changing other content.
let trimmed_len = line.trim_end_matches(['\n', '\r']).len();
line.truncate(trimmed_len);
match process_replacements(line, &self.replacements) {
Ok(l) => {
*line = l;
Some(Ok(()))
}
Err(error) => Some(Err(error)),
}
}
Err(e) => Some(Err(e.into())),
}
}
fn format_exception(&self, msg: &str) -> String {
format!("{}:{} - {}", self.path.display(), self.line_nr, msg)
}
}
#[derive(Debug, Clone)]
pub struct BenchmarkQuery {
path: Option<String>,
query: String,
column_count: usize,
expected_result: Vec<Vec<String>>,
}
// ---- utility function below
fn directive_value<'a>(
reader: &BenchmarkFileReader,
line: &'a str,
directive: &str,
message: &str,
) -> Result<&'a str> {
let value = line
.get(..directive.len())
.filter(|prefix| prefix.eq_ignore_ascii_case(directive))
.and_then(|_| line.get(directive.len()..))
.map(str::trim)
.filter(|s| !s.is_empty())
.ok_or_else(|| exec_datafusion_err!("{}", reader.format_exception(message)))?;
Ok(value)
}
fn parse_group_from_path(path: &Path, benchmark_directory: &Path) -> String {
let mut group_name = String::new();
let mut parent = path.parent();
while let Some(p) = parent {
if path_ends_with_ignore_ascii_case(p, benchmark_directory) {
break;
}
if let Some(dir_name) = p.file_name() {
group_name = dir_name.to_string_lossy().into_owned();
}
parent = p.parent();
}
if group_name.is_empty() {
warn!("Unable to find group name in path: {}", path.display());
}
group_name
}
fn path_ends_with_ignore_ascii_case(path: &Path, suffix: &Path) -> bool {
let mut path_components = path.components().rev();
for suffix_component in suffix.components().rev() {
let Some(path_component) = path_components.next() else {
return false;
};
if !path_component
.as_os_str()
.to_string_lossy()
.eq_ignore_ascii_case(&suffix_component.as_os_str().to_string_lossy())
{
return false;
}
}
true
}
fn starts_with_ignore_ascii_case(input: &str, prefix: &str) -> bool {
input
.get(..prefix.len())
.is_some_and(|value| value.eq_ignore_ascii_case(prefix))
}
fn split_query_statements(sql: &str) -> impl Iterator<Item = &str> {
sql.split("\n\n")
.flat_map(|query| {
query
.split_inclusive(";\n")
.map(|part| part.trim_end_matches('\n'))
})
.filter(|query| !query.trim().is_empty())
}
fn is_blank_line(line: &str) -> bool {
line.trim().is_empty()
}
fn is_comment_line(line: &str) -> bool {
let line = line.trim_start();
line.starts_with('#') || line.starts_with("--")
}
fn is_blank_or_comment_line(line: &str) -> bool {
is_blank_line(line) || is_comment_line(line)
}
fn insert_replacement(
replacement_map: &mut HashMap<String, String>,
key: &str,
value: String,
) {
replacement_map.insert(key.to_lowercase(), value);
}
fn replace_all<E>(
re: &Regex,
haystack: &str,
replacement: impl Fn(&regex::Captures) -> Result<String, E>,
) -> Result<String, E> {
let mut new = String::with_capacity(haystack.len());
let mut last_match = 0;
for caps in re.captures_iter(haystack) {
let m = caps.get(0).unwrap();
new.push_str(&haystack[last_match..m.start()]);
new.push_str(&replacement(&caps)?);
last_match = m.end();
}
new.push_str(&haystack[last_match..]);
Ok(new)
}
static TRUE_FALSE_REPLACEMENT_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"\$\{(\w+)(?::-([^|}]+))?\|([^|]+)\|([^}]+)}")
.expect("Regex failed to compile")
});
static VARIABLE_REPLACEMENT_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"\$\{(\w+)(?::-([^}]+))?}").expect("Regex failed to compile")
});
/// Replace all `${KEY}` or `${KEY:-default}` placeholders in a string according to the mapping.
/// Also handles `${KEY:-default|True value|false value}` syntax.
fn process_replacements(
input: &str,
replacement_map: &HashMap<String, String>,
) -> Result<String> {
process_replacements_with_env(input, replacement_map, |key| std::env::var(key).ok())
}
fn process_replacements_with_env(
input: &str,
replacement_map: &HashMap<String, String>,
get_env: impl Fn(&str) -> Option<String>,
) -> Result<String> {
debug!("processing replacements for line '{input}'");
// handle ${VAR:-default|true value|false value} syntax
let replacement = |caps: &regex::Captures| -> Result<String> {
let key = &caps[1];
let default = caps.get(2).map(|m| m.as_str().to_string());
let true_val = &caps[3];
let false_val = &caps[4];
let value = lookup_replacement_value(key, replacement_map, &get_env).or(default);
match value {
Some(v) if v.eq_ignore_ascii_case("true") => Ok(true_val.to_string()),
Some(_) => Ok(false_val.to_string()),
None => Err(exec_datafusion_err!("Missing value for key '{key}'")),
}
};
let input = replace_all(&TRUE_FALSE_REPLACEMENT_RE, input, replacement)?;
// handle ${KEY} and ${KEY:-default}`
let replacement = |caps: &regex::Captures| -> Result<String> {
let key = &caps[1];
let default = caps.get(2);
if let Some(v) = lookup_replacement_value(key, replacement_map, &get_env) {
return Ok(v.to_string());
}
// use default if it was set
if let Some(def) = default {
Ok(def.as_str().to_string())
} else {
Err(exec_datafusion_err!("Missing value for key '{key}'"))
}
};
replace_all(&VARIABLE_REPLACEMENT_RE, &input, replacement)
}
fn lookup_replacement_value(
key: &str,
replacement_map: &HashMap<String, String>,
get_env: &impl Fn(&str) -> Option<String>,
) -> Option<String> {
if let Some(v) = replacement_map.get(&key.to_lowercase()) {
return Some(v.to_string());
}
// look in env variables
get_env(&key.to_uppercase())
}
fn read_query_from_reader(
reader: &mut BenchmarkFileReader,
sql: &str,
header: &str,
) -> Result<BenchmarkQuery> {
let column_count = header.len();
let mut expected_result = vec![];
let mut line = String::new();
let mut reader_result = reader.read_line(&mut line);
loop {
match reader_result {
Some(Ok(_)) => {
if is_comment_line(&line) {
// comment, ignore
} else if is_blank_line(&line) {
break;
} else {
let result_splits: Vec<&str> = line.split(['\t', '|']).collect();
if result_splits.len() != column_count {
return Err(exec_datafusion_err!(
"{} {line}",
reader.format_exception(&format!(
"expected {} values but got {}",
column_count,
result_splits.len(),
))
));
}
expected_result
.push(result_splits.into_iter().map(|s| s.to_string()).collect());
}
}
Some(Err(e)) => return Err(e),
None => break,
}
// Clear the line buffer for the next iteration.
line.clear();
reader_result = reader.read_line(&mut line);
}
Ok(BenchmarkQuery {
path: None,
query: sql.to_string(),
column_count,
expected_result,
})
}
async fn read_query_from_file(
ctx: &SessionContext,
path: impl AsRef<Path>,
replacement_mapping: &HashMap<String, String>,
) -> Result<BenchmarkQuery> {
// Process replacements in file path
let path = path.as_ref().to_string_lossy();
let path = process_replacements(&path, replacement_mapping)?;
let df: DataFrame = ctx
.read_csv(
path.clone(),
CsvReadOptions::new()
.has_header(true)
.delimiter(b'|')
.null_regex(Some("NULL".to_string()))
// we only want string values, we do not want to infer the schema
.schema_infer_max_records(0),
)
.await?;
// Get schema to determine column count
let schema = df.schema();
let column_count = schema.fields().len();
if column_count == 0 {
return Err(exec_datafusion_err!(
"Result file {path} did not contain any columns"
));
}
// Execute and collect results
let batches = df.collect().await?;
// Convert record batches to string vectors
let expected_result = format_record_batches(&batches)?;
Ok(BenchmarkQuery {
path: Some(path),
query: String::new(),
column_count,
expected_result,
})
}
fn format_record_batches(
batches: &[RecordBatch],
) -> Result<Vec<Vec<String>>, DataFusionError> {
let mut expected_result = vec![];
let arrow_format_options = FormatOptions::default()
.with_null("NULL")
.with_display_error(true);
for batch in batches {
let schema = batch.schema_ref();
let formatters = batch
.columns()
.iter()
.zip(schema.fields().iter())
.map(|(c, field)| make_array_formatter(c, &arrow_format_options, Some(field)))
.collect::<Result<Vec<_>, ArrowError>>()?;
for row in 0..batch.num_rows() {
let mut cells = vec![];
for formatter in &formatters {
cells.push(formatter.value(row).to_string());
}
expected_result.push(cells);
}
}
Ok(expected_result)
}
fn make_array_formatter<'a>(
array: &'a dyn Array,
options: &FormatOptions<'a>,
field: Option<&'a Field>,
) -> Result<ArrayFormatter<'a>, ArrowError> {
match options.formatter_factory() {
None => ArrayFormatter::try_new(array, options),
Some(formatters) => formatters
.create_array_formatter(array, options, field)
.transpose()
.unwrap_or_else(|| ArrayFormatter::try_new(array, options)),
}
}
#[cfg(test)]
mod tests {
use super::*;
use datafusion::prelude::SessionContext;
use std::fs;
use std::path::{Path, PathBuf};
use tempfile::{TempDir, tempdir};
fn write_test_file(temp_dir: &TempDir, name: &str, contents: &str) -> PathBuf {
let path = temp_dir.path().join(name);
fs::write(&path, contents).expect("failed to write benchmark test file");
path
}
async fn parse_benchmark_file(path: &Path) -> Result<SqlBenchmark> {
let ctx = SessionContext::new();
let path_string = path.to_string_lossy().into_owned();
SqlBenchmark::new(&ctx, &path_string, "/tmp").await
}
async fn parse_benchmark(contents: &str) -> Result<SqlBenchmark> {
let temp_dir = tempdir().expect("failed to create benchmark test directory");
let path = write_test_file(&temp_dir, "parser.benchmark", contents);
parse_benchmark_file(&path).await
}
async fn assert_parse_error(contents: &str, expected_message: &str) {
let error = parse_benchmark(contents)
.await
.expect_err("benchmark parsing should fail");
let message = error.to_string();
assert!(
message.contains(expected_message),
"expected error containing {expected_message:?}, got {message:?}"
);
}
fn assert_result_error_contains<T: Debug>(result: Result<T>, expected_message: &str) {
let error = result.expect_err("operation should fail");
let message = error.to_string();
assert!(
message.contains(expected_message),
"expected error containing {expected_message:?}, got {message:?}"
);
}
fn formatted_last_results(benchmark: &SqlBenchmark) -> Vec<Vec<String>> {
format_record_batches(
benchmark
.last_results
.as_ref()
.expect("last results should be set"),
)
.expect("results should format")
}
fn read_all_files_in_dir(path: &Path) -> String {
let mut entries = fs::read_dir(path)
.expect("directory should be readable")
.filter_map(Result::ok)
.map(|entry| entry.path())
.filter(|path| path.is_file())
.collect::<Vec<_>>();
entries.sort();
let mut contents = String::new();
for path in entries {
contents
.push_str(&fs::read_to_string(path).expect("file should be readable"));
}
contents
}
fn replacement_map(entries: &[(&str, &str)]) -> HashMap<String, String> {
let mut replacements = HashMap::new();
for (key, value) in entries {
insert_replacement(&mut replacements, key, value.to_string());
}
replacements
}
fn env_map(entries: &[(&str, &str)]) -> HashMap<String, String> {
entries
.iter()
.map(|(key, value)| (key.to_string(), value.to_string()))
.collect()
}
// Replacement tests cover benchmark variable expansion syntax.
#[test]
fn process_replacements_replaces_map_values_case_insensitively() {
let replacements = replacement_map(&[
("BENCH_NAME", "tpch"),
("QUERY_NUMBER_PADDED", "01"),
("format_1", "parquet"),
]);
let actual = process_replacements_with_env(
"${bench_name}/q${query_number_padded}.${FORMAT_1}",
&replacements,
|_| None,
)
.expect("replacement should succeed");
assert_eq!(actual, "tpch/q01.parquet");
}
#[test]
fn process_replacements_uses_env_when_map_value_is_missing() {
let replacements = HashMap::new();
let env = env_map(&[("DATA_DIR", "/tmp/data")]);
let actual = process_replacements_with_env(
"${data_dir}/lineitem.parquet",
&replacements,
|key| env.get(key).cloned(),
)
.expect("replacement should succeed");
assert_eq!(actual, "/tmp/data/lineitem.parquet");
}
#[test]
fn process_replacements_prefers_map_over_env() {
let replacements = replacement_map(&[("BENCH_SIZE", "10")]);
let env = env_map(&[("BENCH_SIZE", "100")]);
let actual =
process_replacements_with_env("sf${BENCH_SIZE}", &replacements, |key| {
env.get(key).cloned()
})
.expect("replacement should succeed");
assert_eq!(actual, "sf10");
}
#[test]
fn process_replacements_uses_default_for_missing_variable() {
let replacements = HashMap::new();
let actual = process_replacements_with_env(
"load_${BENCH_SUBGROUP:-groupby}_${FILE_TYPE:-csv}.sql",
&replacements,
|_| None,
)
.expect("replacement should succeed");
assert_eq!(actual, "load_groupby_csv.sql");
}
#[test]
fn process_replacements_reports_missing_variable_without_default() {
let replacements = HashMap::new();
let error = process_replacements_with_env("${MISSING}", &replacements, |_| None)
.expect_err("replacement should fail");
assert!(
error
.to_string()
.contains("Missing value for key 'MISSING'"),
"unexpected error: {error}"
);
}
#[test]
fn process_replacements_applies_true_false_true_branch() {
let replacements = HashMap::new();
let env = env_map(&[("USE_PARQUET", "TrUe")]);
let actual = process_replacements_with_env(
"load_${USE_PARQUET:-false|parquet|csv}.sql",
&replacements,
|key| env.get(key).cloned(),
)
.expect("replacement should succeed");
assert_eq!(actual, "load_parquet.sql");
}
#[test]
fn process_replacements_applies_true_false_false_branch() {
let replacements = HashMap::new();
let env = env_map(&[("USE_PARQUET", "false")]);
let actual = process_replacements_with_env(
"load_${USE_PARQUET:-true|parquet|csv}.sql",
&replacements,
|key| env.get(key).cloned(),
)
.expect("replacement should succeed");
assert_eq!(actual, "load_csv.sql");
}
#[test]
fn process_replacements_uses_map_for_true_false_branch() {
let replacements = replacement_map(&[("USE_PARQUET", "true")]);
let actual = process_replacements_with_env(
"load_${USE_PARQUET:-false|parquet|csv}.sql",
&replacements,
|_| None,
)
.expect("replacement should succeed");
assert_eq!(actual, "load_parquet.sql");
}
#[test]
fn process_replacements_prefers_map_over_env_for_true_false_branch() {
let replacements = replacement_map(&[("USE_PARQUET", "false")]);
let env = env_map(&[("USE_PARQUET", "true")]);
let actual = process_replacements_with_env(
"load_${USE_PARQUET:-true|parquet|csv}.sql",
&replacements,
|key| env.get(key).cloned(),
)
.expect("replacement should succeed");
assert_eq!(actual, "load_csv.sql");
}
#[test]
fn process_replacements_uses_true_false_default_for_missing_true_value() {
let replacements = HashMap::new();
let actual = process_replacements_with_env(
"load_${USE_PARQUET:-true|parquet|csv}.sql",
&replacements,
|_| None,
)
.expect("replacement should succeed");
assert_eq!(actual, "load_parquet.sql");
}
#[test]
fn process_replacements_uses_true_false_default_for_missing_false_value() {
let replacements = HashMap::new();
let actual = process_replacements_with_env(
"load_${USE_PARQUET:-false|parquet|csv}.sql",
&replacements,
|_| None,
)
.expect("replacement should succeed");
assert_eq!(actual, "load_csv.sql");
}
#[test]
fn process_replacements_reports_missing_true_false_variable_without_default() {
let replacements = HashMap::new();
let error = process_replacements_with_env(
"load_${USE_PARQUET|parquet|csv}.sql",
&replacements,
|_| None,
)
.expect_err("replacement should fail");
assert!(
error
.to_string()
.contains("Missing value for key 'USE_PARQUET'"),
"unexpected error: {error}"
);
}
#[test]
fn process_replacements_resolves_variables_after_true_false_replacement() {
let replacements = replacement_map(&[("FILE_TYPE", "parquet")]);
let env = env_map(&[("USE_TYPED_PATH", "true")]);
let actual = process_replacements_with_env(
"${USE_TYPED_PATH:-false|data.${FILE_TYPE}|data.csv}",
&replacements,
|key| env.get(key).cloned(),
)
.expect("replacement should succeed");
assert_eq!(actual, "data.parquet");
}
#[test]
fn process_replacements_leaves_unsupported_placeholder_syntax_unchanged() {
let replacements = HashMap::new();
let actual =
process_replacements_with_env("${BAD-KEY:-fallback}", &replacements, |_| {
None
})
.expect("unsupported placeholder should not match replacement regex");
assert_eq!(actual, "${BAD-KEY:-fallback}");
}
// Parser tests cover benchmark directives and parse-time validation.
#[tokio::test]
async fn parser_accepts_metadata_expect_echo_and_sql_sections() {
let benchmark = parse_benchmark(
r#"
# top-level comments are ignored
name Parser Success
group Parser Group
subgroup Parser Subgroup
expect_plan ProjectionExec with details
echo hello from parser
load
-- query comments are ignored
CREATE TABLE t AS VALUES (1);
init
CREATE VIEW v AS SELECT * FROM t;
run
SELECT * FROM v;
cleanup
DROP VIEW v;
"#,
)
.await
.expect("benchmark should parse");
assert_eq!(benchmark.name(), "Parser Success");
assert_eq!(benchmark.group(), "Parser Group");
assert_eq!(benchmark.subgroup(), "Parser Subgroup");
assert_eq!(benchmark.expect, vec!["ProjectionExec with details"]);
assert_eq!(benchmark.echo, vec!["hello from parser"]);
assert_eq!(
benchmark
.queries()
.get(&QueryDirective::Load)
.expect("load query"),
&vec!["CREATE TABLE t AS VALUES (1);".to_string()]
);
assert_eq!(
benchmark
.queries()
.get(&QueryDirective::Init)
.expect("init query"),
&vec!["CREATE VIEW v AS SELECT * FROM t;".to_string()]
);
assert_eq!(
benchmark
.queries()
.get(&QueryDirective::Run)
.expect("run query"),
&vec!["SELECT * FROM v;".to_string()]
);
assert_eq!(
benchmark
.queries()
.get(&QueryDirective::Cleanup)
.expect("cleanup query"),
&vec!["DROP VIEW v;".to_string()]
);
}
#[tokio::test]
async fn parser_splits_inline_run_block_on_semicolon_newline() {
let benchmark = parse_benchmark(
r#"
run
CREATE TABLE t AS SELECT 1 AS value;
SELECT value + 1 AS value FROM t;
DROP TABLE t;
"#,
)
.await
.expect("benchmark should parse");
assert_eq!(
benchmark
.queries()
.get(&QueryDirective::Run)
.expect("run query"),
&vec![
"CREATE TABLE t AS SELECT 1 AS value;".to_string(),
"SELECT value + 1 AS value FROM t;".to_string(),
"DROP TABLE t;".to_string(),
]
);
}
#[tokio::test]
async fn parser_accepts_assert_with_expected_rows() {
let benchmark = parse_benchmark(
r#"
assert II
select 1, 'one'
----
1|one
2 two
"#,
)
.await
.expect("benchmark should parse");
let query = benchmark
.assert_queries()
.first()
.expect("assert query should be parsed");
assert_eq!(query.column_count, 2);
assert!(query.query.contains("select 1, 'one'"));
assert_eq!(
query.expected_result,
vec![
vec!["1".to_string(), "one".to_string()],
vec!["2".to_string(), "two".to_string()]
]
);
}
#[tokio::test]
async fn parser_accepts_result_query_with_expected_rows() {
let benchmark = parse_benchmark(
r#"
result_query II
select 1, 'one'
----
1|one
NULL|(empty)
"#,
)
.await
.expect("benchmark should parse");
let query = benchmark
.result_queries()
.first()
.expect("result query should be parsed");
assert_eq!(query.path, None);
assert_eq!(query.column_count, 2);
assert!(query.query.contains("select 1, 'one'"));
assert_eq!(
query.expected_result,
vec![
vec!["1".to_string(), "one".to_string()],
vec!["NULL".to_string(), "(empty)".to_string()]
]
);
}
#[tokio::test]
async fn parser_records_result_file_without_parsing_contents() {
let temp_dir = tempdir().expect("failed to create benchmark test directory");
let result_path =
write_test_file(&temp_dir, "result.csv", "col_a|col_b\n1|one\nNULL|two\n");
let benchmark_path = write_test_file(
&temp_dir,
"result.benchmark",
&format!("result {}\n", result_path.display()),
);
let benchmark = parse_benchmark_file(&benchmark_path)
.await
.expect("benchmark should parse");
let query = benchmark
.result_queries()
.first()
.expect("result file should be parsed");
assert_eq!(query.path, Some(result_path.to_string_lossy().into_owned()));
assert_eq!(query.column_count, 0);
assert!(query.expected_result.is_empty());
}
#[tokio::test]
async fn parser_accepts_include_file() {
let temp_dir = tempdir().expect("failed to create benchmark test directory");
let include_path =
write_test_file(&temp_dir, "include.benchmark", "run\nselect 1\n");
let benchmark_path = write_test_file(
&temp_dir,
"include_driver.benchmark",
&format!("include {}\n", include_path.display()),
);
let result = parse_benchmark_file(&benchmark_path).await;
let benchmark = result.expect("benchmark should parse");
assert_eq!(
benchmark
.queries()
.get(&QueryDirective::Run)
.expect("run query"),
&vec!["select 1".to_string()]
);
}
#[tokio::test]
async fn parser_accepts_template_file_with_parameters() {
let temp_dir = tempdir().expect("failed to create benchmark test directory");
let template_path = write_test_file(
&temp_dir,
"template_success.benchmark",
"# template comments are ignored\nrun\n-- query comments are ignored\nselect '${TABLE_NAME}', '${BENCHMARK_DIR}'\n",
);
let benchmark_path = write_test_file(
&temp_dir,
"template_success_driver.benchmark",
&format!(
"template {}\n# parameter comments are ignored\nTABLE_NAME=orders\n",
template_path.display()
),
);
let result = parse_benchmark_file(&benchmark_path).await;
let benchmark = result.expect("benchmark should parse");
assert_eq!(benchmark.benchmark_path(), template_path.as_path());
assert_eq!(
benchmark
.queries()
.get(&QueryDirective::Run)
.expect("run query"),
&vec!["select 'orders', '/tmp'".to_string()]
);
}
#[tokio::test]
async fn parser_trims_template_parameter_keys_and_values() {
let temp_dir = tempdir().expect("failed to create benchmark test directory");
let template_path = write_test_file(
&temp_dir,
"template_trim.benchmark",
"run\nselect '${TABLE_NAME}'\n",
);
let benchmark_path = write_test_file(
&temp_dir,
"template_trim_driver.benchmark",
&format!(
"template {}\n TABLE_NAME = orders \n",
template_path.display()
),
);
let benchmark = parse_benchmark_file(&benchmark_path)
.await
.expect("benchmark should parse");
assert_eq!(
benchmark
.queries()
.get(&QueryDirective::Run)
.expect("run query"),
&vec!["select 'orders'".to_string()]
);
assert_eq!(
benchmark.replacement_mapping().get("table_name"),
Some(&"orders".to_string())
);
}
#[tokio::test]
async fn parser_preserves_expected_result_cell_whitespace() {
let benchmark = parse_benchmark("assert I\nselect ' x '\n----\n x \n")
.await
.expect("benchmark should parse");
let query = benchmark
.assert_queries()
.first()
.expect("assert query should be parsed");
assert_eq!(query.expected_result, vec![vec![" x ".to_string()]]);
}
#[tokio::test]
async fn parser_accepts_indented_comments_and_blank_lines() {
let benchmark =
parse_benchmark(" # comment\n -- comment\n run\n select 1\n \n")
.await
.expect("benchmark should parse");
assert_eq!(
benchmark
.queries()
.get(&QueryDirective::Run)
.expect("run query"),
&vec!["select 1".to_string()]
);
}
#[tokio::test]
async fn parser_accepts_case_insensitive_query_directives() {
let benchmark = parse_benchmark("RUN\nselect 1\n")
.await
.expect("benchmark should parse");
assert_eq!(
benchmark
.queries()
.get(&QueryDirective::Run)
.expect("run query"),
&vec!["select 1".to_string()]
);
}
#[tokio::test]
async fn parser_accepts_query_file_and_splits_statements() {
let temp_dir = tempdir().expect("failed to create benchmark test directory");
let query_path = write_test_file(
&temp_dir,
"queries.sql",
"-- leading comment\nSELECT 1 AS value;\nSELECT 2 AS value;\n\n# another comment\nWITH t AS (SELECT 3 AS value) SELECT * FROM t;\n",
);
let benchmark_path = write_test_file(
&temp_dir,
"query_file.benchmark",
&format!("run {}\n", query_path.display()),
);
let benchmark = parse_benchmark_file(&benchmark_path)
.await
.expect("benchmark should parse");
assert_eq!(
benchmark
.queries()
.get(&QueryDirective::Run)
.expect("run queries"),
&vec![
"SELECT 1 AS value;".to_string(),
"SELECT 2 AS value;".to_string(),
"WITH t AS (SELECT 3 AS value) SELECT * FROM t;".to_string(),
]
);
}
#[tokio::test]
async fn parser_accepts_replacements_in_query_file_path() {
let temp_dir = tempdir().expect("failed to create benchmark test directory");
let query_path =
write_test_file(&temp_dir, "queries.sql", "SELECT 5 AS value;\n");
let template_path = write_test_file(
&temp_dir,
"query_file_path_template.benchmark",
"run ${QUERY_PATH}\n",
);
let benchmark_path = write_test_file(
&temp_dir,
"query_file_path_driver.benchmark",
&format!(
"template {}\nQUERY_PATH={}\n",
template_path.display(),
query_path.display()
),
);
let benchmark = parse_benchmark_file(&benchmark_path)
.await
.expect("benchmark should parse");
assert_eq!(
benchmark
.queries()
.get(&QueryDirective::Run)
.expect("run query"),
&vec!["SELECT 5 AS value;".to_string()]
);
}
#[tokio::test]
async fn parser_applies_data_dir_replacement_in_load_query_file() {
let temp_dir = tempdir().expect("failed to create benchmark test directory");
let data_dir = temp_dir.path().join("non_default_data");
let csv_dir = data_dir.join("tpch_sf1/csv/generated");
fs::create_dir_all(&csv_dir).expect("failed to create generated data directory");
fs::write(csv_dir.join("generated.1.csv"), "value\n42\n")
.expect("failed to write generated csv file");
let load_path = write_test_file(
&temp_dir,
"load_generated_csv.sql",
"CREATE EXTERNAL TABLE generated(value INT) STORED AS CSV LOCATION '${DATA_DIR:-data}/tpch_sf${BENCH_SIZE:-1}/csv/generated/generated.1.csv' OPTIONS ('format.has_header' 'true');\n",
);
let template_path = write_test_file(
&temp_dir,
"load_file_template.benchmark",
&format!(
"load {}\n\nrun\nSELECT value FROM generated;\n",
load_path.display()
),
);
let benchmark_path = write_test_file(
&temp_dir,
"load_file_driver.benchmark",
&format!(
"template {}\nDATA_DIR={}\n",
template_path.display(),
data_dir.display()
),
);
let ctx = SessionContext::new();
let path_string = benchmark_path.to_string_lossy().into_owned();
let mut benchmark = SqlBenchmark::new(&ctx, &path_string, "/tmp")
.await
.expect("benchmark should parse");
let load_queries = benchmark
.queries()
.get(&QueryDirective::Load)
.expect("load queries");
assert_eq!(load_queries.len(), 1);
assert!(
load_queries.iter().all(|query| !query.contains("${")),
"all placeholders should be replaced: {load_queries:?}"
);
let expected_location = format!(
"LOCATION '{}/tpch_sf1/csv/generated/generated.1.csv'",
data_dir.display()
);
assert!(
load_queries[0].contains(&expected_location),
"all load locations should use the non-default DATA_DIR: {load_queries:?}"
);
benchmark
.initialize(&ctx)
.await
.expect("benchmark should load generated csv file");
benchmark
.run(&ctx, true)
.await
.expect("benchmark should read generated csv file");
assert_eq!(formatted_last_results(&benchmark), vec![vec!["42"]]);
}
#[tokio::test]
async fn parser_rejects_inline_sql_when_query_file_is_provided() {
let temp_dir = tempdir().expect("failed to create benchmark test directory");
let query_path =
write_test_file(&temp_dir, "queries.sql", "SELECT 1 AS value;\n");
let benchmark_path = write_test_file(
&temp_dir,
"query_file_with_inline_body.benchmark",
&format!("run {}\nSELECT 999 AS value;\n", query_path.display()),
);
let result = parse_benchmark_file(&benchmark_path).await;
assert_result_error_contains(
result,
"run directive must use either a query file or inline SQL, not both",
);
}
#[tokio::test]
async fn parser_rejects_inline_sql_when_load_file_is_provided() {
let temp_dir = tempdir().expect("failed to create benchmark test directory");
let query_path = write_test_file(
&temp_dir,
"load.sql",
"CREATE TABLE t AS SELECT 1 AS value;\n",
);
let benchmark_path = write_test_file(
&temp_dir,
"load_file_with_inline_body.benchmark",
&format!(
"load {}\nCREATE TABLE u AS SELECT 2 AS value;\n",
query_path.display()
),
);
let result = parse_benchmark_file(&benchmark_path).await;
assert_result_error_contains(
result,
"load directive must use either a query file or inline SQL, not both",
);
}
#[tokio::test]
async fn parser_rejects_inline_sql_when_init_file_is_provided() {
let temp_dir = tempdir().expect("failed to create benchmark test directory");
let query_path = write_test_file(
&temp_dir,
"init.sql",
"CREATE VIEW v AS SELECT 1 AS value;\n",
);
let benchmark_path = write_test_file(
&temp_dir,
"init_file_with_inline_body.benchmark",
&format!(
"init {}\nCREATE VIEW w AS SELECT 2 AS value;\n",
query_path.display()
),
);
let result = parse_benchmark_file(&benchmark_path).await;
assert_result_error_contains(
result,
"init directive must use either a query file or inline SQL, not both",
);
}
#[tokio::test]
async fn parser_rejects_inline_sql_when_cleanup_file_is_provided() {
let temp_dir = tempdir().expect("failed to create benchmark test directory");
let query_path = write_test_file(&temp_dir, "cleanup.sql", "DROP TABLE t;\n");
let benchmark_path = write_test_file(
&temp_dir,
"cleanup_file_with_inline_body.benchmark",
&format!("cleanup {}\nDROP TABLE u;\n", query_path.display()),
);
let result = parse_benchmark_file(&benchmark_path).await;
assert_result_error_contains(
result,
"cleanup directive must use either a query file or inline SQL, not both",
);
}
#[tokio::test]
async fn parser_ignores_query_file_with_only_comments_and_blank_lines() {
let temp_dir = tempdir().expect("failed to create benchmark test directory");
let query_path = write_test_file(
&temp_dir,
"queries.sql",
"# comment\n\n-- another comment\n\n",
);
let benchmark_path = write_test_file(
&temp_dir,
"empty_query_file.benchmark",
&format!("run {}\n", query_path.display()),
);
let benchmark = parse_benchmark_file(&benchmark_path)
.await
.expect("benchmark should parse");
assert!(!benchmark.queries().contains_key(&QueryDirective::Run));
}
#[tokio::test]
async fn parser_splits_query_file_with_windows_line_endings() {
let temp_dir = tempdir().expect("failed to create benchmark test directory");
let query_path = write_test_file(
&temp_dir,
"queries.sql",
"SELECT 1 AS value;\r\nSELECT 2 AS value;\r\n",
);
let benchmark_path = write_test_file(
&temp_dir,
"windows_query_file.benchmark",
&format!("run {}\n", query_path.display()),
);
let benchmark = parse_benchmark_file(&benchmark_path)
.await
.expect("benchmark should parse");
assert_eq!(
benchmark
.queries()
.get(&QueryDirective::Run)
.expect("run queries"),
&vec![
"SELECT 1 AS value;".to_string(),
"SELECT 2 AS value;".to_string()
]
);
}
#[tokio::test]
async fn parser_rejects_unknown_command() {
assert_parse_error("wat\n", "Unrecognized command: wat").await;
}
#[tokio::test]
async fn parser_rejects_assert_without_column_count() {
assert_parse_error(
"assert\nselect 1\n----\n1\n",
"assert must be followed by a column count",
)
.await;
}
#[tokio::test]
async fn parser_rejects_assert_without_result_separator() {
assert_parse_error(
"assert I\nselect 1\n1\n",
"assert must be followed by a query and a result (separated by ----)",
)
.await;
}
#[tokio::test]
async fn parser_rejects_result_query_without_separator() {
assert_parse_error(
"result_query I\nselect 1\n1\n",
"result_query must be followed by a query and a result (separated by ----)",
)
.await;
}
#[tokio::test]
async fn parser_rejects_result_query_with_wrong_column_count() {
assert_parse_error(
"result_query II\nselect 1\n----\n1\n",
"expected 2 values but got 1",
)
.await;
}
#[tokio::test]
async fn parser_rejects_multiple_result_queries() {
assert_parse_error(
"result_query I\nselect 1\n----\n1\n\nresult_query I\nselect 2\n----\n2\n",
"multiple results found",
)
.await;
}
#[tokio::test]
async fn parser_rejects_duplicate_run_directives() {
assert_parse_error("run\nselect 1\n\nrun\nselect 2\n", "Multiple calls to run")
.await;
}
#[tokio::test]
async fn parser_accepts_multiple_load_directives() {
let benchmark = parse_benchmark(
"load\nCREATE TABLE t AS SELECT 1;\n\nload\nCREATE TABLE u AS SELECT 2;\n",
)
.await
.expect("benchmark should parse");
assert_eq!(
benchmark
.queries()
.get(&QueryDirective::Load)
.expect("load queries"),
&vec![
"CREATE TABLE t AS SELECT 1;".to_string(),
"CREATE TABLE u AS SELECT 2;".to_string(),
]
);
}
#[tokio::test]
async fn parser_accepts_multiple_init_directives() {
let benchmark = parse_benchmark(
"init\nCREATE VIEW v AS SELECT 1;\n\ninit\nCREATE VIEW w AS SELECT 2;\n",
)
.await;
let benchmark = benchmark.expect("benchmark should parse");
assert_eq!(
benchmark
.queries()
.get(&QueryDirective::Init)
.expect("init queries"),
&vec![
"CREATE VIEW v AS SELECT 1;".to_string(),
"CREATE VIEW w AS SELECT 2;".to_string(),
]
);
}
#[tokio::test]
async fn parser_accepts_multiple_cleanup_directives() {
let benchmark =
parse_benchmark("cleanup\nDROP TABLE t;\n\ncleanup\nDROP TABLE u;\n")
.await
.expect("benchmark should parse");
assert_eq!(
benchmark
.queries()
.get(&QueryDirective::Cleanup)
.expect("cleanup queries"),
&vec!["DROP TABLE t;".to_string(), "DROP TABLE u;".to_string(),]
);
}
#[tokio::test]
async fn parser_rejects_missing_query_file() {
let temp_dir = tempdir().expect("failed to create benchmark test directory");
let missing_path = temp_dir.path().join("missing.sql");
let benchmark_path = write_test_file(
&temp_dir,
"missing_query_file.benchmark",
&format!("run {}\n", missing_path.display()),
);
let result = parse_benchmark_file(&benchmark_path).await;
assert_result_error_contains(result, "Failed to read query file");
}
#[tokio::test]
async fn parser_rejects_template_with_invalid_parameter_assignment() {
let temp_dir = tempdir().expect("failed to create benchmark test directory");
let template_path =
write_test_file(&temp_dir, "template.benchmark", "run\nselect 1\n");
let benchmark_path = write_test_file(
&temp_dir,
"template_driver.benchmark",
&format!("template {}\nINVALID\n", template_path.display()),
);
let ctx = SessionContext::new();
let benchmark_path_string = benchmark_path.to_string_lossy().into_owned();
let result = SqlBenchmark::new(&ctx, &benchmark_path_string, "/tmp").await;
let error = result.expect_err("benchmark parsing should fail");
let message = error.to_string();
assert!(
message.contains("Expected a template parameter in the form of X=Y"),
"expected template parameter error, got {message:?}"
);
}
#[tokio::test]
async fn parser_rejects_metadata_and_result_directives_without_values() {
assert_parse_error("name\n", "name must be followed by a value").await;
assert_parse_error("group\n", "group must be followed by a value").await;
assert_parse_error("subgroup\n", "subgroup must be followed by a value").await;
assert_parse_error(
"expect_plan\n",
"expect_plan must be followed by a string to search in the physical plan",
)
.await;
assert_parse_error("echo\n", "Echo requires an argument").await;
assert_parse_error(
"result\n",
"result must be followed by a path to a result file",
)
.await;
assert_parse_error("include\n", "include requires a single argument").await;
assert_parse_error("template\n", "template requires a single template path")
.await;
}
#[tokio::test]
async fn parser_rejects_include_and_template_with_too_many_arguments() {
assert_parse_error("include a b\n", "include requires a single argument").await;
assert_parse_error("template a b\n", "template requires a single template path")
.await;
}
#[tokio::test]
async fn parser_rejects_missing_include_file() {
let temp_dir = tempdir().expect("failed to create benchmark test directory");
let missing_path = temp_dir.path().join("missing_include.benchmark");
let benchmark_path = write_test_file(
&temp_dir,
"missing_include_driver.benchmark",
&format!("include {}\n", missing_path.display()),
);
let result = parse_benchmark_file(&benchmark_path).await;
assert_result_error_contains(result, "No such file");
}
#[tokio::test]
async fn parser_rejects_missing_template_file() {
let temp_dir = tempdir().expect("failed to create benchmark test directory");
let missing_path = temp_dir.path().join("missing_template.benchmark");
let benchmark_path = write_test_file(
&temp_dir,
"missing_template_driver.benchmark",
&format!("template {}\n", missing_path.display()),
);
let result = parse_benchmark_file(&benchmark_path).await;
assert_result_error_contains(result, "No such file");
}
#[tokio::test]
async fn parser_uses_metadata_values_as_replacements() {
let benchmark = parse_benchmark(
r#"
name Q01
group tpch
subgroup sf1
run
SELECT '${BENCH_NAME}', '${BENCH_GROUP}', '${BENCH_SUBGROUP}'
"#,
)
.await
.expect("benchmark should parse");
assert_eq!(
benchmark
.queries()
.get(&QueryDirective::Run)
.expect("run query"),
&vec!["SELECT 'Q01', 'tpch', 'sf1'".to_string()]
);
}
#[tokio::test]
async fn parser_accepts_replacement_in_result_file_path() {
let temp_dir = tempdir().expect("failed to create benchmark test directory");
let result_path = write_test_file(&temp_dir, "result.csv", "value\n1\n");
let template_path = write_test_file(
&temp_dir,
"result_path_template.benchmark",
"result ${RESULT_PATH}\n",
);
let benchmark_path = write_test_file(
&temp_dir,
"result_path_driver.benchmark",
&format!(
"template {}\nRESULT_PATH={}\n",
template_path.display(),
result_path.display()
),
);
let benchmark = parse_benchmark_file(&benchmark_path)
.await
.expect("benchmark should parse");
let query = benchmark
.result_queries()
.first()
.expect("result query should be parsed");
assert_eq!(query.path, Some(result_path.to_string_lossy().into_owned()));
assert_eq!(query.column_count, 0);
assert!(query.expected_result.is_empty());
}
#[tokio::test]
async fn parser_rejects_missing_replacement_in_result_file_path() {
assert_parse_error("result ${MISSING_RESULT_PATH}\n", "Missing value for key")
.await;
}
#[tokio::test]
async fn parser_accepts_missing_result_file() {
let temp_dir = tempdir().expect("failed to create benchmark test directory");
let missing_path = temp_dir.path().join("missing_result.csv");
let benchmark_path = write_test_file(
&temp_dir,
"missing_result_file.benchmark",
&format!("result {}\n", missing_path.display()),
);
let benchmark = parse_benchmark_file(&benchmark_path)
.await
.expect("benchmark should parse");
let query = benchmark
.result_queries()
.first()
.expect("result file should be parsed");
assert_eq!(
query.path,
Some(missing_path.to_string_lossy().into_owned())
);
assert!(query.expected_result.is_empty());
}
#[tokio::test]
async fn parser_accepts_malformed_result_file() {
let temp_dir = tempdir().expect("failed to create benchmark test directory");
let result_path = temp_dir.path().join("malformed_result.csv");
fs::write(&result_path, [0xff]).expect("failed to write malformed result file");
let benchmark_path = write_test_file(
&temp_dir,
"malformed_result_file.benchmark",
&format!("result {}\n", result_path.display()),
);
let benchmark = parse_benchmark_file(&benchmark_path)
.await
.expect("benchmark should parse");
let query = benchmark
.result_queries()
.first()
.expect("result file should be parsed");
assert_eq!(query.path, Some(result_path.to_string_lossy().into_owned()));
assert!(query.expected_result.is_empty());
}
// Lifecycle tests cover initialization, assertions, and cleanup execution.
#[tokio::test]
async fn initialize_executes_load_before_init_and_is_idempotent() {
let mut benchmark = parse_benchmark(
r#"
load
CREATE TABLE t AS SELECT 1 AS value;
load
CREATE TABLE u AS SELECT value + 1 AS value FROM t;
init
CREATE TABLE v AS SELECT value + 1 AS value FROM u;
init
CREATE TABLE initialized AS SELECT value + 1 AS value FROM v;
run
SELECT value FROM initialized;
"#,
)
.await
.expect("benchmark should parse");
let ctx = SessionContext::new();
benchmark
.initialize(&ctx)
.await
.expect("initialize should succeed");
benchmark
.initialize(&ctx)
.await
.expect("second initialize should be a no-op");
assert!(benchmark.is_loaded());
let rows = ctx
.sql("SELECT value FROM initialized")
.await
.expect("query should plan")
.collect()
.await
.expect("query should run");
assert_eq!(format_record_batches(&rows).unwrap(), vec![vec!["4"]]);
}
#[tokio::test]
async fn initialize_rejects_benchmark_without_run_query() {
let mut benchmark = parse_benchmark(
r#"
load
CREATE TABLE t AS SELECT 1 AS value;
"#,
)
.await
.expect("benchmark should parse");
let ctx = SessionContext::new();
assert_result_error_contains(
benchmark.initialize(&ctx).await,
"Invalid benchmark file: no \"run\" query specified",
);
}
#[tokio::test]
async fn initialize_propagates_load_query_failures() {
let mut benchmark = parse_benchmark(
r#"
load
CREATE TABLE t AS SELECT * FROM missing_load_table;
run
SELECT 1;
"#,
)
.await
.expect("benchmark should parse");
let ctx = SessionContext::new();
assert_result_error_contains(
benchmark.initialize(&ctx).await,
"missing_load_table",
);
}
#[tokio::test]
async fn initialize_propagates_init_query_failures() {
let mut benchmark = parse_benchmark(
r#"
init
CREATE TABLE t AS SELECT * FROM missing_init_table;
run
SELECT 1;
"#,
)
.await
.expect("benchmark should parse");
let ctx = SessionContext::new();
assert_result_error_contains(
benchmark.initialize(&ctx).await,
"missing_init_table",
);
}
#[tokio::test]
async fn cleanup_executes_cleanup_queries() {
let mut benchmark = parse_benchmark(
r#"
run
SELECT 1;
cleanup
CREATE TABLE cleanup_marker_a AS SELECT 7 AS value;
cleanup
CREATE TABLE cleanup_marker_b AS SELECT value + 1 AS value FROM cleanup_marker_a;
"#,
)
.await
.expect("benchmark should parse");
let ctx = SessionContext::new();
benchmark.cleanup(&ctx).await.expect("cleanup should run");
let rows = ctx
.sql("SELECT value FROM cleanup_marker_b")
.await
.expect("query should plan")
.collect()
.await
.expect("query should run");
assert_eq!(format_record_batches(&rows).unwrap(), vec![vec!["8"]]);
}
#[tokio::test]
async fn cleanup_propagates_query_failures() {
let mut benchmark = parse_benchmark(
r#"
run
SELECT 1;
cleanup
SELECT * FROM missing_cleanup_table;
"#,
)
.await
.expect("benchmark should parse");
let ctx = SessionContext::new();
assert_result_error_contains(
benchmark.cleanup(&ctx).await,
"missing_cleanup_table",
);
}
#[tokio::test]
async fn assert_executes_assert_queries_successfully() {
let mut benchmark = parse_benchmark(
r#"
assert I
SELECT 1 AS value
----
1
run
SELECT 1;
"#,
)
.await
.expect("benchmark should parse");
let ctx = SessionContext::new();
benchmark.assert(&ctx).await.expect("assert should pass");
}
#[tokio::test]
async fn assert_accepts_null_expected_for_empty_actual() {
let mut benchmark = parse_benchmark(
r#"
assert I
SELECT '' AS value
----
NULL
run
SELECT 1;
"#,
)
.await
.expect("benchmark should parse");
let ctx = SessionContext::new();
benchmark.assert(&ctx).await.expect("assert should pass");
}
#[tokio::test]
async fn assert_accepts_empty_marker_for_empty_actual() {
let mut benchmark = parse_benchmark(
r#"
assert I
SELECT '' AS value
----
(empty)
run
SELECT 1;
"#,
)
.await
.expect("benchmark should parse");
let ctx = SessionContext::new();
benchmark.assert(&ctx).await.expect("assert should pass");
}
#[tokio::test]
async fn assert_accepts_empty_marker_for_null_actual() {
let mut benchmark = parse_benchmark(
r#"
assert I
SELECT CAST(NULL AS VARCHAR) AS value
----
(empty)
run
SELECT 1;
"#,
)
.await
.expect("benchmark should parse");
let ctx = SessionContext::new();
benchmark.assert(&ctx).await.expect("assert should pass");
}
#[tokio::test]
async fn assert_succeeds_with_zero_actual_and_expected_rows() {
let mut benchmark = parse_benchmark(
r#"
assert I
SELECT 1 AS value WHERE false
----
run
SELECT 1;
"#,
)
.await
.expect("benchmark should parse");
let ctx = SessionContext::new();
benchmark.assert(&ctx).await.expect("assert should pass");
}
#[tokio::test]
async fn assert_propagates_query_failures() {
let mut benchmark = parse_benchmark(
r#"
assert I
SELECT * FROM missing_assert_table
----
1
run
SELECT 1;
"#,
)
.await
.expect("benchmark should parse");
let ctx = SessionContext::new();
assert_result_error_contains(
benchmark.assert(&ctx).await,
"missing_assert_table",
);
}
#[tokio::test]
async fn assert_reports_row_count_mismatch() {
let mut benchmark = parse_benchmark(
r#"
assert I
SELECT 1 AS value
----
1
2
run
SELECT 1;
"#,
)
.await
.expect("benchmark should parse");
let ctx = SessionContext::new();
assert_result_error_contains(
benchmark.assert(&ctx).await,
"expected 2 rows but got 1",
);
}
#[tokio::test]
async fn assert_reports_column_count_mismatch() {
let mut benchmark = parse_benchmark(
r#"
assert I
SELECT 1 AS a, 2 AS b
----
1
run
SELECT 1;
"#,
)
.await
.expect("benchmark should parse");
let ctx = SessionContext::new();
assert_result_error_contains(
benchmark.assert(&ctx).await,
"expected 1 columns but got 2",
);
}
#[tokio::test]
async fn assert_reports_value_mismatch() {
let mut benchmark = parse_benchmark(
r#"
assert I
SELECT 1 AS value
----
2
run
SELECT 1;
"#,
)
.await
.expect("benchmark should parse");
let ctx = SessionContext::new();
assert_result_error_contains(
benchmark.assert(&ctx).await,
"expected value \"2\" but got value \"1\"",
);
}
// Run tests cover result buffering and physical-plan expectations.
#[tokio::test]
async fn run_saves_uppercase_select_results() {
let mut benchmark = parse_benchmark("run\nSELECT 1 AS value\n")
.await
.expect("benchmark should parse");
let ctx = SessionContext::new();
benchmark.run(&ctx, true).await.expect("run should succeed");
assert_eq!(formatted_last_results(&benchmark), vec![vec!["1"]]);
}
#[tokio::test]
async fn run_saves_with_query_results() {
let mut benchmark =
parse_benchmark("run\nWITH t AS (SELECT 3 AS value) SELECT value FROM t\n")
.await
.expect("benchmark should parse");
let ctx = SessionContext::new();
benchmark.run(&ctx, true).await.expect("run should succeed");
assert_eq!(formatted_last_results(&benchmark), vec![vec!["3"]]);
}
#[tokio::test]
async fn run_only_keeps_last_select_or_with_result() {
let temp_dir = tempdir().expect("failed to create benchmark test directory");
let query_path = write_test_file(
&temp_dir,
"queries.sql",
"SELECT 1 AS value;\nSELECT 2 AS value;\nWITH t AS (SELECT 3 AS value) SELECT value FROM t;\n",
);
let benchmark_path = write_test_file(
&temp_dir,
"run_file.benchmark",
&format!("run {}\n", query_path.display()),
);
let mut benchmark = parse_benchmark_file(&benchmark_path)
.await
.expect("benchmark should parse");
let ctx = SessionContext::new();
benchmark.run(&ctx, true).await.expect("run should succeed");
assert_eq!(formatted_last_results(&benchmark), vec![vec!["3"]]);
}
#[tokio::test]
async fn run_inline_multi_statement_only_keeps_last_select_or_with_result() {
let mut benchmark = parse_benchmark(
"run\nCREATE TABLE t AS SELECT 1 AS value;\nSELECT 2 AS value;\nWITH u AS (SELECT 3 AS value) SELECT value FROM u;\n",
)
.await
.expect("benchmark should parse");
let ctx = SessionContext::new();
benchmark.run(&ctx, true).await.expect("run should succeed");
assert_eq!(formatted_last_results(&benchmark), vec![vec!["3"]]);
}
#[tokio::test]
async fn run_does_not_save_results_for_non_select_statement() {
let mut benchmark =
parse_benchmark("run\nCREATE TABLE run_created AS SELECT 1 AS value;\n")
.await
.expect("benchmark should parse");
let ctx = SessionContext::new();
benchmark.run(&ctx, true).await.expect("run should succeed");
assert!(
benchmark
.last_results
.as_ref()
.expect("last results should be set")
.is_empty()
);
}
#[tokio::test]
async fn run_propagates_query_failures_when_buffering_results() {
let mut benchmark = parse_benchmark("run\nSELECT * FROM missing_run_table\n")
.await
.expect("benchmark should parse");
let ctx = SessionContext::new();
assert_result_error_contains(
benchmark.run(&ctx, true).await,
"missing_run_table",
);
}
#[tokio::test]
async fn run_propagates_query_failures_when_streaming_results() {
let mut benchmark = parse_benchmark("run\nSELECT * FROM missing_stream_table\n")
.await
.expect("benchmark should parse");
let ctx = SessionContext::new();
assert_result_error_contains(
benchmark.run(&ctx, false).await,
"missing_stream_table",
);
}
#[tokio::test]
async fn run_rejects_missing_expect_plan_for_buffered_and_streaming_modes() {
let ctx = SessionContext::new();
let benchmark_text = "expect_plan definitely_not_in_plan\nrun\nSELECT 1\n";
let mut buffered = parse_benchmark(benchmark_text)
.await
.expect("benchmark should parse");
assert_result_error_contains(
buffered.run(&ctx, true).await,
"does not contain the expected string 'definitely_not_in_plan'",
);
let mut streaming = parse_benchmark(benchmark_text)
.await
.expect("benchmark should parse");
assert_result_error_contains(
streaming.run(&ctx, false).await,
"does not contain the expected string 'definitely_not_in_plan'",
);
}
#[tokio::test]
async fn run_accepts_matching_expect_plan_for_buffered_and_streaming_modes() {
let ctx = SessionContext::new();
let benchmark_text = "expect_plan PlaceholderRowExec\nrun\nSELECT 1\n";
let mut buffered = parse_benchmark(benchmark_text)
.await
.expect("benchmark should parse");
buffered
.run(&ctx, true)
.await
.expect("buffered run should accept matching plan");
assert_eq!(formatted_last_results(&buffered), vec![vec!["1"]]);
let mut streaming = parse_benchmark(benchmark_text)
.await
.expect("benchmark should parse");
streaming
.run(&ctx, false)
.await
.expect("streaming run should accept matching plan");
}
// Verification tests cover result_query and persisted-result comparison paths.
#[tokio::test]
async fn verify_without_result_query_returns_ok() {
let mut benchmark = parse_benchmark("run\nSELECT 1 AS value\n")
.await
.expect("benchmark should parse");
let ctx = SessionContext::new();
benchmark.verify(&ctx).await.expect("verify should pass");
}
#[tokio::test]
async fn verify_errors_when_benchmark_has_not_run() {
let mut benchmark = parse_benchmark(
r#"
result_query I
SELECT 1 AS value
----
1
run
SELECT 1;
"#,
)
.await
.expect("benchmark should parse");
let ctx = SessionContext::new();
assert_result_error_contains(
benchmark.verify(&ctx).await,
"No results available for verification. Run the benchmark first.",
);
}
#[tokio::test]
async fn verify_uses_last_results_for_result_file_entries() {
let temp_dir = tempdir().expect("failed to create benchmark test directory");
let result_path = write_test_file(&temp_dir, "result.csv", "value\n1\n");
let mut benchmark = parse_benchmark(&format!(
"result {}\n\nrun\nSELECT 1 AS value\n",
result_path.display()
))
.await
.expect("benchmark should parse");
let ctx = SessionContext::new();
benchmark.run(&ctx, true).await.expect("run should succeed");
benchmark.verify(&ctx).await.expect("verify should pass");
}
#[tokio::test]
async fn verify_uses_last_results_for_zero_row_result_file_entries() {
let temp_dir = tempdir().expect("failed to create benchmark test directory");
let result_path = write_test_file(&temp_dir, "result.csv", "value\n");
let mut benchmark = parse_benchmark(&format!(
"result {}\n\nrun\nSELECT 1 AS value WHERE false\n",
result_path.display()
))
.await
.expect("benchmark should parse");
let ctx = SessionContext::new();
benchmark.run(&ctx, true).await.expect("run should succeed");
benchmark.verify(&ctx).await.expect("verify should pass");
}
#[tokio::test]
async fn verify_rejects_missing_result_file() {
let temp_dir = tempdir().expect("failed to create benchmark test directory");
let missing_path = temp_dir.path().join("missing_result.csv");
let mut benchmark = parse_benchmark(&format!(
"result {}\n\nrun\nSELECT 1 AS value\n",
missing_path.display()
))
.await
.expect("benchmark should parse");
let ctx = SessionContext::new();
benchmark.run(&ctx, true).await.expect("run should succeed");
assert_result_error_contains(benchmark.verify(&ctx).await, "missing_result.csv");
}
#[tokio::test]
async fn verify_rejects_malformed_result_file() {
let temp_dir = tempdir().expect("failed to create benchmark test directory");
let result_path = temp_dir.path().join("malformed_result.csv");
fs::write(&result_path, [0xff]).expect("failed to write malformed result file");
let mut benchmark = parse_benchmark(&format!(
"result {}\n\nrun\nSELECT 1 AS value\n",
result_path.display()
))
.await
.expect("benchmark should parse");
let ctx = SessionContext::new();
benchmark.run(&ctx, true).await.expect("run should succeed");
assert_result_error_contains(benchmark.verify(&ctx).await, "CSV");
}
#[tokio::test]
async fn verify_executes_result_query_instead_of_last_results() {
let mut benchmark = parse_benchmark(
r#"
run
SELECT 100 AS value
result_query I
SELECT 1 AS value
----
1
"#,
)
.await
.expect("benchmark should parse");
let ctx = SessionContext::new();
benchmark.run(&ctx, true).await.expect("run should succeed");
benchmark.verify(&ctx).await.expect("verify should pass");
}
#[tokio::test]
async fn verify_propagates_result_query_failures() {
let mut benchmark = parse_benchmark(
r#"
run
SELECT 1 AS value
result_query I
SELECT * FROM missing_verify_table
----
1
"#,
)
.await
.expect("benchmark should parse");
let ctx = SessionContext::new();
benchmark.run(&ctx, true).await.expect("run should succeed");
assert_result_error_contains(
benchmark.verify(&ctx).await,
"missing_verify_table",
);
}
#[tokio::test]
async fn verify_reports_result_mismatch_context() {
let mut benchmark = parse_benchmark(
r#"
run
SELECT 1 AS value
result_query I
SELECT 1 AS value
----
2
"#,
)
.await
.expect("benchmark should parse");
let ctx = SessionContext::new();
benchmark.run(&ctx, true).await.expect("run should succeed");
let error = benchmark
.verify(&ctx)
.await
.expect_err("verify should fail");
let message = error.to_string();
assert!(
message.contains("row 1, column 1")
&& message.contains("expected value \"2\"")
&& message.contains("got value \"1\""),
"unexpected error: {message}"
);
}
// Persistence tests cover CSV writing and persist-time error paths.
#[tokio::test]
async fn persist_without_result_query_returns_ok() {
let mut benchmark = parse_benchmark("run\nSELECT 1 AS value\n")
.await
.expect("benchmark should parse");
let ctx = SessionContext::new();
benchmark.persist(&ctx).await.expect("persist should pass");
}
#[tokio::test]
async fn persist_rejects_result_query_without_file_path() {
let mut benchmark = parse_benchmark(
r#"
run
SELECT 1 AS value
result_query I
SELECT 1 AS value
----
1
"#,
)
.await
.expect("benchmark should parse");
let ctx = SessionContext::new();
assert_result_error_contains(
benchmark.persist(&ctx).await,
"Unable to persist results from query",
);
}
#[tokio::test]
async fn persist_rejects_run_without_saved_result_batches() {
let temp_dir = tempdir().expect("failed to create benchmark test directory");
let output_path = temp_dir.path().join("persisted");
let mut benchmark =
parse_benchmark("run\nCREATE TABLE persist_source AS SELECT 1 AS value;\n")
.await
.expect("benchmark should parse");
benchmark.result_queries.push(BenchmarkQuery {
path: Some(output_path.to_string_lossy().into_owned()),
query: String::new(),
column_count: 1,
expected_result: vec![],
});
let ctx = SessionContext::new();
assert_result_error_contains(
benchmark.persist(&ctx).await,
"Results should be loaded",
);
}
#[tokio::test]
async fn persist_writes_header_and_pipe_delimited_rows() {
let temp_dir = tempdir().expect("failed to create benchmark test directory");
let output_path = temp_dir.path().join("persisted");
let mut benchmark = parse_benchmark("run\nSELECT 1 AS a, 'one' AS b\n")
.await
.expect("benchmark should parse");
benchmark.result_queries.push(BenchmarkQuery {
path: Some(output_path.to_string_lossy().into_owned()),
query: String::new(),
column_count: 2,
expected_result: vec![],
});
let ctx = SessionContext::new();
benchmark.persist(&ctx).await.expect("persist should pass");
let contents = read_all_files_in_dir(&output_path);
assert!(
contents.contains("a|b\n") && contents.contains("1|one\n"),
"unexpected persisted contents: {contents:?}"
);
}
#[tokio::test]
async fn persist_writes_header_for_zero_row_select_results() {
let temp_dir = tempdir().expect("failed to create benchmark test directory");
let output_path = temp_dir.path().join("persisted_empty");
let mut benchmark = parse_benchmark("run\nSELECT 1 AS value WHERE false\n")
.await
.expect("benchmark should parse");
benchmark.result_queries.push(BenchmarkQuery {
path: Some(output_path.to_string_lossy().into_owned()),
query: String::new(),
column_count: 1,
expected_result: vec![],
});
let ctx = SessionContext::new();
benchmark.persist(&ctx).await.expect("persist should pass");
let contents = read_all_files_in_dir(&output_path);
assert!(
contents.contains("value\n"),
"unexpected persisted contents: {contents:?}"
);
}
// Path helper tests cover group derivation from benchmark file paths.
#[test]
fn parse_group_from_path_returns_group_under_benchmark_directory() {
let group = parse_group_from_path(
Path::new("sql_benchmarks/tpch/benchmarks/q01.benchmark"),
Path::new("sql_benchmarks"),
);
assert_eq!(group, "tpch");
}
#[test]
fn parse_group_from_path_matches_benchmark_directory_case_insensitively() {
let group = parse_group_from_path(
Path::new("/tmp/SQL_BENCHMARKS/Tpch/benchmarks/q01.benchmark"),
Path::new("sql_benchmarks"),
);
assert_eq!(group, "Tpch");
}
#[test]
fn parse_group_from_path_handles_relative_and_absolute_paths() {
let relative = parse_group_from_path(
Path::new("sql_benchmarks/h2o/q01.benchmark"),
Path::new("sql_benchmarks"),
);
let absolute = parse_group_from_path(
Path::new("/tmp/sql_benchmarks/imdb/q01.benchmark"),
Path::new("sql_benchmarks"),
);
assert_eq!(relative, "h2o");
assert_eq!(absolute, "imdb");
}
#[test]
fn parse_group_from_path_pins_fallback_for_paths_outside_benchmark_directory() {
let group = parse_group_from_path(
Path::new("outside/group/q01.benchmark"),
Path::new("sql_benchmarks"),
);
assert_eq!(group, "outside");
}
#[test]
fn path_ends_with_ignore_ascii_case_matches_component_suffixes() {
assert!(path_ends_with_ignore_ascii_case(
Path::new("/tmp/SQL_BENCHMARKS"),
Path::new("sql_benchmarks")
));
assert!(!path_ends_with_ignore_ascii_case(
Path::new("/tmp/sql_benchmarks_extra"),
Path::new("sql_benchmarks")
));
}
}