datafusion-cli 55.0.0

Command Line Client for DataFusion query engine.
Documentation
// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements.  See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership.  The ASF licenses this file
// to you under the Apache License, Version 2.0 (the
// "License"); you may not use this file except in compliance
// with the License.  You may obtain a copy of the License at
//
//   http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied.  See the License for the
// specific language governing permissions and limitations
// under the License.

//! Exposes the process's standard input as a `stdin://` object store so that
//! piped data (e.g. `cat data.csv | datafusion-cli`) can be queried via
//! `CREATE EXTERNAL TABLE ... LOCATION '/dev/stdin'`.

use std::io::{IsTerminal, Read};
use std::sync::Arc;

use datafusion::common::exec_datafusion_err;
use datafusion::config::ConfigFileType;
use datafusion::error::Result;
use datafusion::execution::context::SessionState;
use futures::TryStreamExt;

use object_store::memory::InMemory;
use object_store::path::Path as ObjectStorePath;
use object_store::{ObjectStore, ObjectStoreExt};
use url::Url;

/// Marker [`SessionConfig`] extension recording that the session reads its SQL
/// commands from stdin (the interactive or piped REPL). stdin cannot then also
/// serve as a data source: reading it for table data would silently consume
/// the remaining SQL statements.
///
/// [`SessionConfig`]: datafusion::execution::context::SessionConfig
#[derive(Debug)]
pub struct StdinCarriesCommands;

/// Filesystem paths that refer to the process's standard input.
///
/// These are intentionally limited to the well known pseudo-files exposed by
/// the operating system so that ordinary files are never accidentally treated
/// as stdin.
const STDIN_LOCATIONS: [&str; 3] = ["/dev/stdin", "/dev/fd/0", "/proc/self/fd/0"];

/// Returns `true` if `path` refers to the process's standard input.
///
/// Re-exported as [`crate::object_storage::is_stdin_location`] so the CLI entry
/// point can detect when it reads its SQL from stdin via `-f /dev/stdin` and
/// avoid also offering stdin as a `LOCATION '/dev/stdin'` data source.
pub fn is_stdin_location(path: &str) -> bool {
    STDIN_LOCATIONS.contains(&path)
}

/// Utilities for exposing the process's standard input as an object store.
///
/// stdin is surfaced as a `stdin://` object store and dispatched alongside the
/// other schemes (`s3`, `gs`, `http`, ...) so that reading piped data flows
/// through the normal object-store/listing code path, conceptually similar to
/// DuckDB's `PipeFileSystem`.
pub(crate) struct StdinUtils;

impl StdinUtils {
    /// The URL scheme used to expose stdin as an object store, mirroring how
    /// `s3`, `gs`, `http`, etc. are addressed.
    pub(crate) const SCHEME: &'static str = "stdin";

    /// Rewrites the well known stdin pseudo-paths (e.g. `/dev/stdin`) to a
    /// canonical `stdin://` URL so that reading from standard input flows
    /// through the same object-store/listing code path as any other scheme.
    /// Non-stdin locations are returned unchanged.
    ///
    /// The listing layer filters candidate files by extension, so the canonical
    /// object is named with the extension matching the declared `STORED AS`
    /// format. The name thereby also records which format stdin was consumed
    /// as: a later stdin-backed table declaring a different format resolves to
    /// a path the buffered store does not contain and is rejected by
    /// [`Self::get_or_create`].
    pub(crate) fn rewrite_location(
        location: &str,
        format: Option<&ConfigFileType>,
    ) -> String {
        if !is_stdin_location(location) {
            return location.to_string();
        }

        let object_name = match format {
            Some(ConfigFileType::CSV) => "stdin.csv",
            Some(ConfigFileType::JSON) => "stdin.json",
            Some(ConfigFileType::PARQUET) => "stdin.parquet",
            _ => "stdin",
        };
        format!("{}:///{object_name}", Self::SCHEME)
    }

    /// Returns the object store backing the `stdin://` scheme, buffering all of
    /// standard input when the store is first constructed and reusing that
    /// buffer for any subsequent `stdin://` table created in the same session.
    ///
    /// stdin is a one-shot stream: it can only be read once. The object store
    /// registry keys by scheme/authority, so every `stdin://` URL maps to the
    /// same store. Without this guard, a second `CREATE EXTERNAL TABLE ...
    /// LOCATION '/dev/stdin'` would re-read (now-EOF) stdin, build an empty
    /// store, and overwrite the populated one, silently emptying the earlier
    /// table. Reusing the already-registered store avoids that.
    ///
    /// A later stdin-backed table declaring a different `STORED AS` format
    /// resolves to an object the store does not contain (the object name
    /// records the format stdin was consumed as) and is rejected with a clear
    /// error — both reading the buffer as another format and re-reading stdin
    /// would be silently wrong.
    pub(crate) async fn get_or_create(
        state: &SessionState,
        url: &Url,
    ) -> Result<Arc<dyn ObjectStore>> {
        let Ok(existing) = state.runtime_env().object_store_registry.get_store(url)
        else {
            return Self::object_store(state, url).await;
        };

        let path = ObjectStorePath::from_url_path(url.path())?;
        if existing.head(&path).await.is_err() {
            let buffered = existing
                .list(None)
                .try_next()
                .await
                .ok()
                .flatten()
                .map(|meta| format!(" as '{}'", meta.location))
                .unwrap_or_default();
            return Err(exec_datafusion_err!(
                "stdin was already read{buffered} by an earlier statement; all \
                 tables backed by stdin in a session must declare the same \
                 STORED AS format"
            ));
        }
        Ok(existing)
    }

