diff --git a/README.md b/README.md index 78f8f5fb..07962dfa 100644 --- a/README.md +++ b/README.md @@ -222,11 +222,14 @@ 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 --queries-file schema.sql # Run queries from a file +clickhousectl local client --query "SELECT 1" --query "SELECT 2" # Run queries in order +clickhousectl local client --queries-file schema.sql seed.sql # Run query files in order clickhousectl local client --host remote-host --port 9000 # Connect to a specific host/port clickhousectl local client --host remote-host --version 26.8.1.1760 # Use an exact installed client binary ``` +`--query` can be repeated. `--queries-file` accepts multiple paths after one flag and can also be repeated. Their values are forwarded in order. The native client does not allow inline queries and query files in the same invocation, so `local client` rejects that combination as a usage error instead of reordering it. Arguments after `--` are forwarded after these generated query arguments. + Named connections and direct connections select the client binary differently. A named connection uses the version recorded in the managed server's metadata, regardless of the global default. A direct connection (`--host`, `--port`, or both) uses the exact installed version passed with `--version`; if that flag is omitted, it uses the version selected by `local use`. Direct connections never infer a binary from the contents of `~/.clickhouse/versions`. With no valid default, omitting `--version` fails whether zero, one, or multiple versions are installed. Use `local list` to find an exact installed version, then pass it with `--version` or select it globally with `local use`. A missing explicit version and a default that points to a removed version both fail with repair instructions. Direct `--version` selection neither installs a binary nor changes the default. diff --git a/crates/clickhousectl/src/local/cli.rs b/crates/clickhousectl/src/local/cli.rs index 65d1791c..aa66d61d 100644 --- a/crates/clickhousectl/src/local/cli.rs +++ b/crates/clickhousectl/src/local/cli.rs @@ -103,7 +103,9 @@ CONTEXT FOR AGENTS: 2. Direct: pass --host, --port, or both to bypass local server lookup. A missing host defaults to localhost (for example, `local client --port 9000`); a missing port defaults to 9000. Named and direct selectors cannot be combined. - --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. Inline queries and query files cannot be combined, matching + the native client. 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." )] @@ -129,13 +131,13 @@ CONTEXT FOR AGENTS: )] port: Option, - /// Execute a SQL query - #[arg(long, short)] - query: Option, + /// Execute a SQL query; repeat for multiple queries + #[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)] @@ -766,6 +768,119 @@ mod tests { ); } + #[test] + fn clickhouse_client_queries_preserve_empty_values_repeats_and_order() { + let LocalCommands::Client { + query, + queries_file, + args, + .. + } = local_command(&["client"]) + else { + panic!("expected local client command"); + }; + 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", + "", + "--query", + "SELECT 3", + "--", + "--format", + "JSONEachRow", + ]) + else { + panic!("expected local client command"); + }; + assert_eq!(query, ["SELECT 1", "", "SELECT 3"]); + assert!(queries_file.is_empty()); + assert_eq!(args, ["--format", "JSONEachRow"]); + } + + #[test] + fn clickhouse_client_query_files_preserve_empty_values_repeats_and_order() { + let LocalCommands::Client { + query, + queries_file, + args, + .. + } = local_command(&[ + "client", + "--queries-file", + "schema.sql", + "seed.sql", + "--queries-file", + "", + "verify.sql", + "--", + "--echo", + ]) + else { + panic!("expected local client command"); + }; + assert!(query.is_empty()); + assert_eq!(queries_file, ["schema.sql", "seed.sql", "", "verify.sql"]); + assert_eq!(args, ["--echo"]); + } + + #[test] + fn clickhouse_client_rejects_combined_query_sources_in_either_order() { + for args in [ + &[ + "client", + "--query", + "SELECT 1", + "--queries-file", + "queries.sql", + ][..], + &[ + "client", + "--queries-file", + "queries.sql", + "--query", + "SELECT 1", + ][..], + ] { + let error = try_local_command(args) + .err() + .expect("query sources should conflict"); + assert_eq!(error.kind(), clap::error::ErrorKind::ArgumentConflict); + let message = error.to_string(); + assert!(message.contains("--query"), "{message}"); + assert!(message.contains("--queries-file"), "{message}"); + assert!(message.contains("cannot be used with"), "{message}"); + } + } + + #[test] + fn clickhouse_client_query_help_documents_native_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("Inline queries and query files cannot be combined"), + "{help}" + ); + } + #[test] fn parses_remove_without_force() { let LocalCommands::Remove { version, force } = local_command(&["remove", "25.12.5.44"]) diff --git a/crates/clickhousectl/src/local/mod.rs b/crates/clickhousectl/src/local/mod.rs index 7d1cefc3..f67c93a3 100644 --- a/crates/clickhousectl/src/local/mod.rs +++ b/crates/clickhousectl/src/local/mod.rs @@ -257,8 +257,8 @@ fn run_client( version: Option, host: Option, port: 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). @@ -298,12 +298,12 @@ fn run_client( .arg("--port") .arg(tcp_port.to_string()); - if let Some(q) = &query { - cmd.arg("--query").arg(q); + for query in query { + cmd.arg("--query").arg(query); } - if let Some(f) = &queries_file { - cmd.arg("--queries-file").arg(f); + if !queries_file.is_empty() { + cmd.arg("--queries-file").args(queries_file); } cmd.args(&args); diff --git a/crates/clickhousectl/tests/local_client_selectors_test.rs b/crates/clickhousectl/tests/local_client_selectors_test.rs index 43a1d979..5f7be35c 100644 --- a/crates/clickhousectl/tests/local_client_selectors_test.rs +++ b/crates/clickhousectl/tests/local_client_selectors_test.rs @@ -1,4 +1,4 @@ -//! End-to-end coverage for local client selectors (issues #466 and #469). +//! End-to-end coverage for local client selectors and query inputs (issues #466, #469, and #470). use serde_json::json; use std::os::unix::fs::PermissionsExt; @@ -81,6 +81,147 @@ fn assert_client(output: Output, version: &str, expected_args: &[&str]) { assert_eq!(lines, expected); } +#[test] +fn clickhouse_client_preserves_native_query_argv_across_supported_versions() { + let project = tempfile::tempdir().expect("create project tempdir"); + let home = tempfile::tempdir().expect("create home tempdir"); + install_fake_clickhouse(home.path(), VERSION_A); + install_fake_clickhouse(home.path(), VERSION_B); + + let cases = [ + ( + &[][..], + &["client", "--host", "db.example", "--port", "9000"][..], + ), + ( + &["--query", "SELECT 1"][..], + &[ + "client", + "--host", + "db.example", + "--port", + "9000", + "--query", + "SELECT 1", + ][..], + ), + ( + &[ + "--query", + "SELECT 1", + "-q", + "", + "--query", + "SELECT 3", + "--", + "--format", + "JSONEachRow", + ][..], + &[ + "client", + "--host", + "db.example", + "--port", + "9000", + "--query", + "SELECT 1", + "--query", + "", + "--query", + "SELECT 3", + "--format", + "JSONEachRow", + ][..], + ), + ( + &["--queries-file", "schema.sql"][..], + &[ + "client", + "--host", + "db.example", + "--port", + "9000", + "--queries-file", + "schema.sql", + ][..], + ), + ( + &[ + "--queries-file", + "schema.sql", + "seed.sql", + "--queries-file", + "", + "verify.sql", + "--", + "--echo", + ][..], + &[ + "client", + "--host", + "db.example", + "--port", + "9000", + "--queries-file", + "schema.sql", + "seed.sql", + "", + "verify.sql", + "--echo", + ][..], + ), + ]; + + for version in [VERSION_A, VERSION_B] { + for (input, expected) in cases { + let mut args = vec![ + "local", + "client", + "--host", + "db.example", + "--version", + version, + ]; + args.extend_from_slice(input); + assert_client(run(project.path(), home.path(), &args), version, expected); + } + } +} + +#[test] +fn combined_clickhouse_client_query_sources_are_usage_errors_before_exec() { + let project = tempfile::tempdir().expect("create project tempdir"); + let home = tempfile::tempdir().expect("create home tempdir"); + let cases = [ + &[ + "local", + "client", + "--query", + "SELECT 1", + "--queries-file", + "queries.sql", + ][..], + &[ + "local", + "client", + "--queries-file", + "queries.sql", + "--query", + "SELECT 1", + ][..], + ]; + + for args in cases { + let output = run(project.path(), home.path(), args); + assert_eq!(output.status.code(), Some(2), "args: {args:?}"); + assert!(output.stdout.is_empty(), "child must not run: {args:?}"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("--query"), "stderr: {stderr}"); + assert!(stderr.contains("--queries-file"), "stderr: {stderr}"); + assert!(stderr.contains("cannot be used with"), "stderr: {stderr}"); + } +} + #[test] fn clickhouse_direct_client_defaults_missing_host_or_port() { let project = tempfile::tempdir().expect("create project tempdir");