diff --git a/README.md b/README.md index 7a655ea8..2a01674b 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 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` 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 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..d7987172 100644 --- a/crates/clickhousectl/src/local/cli.rs +++ b/crates/clickhousectl/src/local/cli.rs @@ -249,7 +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 execute SQL inline or from a file. + 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." )] @@ -274,13 +276,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; 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 - #[arg(long)] - queries_file: Option, + /// 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 #[arg(trailing_var_arg = true, allow_hyphen_values = true)] @@ -940,6 +942,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", + "seed.sql", + "--queries-file", + "", + "verify.sql", + "--", + "--queries-file", + "tail.sql", + ]) + else { + panic!("expected ClickHouse client"); + }; + assert!(query.is_empty()); + assert_eq!(queries_file, ["schema.sql", "seed.sql", "", "verify.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(&[ @@ -987,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/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..d24f0bca 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,188 @@ 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_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. + 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", + "seed.sql", + "--queries-file", + "", + "verify.sql", + "--", + "--queries-file", + "tail.sql", + "--format", + "CSV", + ], + ), + VERSION_BEFORE_REPEATED_QUERY, + &[ + "client", + "--host", + "localhost", + "--port", + "19000", + "--queries-file", + "schema.sql", + "--queries-file", + "seed.sql", + "--queries-file", + "", + "--queries-file", + "verify.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");