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}