|
| 1 | +// Licensed to the Apache Software Foundation (ASF) under one |
| 2 | +// or more contributor license agreements. See the NOTICE file |
| 3 | +// distributed with this work for additional information |
| 4 | +// regarding copyright ownership. The ASF licenses this file |
| 5 | +// to you under the Apache License, Version 2.0 (the |
| 6 | +// "License"); you may not use this file except in compliance |
| 7 | +// with the License. You may obtain a copy of the License at |
| 8 | +// |
| 9 | +// http://www.apache.org/licenses/LICENSE-2.0 |
| 10 | +// |
| 11 | +// Unless required by applicable law or agreed to in writing, |
| 12 | +// software distributed under the License is distributed on an |
| 13 | +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY |
| 14 | +// KIND, either express or implied. See the License for the |
| 15 | +// specific language governing permissions and limitations |
| 16 | +// under the License. |
| 17 | + |
| 18 | +//! Statistics-driven aggregates over `type=format-table` tables. |
| 19 | +//! |
| 20 | +//! A format table has no manifest, so scan planning cannot fill in per-file |
| 21 | +//! row counts. Those counts must be reported as *unknown*; if the placeholder |
| 22 | +//! is reported as an exact statistic instead, DataFusion's |
| 23 | +//! `aggregate_statistics` rule answers `COUNT(*)` from it and never opens a |
| 24 | +//! single data file — a silent wrong answer. |
| 25 | +
|
| 26 | +mod common; |
| 27 | + |
| 28 | +use std::path::Path; |
| 29 | +use std::sync::Arc; |
| 30 | + |
| 31 | +use arrow_array::{Int64Array, RecordBatch}; |
| 32 | +use arrow_schema::{DataType as ArrowDataType, Field, Schema as ArrowSchema}; |
| 33 | +use paimon::catalog::{Catalog, Identifier}; |
| 34 | +use paimon::spec::{BigIntType, DataType, Schema, SchemaBuilder, VarCharType}; |
| 35 | +use paimon_datafusion::SQLContext; |
| 36 | +use parquet::arrow::ArrowWriter; |
| 37 | +use tempfile::TempDir; |
| 38 | + |
| 39 | +const DATABASE: &str = "test_db"; |
| 40 | +const TABLE: &str = "events"; |
| 41 | + |
| 42 | +/// Creates a `type=format-table` table in a filesystem catalog and returns the |
| 43 | +/// table directory, where callers drop raw data files. |
| 44 | +async fn setup_format_table() -> (TempDir, SQLContext, std::path::PathBuf) { |
| 45 | + setup_table(Schema::builder().column("id", DataType::BigInt(BigIntType::new()))).await |
| 46 | +} |
| 47 | + |
| 48 | +/// Same, partitioned by a single `dt` column (Hive layout: the partition value |
| 49 | +/// lives in the directory name, not in the data file). |
| 50 | +async fn setup_partitioned_format_table() -> (TempDir, SQLContext, std::path::PathBuf) { |
| 51 | + setup_table( |
| 52 | + Schema::builder() |
| 53 | + .column("dt", DataType::VarChar(VarCharType::new(32).unwrap())) |
| 54 | + .column("id", DataType::BigInt(BigIntType::new())) |
| 55 | + .partition_keys(vec!["dt".to_string()]), |
| 56 | + ) |
| 57 | + .await |
| 58 | +} |
| 59 | + |
| 60 | +async fn setup_table(builder: SchemaBuilder) -> (TempDir, SQLContext, std::path::PathBuf) { |
| 61 | + let (tmp, catalog) = common::create_test_env(); |
| 62 | + catalog |
| 63 | + .create_database(DATABASE, false, Default::default()) |
| 64 | + .await |
| 65 | + .expect("CREATE DATABASE failed"); |
| 66 | + let schema = builder |
| 67 | + .option("type", "format-table") |
| 68 | + .option("file.format", "parquet") |
| 69 | + .build() |
| 70 | + .unwrap(); |
| 71 | + catalog |
| 72 | + .create_table(&Identifier::new(DATABASE, TABLE), schema, false) |
| 73 | + .await |
| 74 | + .expect("CREATE TABLE failed"); |
| 75 | + let table_dir = tmp.path().join(format!("{DATABASE}.db")).join(TABLE); |
| 76 | + let context = common::create_sql_context(catalog).await; |
| 77 | + (tmp, context, table_dir) |
| 78 | +} |
| 79 | + |
| 80 | +fn write_parquet(path: &Path, values: &[i64]) { |
| 81 | + let schema = Arc::new(ArrowSchema::new(vec![Field::new( |
| 82 | + "id", |
| 83 | + ArrowDataType::Int64, |
| 84 | + true, |
| 85 | + )])); |
| 86 | + let batch = RecordBatch::try_new( |
| 87 | + Arc::clone(&schema), |
| 88 | + vec![Arc::new(Int64Array::from(values.to_vec()))], |
| 89 | + ) |
| 90 | + .unwrap(); |
| 91 | + let file = std::fs::File::create(path).unwrap(); |
| 92 | + let mut writer = ArrowWriter::try_new(file, schema, None).unwrap(); |
| 93 | + writer.write(&batch).unwrap(); |
| 94 | + writer.close().unwrap(); |
| 95 | +} |
| 96 | + |
| 97 | +async fn scalar_count(context: &SQLContext, sql: &str) -> i64 { |
| 98 | + let batches = context.sql(sql).await.unwrap().collect().await.unwrap(); |
| 99 | + let batch = batches |
| 100 | + .iter() |
| 101 | + .find(|batch| batch.num_rows() > 0) |
| 102 | + .expect("aggregate must return one row"); |
| 103 | + batch |
| 104 | + .column(0) |
| 105 | + .as_any() |
| 106 | + .downcast_ref::<Int64Array>() |
| 107 | + .expect("count column must be int64") |
| 108 | + .value(0) |
| 109 | +} |
| 110 | + |
| 111 | +async fn scanned_rows(context: &SQLContext, sql: &str) -> i64 { |
| 112 | + let batches = context.sql(sql).await.unwrap().collect().await.unwrap(); |
| 113 | + batches.iter().map(|batch| batch.num_rows() as i64).sum() |
| 114 | +} |
| 115 | + |
| 116 | +/// `COUNT(*)` on a format table must agree with what an actual scan returns. |
| 117 | +/// Regression for the silent `COUNT(*) = 0`: the placeholder |
| 118 | +/// `DataFileMeta::row_count` was reported as an exact statistic. |
| 119 | +#[tokio::test] |
| 120 | +async fn test_format_table_count_star_matches_scanned_rows() { |
| 121 | + let (_tmp, context, table_dir) = setup_format_table().await; |
| 122 | + write_parquet(&table_dir.join("part-0.parquet"), &[1, 2, 3]); |
| 123 | + write_parquet(&table_dir.join("part-1.parquet"), &[4, 5]); |
| 124 | + |
| 125 | + let scanned = scanned_rows(&context, "SELECT id FROM paimon.test_db.events").await; |
| 126 | + assert_eq!(scanned, 5, "the scan itself must see all rows"); |
| 127 | + |
| 128 | + let counted = scalar_count(&context, "SELECT COUNT(*) AS c FROM paimon.test_db.events").await; |
| 129 | + assert_eq!( |
| 130 | + counted, scanned, |
| 131 | + "COUNT(*) must not be answered from the placeholder row count" |
| 132 | + ); |
| 133 | + |
| 134 | + let counted_col = |
| 135 | + scalar_count(&context, "SELECT COUNT(id) AS c FROM paimon.test_db.events").await; |
| 136 | + assert_eq!( |
| 137 | + counted_col, scanned, |
| 138 | + "COUNT(col) must not be short-circuited" |
| 139 | + ); |
| 140 | +} |
| 141 | + |
| 142 | +/// A format table with no data files really has zero rows; reporting the row |
| 143 | +/// count as unknown must not turn that into a wrong answer either. |
| 144 | +#[tokio::test] |
| 145 | +async fn test_empty_format_table_counts_zero() { |
| 146 | + let (_tmp, context, _table_dir) = setup_format_table().await; |
| 147 | + |
| 148 | + assert_eq!( |
| 149 | + scalar_count(&context, "SELECT COUNT(*) AS c FROM paimon.test_db.events").await, |
| 150 | + 0 |
| 151 | + ); |
| 152 | +} |
| 153 | + |
| 154 | +/// The partition-pruned plan reaches `partition_statistics()` too, so a pruned |
| 155 | +/// `COUNT(*)` must also come from the data and not from the placeholder. |
| 156 | +/// |
| 157 | +/// Only predicated queries are asserted here. An unfiltered scan over a |
| 158 | +/// partitioned format table on a `file://` warehouse currently finds no splits |
| 159 | +/// at all — `table_path` keeps the `file:///` form while the listed status |
| 160 | +/// paths come back as `file:/`, so the `strip_prefix` in |
| 161 | +/// `partition_row_from_path` (paimon/src/table/format_table_scan.rs:487-493) |
| 162 | +/// drops every file. That is a separate defect from the one under test. |
| 163 | +#[tokio::test] |
| 164 | +async fn test_partitioned_format_table_count_with_partition_predicate() { |
| 165 | + let (_tmp, context, table_dir) = setup_partitioned_format_table().await; |
| 166 | + for (dt, values) in [ |
| 167 | + ("2026-07-21", vec![1i64, 2, 3]), |
| 168 | + ("2026-07-22", vec![4i64, 5]), |
| 169 | + ] { |
| 170 | + let partition_dir = table_dir.join(format!("dt={dt}")); |
| 171 | + std::fs::create_dir_all(&partition_dir).unwrap(); |
| 172 | + write_parquet(&partition_dir.join("part-0.parquet"), &values); |
| 173 | + } |
| 174 | + |
| 175 | + for (dt, expected) in [("2026-07-21", 3), ("2026-07-22", 2), ("2026-07-23", 0)] { |
| 176 | + let sql = format!("SELECT id FROM paimon.test_db.events WHERE dt = '{dt}'"); |
| 177 | + let scanned = scanned_rows(&context, &sql).await; |
| 178 | + assert_eq!(scanned, expected, "scan of dt={dt}"); |
| 179 | + |
| 180 | + let sql = format!("SELECT COUNT(*) AS c FROM paimon.test_db.events WHERE dt = '{dt}'"); |
| 181 | + assert_eq!( |
| 182 | + scalar_count(&context, &sql).await, |
| 183 | + expected, |
| 184 | + "count of dt={dt}" |
| 185 | + ); |
| 186 | + } |
| 187 | +} |
| 188 | + |
| 189 | +/// A data file that genuinely holds zero rows must also count 0. |
| 190 | +#[tokio::test] |
| 191 | +async fn test_format_table_with_empty_file_counts_zero() { |
| 192 | + let (_tmp, context, table_dir) = setup_format_table().await; |
| 193 | + write_parquet(&table_dir.join("part-0.parquet"), &[]); |
| 194 | + |
| 195 | + assert_eq!( |
| 196 | + scalar_count(&context, "SELECT COUNT(*) AS c FROM paimon.test_db.events").await, |
| 197 | + 0 |
| 198 | + ); |
| 199 | +} |
0 commit comments