    /// Builds the object store backing the `stdin://` scheme by reading all of
    /// standard input into memory.
    ///
    /// A pipe (e.g. `cat data.csv | datafusion-cli`) is not seekable and reports
    /// a size of `0`, so it cannot be read directly by the file based formats
    /// (CSV requires seeking, Parquet needs the footer at the end of the file).
    /// Buffering the whole input up front sidesteps these limitations and lets
    /// the data be read like any other object, including being scanned more than
    /// once.
    async fn object_store(
        state: &SessionState,
        url: &Url,
    ) -> Result<Arc<dyn ObjectStore>> {
        if state
            .config()
            .get_extension::<StdinCarriesCommands>()
            .is_some()
        {
            return Err(exec_datafusion_err!(
                "stdin is already being read for SQL commands, so it cannot \
                 also supply table data; pass the query with -c/--command or \
                 -f/--file so that stdin carries the data, e.g. \
                 `cat data.csv | datafusion-cli -f query.sql`"
            ));
        }
        if std::io::stdin().is_terminal() {
            return Err(exec_datafusion_err!(
                "stdin is connected to a terminal, not piped data; pipe the \
                 input in, e.g. `cat data.csv | datafusion-cli -f query.sql`"
            ));
        }

        let mut buffer = Vec::new();
        std::io::stdin()
            .lock()
            .read_to_end(&mut buffer)
            .map_err(|e| exec_datafusion_err!("Failed to read from stdin: {e}"))?;
        Self::in_memory_object_store(url, buffer).await
    }

