Skip to main content

datafusion_cli/object_storage/
stdin.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//! Exposes the process's standard input as a `stdin://` object store so that
19//! piped data (e.g. `cat data.csv | datafusion-cli`) can be queried via
20//! `CREATE EXTERNAL TABLE ... LOCATION '/dev/stdin'`.
21
22use std::io::{IsTerminal, Read};
23use std::sync::Arc;
24
25use datafusion::common::exec_datafusion_err;
26use datafusion::config::ConfigFileType;
27use datafusion::error::Result;
28use datafusion::execution::context::SessionState;
29use futures::TryStreamExt;
30
31use object_store::memory::InMemory;
32use object_store::path::Path as ObjectStorePath;
33use object_store::{ObjectStore, ObjectStoreExt};
34use url::Url;
35
36/// Marker [`SessionConfig`] extension recording that the session reads its SQL
37/// commands from stdin (the interactive or piped REPL). stdin cannot then also
38/// serve as a data source: reading it for table data would silently consume
39/// the remaining SQL statements.
40///
41/// [`SessionConfig`]: datafusion::execution::context::SessionConfig
42#[derive(Debug)]
43pub struct StdinCarriesCommands;
44
45/// Filesystem paths that refer to the process's standard input.
46///
47/// These are intentionally limited to the well known pseudo-files exposed by
48/// the operating system so that ordinary files are never accidentally treated
49/// as stdin.
50const STDIN_LOCATIONS: [&str; 3] = ["/dev/stdin", "/dev/fd/0", "/proc/self/fd/0"];
51
52/// Returns `true` if `path` refers to the process's standard input.
53///
54/// Re-exported as [`crate::object_storage::is_stdin_location`] so the CLI entry
55/// point can detect when it reads its SQL from stdin via `-f /dev/stdin` and
56/// avoid also offering stdin as a `LOCATION '/dev/stdin'` data source.
57pub fn is_stdin_location(path: &str) -> bool {
58    STDIN_LOCATIONS.contains(&path)
59}
60
61/// Utilities for exposing the process's standard input as an object store.
62///
63/// stdin is surfaced as a `stdin://` object store and dispatched alongside the
64/// other schemes (`s3`, `gs`, `http`, ...) so that reading piped data flows
65/// through the normal object-store/listing code path, conceptually similar to
66/// DuckDB's `PipeFileSystem`.
67pub(crate) struct StdinUtils;
68
69impl StdinUtils {
70    /// The URL scheme used to expose stdin as an object store, mirroring how
71    /// `s3`, `gs`, `http`, etc. are addressed.
72    pub(crate) const SCHEME: &'static str = "stdin";
73
74    /// Rewrites the well known stdin pseudo-paths (e.g. `/dev/stdin`) to a
75    /// canonical `stdin://` URL so that reading from standard input flows
76    /// through the same object-store/listing code path as any other scheme.
77    /// Non-stdin locations are returned unchanged.
78    ///
79    /// The listing layer filters candidate files by extension, so the canonical
80    /// object is named with the extension matching the declared `STORED AS`
81    /// format. The name thereby also records which format stdin was consumed
82    /// as: a later stdin-backed table declaring a different format resolves to
83    /// a path the buffered store does not contain and is rejected by
84    /// [`Self::get_or_create`].
85    pub(crate) fn rewrite_location(
86        location: &str,
87        format: Option<&ConfigFileType>,
88    ) -> String {
89        if !is_stdin_location(location) {
90            return location.to_string();
91        }
92
93        let object_name = match format {
94            Some(ConfigFileType::CSV) => "stdin.csv",
95            Some(ConfigFileType::JSON) => "stdin.json",
96            Some(ConfigFileType::PARQUET) => "stdin.parquet",
97            _ => "stdin",
98        };
99        format!("{}:///{object_name}", Self::SCHEME)
100    }
101
102    /// Returns the object store backing the `stdin://` scheme, buffering all of
103    /// standard input when the store is first constructed and reusing that
104    /// buffer for any subsequent `stdin://` table created in the same session.
105    ///
106    /// stdin is a one-shot stream: it can only be read once. The object store
107    /// registry keys by scheme/authority, so every `stdin://` URL maps to the
108    /// same store. Without this guard, a second `CREATE EXTERNAL TABLE ...
109    /// LOCATION '/dev/stdin'` would re-read (now-EOF) stdin, build an empty
110    /// store, and overwrite the populated one, silently emptying the earlier
111    /// table. Reusing the already-registered store avoids that.
112    ///
113    /// A later stdin-backed table declaring a different `STORED AS` format
114    /// resolves to an object the store does not contain (the object name
115    /// records the format stdin was consumed as) and is rejected with a clear
116    /// error — both reading the buffer as another format and re-reading stdin
117    /// would be silently wrong.
118    pub(crate) async fn get_or_create(
119        state: &SessionState,
120        url: &Url,
121    ) -> Result<Arc<dyn ObjectStore>> {
122        let Ok(existing) = state.runtime_env().object_store_registry.get_store(url)
123        else {
124            return Self::object_store(state, url).await;
125        };
126
127        let path = ObjectStorePath::from_url_path(url.path())?;
128        if existing.head(&path).await.is_err() {
129            let buffered = existing
130                .list(None)
131                .try_next()
132                .await
133                .ok()
134                .flatten()
135                .map(|meta| format!(" as '{}'", meta.location))
136                .unwrap_or_default();
137            return Err(exec_datafusion_err!(
138                "stdin was already read{buffered} by an earlier statement; all \
139                 tables backed by stdin in a session must declare the same \
140                 STORED AS format"
141            ));
142        }
143        Ok(existing)
144    }
145
146    /// Builds the object store backing the `stdin://` scheme by reading all of
147    /// standard input into memory.
148    ///
149    /// A pipe (e.g. `cat data.csv | datafusion-cli`) is not seekable and reports
150    /// a size of `0`, so it cannot be read directly by the file based formats
151    /// (CSV requires seeking, Parquet needs the footer at the end of the file).
152    /// Buffering the whole input up front sidesteps these limitations and lets
153    /// the data be read like any other object, including being scanned more than
154    /// once.
155    async fn object_store(
156        state: &SessionState,
157        url: &Url,
158    ) -> Result<Arc<dyn ObjectStore>> {
159        if state
160            .config()
161            .get_extension::<StdinCarriesCommands>()
162            .is_some()
163        {
164            return Err(exec_datafusion_err!(
165                "stdin is already being read for SQL commands, so it cannot \
166                 also supply table data; pass the query with -c/--command or \
167                 -f/--file so that stdin carries the data, e.g. \
168                 `cat data.csv | datafusion-cli -f query.sql`"
169            ));
170        }
171        if std::io::stdin().is_terminal() {
172            return Err(exec_datafusion_err!(
173                "stdin is connected to a terminal, not piped data; pipe the \
174                 input in, e.g. `cat data.csv | datafusion-cli -f query.sql`"
175            ));
176        }
177
178        let mut buffer = Vec::new();
179        std::io::stdin()
180            .lock()
181            .read_to_end(&mut buffer)
182            .map_err(|e| exec_datafusion_err!("Failed to read from stdin: {e}"))?;
183        Self::in_memory_object_store(url, buffer).await
184    }
185
186    /// Stores `data` at the path referenced by `url` in a fresh [`InMemory`]
187    /// store.
188    async fn in_memory_object_store(
189        url: &Url,
190        data: Vec<u8>,
191    ) -> Result<Arc<dyn ObjectStore>> {
192        let store = InMemory::new();
193        store
194            .put(&ObjectStorePath::from_url_path(url.path())?, data.into())
195            .await?;
196        Ok(Arc::new(store))
197    }
198}
199
200#[cfg(test)]
201mod tests {
202    use super::*;
203
204    use datafusion::prelude::{SessionConfig, SessionContext};
205
206    #[test]
207    fn rewrites_stdin_locations() {
208        // stdin pseudo-paths are rewritten to a `stdin://` URL carrying the
209        // extension that matches the declared format.
210        assert_eq!(
211            StdinUtils::rewrite_location("/dev/stdin", Some(&ConfigFileType::CSV)),
212            "stdin:///stdin.csv"
213        );
214        assert_eq!(
215            StdinUtils::rewrite_location("/dev/fd/0", Some(&ConfigFileType::JSON)),
216            "stdin:///stdin.json"
217        );
218        assert_eq!(
219            StdinUtils::rewrite_location(
220                "/proc/self/fd/0",
221                Some(&ConfigFileType::PARQUET)
222            ),
223            "stdin:///stdin.parquet"
224        );
225        assert_eq!(
226            StdinUtils::rewrite_location("/dev/stdin", None),
227            "stdin:///stdin"
228        );
229
230        // Ordinary locations are left untouched.
231        for location in ["/dev/stdout", "data/stdin.csv", "stdin", "s3://b/f.csv"] {
232            assert_eq!(
233                StdinUtils::rewrite_location(location, Some(&ConfigFileType::CSV)),
234                location
235            );
236        }
237    }
238
239    /// Buffers `data` into the `stdin://` object store and reads it back through
240    /// a `CREATE EXTERNAL TABLE`, returning the number of rows in the table.
241    ///
242    /// This exercises the full path used for `/dev/stdin` short of the actual
243    /// stdin read, which cannot be driven from a unit test.
244    async fn count_stdin_rows(
245        data: Vec<u8>,
246        stored_as: &str,
247        format: Option<ConfigFileType>,
248        options: &str,
249    ) -> Result<usize> {
250        let location = StdinUtils::rewrite_location("/dev/stdin", format.as_ref());
251        let url = Url::parse(&location).unwrap();
252        let store = StdinUtils::in_memory_object_store(&url, data).await?;
253
254        let ctx = SessionContext::new();
255        ctx.register_object_store(&url, store);
256        ctx.sql(&format!(
257            "CREATE EXTERNAL TABLE t STORED AS {stored_as} LOCATION '{location}' {options}"
258        ))
259        .await?
260        .collect()
261        .await?;
262
263        ctx.sql("SELECT * FROM t").await?.count().await
264    }
265
266    #[tokio::test]
267    async fn reuses_buffered_stdin_store() -> Result<()> {
268        // stdin can only be read once, so a second `stdin://` table must reuse
269        // the store buffered by the first instead of re-reading (now-empty)
270        // stdin and overwriting it.
271        //
272        // The very first read happens inside `get_or_create` -> `object_store`,
273        // which consumes the real process stdin and so cannot be driven from a
274        // unit test. Seed the registry with the store that first read would have
275        // produced (as the first `CREATE EXTERNAL TABLE` does), then drive the
276        // lookup through `get_or_create` and assert it hands back that exact
277        // store rather than rebuilding it.
278        let url = Url::parse("stdin:///stdin.csv").unwrap();
279        let path = ObjectStorePath::from_url_path(url.path())?;
280        let buffered: Arc<dyn ObjectStore> = Arc::new(InMemory::new());
281        buffered.put(&path, b"a\n1\n2\n".to_vec().into()).await?;
282
283        let ctx = SessionContext::new();
284        ctx.register_object_store(&url, Arc::clone(&buffered));
285
286        let reused = StdinUtils::get_or_create(&ctx.state(), &url).await?;
287        assert!(
288            Arc::ptr_eq(&buffered, &reused),
289            "get_or_create must reuse the registered stdin store, not rebuild it"
290        );
291        let bytes = reused.get(&path).await?.bytes().await?;
292        assert_eq!(bytes.as_ref(), b"a\n1\n2\n");
293        Ok(())
294    }
295
296    #[tokio::test]
297    async fn rejects_second_stdin_table_with_different_format() -> Result<()> {
298        // The buffered object's name records the format stdin was consumed
299        // as; a later stdin table declaring a different format must fail with
300        // a clear error rather than a downstream "not found" (or silently
301        // misreading the bytes as another format).
302        let csv_url = Url::parse("stdin:///stdin.csv").unwrap();
303        let store =
304            StdinUtils::in_memory_object_store(&csv_url, b"a\n1\n".to_vec()).await?;
305
306        let ctx = SessionContext::new();
307        ctx.register_object_store(&csv_url, store);
308
309        let json_url = Url::parse("stdin:///stdin.json").unwrap();
310        let err = StdinUtils::get_or_create(&ctx.state(), &json_url)
311            .await
312            .unwrap_err()
313            .to_string();
314        assert!(
315            err.contains("must declare the same STORED AS format")
316                && err.contains("stdin.csv"),
317            "unexpected error: {err}"
318        );
319        Ok(())
320    }
321
322    #[tokio::test]
323    async fn errors_when_stdin_carries_commands() {
324        // Once the REPL owns stdin for SQL commands, building the stdin store
325        // must fail with a clear error instead of swallowing the remaining
326        // statements as table data.
327        let config = SessionConfig::new().with_extension(Arc::new(StdinCarriesCommands));
328        let ctx = SessionContext::new_with_config(config);
329
330        let url = Url::parse("stdin:///stdin.csv").unwrap();
331        let err = StdinUtils::get_or_create(&ctx.state(), &url)
332            .await
333            .unwrap_err();
334        assert!(
335            err.to_string().contains("SQL commands"),
336            "unexpected error: {err}"
337        );
338    }
339
340    #[tokio::test]
341    async fn stdin_object_store_reads_csv() -> Result<()> {
342        let data = b"a,b\n1,foo\n2,bar\n".to_vec();
343        let rows = count_stdin_rows(
344            data,
345            "CSV",
346            Some(ConfigFileType::CSV),
347            "OPTIONS ('format.has_header' 'true')",
348        )
349        .await?;
350        assert_eq!(rows, 2);
351        Ok(())
352    }
353
354    #[tokio::test]
355    async fn stdin_object_store_reads_json() -> Result<()> {
356        let data = b"{\"a\": 1, \"b\": \"foo\"}\n{\"a\": 2, \"b\": \"bar\"}\n".to_vec();
357        let rows = count_stdin_rows(data, "JSON", Some(ConfigFileType::JSON), "").await?;
358        assert_eq!(rows, 2);
359        Ok(())
360    }
361
362    #[tokio::test]
363    async fn stdin_object_store_reads_parquet() -> Result<()> {
364        use datafusion::arrow::array::Int32Array;
365        use datafusion::arrow::datatypes::{DataType, Field, Schema};
366        use datafusion::arrow::record_batch::RecordBatch;
367        use parquet::arrow::ArrowWriter;
368
369        // Parquet requires random access to the footer, which a real pipe cannot
370        // provide; the in-memory buffer makes this work.
371        let schema = Arc::new(Schema::new(vec![Field::new("a", DataType::Int32, false)]));
372        let batch = RecordBatch::try_new(
373            Arc::clone(&schema),
374            vec![Arc::new(Int32Array::from(vec![1, 2, 3]))],
375        )
376        .unwrap();
377
378        let mut data = Vec::new();
379        let mut writer = ArrowWriter::try_new(&mut data, schema, None).unwrap();
380        writer.write(&batch).unwrap();
381        writer.close().unwrap();
382
383        let rows =
384            count_stdin_rows(data, "PARQUET", Some(ConfigFileType::PARQUET), "").await?;
385        assert_eq!(rows, 3);
386        Ok(())
387    }
388}