Skip to main content

datafusion_cli/
catalog.rs

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
18use std::sync::{Arc, Weak};
19
20use crate::object_storage::{AwsOptions, GcpOptions, get_object_store};
21
22use datafusion::catalog::{CatalogProvider, CatalogProviderList, SchemaProvider};
23
24use datafusion::common::plan_datafusion_err;
25use datafusion::datasource::TableProvider;
26use datafusion::datasource::listing::ListingTableUrl;
27use datafusion::error::Result;
28use datafusion::execution::context::SessionState;
29use datafusion::execution::session_state::SessionStateBuilder;
30
31use async_trait::async_trait;
32use dirs::home_dir;
33use parking_lot::RwLock;
34
35/// Wraps another catalog, automatically register require object stores for the file locations
36#[derive(Debug)]
37pub struct DynamicObjectStoreCatalog {
38    inner: Arc<dyn CatalogProviderList>,
39    state: Weak<RwLock<SessionState>>,
40}
41
42impl DynamicObjectStoreCatalog {
43    pub fn new(
44        inner: Arc<dyn CatalogProviderList>,
45        state: Weak<RwLock<SessionState>>,
46    ) -> Self {
47        Self { inner, state }
48    }
49}
50
51impl CatalogProviderList for DynamicObjectStoreCatalog {
52    fn register_catalog(
53        &self,
54        name: String,
55        catalog: Arc<dyn CatalogProvider>,
56    ) -> Option<Arc<dyn CatalogProvider>> {
57        self.inner.register_catalog(name, catalog)
58    }
59
60    fn catalog_names(&self) -> Vec<String> {
61        self.inner.catalog_names()
62    }
63
64    fn catalog(&self, name: &str) -> Option<Arc<dyn CatalogProvider>> {
65        let state = self.state.clone();
66        self.inner.catalog(name).map(|catalog| {
67            Arc::new(DynamicObjectStoreCatalogProvider::new(catalog, state)) as _
68        })
69    }
70}
71
72/// Wraps another catalog provider
73#[derive(Debug)]
74struct DynamicObjectStoreCatalogProvider {
75    inner: Arc<dyn CatalogProvider>,
76    state: Weak<RwLock<SessionState>>,
77}
78
79impl DynamicObjectStoreCatalogProvider {
80    pub fn new(
81        inner: Arc<dyn CatalogProvider>,
82        state: Weak<RwLock<SessionState>>,
83    ) -> Self {
84        Self { inner, state }
85    }
86}
87
88impl CatalogProvider for DynamicObjectStoreCatalogProvider {
89    fn schema_names(&self) -> Vec<String> {
90        self.inner.schema_names()
91    }
92
93    fn schema(&self, name: &str) -> Option<Arc<dyn SchemaProvider>> {
94        let state = self.state.clone();
95        self.inner.schema(name).map(|schema| {
96            Arc::new(DynamicObjectStoreSchemaProvider::new(schema, state)) as _
97        })
98    }
99
100    fn register_schema(
101        &self,
102        name: &str,
103        schema: Arc<dyn SchemaProvider>,
104    ) -> Result<Option<Arc<dyn SchemaProvider>>> {
105        self.inner.register_schema(name, schema)
106    }
107}
108
109/// Wraps another schema provider. [DynamicObjectStoreSchemaProvider] is responsible for registering the required
110/// object stores for the file locations.
111#[derive(Debug)]
112struct DynamicObjectStoreSchemaProvider {
113    inner: Arc<dyn SchemaProvider>,
114    state: Weak<RwLock<SessionState>>,
115}
116
117impl DynamicObjectStoreSchemaProvider {
118    pub fn new(
119        inner: Arc<dyn SchemaProvider>,
120        state: Weak<RwLock<SessionState>>,
121    ) -> Self {
122        Self { inner, state }
123    }
124}
125
126#[async_trait]
127impl SchemaProvider for DynamicObjectStoreSchemaProvider {
128    fn table_names(&self) -> Vec<String> {
129        self.inner.table_names()
130    }
131
132    fn register_table(
133        &self,
134        name: String,
135        table: Arc<dyn TableProvider>,
136    ) -> Result<Option<Arc<dyn TableProvider>>> {
137        self.inner.register_table(name, table)
138    }
139
140    async fn table(&self, name: &str) -> Result<Option<Arc<dyn TableProvider>>> {
141        let inner_table = self.inner.table(name).await;
142        if inner_table.is_ok()
143            && let Some(inner_table) = inner_table?
144        {
145            return Ok(Some(inner_table));
146        }
147
148        // if the inner schema provider didn't have a table by
149        // that name, try to treat it as a listing table
150        let mut state = self
151            .state
152            .upgrade()
153            .ok_or_else(|| plan_datafusion_err!("locking error"))?
154            .read()
155            .clone();
156        let mut builder = SessionStateBuilder::from(state.clone());
157        let optimized_name = substitute_tilde(name.to_owned());
158        let table_url = ListingTableUrl::parse(optimized_name.as_str())?;
159        let scheme = table_url.scheme();
160        let url = table_url.as_ref();
161
162        // If the store is already registered for this URL then `get_store`
163        // will return `Ok` which means we don't need to register it again. However,
164        // if `get_store` returns an `Err` then it means the corresponding store is
165        // not registered yet and we need to register it
166        match state.runtime_env().object_store_registry.get_store(url) {
167            Ok(_) => { /*Nothing to do here, store for this URL is already registered*/ }
168            Err(_) => {
169                // Register the store for this URL. Here we don't have access
170                // to any command options so the only choice is to use an empty collection
171                match scheme {
172                    "s3" | "oss" | "cos" => {
173                        if let Some(table_options) = builder.table_options() {
174                            table_options.extensions.insert(AwsOptions::default())
175                        }
176                    }
177                    "gs" | "gcs" => {
178                        if let Some(table_options) = builder.table_options() {
179                            table_options.extensions.insert(GcpOptions::default())
180                        }
181                    }
182                    _ => {}
183                };
184                state = builder.build();
185                let store = get_object_store(
186                    &state,
187                    table_url.scheme(),
188                    url,
189                    &state.default_table_options(),
190                    false,
191                )
192                .await?;
193                state.runtime_env().register_object_store(url, store);
194            }
195        }
196        self.inner.table(name).await
197    }
198
199    fn deregister_table(&self, name: &str) -> Result<Option<Arc<dyn TableProvider>>> {
200        self.inner.deregister_table(name)
201    }
202
203    fn table_exist(&self, name: &str) -> bool {
204        self.inner.table_exist(name)
205    }
206}
207
208pub fn substitute_tilde(cur: String) -> String {
209    if let Some(usr_dir_path) = home_dir()
210        && let Some(usr_dir) = usr_dir_path.to_str()
211        && cur.starts_with('~')
212        && !usr_dir.is_empty()
213    {
214        return cur.replacen('~', usr_dir, 1);
215    }
216    cur
217}
218#[cfg(test)]
219mod tests {
220    use std::{env, vec};
221
222    use super::*;
223
224    use datafusion::catalog::SchemaProvider;
225    use datafusion::prelude::SessionContext;
226
227    fn setup_context() -> (SessionContext, Arc<dyn SchemaProvider>) {
228        let ctx = SessionContext::new();
229        ctx.register_catalog_list(Arc::new(DynamicObjectStoreCatalog::new(
230            ctx.state().catalog_list().clone(),
231            ctx.state_weak_ref(),
232        )));
233
234        let provider = &DynamicObjectStoreCatalog::new(
235            ctx.state().catalog_list().clone(),
236            ctx.state_weak_ref(),
237        ) as &dyn CatalogProviderList;
238        let catalog = provider
239            .catalog(provider.catalog_names().first().unwrap())
240            .unwrap();
241        let schema = catalog
242            .schema(catalog.schema_names().first().unwrap())
243            .unwrap();
244        (ctx, schema)
245    }
246
247    #[tokio::test]
248    async fn query_http_location_test() -> Result<()> {
249        // This is a unit test so not expecting a connection or a file to be
250        // available
251        let domain = "example.com";
252        let location = format!("http://{domain}/file.parquet");
253
254        let (ctx, schema) = setup_context();
255
256        // That's a non registered table so expecting None here
257        let table = schema.table(&location).await?;
258        assert!(table.is_none());
259
260        // It should still create an object store for the location in the SessionState
261        let store = ctx
262            .runtime_env()
263            .object_store(ListingTableUrl::parse(location)?)?;
264
265        assert_eq!(format!("{store}"), "HttpStore");
266
267        // The store must be configured for this domain
268        let expected_domain = format!("Domain(\"{domain}\")");
269        assert!(format!("{store:?}").contains(&expected_domain));
270
271        Ok(())
272    }
273
274    #[tokio::test]
275    async fn query_s3_location_test() -> Result<()> {
276        let aws_envs = vec![
277            "AWS_ENDPOINT",
278            "AWS_ACCESS_KEY_ID",
279            "AWS_SECRET_ACCESS_KEY",
280            "AWS_ALLOW_HTTP",
281        ];
282        for aws_env in aws_envs {
283            if env::var(aws_env).is_err() {
284                eprint!("aws envs not set, skipping s3 test");
285                return Ok(());
286            }
287        }
288
289        let bucket = "examples3bucket";
290        let location = format!("s3://{bucket}/file.parquet");
291
292        let (ctx, schema) = setup_context();
293
294        let table = schema.table(&location).await?;
295        assert!(table.is_none());
296
297        let store = ctx
298            .runtime_env()
299            .object_store(ListingTableUrl::parse(location)?)?;
300        assert_eq!(format!("{store}"), format!("AmazonS3({bucket})"));
301
302        // The store must be configured for this domain
303        let expected_bucket = format!("bucket: \"{bucket}\"");
304        assert!(format!("{store:?}").contains(&expected_bucket));
305
306        Ok(())
307    }
308
309    #[tokio::test]
310    async fn query_gs_location_test() -> Result<()> {
311        let bucket = "examplegsbucket";
312        let location = format!("gs://{bucket}/file.parquet");
313
314        let (ctx, schema) = setup_context();
315
316        let table = schema.table(&location).await?;
317        assert!(table.is_none());
318
319        let store = ctx
320            .runtime_env()
321            .object_store(ListingTableUrl::parse(location)?)?;
322        assert_eq!(format!("{store}"), format!("GoogleCloudStorage({bucket})"));
323
324        // The store must be configured for this domain
325        let expected_bucket = format!("bucket_name_encoded: \"{bucket}\"");
326        assert!(format!("{store:?}").contains(&expected_bucket));
327
328        Ok(())
329    }
330
331    #[tokio::test]
332    async fn query_invalid_location_test() {
333        let location = "ts://file.parquet";
334        let (_ctx, schema) = setup_context();
335
336        assert!(schema.table(location).await.is_err());
337    }
338
339    #[cfg(not(target_os = "windows"))]
340    #[test]
341    fn test_substitute_tilde() {
342        use std::{env, path::PathBuf};
343        let original_home = home_dir();
344        let test_home_path = if cfg!(windows) {
345            "C:\\Users\\user"
346        } else {
347            "/home/user"
348        };
349        unsafe {
350            env::set_var(
351                if cfg!(windows) { "USERPROFILE" } else { "HOME" },
352                test_home_path,
353            );
354        }
355        let input = "~/Code/datafusion/benchmarks/data/tpch_sf1/part/part-0.parquet";
356        let expected = PathBuf::from(test_home_path)
357            .join("Code")
358            .join("datafusion")
359            .join("benchmarks")
360            .join("data")
361            .join("tpch_sf1")
362            .join("part")
363            .join("part-0.parquet")
364            .to_string_lossy()
365            .to_string();
366        let actual = substitute_tilde(input.to_string());
367        assert_eq!(actual, expected);
368        unsafe {
369            match original_home {
370                Some(home_path) => env::set_var(
371                    if cfg!(windows) { "USERPROFILE" } else { "HOME" },
372                    home_path.to_str().unwrap(),
373                ),
374                None => {
375                    env::remove_var(if cfg!(windows) { "USERPROFILE" } else { "HOME" })
376                }
377            }
378        }
379    }
380}