Skip to content

Commit 96a6096

Browse files
authored
feat: support reading from stdin in datafusion-cli (#22839)
## Which issue does this PR close? - Closes #9430. ## Rationale for this change Users frequently want to pipe data into the CLI, e.g. `cat data.csv | datafusion-cli`, but pointing `LOCATION` at `/dev/stdin` did not work: - CSV failed with `Illegal seek` (a pipe is not seekable). - Parquet failed with `file size of 0 is less than footer` (a pipe reports size 0). - JSON silently returned 0 rows. This PR makes reading from standard input work for CSV, JSON, and Parquet. ## What changes are included in this PR? stdin is exposed as a `stdin://` object store, dispatched alongside the other schemes (`s3`, `gs`, `http`, ...) in `get_object_store` — conceptually similar to DuckDB's `PipeFileSystem`. - `rewrite_stdin_location` maps the well-known stdin pseudo-paths (`/dev/stdin`, `/dev/fd/0`, `/proc/self/fd/0`) to a canonical `stdin:///stdin.<ext>` URL, so they flow through the normal object-store/listing code path. The extension matches the declared `STORED AS` format because the listing layer filters candidate files by extension. - The `stdin://` store reads all of standard input into an in-memory object store. Buffering up front is required because a pipe is not seekable and Parquet stores its metadata at the end of the file. Known scope/limitations (left as potential follow-ups): - Only `CREATE EXTERNAL TABLE` is supported (not dynamic `SELECT * FROM '/dev/stdin'`). - Input is fully buffered in memory, so it must fit in memory. - stdin can only be consumed once per session. - Unix-only (`/dev/stdin` does not exist on Windows); writing to `/dev/stdout` is out of scope. ## Are these changes tested? Yes: - Unit tests in `object_storage.rs` cover `rewrite_stdin_location` and end-to-end reads for CSV, JSON, and Parquet via the in-memory store. - A `#[cfg(unix)]` integration test in `cli_integration.rs` drives the real binary through an actual pipe, exercising the real stdin read. - Manually verified all three formats via real pipes, and confirmed normal local-file reads are unaffected. ## Are there any user-facing changes? Yes — reading from stdin via `LOCATION '/dev/stdin'` is now supported. Documented in `docs/source/user-guide/cli/datasources.md` (new "Reading from standard input" section). No breaking changes.
1 parent a0e6d49 commit 96a6096

6 files changed

Lines changed: 659 additions & 4 deletions

File tree

datafusion-cli/src/exec.rs

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ use crate::print_format::PrintFormat;
2323
use crate::{
2424
command::{Command, OutputFormat},
2525
helper::CliHelper,
26-
object_storage::get_object_store,
26+
object_storage::{get_object_store, stdin::StdinUtils},
2727
print_options::{MaxRows, PrintOptions},
2828
};
2929
use datafusion::common::instant::Instant;
@@ -417,9 +417,14 @@ async fn create_plan(
417417
// Note that cmd is a mutable reference so that create_external_table function can remove all
418418
// datafusion-cli specific options before passing through to datafusion. Otherwise, datafusion
419419
// will raise Configuration errors.
420-
if let LogicalPlan::Ddl(DdlStatement::CreateExternalTable(cmd)) = &plan {
420+
if let LogicalPlan::Ddl(DdlStatement::CreateExternalTable(cmd)) = &mut plan {
421421
// To support custom formats, treat error as None
422422
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+
cmd.location = StdinUtils::rewrite_location(&cmd.location, format.as_ref());
427+
423428
register_object_store_and_config_extensions(
424429
ctx,
425430
&cmd.location,

datafusion-cli/src/main.rs

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ use datafusion_cli::functions::{
3737
use datafusion_cli::object_storage::instrumented::{
3838
InstrumentedObjectStoreMode, InstrumentedObjectStoreRegistry,
3939
};
40+
use datafusion_cli::object_storage::{StdinCarriesCommands, is_stdin_location};
4041
use datafusion_cli::{
4142
DATAFUSION_CLI_VERSION, exec,
4243
pool_type::PoolType,
@@ -158,6 +159,23 @@ struct Args {
158159
object_store_profiling: InstrumentedObjectStoreMode,
159160
}
160161

162+
impl Args {
163+
/// Without -c/-f the CLI enters the REPL, which reads its SQL from
164+
/// stdin — interactively or piped.
165+
fn repl_mode(&self) -> bool {
166+
self.command.is_empty() && self.file.is_empty()
167+
}
168+
169+
/// Whether the CLI consumes stdin for its own SQL input. This covers the
170+
/// REPL (no -c/-f, reading SQL interactively or piped) as well as an
171+
/// explicit `-f /dev/stdin` (or the other stdin pseudo-paths), where the
172+
/// SQL file *is* stdin. In either case stdin is already spoken for and
173+
/// cannot also back a `LOCATION '/dev/stdin'` table.
174+
fn reads_sql_from_stdin(&self) -> bool {
175+
self.repl_mode() || self.file.iter().any(|f| is_stdin_location(f))
176+
}
177+
}
178+
161179
#[tokio::main]
162180
/// Calls [`main_inner`], then handles printing errors and returning the correct exit code
163181
pub async fn main() -> ExitCode {
@@ -268,6 +286,7 @@ async fn main_inner() -> Result<()> {
268286
instrumented_registry: Arc::clone(&instrumented_registry),
269287
};
270288

289+
let repl_mode = args.repl_mode();
271290
let commands = args.command;
272291
let files = args.file;
273292
let rc = match args.rc {
@@ -285,7 +304,7 @@ async fn main_inner() -> Result<()> {
285304
}
286305
};
287306

288-
if commands.is_empty() && files.is_empty() {
307+
if repl_mode {
289308
if !rc.is_empty() {
290309
exec::exec_from_files(&ctx, rc, &print_options).await?;
291310
}
@@ -330,8 +349,16 @@ fn get_session_config(args: &Args) -> Result<SessionConfig> {
330349
config_options.format.null = String::from("NULL");
331350
}
332351

333-
let session_config =
352+
let mut session_config =
334353
SessionConfig::from(config_options).with_information_schema(true);
354+
355+
if args.reads_sql_from_stdin() {
356+
// When stdin carries the session's SQL — the REPL (including any rc
357+
// file run before it) or an explicit `-f /dev/stdin` — it cannot also
358+
// serve as a data source for `LOCATION '/dev/stdin'`.
359+
session_config = session_config.with_extension(Arc::new(StdinCarriesCommands));
360+
}
361+
335362
Ok(session_config)
336363
}
337364

datafusion-cli/src/object_storage.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@
1616
// under the License.
1717

1818
pub mod instrumented;
19+
pub(crate) mod stdin;
20+
21+
pub use stdin::{StdinCarriesCommands, is_stdin_location};
1922

2023
use async_trait::async_trait;
2124
use aws_config::BehaviorVersion;
@@ -564,6 +567,9 @@ pub(crate) async fn get_object_store(
564567
.with_url(url.origin().ascii_serialization())
565568
.build()?,
566569
),
570+
_ if scheme == stdin::StdinUtils::SCHEME => {
571+
stdin::StdinUtils::get_or_create(state, url).await?
572+
}
567573
_ => {
568574
// For other types, try to get from `object_store_registry`:
569575
state

0 commit comments

Comments
 (0)