Skip to main content

datafusion_cli/
exec.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
18//! Execution functions
19
20use crate::cli_context::CliSessionContext;
21use crate::helper::split_from_semicolon;
22use crate::print_format::PrintFormat;
23use crate::{
24    command::{Command, OutputFormat},
25    helper::CliHelper,
26    object_storage::{get_object_store, stdin::StdinUtils},
27    print_options::{MaxRows, PrintOptions},
28};
29use datafusion::common::instant::Instant;
30use datafusion::common::{plan_datafusion_err, plan_err};
31use datafusion::config::{ConfigFileType, Dialect};
32use datafusion::datasource::listing::ListingTableUrl;
33use datafusion::error::{DataFusionError, Result};
34use datafusion::execution::memory_pool::MemoryConsumer;
35use datafusion::logical_expr::{DdlStatement, LogicalPlan};
36use datafusion::physical_plan::execution_plan::EmissionType;
37use datafusion::physical_plan::spill::get_record_batch_memory_size;
38use datafusion::physical_plan::{ExecutionPlanProperties, execute_stream};
39use datafusion::sql::parser::{DFParser, Statement};
40use datafusion::sql::sqlparser;
41use datafusion::sql::sqlparser::dialect::dialect_from_str;
42use futures::StreamExt;
43use log::warn;
44use object_store::Error::Generic;
45use rustyline::Editor;
46use rustyline::error::ReadlineError;
47use std::collections::HashMap;
48use std::fs::File;
49use std::io::BufReader;
50use std::io::prelude::*;
51use tokio::signal;
52
53/// run and execute SQL statements and commands, against a context with the given print options
54pub async fn exec_from_commands(
55    ctx: &dyn CliSessionContext,
56    commands: Vec<String>,
57    print_options: &PrintOptions,
58) -> Result<()> {
59    for sql in commands {
60        exec_and_print(ctx, print_options, sql).await?;
61    }
62
63    Ok(())
64}
65
66/// run and execute SQL statements and commands from a file, against a context with the given print options
67pub async fn exec_from_lines(
68    ctx: &dyn CliSessionContext,
69    reader: &mut BufReader<File>,
70    print_options: &PrintOptions,
71) -> Result<()> {
72    let mut query = "".to_owned();
73
74    for line in reader.lines() {
75        match line {
76            Ok(line) if line.starts_with("#!") => {
77                continue;
78            }
79            Ok(line) if line.starts_with("--") => {
80                continue;
81            }
82            Ok(line) => {
83                let line = line.trim_end();
84                query.push_str(line);
85                if line.ends_with(';') {
86                    match exec_and_print(ctx, print_options, query).await {
87                        Ok(_) => {}
88                        Err(err) => eprintln!("{err}"),
89                    }
90                    query = "".to_string();
91                } else {
92                    query.push('\n');
93                }
94            }
95            _ => {
96                break;
97            }
98        }
99    }
100
101    // run the left over query if the last statement doesn't contain ‘;’
102    // ignore if it only consists of '\n'
103    if query.contains(|c| c != '\n') {
104        exec_and_print(ctx, print_options, query).await?;
105    }
106
107    Ok(())
108}
109
110pub async fn exec_from_files(
111    ctx: &dyn CliSessionContext,
112    files: Vec<String>,
113    print_options: &PrintOptions,
114) -> Result<()> {
115    let files = files
116        .into_iter()
117        .map(|file_path| File::open(file_path).unwrap())
118        .collect::<Vec<_>>();
119
120    for file in files {
121        let mut reader = BufReader::new(file);
122        exec_from_lines(ctx, &mut reader, print_options).await?;
123    }
124
125    Ok(())
126}
127
128/// run and execute SQL statements and commands against a context with the given print options
129pub async fn exec_from_repl(
130    ctx: &dyn CliSessionContext,
131    print_options: &mut PrintOptions,
132) -> rustyline::Result<()> {
133    let mut rl = Editor::new()?;
134    rl.set_helper(Some(CliHelper::new(
135        &ctx.task_ctx().session_config().options().sql_parser.dialect,
136        print_options.color,
137    )));
138    rl.load_history(".history").ok();
139
140    loop {
141        match rl.readline("> ") {
142            Ok(line) if line.starts_with('\\') => {
143                rl.add_history_entry(line.trim_end())?;
144                let command = line.split_whitespace().collect::<Vec<_>>().join(" ");
145                if let Ok(cmd) = &command[1..].parse::<Command>() {
146                    match cmd {
147                        Command::Quit => break,
148                        Command::OutputFormat(subcommand) => {
149                            if let Some(subcommand) = subcommand {
150                                if let Ok(command) = subcommand.parse::<OutputFormat>() {
151                                    if let Err(e) = command.execute(print_options) {
152                                        eprintln!("{e}")
153                                    }
154                                } else {
155                                    eprintln!(
156                                        "'\\{}' is not a valid command, you can use '\\?' to see all commands",
157                                        &line[1..]
158                                    );
159                                }
160                            } else {
161                                println!("Output format is {:?}.", print_options.format);
162                            }
163                        }
164                        _ => {
165                            if let Err(e) = cmd.execute(ctx, print_options).await {
166                                eprintln!("{e}")
167                            }
168                        }
169                    }
170                } else {
171                    eprintln!(
172                        "'\\{}' is not a valid command, you can use '\\?' to see all commands",
173                        &line[1..]
174                    );
175                }
176            }
177            Ok(line) => {
178                let lines = split_from_semicolon(&line);
179                for line in lines {
180                    rl.add_history_entry(line.trim_end())?;
181                    tokio::select! {
182                        res = exec_and_print(ctx, print_options, line) => match res {
183                            Ok(_) => {}
184                            Err(err) => eprintln!("{err}"),
185                        },
186                        _ = signal::ctrl_c() => {
187                            println!("^C");
188                            continue
189                        },
190                    }
191                    // dialect might have changed
192                    rl.helper_mut().unwrap().set_dialect(
193                        &ctx.task_ctx().session_config().options().sql_parser.dialect,
194                    );
195                }
196            }
197            Err(ReadlineError::Interrupted) => {
198                println!("^C");
199                rl.helper().unwrap().reset_hint();
200                continue;
201            }
202            Err(ReadlineError::Eof) => {
203                println!("\\q");
204                break;
205            }
206            Err(err) => {
207                eprintln!("Unknown error happened {err:?}");
208                break;
209            }
210        }
211    }
212
213    rl.save_history(".history")
214}
215
216pub(super) async fn exec_and_print(
217    ctx: &dyn CliSessionContext,
218    print_options: &PrintOptions,
219    sql: String,
220) -> Result<()> {
221    let task_ctx = ctx.task_ctx();
222    let options = task_ctx.session_config().options();
223    let dialect = &options.sql_parser.dialect;
224    let dialect = dialect_from_str(dialect).ok_or_else(|| {
225        plan_datafusion_err!(
226            "Unsupported SQL dialect: {dialect}. Available dialects: {}.",
227            Dialect::available()
228        )
229    })?;
230
231    let statements = DFParser::parse_sql_with_dialect(&sql, dialect.as_ref())?;
232    for statement in statements {
233        StatementExecutor::new(statement)
234            .execute(ctx, print_options)
235            .await?;
236    }
237
238    Ok(())
239}
240
241/// Executor for SQL statements, including special handling for S3 region detection retry logic
242struct StatementExecutor {
243    statement: Statement,
244    statement_for_retry: Option<Statement>,
245}
246
247impl StatementExecutor {
248    fn new(statement: Statement) -> Self {
249        let statement_for_retry = matches!(statement, Statement::CreateExternalTable(_))
250            .then(|| statement.clone());
251
252        Self {
253            statement,
254            statement_for_retry,
255        }
256    }
257
258    async fn execute(
259        self,
260        ctx: &dyn CliSessionContext,
261        print_options: &PrintOptions,
262    ) -> Result<()> {
263        let now = Instant::now();
264        let (df, adjusted) = self
265            .create_and_execute_logical_plan(ctx, print_options)
266            .await?;
267        let physical_plan = df.create_physical_plan().await?;
268        let task_ctx = ctx.task_ctx();
269        let options = task_ctx.session_config().options();
270
271        // Track memory usage for the query result if it's bounded
272        let reservation =
273            MemoryConsumer::new("DataFusion-Cli").register(task_ctx.memory_pool());
274
275        if physical_plan.boundedness().is_unbounded() {
276            if physical_plan.pipeline_behavior() == EmissionType::Final {
277                return plan_err!(
278                    "The given query can generate a valid result only once \
279                    the source finishes, but the source is unbounded"
280                );
281            }
282            // As the input stream comes, we can generate results.
283            // However, memory safety is not guaranteed.
284            let stream = execute_stream(physical_plan, task_ctx.clone())?;
285            print_options
286                .print_stream(stream, now, &options.format)
287                .await?;
288        } else {
289            // Bounded stream; collected results size is limited by the maxrows option
290            let schema = physical_plan.schema();
291            let mut stream = execute_stream(physical_plan, task_ctx.clone())?;
292            let mut results = vec![];
293            let mut row_count = 0_usize;
294            let max_rows = match print_options.maxrows {
295                MaxRows::Unlimited => usize::MAX,
296                MaxRows::Limited(n) => n,
297            };
298            while let Some(batch) = stream.next().await {
299                let batch = batch?;
300                let curr_num_rows = batch.num_rows();
301                // Stop collecting results if the number of rows exceeds the limit
302                // results batch should include the last batch that exceeds the limit
303                if row_count < max_rows.saturating_add(curr_num_rows) {
304                    // Try to grow the reservation to accommodate the batch in memory
305                    reservation.try_grow(get_record_batch_memory_size(&batch))?;
306                    results.push(batch);
307                }
308                row_count += curr_num_rows;
309            }
310            adjusted.into_inner().print_batches(
311                schema,
312                &results,
313                now,
314                row_count,
315                &options.format,
316            )?;
317            reservation.free();
318        }
319
320        Ok(())
321    }
322
323    async fn create_and_execute_logical_plan(
324        mut self,
325        ctx: &dyn CliSessionContext,
326        print_options: &PrintOptions,
327    ) -> Result<(datafusion::dataframe::DataFrame, AdjustedPrintOptions)> {
328        let adjusted = AdjustedPrintOptions::new(print_options.clone())
329            .with_statement(&self.statement);
330
331        let plan = create_plan(ctx, self.statement, false).await?;
332        let adjusted = adjusted.with_plan(&plan);
333
334        let df = match ctx.execute_logical_plan(plan).await {
335            Ok(df) => Ok(df),
336            Err(DataFusionError::ObjectStore(err))
337                if matches!(err.as_ref(), Generic { store, source: _ } if "S3".eq_ignore_ascii_case(store))
338                    && self.statement_for_retry.is_some() =>
339            {
340                warn!(
341                    "S3 region is incorrect, auto-detecting the correct region (this may be slow). Consider updating your region configuration."
342                );
343                let plan =
344                    create_plan(ctx, self.statement_for_retry.take().unwrap(), true)
345                        .await?;
346                ctx.execute_logical_plan(plan).await
347            }
348            Err(e) => Err(e),
349        }?;
350
351        Ok((df, adjusted))
352    }
353}
354
355/// Track adjustments to the print options based on the plan / statement being executed
356#[derive(Debug)]
357struct AdjustedPrintOptions {
358    inner: PrintOptions,
359}
360
361impl AdjustedPrintOptions {
362    fn new(inner: PrintOptions) -> Self {
363        Self { inner }
364    }
365    /// Adjust print options based on any statement specific requirements
366    fn with_statement(mut self, statement: &Statement) -> Self {
367        if let Statement::Statement(sql_stmt) = statement {
368            // SHOW / SHOW ALL
369            if let sqlparser::ast::Statement::ShowVariable { .. } = sql_stmt.as_ref() {
370                self.inner.maxrows = MaxRows::Unlimited
371            }
372        }
373        self
374    }
375
376    /// Adjust print options based on any plan specific requirements
377    fn with_plan(mut self, plan: &LogicalPlan) -> Self {
378        // For plans like `Explain` ignore `MaxRows` option and always display
379        // all rows
380        if matches!(
381            plan,
382            LogicalPlan::Explain(_)
383                | LogicalPlan::DescribeTable(_)
384                | LogicalPlan::Analyze(_)
385        ) {
386            self.inner.maxrows = MaxRows::Unlimited;
387        }
388        self
389    }
390
391    /// Finalize and return the inner `PrintOptions`
392    fn into_inner(mut self) -> PrintOptions {
393        if self.inner.format == PrintFormat::Automatic {
394            self.inner.format = PrintFormat::Table;
395        }
396
397        self.inner
398    }
399}
400
401fn config_file_type_from_str(ext: &str) -> Option<ConfigFileType> {
402    match ext.to_lowercase().as_str() {
403        "csv" => Some(ConfigFileType::CSV),
404        "json" => Some(ConfigFileType::JSON),
405        "parquet" => Some(ConfigFileType::PARQUET),
406        _ => None,
407    }
408}
409
410async fn create_plan(
411    ctx: &dyn CliSessionContext,
412    statement: Statement,
413    resolve_region: bool,
414) -> Result<LogicalPlan, DataFusionError> {
415    let mut plan = ctx.session_state().statement_to_plan(statement).await?;
416
417    // Note that cmd is a mutable reference so that create_external_table function can remove all
418    // datafusion-cli specific options before passing through to datafusion. Otherwise, datafusion
419    // will raise Configuration errors.
420    if let LogicalPlan::Ddl(DdlStatement::CreateExternalTable(cmd)) = &mut plan {
421        // To support custom formats, treat error as None
422        let format = config_file_type_from_str(&cmd.file_type);
423
424        // Expose stdin (e.g. `cat data.csv | datafusion-cli`) as a `stdin://`
425        // object store, registered like any other scheme in `get_object_store`.
426        for location in &mut cmd.locations {
427            *location = StdinUtils::rewrite_location(location, format.as_ref());
428            register_object_store_and_config_extensions(
429                ctx,
430                location,
431                &cmd.options,
432                format.clone(),
433                resolve_region,
434            )
435            .await?;
436        }
437    }
438
439    if let LogicalPlan::Copy(copy_to) = &mut plan {
440        let format = config_file_type_from_str(&copy_to.file_type.get_ext());
441
442        register_object_store_and_config_extensions(
443            ctx,
444            &copy_to.output_url,
445            &copy_to.options,
446            format,
447            false,
448        )
449        .await?;
450    }
451    Ok(plan)
452}
453
454/// Asynchronously registers an object store and its configuration extensions
455/// to the session context.
456///
457/// This function dynamically registers a cloud object store based on the given
458/// location and options. It first parses the location to determine the scheme
459/// and constructs the URL accordingly. Depending on the scheme, it also registers
460/// relevant options. The function then alters the default table options with the
461/// given custom options. Finally, it retrieves and registers the object store
462/// in the session context.
463///
464/// # Parameters
465///
466/// * `ctx`: A reference to the `SessionContext` for registering the object store.
467/// * `location`: A string reference representing the location of the object store.
468/// * `options`: A reference to a hash map containing configuration options for
469///   the object store.
470///
471/// # Returns
472///
473/// A `Result<()>` which is an Ok value indicating successful registration, or
474/// an error upon failure.
475///
476/// # Errors
477///
478/// This function can return an error if the location parsing fails, options
479/// alteration fails, or if the object store cannot be retrieved and registered
480/// successfully.
481pub(crate) async fn register_object_store_and_config_extensions(
482    ctx: &dyn CliSessionContext,
483    location: &String,
484    options: &HashMap<String, String>,
485    format: Option<ConfigFileType>,
486    resolve_region: bool,
487) -> Result<()> {
488    // Parse the location URL to extract the scheme and other components
489    let table_path = ListingTableUrl::parse(location)?;
490
491    // Extract the scheme (e.g., "s3", "gcs") from the parsed URL
492    let scheme = table_path.scheme();
493
494    // Obtain a reference to the URL
495    let url = table_path.as_ref();
496
497    // Register the options based on the scheme extracted from the location
498    ctx.register_table_options_extension_from_scheme(scheme);
499
500    // Clone and modify the default table options based on the provided options
501    let mut table_options = ctx.session_state().default_table_options();
502    if let Some(format) = format {
503        table_options.set_config_format(format);
504    }
505    table_options.alter_with_string_hash_map(options)?;
506
507    // Retrieve the appropriate object store based on the scheme, URL, and modified table options
508    let store = get_object_store(
509        &ctx.session_state(),
510        scheme,
511        url,
512        &table_options,
513        resolve_region,
514    )
515    .await?;
516
517    // Register the retrieved object store in the session context's runtime environment
518    ctx.register_object_store(url, store);
519
520    Ok(())
521}
522
523#[cfg(test)]
524mod tests {
525    use super::*;
526
527    use datafusion::common::plan_err;
528
529    use datafusion::prelude::SessionContext;
530    use datafusion_common::assert_contains;
531    use url::Url;
532
533    async fn create_external_table_test(location: &str, sql: &str) -> Result<()> {
534        let ctx = SessionContext::new();
535        let plan = ctx.state().create_logical_plan(sql).await?;
536
537        if let LogicalPlan::Ddl(DdlStatement::CreateExternalTable(cmd)) = &plan {
538            let format = config_file_type_from_str(&cmd.file_type);
539            for location in &cmd.locations {
540                register_object_store_and_config_extensions(
541                    &ctx,
542                    location,
543                    &cmd.options,
544                    format.clone(),
545                    false,
546                )
547                .await?;
548            }
549        } else {
550            return plan_err!("LogicalPlan is not a CreateExternalTable");
551        }
552
553        // Ensure the URL is supported by the object store
554        ctx.runtime_env()
555            .object_store(ListingTableUrl::parse(location)?)?;
556
557        Ok(())
558    }
559
560    async fn copy_to_table_test(location: &str, sql: &str) -> Result<()> {
561        let ctx = SessionContext::new();
562        // AWS CONFIG register.
563
564        let plan = ctx.state().create_logical_plan(sql).await?;
565
566        if let LogicalPlan::Copy(cmd) = &plan {
567            let format = config_file_type_from_str(&cmd.file_type.get_ext());
568            register_object_store_and_config_extensions(
569                &ctx,
570                &cmd.output_url,
571                &cmd.options,
572                format,
573                false,
574            )
575            .await?;
576        } else {
577            return plan_err!("LogicalPlan is not a CreateExternalTable");
578        }
579
580        // Ensure the URL is supported by the object store
581        ctx.runtime_env()
582            .object_store(ListingTableUrl::parse(location)?)?;
583
584        Ok(())
585    }
586
587    #[tokio::test]
588    async fn create_object_store_table_http() -> Result<()> {
589        // Should be OK
590        let location = "http://example.com/file.parquet";
591        let sql =
592            format!("CREATE EXTERNAL TABLE test STORED AS PARQUET LOCATION '{location}'");
593        create_external_table_test(location, &sql).await?;
594
595        Ok(())
596    }
597    #[tokio::test]
598    async fn copy_to_external_object_store_test() -> Result<()> {
599        let aws_envs = vec![
600            "AWS_ENDPOINT",
601            "AWS_ACCESS_KEY_ID",
602            "AWS_SECRET_ACCESS_KEY",
603            "AWS_ALLOW_HTTP",
604        ];
605        for aws_env in aws_envs {
606            if std::env::var(aws_env).is_err() {
607                eprint!("aws envs not set, skipping s3 test");
608                return Ok(());
609            }
610        }
611
612        let locations = vec![
613            "s3://bucket/path/file.parquet",
614            "oss://bucket/path/file.parquet",
615            "cos://bucket/path/file.parquet",
616            "gcs://bucket/path/file.parquet",
617        ];
618        let ctx = SessionContext::new();
619        let task_ctx = ctx.task_ctx();
620        let dialect = &task_ctx.session_config().options().sql_parser.dialect;
621        let dialect = dialect_from_str(dialect).ok_or_else(|| {
622            plan_datafusion_err!(
623                "Unsupported SQL dialect: {dialect}. Available dialects: {}.",
624                Dialect::available()
625            )
626        })?;
627        for location in locations {
628            let sql = format!("copy (values (1,2)) to '{location}' STORED AS PARQUET;");
629            let statements = DFParser::parse_sql_with_dialect(&sql, dialect.as_ref())?;
630            for statement in statements {
631                //Should not fail
632                let mut plan = create_plan(&ctx, statement, false).await?;
633                if let LogicalPlan::Copy(copy_to) = &mut plan {
634                    assert_eq!(copy_to.output_url, location);
635                    assert_eq!(copy_to.file_type.get_ext(), "parquet".to_string());
636                    ctx.runtime_env()
637                        .object_store_registry
638                        .get_store(&Url::parse(&copy_to.output_url).unwrap())?;
639                } else {
640                    return plan_err!("LogicalPlan is not a CopyTo");
641                }
642            }
643        }
644        Ok(())
645    }
646
647    #[tokio::test]
648    async fn copy_to_object_store_table_s3() -> Result<()> {
649        let access_key_id = "fake_access_key_id";
650        let secret_access_key = "fake_secret_access_key";
651        let location = "s3://bucket/path/file.parquet";
652
653        // Missing region, use object_store defaults
654        let sql = format!("COPY (values (1,2)) TO '{location}' STORED AS PARQUET
655            OPTIONS ('aws.access_key_id' '{access_key_id}', 'aws.secret_access_key' '{secret_access_key}')");
656        copy_to_table_test(location, &sql).await?;
657
658        Ok(())
659    }
660
661    #[tokio::test]
662    async fn create_object_store_table_s3() -> Result<()> {
663        let access_key_id = "fake_access_key_id";
664        let secret_access_key = "fake_secret_access_key";
665        let region = "fake_us-east-2";
666        let session_token = "fake_session_token";
667        let location = "s3://bucket/path/file.parquet";
668
669        // Missing region, use object_store defaults
670        let sql = format!("CREATE EXTERNAL TABLE test STORED AS PARQUET
671            OPTIONS('aws.access_key_id' '{access_key_id}', 'aws.secret_access_key' '{secret_access_key}') LOCATION '{location}'");
672        create_external_table_test(location, &sql).await?;
673
674        // Should be OK
675        let sql = format!("CREATE EXTERNAL TABLE test STORED AS PARQUET
676            OPTIONS('aws.access_key_id' '{access_key_id}', 'aws.secret_access_key' '{secret_access_key}', 'aws.region' '{region}', 'aws.session_token' '{session_token}') LOCATION '{location}'");
677        create_external_table_test(location, &sql).await?;
678
679        Ok(())
680    }
681
682    #[tokio::test]
683    async fn create_object_store_table_oss() -> Result<()> {
684        let access_key_id = "fake_access_key_id";
685        let secret_access_key = "fake_secret_access_key";
686        let endpoint = "fake_endpoint";
687        let location = "oss://bucket/path/file.parquet";
688
689        // Should be OK
690        let sql = format!("CREATE EXTERNAL TABLE test STORED AS PARQUET
691            OPTIONS('aws.access_key_id' '{access_key_id}', 'aws.secret_access_key' '{secret_access_key}', 'aws.oss.endpoint' '{endpoint}') LOCATION '{location}'");
692        create_external_table_test(location, &sql).await?;
693
694        Ok(())
695    }
696
697    #[tokio::test]
698    async fn create_object_store_table_cos() -> Result<()> {
699        let access_key_id = "fake_access_key_id";
700        let secret_access_key = "fake_secret_access_key";
701        let endpoint = "fake_endpoint";
702        let location = "cos://bucket/path/file.parquet";
703
704        // Should be OK
705        let sql = format!("CREATE EXTERNAL TABLE test STORED AS PARQUET
706            OPTIONS('aws.access_key_id' '{access_key_id}', 'aws.secret_access_key' '{secret_access_key}', 'aws.cos.endpoint' '{endpoint}') LOCATION '{location}'");
707        create_external_table_test(location, &sql).await?;
708
709        Ok(())
710    }
711
712    #[tokio::test]
713    async fn create_object_store_table_gcs() -> Result<()> {
714        let service_account_path = "fake_service_account_path";
715        let service_account_key = "{\"private_key\": \"fake_private_key.pem\",\"client_email\":\"fake_client_email\", \"private_key_id\":\"id\"}";
716        let application_credentials_path = "fake_application_credentials_path";
717        let location = "gcs://bucket/path/file.parquet";
718
719        // for service_account_path
720        let sql = format!("CREATE EXTERNAL TABLE test STORED AS PARQUET
721            OPTIONS('gcp.service_account_path' '{service_account_path}') LOCATION '{location}'");
722        let err = create_external_table_test(location, &sql)
723            .await
724            .unwrap_err();
725        assert_contains!(err.to_string(), "os error 2");
726
727        // for service_account_key
728        let sql = format!(
729            "CREATE EXTERNAL TABLE test STORED AS PARQUET OPTIONS('gcp.service_account_key' '{service_account_key}') LOCATION '{location}'"
730        );
731        let err = create_external_table_test(location, &sql)
732            .await
733            .unwrap_err();
734        assert_contains!(err.to_string(), "Error reading pem file: no items found");
735
736        // for application_credentials_path
737        let sql = format!("CREATE EXTERNAL TABLE test STORED AS PARQUET
738            OPTIONS('gcp.application_credentials_path' '{application_credentials_path}') LOCATION '{location}'");
739        let err = create_external_table_test(location, &sql)
740            .await
741            .unwrap_err();
742        assert_contains!(err.to_string(), "os error 2");
743
744        Ok(())
745    }
746
747    #[tokio::test]
748    async fn create_external_table_local_file() -> Result<()> {
749        let location = "path/to/file.parquet";
750
751        // Ensure that local files are also registered
752        let sql =
753            format!("CREATE EXTERNAL TABLE test STORED AS PARQUET LOCATION '{location}'");
754        create_external_table_test(location, &sql).await.unwrap();
755
756        Ok(())
757    }
758
759    #[tokio::test]
760    async fn create_external_table_format_option() -> Result<()> {
761        let location = "path/to/file.cvs";
762
763        // Test with format options
764        let sql = format!(
765            "CREATE EXTERNAL TABLE test STORED AS CSV LOCATION '{location}' OPTIONS('format.has_header' 'true')"
766        );
767        create_external_table_test(location, &sql).await.unwrap();
768
769        Ok(())
770    }
771}