    /// Stores `data` at the path referenced by `url` in a fresh [`InMemory`]
    /// store.
    async fn in_memory_object_store(
        url: &Url,
        data: Vec<u8>,
    ) -> Result<Arc<dyn ObjectStore>> {
        let store = InMemory::new();
        store
            .put(&ObjectStorePath::from_url_path(url.path())?, data.into())
            .await?;
        Ok(Arc::new(store))
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    use datafusion::prelude::{SessionConfig, SessionContext};

    #[test]
    fn rewrites_stdin_locations() {
        // stdin pseudo-paths are rewritten to a `stdin://` URL carrying the
        // extension that matches the declared format.
        assert_eq!(
            StdinUtils::rewrite_location("/dev/stdin", Some(&ConfigFileType::CSV)),
            "stdin:///stdin.csv"
        );
        assert_eq!(
            StdinUtils::rewrite_location("/dev/fd/0", Some(&ConfigFileType::JSON)),
            "stdin:///stdin.json"
        );
        assert_eq!(
            StdinUtils::rewrite_location(
                "/proc/self/fd/0",
                Some(&ConfigFileType::PARQUET)
            ),
            "stdin:///stdin.parquet"
        );
        assert_eq!(
            StdinUtils::rewrite_location("/dev/stdin", None),
            "stdin:///stdin"
        );

        // Ordinary locations are left untouched.
        for location in ["/dev/stdout", "data/stdin.csv", "stdin", "s3://b/f.csv"] {
            assert_eq!(
                StdinUtils::rewrite_location(location, Some(&ConfigFileType::CSV)),
                location
            );
        }
    }

    /// Buffers `data` into the `stdin://` object store and reads it back through
    /// a `CREATE EXTERNAL TABLE`, returning the number of rows in the table.
    ///
    /// This exercises the full path used for `/dev/stdin` short of the actual
    /// stdin read, which cannot be driven from a unit test.
    async fn count_stdin_rows(
        data: Vec<u8>,
        stored_as: &str,
        format: Option<ConfigFileType>,
        options: &str,
    ) -> Result<usize> {
        let location = StdinUtils::rewrite_location("/dev/stdin", format.as_ref());
        let url = Url::parse(&location).unwrap();
        let store = StdinUtils::in_memory_object_store(&url, data).await?;

        let ctx = SessionContext::new();
        ctx.register_object_store(&url, store);
        ctx.sql(&format!(
            "CREATE EXTERNAL TABLE t STORED AS {stored_as} LOCATION '{location}' {options}"
        ))
        .await?
        .collect()
        .await?;

        ctx.sql("SELECT * FROM t").await?.count().await
    }

    #[tokio::test]
    async fn reuses_buffered_stdin_store() -> Result<()> {
        // stdin can only be read once, so a second `stdin://` table must reuse
        // the store buffered by the first instead of re-reading (now-empty)
        // stdin and overwriting it.
        //
        // The very first read happens inside `get_or_create` -> `object_store`,
        // which consumes the real process stdin and so cannot be driven from a
        // unit test. Seed the registry with the store that first read would have
        // produced (as the first `CREATE EXTERNAL TABLE` does), then drive the
        // lookup through `get_or_create` and assert it hands back that exact
        // store rather than rebuilding it.
        let url = Url::parse("stdin:///stdin.csv").unwrap();
        let path = ObjectStorePath::from_url_path(url.path())?;
        let buffered: Arc<dyn ObjectStore> = Arc::new(InMemory::new());
        buffered.put(&path, b"a\n1\n2\n".to_vec().into()).await?;

        let ctx = SessionContext::new();
        ctx.register_object_store(&url, Arc::clone(&buffered));

        let reused = StdinUtils::get_or_create(&ctx.state(), &url).await?;
        assert!(
            Arc::ptr_eq(&buffered, &reused),
            "get_or_create must reuse the registered stdin store, not rebuild it"
        );
        let bytes = reused.get(&path).await?.bytes().await?;
        assert_eq!(bytes.as_ref(), b"a\n1\n2\n");
        Ok(())
    }

    #[tokio::test]
    async fn rejects_second_stdin_table_with_different_format() -> Result<()> {
        // The buffered object's name records the format stdin was consumed
        // as; a later stdin table declaring a different format must fail with
        // a clear error rather than a downstream "not found" (or silently
        // misreading the bytes as another format).
        let csv_url = Url::parse("stdin:///stdin.csv").unwrap();
        let store =
            StdinUtils::in_memory_object_store(&csv_url, b"a\n1\n".to_vec()).await?;

        let ctx = SessionContext::new();
        ctx.register_object_store(&csv_url, store);

        let json_url = Url::parse("stdin:///stdin.json").unwrap();
        let err = StdinUtils::get_or_create(&ctx.state(), &json_url)
            .await
            .unwrap_err()
            .to_string();
        assert!(
            err.contains("must declare the same STORED AS format")
                && err.contains("stdin.csv"),
            "unexpected error: {err}"
        );
        Ok(())
    }

    #[tokio::test]
    async fn errors_when_stdin_carries_commands() {
        // Once the REPL owns stdin for SQL commands, building the stdin store
        // must fail with a clear error instead of swallowing the remaining
        // statements as table data.
        let config = SessionConfig::new().with_extension(Arc::new(StdinCarriesCommands));
        let ctx = SessionContext::new_with_config(config);

        let url = Url::parse("stdin:///stdin.csv").unwrap();
        let err = StdinUtils::get_or_create(&ctx.state(), &url)
            .await
            .unwrap_err();
        assert!(
            err.to_string().contains("SQL commands"),
            "unexpected error: {err}"
        );
    }

    #[tokio::test]
    async fn stdin_object_store_reads_csv() -> Result<()> {
        let data = b"a,b\n1,foo\n2,bar\n".to_vec();
        let rows = count_stdin_rows(
            data,
            "CSV",
            Some(ConfigFileType::CSV),
            "OPTIONS ('format.has_header' 'true')",
        )
        .await?;
        assert_eq!(rows, 2);
        Ok(())
    }

    #[tokio::test]
    async fn stdin_object_store_reads_json() -> Result<()> {
        let data = b"{\"a\": 1, \"b\": \"foo\"}\n{\"a\": 2, \"b\": \"bar\"}\n".to_vec();
        let rows = count_stdin_rows(data, "JSON", Some(ConfigFileType::JSON), "").await?;
        assert_eq!(rows, 2);
        Ok(())
    }

    #[tokio::test]
    async fn stdin_object_store_reads_parquet() -> Result<()> {
        use datafusion::arrow::array::Int32Array;
        use datafusion::arrow::datatypes::{DataType, Field, Schema};
        use datafusion::arrow::record_batch::RecordBatch;
        use parquet::arrow::ArrowWriter;

        // Parquet requires random access to the footer, which a real pipe cannot
        // provide; the in-memory buffer makes this work.
        let schema = Arc::new(Schema::new(vec![Field::new("a", DataType::Int32, false)]));
        let batch = RecordBatch::try_new(
            Arc::clone(&schema),
            vec![Arc::new(Int32Array::from(vec![1, 2, 3]))],
        )
        .unwrap();

        let mut data = Vec::new();
        let mut writer = ArrowWriter::try_new(&mut data, schema, None).unwrap();
        writer.write(&batch).unwrap();
        writer.close().unwrap();

        let rows =
            count_stdin_rows(data, "PARQUET", Some(ConfigFileType::PARQUET), "").await?;
        assert_eq!(rows, 3);
        Ok(())
    }
}