Skip to content

Commit f996751

Browse files
feat(datafusion): Add $options system table (#240)
1 parent 5c4551f commit f996751

5 files changed

Lines changed: 420 additions & 2 deletions

File tree

crates/integrations/datafusion/src/catalog.rs

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ use paimon::catalog::{Catalog, Identifier};
3030

3131
use crate::error::to_datafusion_error;
3232
use crate::runtime::{await_with_runtime, block_on_with_runtime};
33+
use crate::system_tables;
3334
use crate::table::PaimonTableProvider;
3435

3536
/// Provides an interface to manage and access multiple schemas (databases)
@@ -175,8 +176,19 @@ impl SchemaProvider for PaimonSchemaProvider {
175176
}
176177

177178
async fn table(&self, name: &str) -> DFResult<Option<Arc<dyn TableProvider>>> {
179+
let (base, system_name) = system_tables::split_object_name(name);
180+
if let Some(system_name) = system_name {
181+
return await_with_runtime(system_tables::load(
182+
Arc::clone(&self.catalog),
183+
self.database.clone(),
184+
base.to_string(),
185+
system_name.to_string(),
186+
))
187+
.await;
188+
}
189+
178190
let catalog = Arc::clone(&self.catalog);
179-
let identifier = Identifier::new(self.database.clone(), name);
191+
let identifier = Identifier::new(self.database.clone(), base);
180192
await_with_runtime(async move {
181193
match catalog.get_table(&identifier).await {
182194
Ok(table) => {
@@ -191,8 +203,15 @@ impl SchemaProvider for PaimonSchemaProvider {
191203
}
192204

193205
fn table_exist(&self, name: &str) -> bool {
206+
let (base, system_name) = system_tables::split_object_name(name);
207+
if let Some(system_name) = system_name {
208+
if !system_tables::is_registered(system_name) {
209+
return false;
210+
}
211+
}
212+
194213
let catalog = Arc::clone(&self.catalog);
195-
let identifier = Identifier::new(self.database.clone(), name);
214+
let identifier = Identifier::new(self.database.clone(), base.to_string());
196215
block_on_with_runtime(
197216
async move {
198217
match catalog.get_table(&identifier).await {

crates/integrations/datafusion/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ mod full_text_search;
4545
mod physical_plan;
4646
mod relation_planner;
4747
pub mod runtime;
48+
mod system_tables;
4849
mod table;
4950

5051
pub use catalog::{PaimonCatalogProvider, PaimonSchemaProvider};
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
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+
//! Paimon system tables (`<table>$<name>`) as DataFusion table providers.
19+
//!
20+
//! Mirrors Java [SystemTableLoader](https://github.com/apache/paimon/blob/release-1.3/paimon-core/src/main/java/org/apache/paimon/table/system/SystemTableLoader.java):
21+
//! `TABLES` maps each system-table name to its builder function.
22+
23+
use std::sync::Arc;
24+
25+
use datafusion::datasource::TableProvider;
26+
use datafusion::error::{DataFusionError, Result as DFResult};
27+
use paimon::catalog::{Catalog, Identifier, SYSTEM_BRANCH_PREFIX, SYSTEM_TABLE_SPLITTER};
28+
use paimon::table::Table;
29+
30+
use crate::error::to_datafusion_error;
31+
32+
mod options;
33+
34+
type Builder = fn(Table) -> DFResult<Arc<dyn TableProvider>>;
35+
36+
const TABLES: &[(&str, Builder)] = &[("options", options::build)];
37+
38+
/// Parse a Paimon object name into `(base_table, optional system_table_name)`.
39+
///
40+
/// Mirrors Java [Identifier.splitObjectName](https://github.com/apache/paimon/blob/release-1.3/paimon-api/src/main/java/org/apache/paimon/catalog/Identifier.java).
41+
///
42+
/// - `t` → `("t", None)`
43+
/// - `t$options` → `("t", Some("options"))`
44+
/// - `t$branch_main` → `("t", None)` (branch reference, not a system table)
45+
/// - `t$branch_main$options` → `("t", Some("options"))` (branch + system table)
46+
pub(crate) fn split_object_name(name: &str) -> (&str, Option<&str>) {
47+
let mut parts = name.splitn(3, SYSTEM_TABLE_SPLITTER);
48+
let base = parts.next().unwrap_or(name);
49+
match (parts.next(), parts.next()) {
50+
(None, _) => (base, None),
51+
(Some(second), None) => {
52+
if second.starts_with(SYSTEM_BRANCH_PREFIX) {
53+
(base, None)
54+
} else {
55+
(base, Some(second))
56+
}
57+
}
58+
(Some(second), Some(third)) => {
59+
if second.starts_with(SYSTEM_BRANCH_PREFIX) {
60+
(base, Some(third))
61+
} else {
62+
// `$` is legal in table names, so `t$foo$bar` falls through as
63+
// plain `t` and errors later as "table not found".
64+
(base, None)
65+
}
66+
}
67+
}
68+
}
69+
70+
/// Returns true if `name` is a recognised Paimon system table suffix.
71+
pub(crate) fn is_registered(name: &str) -> bool {
72+
TABLES.iter().any(|(n, _)| name.eq_ignore_ascii_case(n))
73+
}
74+
75+
/// Wraps an already-loaded base table as the system table `name`.
76+
fn wrap_to_system_table(name: &str, base_table: Table) -> Option<DFResult<Arc<dyn TableProvider>>> {
77+
TABLES
78+
.iter()
79+
.find(|(n, _)| name.eq_ignore_ascii_case(n))
80+
.map(|(_, build)| build(base_table))
81+
}
82+
83+
/// Loads `<base>$<system_name>` from the catalog and wraps it as a system
84+
/// table provider.
85+
///
86+
/// - Unknown `system_name` → `Ok(None)` (DataFusion reports "table not found")
87+
/// - Base table missing → `Err(Plan)` so users can distinguish it from an
88+
/// unknown system name
89+
pub(crate) async fn load(
90+
catalog: Arc<dyn Catalog>,
91+
database: String,
92+
base: String,
93+
system_name: String,
94+
) -> DFResult<Option<Arc<dyn TableProvider>>> {
95+
if !is_registered(&system_name) {
96+
return Ok(None);
97+
}
98+
let identifier = Identifier::new(database, base.clone());
99+
match catalog.get_table(&identifier).await {
100+
Ok(table) => wrap_to_system_table(&system_name, table)
101+
.expect("is_registered guarantees a builder")
102+
.map(Some),
103+
Err(paimon::Error::TableNotExist { .. }) => Err(DataFusionError::Plan(format!(
104+
"Cannot read system table `${system_name}`: \
105+
base table `{base}` does not exist"
106+
))),
107+
Err(e) => Err(to_datafusion_error(e)),
108+
}
109+
}
110+
111+
#[cfg(test)]
112+
mod tests {
113+
use super::{is_registered, split_object_name};
114+
115+
#[test]
116+
fn is_registered_is_case_insensitive() {
117+
assert!(is_registered("options"));
118+
assert!(is_registered("Options"));
119+
assert!(is_registered("OPTIONS"));
120+
assert!(!is_registered("nonsense"));
121+
}
122+
123+
#[test]
124+
fn plain_table_name() {
125+
assert_eq!(split_object_name("orders"), ("orders", None));
126+
}
127+
128+
#[test]
129+
fn system_table_only() {
130+
assert_eq!(
131+
split_object_name("orders$options"),
132+
("orders", Some("options"))
133+
);
134+
}
135+
136+
#[test]
137+
fn branch_reference_is_not_a_system_table() {
138+
assert_eq!(split_object_name("orders$branch_main"), ("orders", None));
139+
}
140+
141+
#[test]
142+
fn branch_plus_system_table() {
143+
assert_eq!(
144+
split_object_name("orders$branch_main$options"),
145+
("orders", Some("options"))
146+
);
147+
}
148+
149+
#[test]
150+
fn three_parts_without_branch_prefix_is_not_a_system_table() {
151+
assert_eq!(split_object_name("orders$foo$bar"), ("orders", None));
152+
}
153+
154+
#[test]
155+
fn system_table_name_preserves_case() {
156+
assert_eq!(
157+
split_object_name("orders$Options"),
158+
("orders", Some("Options"))
159+
);
160+
}
161+
}
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
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 [OptionsTable](https://github.com/apache/paimon/blob/release-1.3/paimon-core/src/main/java/org/apache/paimon/table/system/OptionsTable.java).
19+
20+
use std::any::Any;
21+
use std::sync::{Arc, OnceLock};
22+
23+
use async_trait::async_trait;
24+
use datafusion::arrow::array::{RecordBatch, StringArray};
25+
use datafusion::arrow::datatypes::{DataType, Field, Schema, SchemaRef};
26+
use datafusion::catalog::Session;
27+
use datafusion::datasource::memory::MemorySourceConfig;
28+
use datafusion::datasource::{TableProvider, TableType};
29+
use datafusion::error::Result as DFResult;
30+
use datafusion::logical_expr::Expr;
31+
use datafusion::physical_plan::ExecutionPlan;
32+
use paimon::table::Table;
33+
34+
pub(super) fn build(table: Table) -> DFResult<Arc<dyn TableProvider>> {
35+
Ok(Arc::new(OptionsTable { table }))
36+
}
37+
38+
fn options_schema() -> SchemaRef {
39+
static SCHEMA: OnceLock<SchemaRef> = OnceLock::new();
40+
SCHEMA
41+
.get_or_init(|| {
42+
Arc::new(Schema::new(vec![
43+
Field::new("key", DataType::Utf8, false),
44+
Field::new("value", DataType::Utf8, false),
45+
]))
46+
})
47+
.clone()
48+
}
49+
50+
#[derive(Debug)]
51+
struct OptionsTable {
52+
table: Table,
53+
}
54+
55+
#[async_trait]
56+
impl TableProvider for OptionsTable {
57+
fn as_any(&self) -> &dyn Any {
58+
self
59+
}
60+
61+
fn schema(&self) -> SchemaRef {
62+
options_schema()
63+
}
64+
65+
fn table_type(&self) -> TableType {
66+
TableType::View
67+
}
68+
69+
async fn scan(
70+
&self,
71+
_state: &dyn Session,
72+
projection: Option<&Vec<usize>>,
73+
_filters: &[Expr],
74+
_limit: Option<usize>,
75+
) -> DFResult<Arc<dyn ExecutionPlan>> {
76+
// Java uses LinkedHashMap insertion order; HashMap has none — sort for stable output.
77+
let mut entries: Vec<(&String, &String)> = self.table.schema().options().iter().collect();
78+
entries.sort_by(|a, b| a.0.cmp(b.0));
79+
80+
let keys = StringArray::from_iter_values(entries.iter().map(|(k, _)| k.as_str()));
81+
let values = StringArray::from_iter_values(entries.iter().map(|(_, v)| v.as_str()));
82+
83+
let schema = options_schema();
84+
let batch = RecordBatch::try_new(schema.clone(), vec![Arc::new(keys), Arc::new(values)])?;
85+
86+
Ok(MemorySourceConfig::try_new_exec(
87+
&[vec![batch]],
88+
schema,
89+
projection.cloned(),
90+
)?)
91+
}
92+
}

0 commit comments

Comments
 (0)