From f93097f4609d1fb0f7d35b655ef02ed656c1690e Mon Sep 17 00:00:00 2001 From: sdairs Date: Wed, 26 Aug 2026 15:34:00 +0100 Subject: [PATCH 1/2] Preserve local client query multiplicity --- README.md | 4 + crates/clickhousectl/src/error.rs | 8 + crates/clickhousectl/src/local/cli.rs | 91 ++++++++- crates/clickhousectl/src/local/mod.rs | 36 +++- .../tests/local_client_selectors_test.rs | 181 ++++++++++++++++++ 5 files changed, 309 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 7a655ea8..ac8baa06 100644 --- a/README.md +++ b/README.md @@ -222,7 +222,9 @@ postgres/ clickhousectl local client # Connects to "default" server clickhousectl local client --name dev # Connects to "dev" server clickhousectl local client --query "SHOW DATABASES" # Run a query +clickhousectl local client --query "SELECT 1" --query "SELECT 2" # Run queries in order clickhousectl local client --queries-file schema.sql # Run queries from a file +clickhousectl local client --queries-file schema.sql --queries-file seed.sql # Run files in order clickhousectl local client --host remote-host --port 9000 # Connect to a specific host/port clickhousectl local client --host remote-host # Direct mode; port defaults to 9000 clickhousectl local client --port 19000 # Direct mode; host defaults to localhost @@ -235,6 +237,8 @@ In direct mode, `--host` and `--port` select the server connection while `--vers Without `--version`, direct mode uses the valid default. If no default exists, zero installed versions is an error, one installed version is used without creating a default, and multiple installed versions require either `--version` or `local use`. A default that names a missing binary is an error; repair it with `local use`, or bypass it for one direct connection with `--version`. +`--query` and `--queries-file` can each be repeated; values, including empty strings, are passed to the native client unchanged and in order. They cannot be combined because the native ClickHouse client rejects that combination, so clickhousectl reports a usage error before resolving a binary. Arguments after `--` are appended after all wrapper-generated arguments. Repeatable `--query` requires ClickHouse 23.9.1.1854 or newer, where [ClickHouse added the native behavior](https://github.com/ClickHouse/ClickHouse/blob/8f9a227de1f530cdbda52c145d41a6b0f1d29961/docs/changelogs/archive/v23.9.1.1854-stable.md); clickhousectl checks the selected client version before execution. + ### Creating and managing ClickHouse servers Start and manage ClickHouse server instances. Each server gets its own isolated data directory at `.clickhouse/servers//data/`. diff --git a/crates/clickhousectl/src/error.rs b/crates/clickhousectl/src/error.rs index 5bf8971f..9406f350 100644 --- a/crates/clickhousectl/src/error.rs +++ b/crates/clickhousectl/src/error.rs @@ -160,6 +160,14 @@ pub enum Error { )] ClientVersionNotInstalled(String), + #[error( + "ClickHouse client version '{version}' does not support repeated --query values. Use ClickHouse {minimum} or newer, or send one --query value." + )] + RepeatedClientQueryUnsupported { + version: String, + minimum: &'static str, + }, + #[error("Version {0} is already installed")] VersionAlreadyInstalled(String), diff --git a/crates/clickhousectl/src/local/cli.rs b/crates/clickhousectl/src/local/cli.rs index 4246302c..ae884a07 100644 --- a/crates/clickhousectl/src/local/cli.rs +++ b/crates/clickhousectl/src/local/cli.rs @@ -249,7 +249,8 @@ CONTEXT FOR AGENTS: 2. Explicit host/port: `clickhousectl local client --host myhost --port 9000` — connects to any ClickHouse server directly, bypassing local server lookup. Host-only uses port 9000; port-only connects to localhost. Direct selectors cannot be combined with --name. - --query and --queries-file execute SQL inline or from a file. + --query and --queries-file are repeatable and preserve every value. ClickHouse rejects using the + two options together, so clickhousectl reports that combination as a usage error. Additional clickhouse-client args can be passed after --. Related: `clickhousectl local server start` to start a local server, `clickhousectl local server list` to see servers." )] @@ -274,13 +275,13 @@ CONTEXT FOR AGENTS: #[arg(long, short = 'v', requires = "direct", conflicts_with = "name")] version: Option, - /// Execute a SQL query - #[arg(long, short)] - query: Option, + /// Execute a SQL query (repeatable; requires ClickHouse 23.9.1.1854+ when repeated) + #[arg(long, short, conflicts_with = "queries_file")] + query: Vec, - /// Execute queries from a SQL file - #[arg(long)] - queries_file: Option, + /// Execute queries from a SQL file (repeatable; cannot be combined with --query) + #[arg(long, conflicts_with = "query")] + queries_file: Vec, /// Additional arguments to pass to clickhouse-client #[arg(trailing_var_arg = true, allow_hyphen_values = true)] @@ -940,6 +941,82 @@ mod tests { } } + #[test] + fn clickhouse_client_parses_minimal_and_repeated_query_inputs_in_order() { + let LocalCommands::Client { + query, + queries_file, + args, + .. + } = local_command(&["client"]) + else { + panic!("expected ClickHouse client"); + }; + assert!(query.is_empty()); + assert!(queries_file.is_empty()); + assert!(args.is_empty()); + + let LocalCommands::Client { + query, + queries_file, + args, + .. + } = local_command(&[ + "client", "--query", "SELECT 1", "-q", "SELECT 2", "--query", "", "--", "--query", + "SELECT 3", "--format", "CSV", + ]) + else { + panic!("expected ClickHouse client"); + }; + assert_eq!(query, ["SELECT 1", "SELECT 2", ""]); + assert!(queries_file.is_empty()); + assert_eq!(args, ["--query", "SELECT 3", "--format", "CSV"]); + } + + #[test] + fn clickhouse_client_parses_repeated_query_files_and_empty_values_in_order() { + let LocalCommands::Client { + query, + queries_file, + args, + .. + } = local_command(&[ + "client", + "--queries-file", + "schema.sql", + "--queries-file", + "seed.sql", + "--queries-file", + "", + "--", + "--queries-file", + "tail.sql", + ]) + else { + panic!("expected ClickHouse client"); + }; + assert!(query.is_empty()); + assert_eq!(queries_file, ["schema.sql", "seed.sql", ""]); + assert_eq!(args, ["--queries-file", "tail.sql"]); + } + + #[test] + fn clickhouse_client_rejects_combined_query_sources_in_every_order() { + for inputs in [ + ["--query", "SELECT 1", "--queries-file", "queries.sql"], + ["--queries-file", "queries.sql", "--query", "SELECT 1"], + ] { + let args: Vec<&str> = ["client"].into_iter().chain(inputs).collect(); + let error = local_parse_error(&args); + assert_eq!(error.kind(), clap::error::ErrorKind::ArgumentConflict); + assert!(error.to_string().contains("--query "), "{error}"); + assert!( + error.to_string().contains("--queries-file "), + "{error}" + ); + } + } + #[test] fn clickhouse_client_preserves_passthrough_selector_like_arguments() { let LocalCommands::Client { name, args, .. } = local_command(&[ diff --git a/crates/clickhousectl/src/local/mod.rs b/crates/clickhousectl/src/local/mod.rs index f1c7b277..93be4ba1 100644 --- a/crates/clickhousectl/src/local/mod.rs +++ b/crates/clickhousectl/src/local/mod.rs @@ -261,8 +261,8 @@ fn run_client( host: Option, port: Option, version_spec: Option, - query: Option, - queries_file: Option, + query: Vec, + queries_file: Vec, args: Vec, ) -> Result<()> { // If --host or --port is set, connect directly (bypass local server lookup). @@ -295,6 +295,8 @@ fn run_client( return Err(Error::VersionNotFound(version)); } + ensure_repeated_query_supported(&version, query.len())?; + let mut cmd = Command::new(&binary); cmd.arg("client") .arg("--host") @@ -302,11 +304,11 @@ fn run_client( .arg("--port") .arg(tcp_port.to_string()); - if let Some(q) = &query { + for q in &query { cmd.arg("--query").arg(q); } - if let Some(f) = &queries_file { + for f in &queries_file { cmd.arg("--queries-file").arg(f); } @@ -319,6 +321,21 @@ fn run_client( Err(Error::Exec(err.to_string())) } +const REPEATED_QUERY_MIN_VERSION: &str = "23.9.1.1854"; + +fn ensure_repeated_query_supported(version: &str, query_count: usize) -> Result<()> { + if query_count > 1 + && version_manager::list::compare_versions(version, REPEATED_QUERY_MIN_VERSION) + == std::cmp::Ordering::Less + { + return Err(Error::RepeatedClientQueryUnsupported { + version: version.to_string(), + minimum: REPEATED_QUERY_MIN_VERSION, + }); + } + Ok(()) +} + fn resolve_direct_client_version(version_spec: Option) -> Result { if let Some(version_spec) = version_spec { let spec = version_spec.into_spec(); @@ -1104,6 +1121,17 @@ fn stop_all_servers_global(json: bool) -> Result<()> { mod tests { use super::*; + #[test] + fn repeated_query_support_matches_the_native_client_contract() { + // ClickHouse introduced repeatable --query in v23.9.1.1854. The pinned + // release evidence is linked from README.md; these tests need no live binary. + assert!(ensure_repeated_query_supported("23.8.1.2992", 1).is_ok()); + assert!(ensure_repeated_query_supported("23.9.1.1853", 2).is_err()); + assert!(ensure_repeated_query_supported(REPEATED_QUERY_MIN_VERSION, 2).is_ok()); + assert!(ensure_repeated_query_supported("25.12.9.61", 3).is_ok()); + assert!(ensure_repeated_query_supported("26.8.1.1760", usize::MAX).is_ok()); + } + fn server_info(name: &str, engine: server::Engine, version: &str) -> server::ServerInfo { server::ServerInfo { name: name.to_string(), diff --git a/crates/clickhousectl/tests/local_client_selectors_test.rs b/crates/clickhousectl/tests/local_client_selectors_test.rs index 8222faf7..b3a6fb11 100644 --- a/crates/clickhousectl/tests/local_client_selectors_test.rs +++ b/crates/clickhousectl/tests/local_client_selectors_test.rs @@ -6,6 +6,7 @@ use std::process::{Command, Output}; const VERSION_A: &str = "25.12.9.61"; const VERSION_B: &str = "26.8.1.1760"; +const VERSION_BEFORE_REPEATED_QUERY: &str = "23.8.1.2992"; const MISSING_VERSION: &str = "27.1.2.3"; fn clickhousectl_binary() -> PathBuf { @@ -184,6 +185,186 @@ fn clickhouse_direct_default_and_single_installed_selection_reach_fake_child() { assert_default(home.path(), None); } +#[test] +fn clickhouse_client_preserves_minimal_query_and_query_file_argv() { + let project = tempfile::tempdir().expect("create project tempdir"); + let home = tempfile::tempdir().expect("create home tempdir"); + install_fake_clickhouse(home.path(), VERSION_B); + + assert_clickhouse_child( + run( + project.path(), + home.path(), + None, + &["local", "client", "--host", "remote", "--query", "SELECT 1"], + ), + VERSION_B, + &[ + "client", "--host", "remote", "--port", "9000", "--query", "SELECT 1", + ], + ); + + assert_clickhouse_child( + run( + project.path(), + home.path(), + None, + &[ + "local", + "client", + "--host", + "remote", + "--queries-file", + "queries.sql", + ], + ), + VERSION_B, + &[ + "client", + "--host", + "remote", + "--port", + "9000", + "--queries-file", + "queries.sql", + ], + ); +} + +#[test] +fn clickhouse_client_preserves_repeated_query_order_empty_values_and_passthrough() { + let project = tempfile::tempdir().expect("create project tempdir"); + let home = tempfile::tempdir().expect("create home tempdir"); + install_fake_clickhouse(home.path(), VERSION_B); + + assert_clickhouse_child( + run( + project.path(), + home.path(), + None, + &[ + "local", "client", "--host", "remote", "--query", "SELECT 1", "-q", "", "--query", + "SELECT 2", "--", "--query", "SELECT 3", "--format", "CSV", + ], + ), + VERSION_B, + &[ + "client", "--host", "remote", "--port", "9000", "--query", "SELECT 1", "--query", "", + "--query", "SELECT 2", "--query", "SELECT 3", "--format", "CSV", + ], + ); +} + +#[test] +fn clickhouse_client_preserves_repeated_query_file_order_empty_values_and_passthrough() { + let project = tempfile::tempdir().expect("create project tempdir"); + let home = tempfile::tempdir().expect("create home tempdir"); + // Repeated --queries-file already existed before repeated --query landed. + install_fake_clickhouse(home.path(), VERSION_BEFORE_REPEATED_QUERY); + + assert_clickhouse_child( + run( + project.path(), + home.path(), + None, + &[ + "local", + "client", + "--port", + "19000", + "--queries-file", + "schema.sql", + "--queries-file", + "", + "--queries-file", + "seed.sql", + "--", + "--queries-file", + "tail.sql", + "--format", + "CSV", + ], + ), + VERSION_BEFORE_REPEATED_QUERY, + &[ + "client", + "--host", + "localhost", + "--port", + "19000", + "--queries-file", + "schema.sql", + "--queries-file", + "", + "--queries-file", + "seed.sql", + "--queries-file", + "tail.sql", + "--format", + "CSV", + ], + ); +} + +#[test] +fn clickhouse_client_rejects_combined_query_sources_before_the_fake_child_in_every_order() { + let project = tempfile::tempdir().expect("create project tempdir"); + let home = tempfile::tempdir().expect("create home tempdir"); + install_fake_clickhouse(home.path(), VERSION_B); + write_default(home.path(), VERSION_B); + + for inputs in [ + ["--query", "SELECT 1", "--queries-file", "queries.sql"], + ["--queries-file", "queries.sql", "--query", "SELECT 1"], + ] { + let args: Vec<&str> = ["local", "client"].into_iter().chain(inputs).collect(); + let output = run(project.path(), home.path(), None, &args); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_eq!(output.status.code(), Some(2), "stderr: {stderr}"); + assert!(stderr.contains("--query "), "{stderr}"); + assert!(stderr.contains("--queries-file "), "{stderr}"); + assert!(output.stdout.is_empty(), "fake child unexpectedly ran"); + } +} + +#[test] +fn clickhouse_client_checks_native_repeated_query_version_support() { + let project = tempfile::tempdir().expect("create project tempdir"); + let home = tempfile::tempdir().expect("create home tempdir"); + install_fake_clickhouse(home.path(), VERSION_BEFORE_REPEATED_QUERY); + write_default(home.path(), VERSION_BEFORE_REPEATED_QUERY); + + assert_clickhouse_child( + run( + project.path(), + home.path(), + None, + &["local", "client", "--host", "remote", "--query", "SELECT 1"], + ), + VERSION_BEFORE_REPEATED_QUERY, + &[ + "client", "--host", "remote", "--port", "9000", "--query", "SELECT 1", + ], + ); + + let output = run( + project.path(), + home.path(), + None, + &[ + "local", "client", "--host", "remote", "--query", "SELECT 1", "--query", "SELECT 2", + ], + ); + assert_runtime_error( + output, + &[ + "does not support repeated --query values", + VERSION_BEFORE_REPEATED_QUERY, + "23.9.1.1854 or newer", + ], + ); +} + #[test] fn clickhouse_direct_without_version_reports_zero_and_multiple_installs() { let project = tempfile::tempdir().expect("create project tempdir"); From bac054cbedfe5666acec8657db82177c1bfca6c0 Mon Sep 17 00:00:00 2001 From: sdairs Date: Thu, 27 Aug 2026 07:56:38 +0100 Subject: [PATCH 2/2] Restore native query-file syntax --- README.md | 4 +-- crates/clickhousectl/src/local/cli.rs | 30 ++++++++++++++----- .../tests/local_client_selectors_test.rs | 10 ++++--- 3 files changed, 31 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index ac8baa06..2a01674b 100644 --- a/README.md +++ b/README.md @@ -224,7 +224,7 @@ clickhousectl local client --name dev # Connects to "dev" server clickhousectl local client --query "SHOW DATABASES" # Run a query clickhousectl local client --query "SELECT 1" --query "SELECT 2" # Run queries in order clickhousectl local client --queries-file schema.sql # Run queries from a file -clickhousectl local client --queries-file schema.sql --queries-file seed.sql # Run files in order +clickhousectl local client --queries-file schema.sql seed.sql # Run files in order clickhousectl local client --host remote-host --port 9000 # Connect to a specific host/port clickhousectl local client --host remote-host # Direct mode; port defaults to 9000 clickhousectl local client --port 19000 # Direct mode; host defaults to localhost @@ -237,7 +237,7 @@ In direct mode, `--host` and `--port` select the server connection while `--vers Without `--version`, direct mode uses the valid default. If no default exists, zero installed versions is an error, one installed version is used without creating a default, and multiple installed versions require either `--version` or `local use`. A default that names a missing binary is an error; repair it with `local use`, or bypass it for one direct connection with `--version`. -`--query` and `--queries-file` can each be repeated; values, including empty strings, are passed to the native client unchanged and in order. They cannot be combined because the native ClickHouse client rejects that combination, so clickhousectl reports a usage error before resolving a binary. Arguments after `--` are appended after all wrapper-generated arguments. Repeatable `--query` requires ClickHouse 23.9.1.1854 or newer, where [ClickHouse added the native behavior](https://github.com/ClickHouse/ClickHouse/blob/8f9a227de1f530cdbda52c145d41a6b0f1d29961/docs/changelogs/archive/v23.9.1.1854-stable.md); clickhousectl checks the selected client version before execution. +`--query` can be repeated, while each `--queries-file` accepts one or more paths and the flag itself can also be repeated. Values, including empty strings, are passed to the native client unchanged and in order. The two options cannot be combined because the native ClickHouse client rejects that combination, so clickhousectl reports a usage error before resolving a binary. Arguments after `--` are appended after all wrapper-generated arguments. Repeatable `--query` requires ClickHouse 23.9.1.1854 or newer, where [ClickHouse added the native behavior](https://github.com/ClickHouse/ClickHouse/blob/8f9a227de1f530cdbda52c145d41a6b0f1d29961/docs/changelogs/archive/v23.9.1.1854-stable.md); clickhousectl checks the selected client version before execution. ### Creating and managing ClickHouse servers diff --git a/crates/clickhousectl/src/local/cli.rs b/crates/clickhousectl/src/local/cli.rs index ae884a07..d7987172 100644 --- a/crates/clickhousectl/src/local/cli.rs +++ b/crates/clickhousectl/src/local/cli.rs @@ -249,8 +249,9 @@ CONTEXT FOR AGENTS: 2. Explicit host/port: `clickhousectl local client --host myhost --port 9000` — connects to any ClickHouse server directly, bypassing local server lookup. Host-only uses port 9000; port-only connects to localhost. Direct selectors cannot be combined with --name. - --query and --queries-file are repeatable and preserve every value. ClickHouse rejects using the - two options together, so clickhousectl reports that combination as a usage error. + Repeat --query to execute multiple inline queries. --queries-file accepts multiple paths after + one flag and can also be repeated. ClickHouse rejects combining the two options, so clickhousectl + reports that combination as a usage error. Additional clickhouse-client args can be passed after --. Related: `clickhousectl local server start` to start a local server, `clickhousectl local server list` to see servers." )] @@ -275,12 +276,12 @@ CONTEXT FOR AGENTS: #[arg(long, short = 'v', requires = "direct", conflicts_with = "name")] version: Option, - /// Execute a SQL query (repeatable; requires ClickHouse 23.9.1.1854+ when repeated) + /// Execute a SQL query; repeat for multiple queries (requires ClickHouse 23.9.1.1854+) #[arg(long, short, conflicts_with = "queries_file")] query: Vec, - /// Execute queries from a SQL file (repeatable; cannot be combined with --query) - #[arg(long, conflicts_with = "query")] + /// Execute queries from SQL files; accepts multiple paths or repeated flags + #[arg(long, num_args = 1.., conflicts_with = "query")] queries_file: Vec, /// Additional arguments to pass to clickhouse-client @@ -984,10 +985,10 @@ mod tests { "client", "--queries-file", "schema.sql", - "--queries-file", "seed.sql", "--queries-file", "", + "verify.sql", "--", "--queries-file", "tail.sql", @@ -996,7 +997,7 @@ mod tests { panic!("expected ClickHouse client"); }; assert!(query.is_empty()); - assert_eq!(queries_file, ["schema.sql", "seed.sql", ""]); + assert_eq!(queries_file, ["schema.sql", "seed.sql", "", "verify.sql"]); assert_eq!(args, ["--queries-file", "tail.sql"]); } @@ -1064,6 +1065,21 @@ mod tests { } } + #[test] + fn clickhouse_client_help_describes_query_multiplicity_and_exclusion() { + let help = Cli::try_parse_from(["clickhousectl", "local", "client", "--help"]) + .err() + .expect("help should exit through clap") + .to_string(); + + assert!(help.contains("repeat for multiple queries"), "{help}"); + assert!( + help.contains("accepts multiple paths or repeated flags"), + "{help}" + ); + assert!(help.contains("ClickHouse rejects combining"), "{help}"); + } + #[test] fn postgres_client_applies_named_and_direct_selector_validation() { let valid: &[&[&str]] = &[ diff --git a/crates/clickhousectl/tests/local_client_selectors_test.rs b/crates/clickhousectl/tests/local_client_selectors_test.rs index b3a6fb11..d24f0bca 100644 --- a/crates/clickhousectl/tests/local_client_selectors_test.rs +++ b/crates/clickhousectl/tests/local_client_selectors_test.rs @@ -256,7 +256,7 @@ fn clickhouse_client_preserves_repeated_query_order_empty_values_and_passthrough } #[test] -fn clickhouse_client_preserves_repeated_query_file_order_empty_values_and_passthrough() { +fn clickhouse_client_preserves_multi_value_and_repeated_query_file_order() { let project = tempfile::tempdir().expect("create project tempdir"); let home = tempfile::tempdir().expect("create home tempdir"); // Repeated --queries-file already existed before repeated --query landed. @@ -274,10 +274,10 @@ fn clickhouse_client_preserves_repeated_query_file_order_empty_values_and_passth "19000", "--queries-file", "schema.sql", + "seed.sql", "--queries-file", "", - "--queries-file", - "seed.sql", + "verify.sql", "--", "--queries-file", "tail.sql", @@ -295,9 +295,11 @@ fn clickhouse_client_preserves_repeated_query_file_order_empty_values_and_passth "--queries-file", "schema.sql", "--queries-file", + "seed.sql", + "--queries-file", "", "--queries-file", - "seed.sql", + "verify.sql", "--queries-file", "tail.sql", "--format",