Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
129 changes: 122 additions & 7 deletions crates/clickhousectl/src/local/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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."
)]
Expand All @@ -129,13 +131,13 @@ CONTEXT FOR AGENTS:
)]
port: Option<u16>,

/// Execute a SQL query
#[arg(long, short)]
query: Option<String>,
/// Execute a SQL query; repeat for multiple queries
#[arg(long, short, conflicts_with = "queries_file")]
query: Vec<String>,

/// Execute queries from a SQL file
#[arg(long)]
queries_file: Option<String>,
/// Execute queries from SQL files; accepts multiple paths or repeated flags
#[arg(long, num_args = 1.., conflicts_with = "query")]
queries_file: Vec<String>,

/// Additional arguments to pass to clickhouse-client
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
Expand Down Expand Up @@ -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"])
Expand Down
12 changes: 6 additions & 6 deletions crates/clickhousectl/src/local/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -257,8 +257,8 @@ fn run_client(
version: Option<String>,
host: Option<String>,
port: Option<u16>,
query: Option<String>,
queries_file: Option<String>,
query: Vec<String>,
queries_file: Vec<String>,
args: Vec<String>,
) -> Result<()> {
// If --host or --port is set, connect directly (bypass local server lookup).
Expand Down Expand Up @@ -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);
Comment thread
sdairs marked this conversation as resolved.
}

cmd.args(&args);
Expand Down
143 changes: 142 additions & 1 deletion crates/clickhousectl/tests/local_client_selectors_test.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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");
Expand Down