blob: fd800618ceea4991540c022c3598794bd6708fc8 [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.
//! Tests for COUNT(*) pushdown optimization via statistics.
//!
//! DataFusion's `aggregate_statistics` optimizer rule can replace a
//! scan+aggregate subtree with a literal projection when the source
//! reports `Precision::Exact(num_rows)` from `partition_statistics()`.
//!
//! These tests verify that:
//! - COUNT(*) pushdown succeeds when statistics are exact (no PaimonTableScan in plan)
//! - COUNT(*) pushdown fails (falls back to scanning) when statistics are inexact
mod common;
use std::sync::Arc;
use datafusion::arrow::array::Int64Array;
use datafusion::physical_plan::{displayable, ExecutionPlan};
use paimon_datafusion::SQLContext;
/// Creates a test sql_context with a table ready for inserts.
async fn setup_table(schema_sql: &str) -> (tempfile::TempDir, SQLContext) {
let (tmp, sql_context) = common::setup_sql_context().await;
sql_context
.sql(&format!("CREATE TABLE paimon.test_db.t {schema_sql}"))
.await
.expect("CREATE TABLE should succeed");
(tmp, sql_context)
}
/// Creates a test sql_context with a partitioned table ready for inserts.
async fn setup_partitioned_table(
columns: &str,
partition_cols: &str,
) -> (tempfile::TempDir, SQLContext) {
let (tmp, sql_context) = common::setup_sql_context().await;
sql_context
.sql(&format!(
"CREATE TABLE paimon.test_db.t ({columns}) PARTITIONED BY ({partition_cols})"
))
.await
.expect("CREATE TABLE should succeed");
(tmp, sql_context)
}
/// Checks if the physical plan contains a PaimonTableScan (meaning data would be scanned).
fn plan_contains_scan(plan: &Arc<dyn ExecutionPlan>) -> bool {
let plan_str = displayable(plan.as_ref()).indent(true).to_string();
plan_str.contains("PaimonTableScan")
}
/// Creates a physical plan for the given SQL and checks if COUNT was pushed down.
/// Returns Ok(plan) if pushdown succeeded (no scan in plan), Err(plan) if it fell back to scanning.
async fn verify_count_pushdown(
sql_context: &SQLContext,
sql: &str,
) -> Result<Arc<dyn ExecutionPlan>, Arc<dyn ExecutionPlan>> {
let df = sql_context.sql(sql).await.expect("Query should succeed");
let plan = df
.create_physical_plan()
.await
.expect("Physical plan should succeed");
if plan_contains_scan(&plan) {
Err(plan)
} else {
Ok(plan)
}
}
/// Executes a COUNT(*) query and returns the count value.
async fn run_count_query(sql_context: &SQLContext, sql: &str) -> i64 {
let batches = sql_context
.sql(sql)
.await
.expect("Query should succeed")
.collect()
.await
.expect("Collect should succeed");
let total_rows: usize = batches.iter().map(|b| b.num_rows()).sum();
assert_eq!(total_rows, 1, "COUNT(*) should return exactly one row");
let count_array = batches[0]
.column(0)
.as_any()
.downcast_ref::<Int64Array>()
.expect("COUNT should return Int64");
count_array.value(0)
}
// ============================================================================
// Test: COUNT(*) without filter should push down
// ============================================================================
#[tokio::test]
async fn test_count_star_no_filter_pushes_down() {
let (_tmp, sql_context) = setup_table("(id INT, value INT)").await;
sql_context
.sql("INSERT INTO paimon.test_db.t VALUES (1, 10), (2, 20), (3, 30)")
.await
.unwrap()
.collect()
.await
.unwrap();
verify_count_pushdown(&sql_context, "SELECT COUNT(*) FROM paimon.test_db.t")
.await
.expect("COUNT(*) should push down (no filter)");
let count = run_count_query(&sql_context, "SELECT COUNT(*) FROM paimon.test_db.t").await;
assert_eq!(count, 3, "COUNT(*) should return 3");
}
// ============================================================================
// Test: COUNT(*) with data filter should NOT push down (must scan)
// ============================================================================
#[tokio::test]
async fn test_count_star_with_data_filter_does_not_push_down() {
let (_tmp, sql_context) = setup_table("(id INT, value INT)").await;
sql_context
.sql("INSERT INTO paimon.test_db.t VALUES (1, 10), (2, 20), (3, 30)")
.await
.unwrap()
.collect()
.await
.unwrap();
let plan = verify_count_pushdown(
&sql_context,
"SELECT COUNT(*) FROM paimon.test_db.t WHERE id > 1",
)
.await;
assert!(
plan.is_err(),
"COUNT(*) with data filter should NOT push down - must scan data"
);
let count = run_count_query(
&sql_context,
"SELECT COUNT(*) FROM paimon.test_db.t WHERE id > 1",
)
.await;
assert_eq!(count, 2, "COUNT(*) should return 2");
}
// ============================================================================
// Test: COUNT(*) with equality filter on non-partition column should NOT push down
// ============================================================================
#[tokio::test]
async fn test_count_star_with_non_partition_equality_does_not_push_down() {
let (_tmp, sql_context) = setup_table("(id INT, value INT)").await;
sql_context
.sql("INSERT INTO paimon.test_db.t VALUES (1, 10), (2, 20), (3, 30)")
.await
.unwrap()
.collect()
.await
.unwrap();
let plan = verify_count_pushdown(
&sql_context,
"SELECT COUNT(*) FROM paimon.test_db.t WHERE id = 2",
)
.await;
assert!(
plan.is_err(),
"COUNT(*) with non-partition equality filter should NOT push down"
);
let count = run_count_query(
&sql_context,
"SELECT COUNT(*) FROM paimon.test_db.t WHERE id = 2",
)
.await;
assert_eq!(count, 1, "COUNT(*) should return 1");
}
// ============================================================================
// Test: COUNT(*) on table with single row should push down
// ============================================================================
#[tokio::test]
async fn test_count_star_single_row_pushes_down() {
let (_tmp, sql_context) = setup_table("(id INT, value INT)").await;
sql_context
.sql("INSERT INTO paimon.test_db.t VALUES (1, 10)")
.await
.unwrap()
.collect()
.await
.unwrap();
verify_count_pushdown(&sql_context, "SELECT COUNT(*) FROM paimon.test_db.t")
.await
.expect("COUNT(*) should push down");
let count = run_count_query(&sql_context, "SELECT COUNT(*) FROM paimon.test_db.t").await;
assert_eq!(count, 1, "COUNT(*) should return 1");
}
// ============================================================================
// Test: COUNT(*) on empty table should push down (return 0)
// ============================================================================
#[tokio::test]
async fn test_count_star_empty_table_pushes_down() {
let (_tmp, sql_context) = setup_table("(id INT, value INT)").await;
verify_count_pushdown(&sql_context, "SELECT COUNT(*) FROM paimon.test_db.t")
.await
.expect("COUNT(*) on empty table should push down");
let count = run_count_query(&sql_context, "SELECT COUNT(*) FROM paimon.test_db.t").await;
assert_eq!(count, 0, "COUNT(*) should return 0");
}
// ============================================================================
// Test: COUNT(*) with exact partition filter should push down
// ============================================================================
#[tokio::test]
async fn test_count_star_with_partition_filter_pushes_down() {
let (_tmp, sql_context) = setup_partitioned_table("id INT, value INT, dt STRING", "dt").await;
sql_context
.sql("INSERT INTO paimon.test_db.t VALUES (1, 10, '2024-01-01'), (2, 20, '2024-01-01'), (3, 30, '2024-01-02')")
.await
.unwrap()
.collect()
.await
.unwrap();
verify_count_pushdown(
&sql_context,
"SELECT COUNT(*) FROM paimon.test_db.t WHERE dt = '2024-01-01'",
)
.await
.expect("COUNT(*) with partition filter should push down");
let count = run_count_query(
&sql_context,
"SELECT COUNT(*) FROM paimon.test_db.t WHERE dt = '2024-01-01'",
)
.await;
assert_eq!(count, 2, "COUNT(*) should return 2");
}
// ============================================================================
// Test: COUNT(*) with mixed partition + data filter should NOT push down
// ============================================================================
#[tokio::test]
async fn test_count_star_with_mixed_partition_data_filter_does_not_push_down() {
let (_tmp, sql_context) = setup_partitioned_table("id INT, value INT, dt STRING", "dt").await;
sql_context
.sql("INSERT INTO paimon.test_db.t VALUES (1, 10, '2024-01-01'), (2, 20, '2024-01-01'), (3, 30, '2024-01-02')")
.await
.unwrap()
.collect()
.await
.unwrap();
let plan = verify_count_pushdown(
&sql_context,
"SELECT COUNT(*) FROM paimon.test_db.t WHERE dt = '2024-01-01' AND value > 15",
)
.await;
assert!(
plan.is_err(),
"COUNT(*) with mixed partition + data filter should NOT push down"
);
let count = run_count_query(
&sql_context,
"SELECT COUNT(*) FROM paimon.test_db.t WHERE dt = '2024-01-01' AND value > 15",
)
.await;
assert_eq!(count, 1, "COUNT(*) should return 1");
}
// ============================================================================
// Test: COUNT(*) with partition IN filter should push down
// ============================================================================
#[tokio::test]
async fn test_count_star_with_partition_in_filter_pushes_down() {
let (_tmp, sql_context) = setup_partitioned_table("id INT, value INT, dt STRING", "dt").await;
sql_context
.sql("INSERT INTO paimon.test_db.t VALUES (1, 10, '2024-01-01'), (2, 20, '2024-01-02'), (3, 30, '2024-01-03')")
.await
.unwrap()
.collect()
.await
.unwrap();
verify_count_pushdown(
&sql_context,
"SELECT COUNT(*) FROM paimon.test_db.t WHERE dt IN ('2024-01-01', '2024-01-02')",
)
.await
.expect("COUNT(*) with partition IN filter should push down");
let count = run_count_query(
&sql_context,
"SELECT COUNT(*) FROM paimon.test_db.t WHERE dt IN ('2024-01-01', '2024-01-02')",
)
.await;
assert_eq!(count, 2, "COUNT(*) should return 2");
}
// ============================================================================
// Test: COUNT(*) on partitioned table without filter should push down
// ============================================================================
#[tokio::test]
async fn test_count_star_partitioned_no_filter_pushes_down() {
let (_tmp, sql_context) = setup_partitioned_table("id INT, value INT, dt STRING", "dt").await;
sql_context
.sql("INSERT INTO paimon.test_db.t VALUES (1, 10, '2024-01-01'), (2, 20, '2024-01-02')")
.await
.unwrap()
.collect()
.await
.unwrap();
verify_count_pushdown(&sql_context, "SELECT COUNT(*) FROM paimon.test_db.t")
.await
.expect("COUNT(*) without filter should push down");
let count = run_count_query(&sql_context, "SELECT COUNT(*) FROM paimon.test_db.t").await;
assert_eq!(count, 2, "COUNT(*) should return 2");
}