Skip to content

Commit 2d0781e

Browse files
feat(datafusion): Add $partitions system table (#294)
1 parent f5dbfd7 commit 2d0781e

8 files changed

Lines changed: 511 additions & 8 deletions

File tree

crates/integrations/datafusion/src/sql_context.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -506,7 +506,7 @@ impl SQLContext {
506506

507507
// Sort replacements by position (descending) so that replacements
508508
// from right to left don't shift indices of earlier ones
509-
replacements.sort_by(|a, b| b.0 .0.cmp(&a.0 .0));
509+
replacements.sort_by_key(|r| std::cmp::Reverse(r.0 .0));
510510

511511
// Build the rewritten SQL by replacing each clause from right to left
512512
let mut rewritten_sql = sql.to_string();

crates/integrations/datafusion/src/system_tables/mod.rs

Lines changed: 50 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -32,13 +32,17 @@ use crate::error::to_datafusion_error;
3232
mod branches;
3333
mod manifests;
3434
mod options;
35+
mod partitions;
3536
mod row_string_cast;
3637
mod schemas;
3738
mod snapshots;
3839
mod tags;
3940

4041
type Builder = fn(Table) -> DFResult<Arc<dyn TableProvider>>;
4142

43+
// Most system tables only need the base `Table`. `partitions` is special-cased
44+
// in `load` because it needs the catalog handle (for metastore-tracked audit
45+
// metadata via `Catalog::list_partitions`).
4246
const TABLES: &[(&str, Builder)] = &[
4347
("branches", branches::build),
4448
("manifests", manifests::build),
@@ -48,6 +52,16 @@ const TABLES: &[(&str, Builder)] = &[
4852
("tags", tags::build),
4953
];
5054

55+
const SYSTEM_TABLE_NAMES: &[&str] = &[
56+
"branches",
57+
"manifests",
58+
"options",
59+
"partitions",
60+
"schemas",
61+
"snapshots",
62+
"tags",
63+
];
64+
5165
/// Parse a Paimon object name into `(base_table, optional system_table_name)`.
5266
///
5367
/// Mirrors Java [Identifier.splitObjectName](https://github.com/apache/paimon/blob/release-1.3/paimon-api/src/main/java/org/apache/paimon/catalog/Identifier.java).
@@ -82,7 +96,9 @@ pub(crate) fn split_object_name(name: &str) -> (&str, Option<&str>) {
8296

8397
/// Returns true if `name` is a recognised Paimon system table suffix.
8498
pub(crate) fn is_registered(name: &str) -> bool {
85-
TABLES.iter().any(|(n, _)| name.eq_ignore_ascii_case(n))
99+
SYSTEM_TABLE_NAMES
100+
.iter()
101+
.any(|n| name.eq_ignore_ascii_case(n))
86102
}
87103

88104
/// Wraps an already-loaded base table as the system table `name`.
@@ -110,9 +126,14 @@ pub(crate) async fn load(
110126
}
111127
let identifier = Identifier::new(database, base.clone());
112128
match catalog.get_table(&identifier).await {
113-
Ok(table) => wrap_to_system_table(&system_name, table)
114-
.expect("is_registered guarantees a builder")
115-
.map(Some),
129+
Ok(table) => {
130+
if system_name.eq_ignore_ascii_case("partitions") {
131+
return partitions::build(catalog, identifier, table).map(Some);
132+
}
133+
wrap_to_system_table(&system_name, table)
134+
.expect("is_registered guarantees a builder")
135+
.map(Some)
136+
}
116137
Err(paimon::Error::TableNotExist { .. }) => Err(DataFusionError::Plan(format!(
117138
"Cannot read system table `${system_name}`: \
118139
base table `{base}` does not exist"
@@ -123,7 +144,28 @@ pub(crate) async fn load(
123144

124145
#[cfg(test)]
125146
mod tests {
126-
use super::{is_registered, split_object_name};
147+
use super::{is_registered, split_object_name, SYSTEM_TABLE_NAMES, TABLES};
148+
149+
/// Guards against the two registries drifting: anything in `TABLES` must
150+
/// also be in `SYSTEM_TABLE_NAMES`, and the only name allowed to be in
151+
/// `SYSTEM_TABLE_NAMES` but not `TABLES` is `partitions` (routed via the
152+
/// special path in `load`).
153+
#[test]
154+
fn registries_stay_in_sync() {
155+
for (name, _) in TABLES {
156+
assert!(
157+
SYSTEM_TABLE_NAMES.contains(name),
158+
"`{name}` is in TABLES but missing from SYSTEM_TABLE_NAMES"
159+
);
160+
}
161+
for name in SYSTEM_TABLE_NAMES {
162+
let in_tables = TABLES.iter().any(|(n, _)| n == name);
163+
assert!(
164+
in_tables || *name == "partitions",
165+
"`{name}` is in SYSTEM_TABLE_NAMES but has no builder and is not the special-cased `partitions`"
166+
);
167+
}
168+
}
127169

128170
#[test]
129171
fn is_registered_is_case_insensitive() {
@@ -142,6 +184,9 @@ mod tests {
142184
assert!(is_registered("manifests"));
143185
assert!(is_registered("Manifests"));
144186
assert!(is_registered("MANIFESTS"));
187+
assert!(is_registered("partitions"));
188+
assert!(is_registered("Partitions"));
189+
assert!(is_registered("PARTITIONS"));
145190
assert!(!is_registered("nonsense"));
146191
}
147192

Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
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+
//! Mirrors Java [PartitionsTable](https://github.com/apache/paimon/blob/release-1.4/paimon-core/src/main/java/org/apache/paimon/table/system/PartitionsTable.java).
19+
20+
use std::any::Any;
21+
use std::collections::{BTreeMap, HashMap};
22+
use std::sync::{Arc, OnceLock};
23+
24+
use async_trait::async_trait;
25+
use datafusion::arrow::array::{
26+
BooleanArray, Int32Array, Int64Array, RecordBatch, StringArray, TimestampMillisecondArray,
27+
};
28+
use datafusion::arrow::datatypes::{DataType, Field, Schema, SchemaRef, TimeUnit};
29+
use datafusion::catalog::Session;
30+
use datafusion::datasource::memory::MemorySourceConfig;
31+
use datafusion::datasource::{TableProvider, TableType};
32+
use datafusion::error::{DataFusionError, Result as DFResult};
33+
use datafusion::logical_expr::Expr;
34+
use datafusion::physical_plan::ExecutionPlan;
35+
use paimon::catalog::{Catalog, Identifier};
36+
use paimon::spec::{CoreOptions, Partition};
37+
use paimon::table::Table;
38+
39+
use crate::error::to_datafusion_error;
40+
41+
pub(super) fn build(
42+
catalog: Arc<dyn Catalog>,
43+
identifier: Identifier,
44+
table: Table,
45+
) -> DFResult<Arc<dyn TableProvider>> {
46+
let table_schema = table.schema();
47+
let partition_keys = table_schema.partition_keys().to_vec();
48+
let default_partition_name = CoreOptions::new(table_schema.options())
49+
.partition_default_name()
50+
.to_string();
51+
Ok(Arc::new(PartitionsTable {
52+
catalog,
53+
identifier,
54+
partition_keys,
55+
default_partition_name,
56+
}))
57+
}
58+
59+
fn partitions_schema() -> SchemaRef {
60+
static SCHEMA: OnceLock<SchemaRef> = OnceLock::new();
61+
SCHEMA
62+
.get_or_init(|| {
63+
Arc::new(Schema::new(vec![
64+
Field::new("partition", DataType::Utf8, true),
65+
Field::new("record_count", DataType::Int64, false),
66+
Field::new("file_size_in_bytes", DataType::Int64, false),
67+
Field::new("file_count", DataType::Int64, false),
68+
Field::new(
69+
"last_update_time",
70+
DataType::Timestamp(TimeUnit::Millisecond, None),
71+
true,
72+
),
73+
Field::new(
74+
"created_at",
75+
DataType::Timestamp(TimeUnit::Millisecond, None),
76+
true,
77+
),
78+
Field::new("created_by", DataType::Utf8, true),
79+
Field::new("updated_by", DataType::Utf8, true),
80+
Field::new("options", DataType::Utf8, true),
81+
Field::new("total_buckets", DataType::Int32, false),
82+
Field::new("done", DataType::Boolean, false),
83+
]))
84+
})
85+
.clone()
86+
}
87+
88+
struct PartitionsTable {
89+
catalog: Arc<dyn Catalog>,
90+
identifier: Identifier,
91+
partition_keys: Vec<String>,
92+
default_partition_name: String,
93+
}
94+
95+
impl std::fmt::Debug for PartitionsTable {
96+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
97+
f.debug_struct("PartitionsTable")
98+
.field("identifier", &self.identifier)
99+
.finish()
100+
}
101+
}
102+
103+
#[async_trait]
104+
impl TableProvider for PartitionsTable {
105+
fn as_any(&self) -> &dyn Any {
106+
self
107+
}
108+
109+
fn schema(&self) -> SchemaRef {
110+
partitions_schema()
111+
}
112+
113+
fn table_type(&self) -> TableType {
114+
TableType::View
115+
}
116+
117+
async fn scan(
118+
&self,
119+
_state: &dyn Session,
120+
projection: Option<&Vec<usize>>,
121+
_filters: &[Expr],
122+
_limit: Option<usize>,
123+
) -> DFResult<Arc<dyn ExecutionPlan>> {
124+
let catalog = self.catalog.clone();
125+
let identifier = self.identifier.clone();
126+
let partitions = crate::runtime::await_with_runtime(async move {
127+
catalog.list_partitions(&identifier).await
128+
})
129+
.await
130+
.map_err(to_datafusion_error)?;
131+
132+
let mut rows: Vec<(String, Partition)> = partitions
133+
.into_iter()
134+
.map(|p| {
135+
let s = format_partition_string(
136+
&p.spec,
137+
&self.partition_keys,
138+
&self.default_partition_name,
139+
);
140+
(s, p)
141+
})
142+
.collect();
143+
rows.sort_by(|a, b| a.0.cmp(&b.0));
144+
145+
let n = rows.len();
146+
let mut partition_strings: Vec<Option<String>> = Vec::with_capacity(n);
147+
let mut record_counts = Vec::with_capacity(n);
148+
let mut file_sizes = Vec::with_capacity(n);
149+
let mut file_counts = Vec::with_capacity(n);
150+
let mut last_update_times: Vec<Option<i64>> = Vec::with_capacity(n);
151+
let mut created_ats: Vec<Option<i64>> = Vec::with_capacity(n);
152+
let mut created_bys: Vec<Option<String>> = Vec::with_capacity(n);
153+
let mut updated_bys: Vec<Option<String>> = Vec::with_capacity(n);
154+
let mut options_jsons: Vec<Option<String>> = Vec::with_capacity(n);
155+
let mut total_buckets = Vec::with_capacity(n);
156+
let mut dones = Vec::with_capacity(n);
157+
158+
for (s, p) in rows {
159+
partition_strings.push(Some(s));
160+
record_counts.push(p.record_count);
161+
file_sizes.push(p.file_size_in_bytes);
162+
file_counts.push(p.file_count);
163+
// 0 marks "no creation_time on any file"; real wall-clock is never
164+
// <= 0 in practice, so this never nullifies a genuine timestamp.
165+
last_update_times.push(if p.last_file_creation_time > 0 {
166+
Some(p.last_file_creation_time)
167+
} else {
168+
None
169+
});
170+
created_ats.push(p.created_at);
171+
created_bys.push(p.created_by);
172+
updated_bys.push(p.updated_by);
173+
// Sort via BTreeMap so the serialised JSON is deterministic across
174+
// runs (Partition.options is a HashMap with unspecified order).
175+
options_jsons.push(
176+
p.options
177+
.as_ref()
178+
.map(|m| {
179+
let sorted: BTreeMap<&String, &String> = m.iter().collect();
180+
serde_json::to_string(&sorted)
181+
.map_err(|e| DataFusionError::External(Box::new(e)))
182+
})
183+
.transpose()?,
184+
);
185+
total_buckets.push(p.total_buckets);
186+
dones.push(p.done);
187+
}
188+
189+
let schema = partitions_schema();
190+
let batch = RecordBatch::try_new(
191+
schema.clone(),
192+
vec![
193+
Arc::new(StringArray::from(partition_strings)),
194+
Arc::new(Int64Array::from(record_counts)),
195+
Arc::new(Int64Array::from(file_sizes)),
196+
Arc::new(Int64Array::from(file_counts)),
197+
Arc::new(TimestampMillisecondArray::from(last_update_times)),
198+
Arc::new(TimestampMillisecondArray::from(created_ats)),
199+
Arc::new(StringArray::from(created_bys)),
200+
Arc::new(StringArray::from(updated_bys)),
201+
Arc::new(StringArray::from(options_jsons)),
202+
Arc::new(Int32Array::from(total_buckets)),
203+
Arc::new(BooleanArray::from(dones)),
204+
],
205+
)?;
206+
207+
Ok(MemorySourceConfig::try_new_exec(
208+
&[vec![batch]],
209+
schema,
210+
projection.cloned(),
211+
)?)
212+
}
213+
}
214+
215+
/// Format `spec` as `key1=val1/key2=val2` in `partition_keys` order. Empty
216+
/// string for non-partitioned tables. NULL spec values fall back to
217+
/// `default_partition_name`.
218+
fn format_partition_string(
219+
spec: &HashMap<String, String>,
220+
partition_keys: &[String],
221+
default_partition_name: &str,
222+
) -> String {
223+
partition_keys
224+
.iter()
225+
.map(|k| {
226+
let v = spec
227+
.get(k)
228+
.map(String::as_str)
229+
.unwrap_or(default_partition_name);
230+
format!("{k}={v}")
231+
})
232+
.collect::<Vec<_>>()
233+
.join("/")
234+
}

0 commit comments

Comments
 (0)