Skip to content
Merged
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
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -226,9 +226,14 @@ clickhousectl local client --queries-file schema.sql # Run queries from a file
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
clickhousectl local client --host remote-host --version 26.8.1.1760 # Use an installed client binary
```

`--name` selects a managed server and cannot be combined with direct `--host` or `--port` selectors.
`--name` selects the connection and local client binary from managed server metadata, so named mode does not need a global default. It cannot be combined with direct `--host` or `--port` selectors, and named mode does not accept `--version`.

In direct mode, `--host` and `--port` select the server connection while `--version` independently selects an already installed local client binary. Numeric selectors such as `26`, `26.8`, and `26.8.1.1760` select the newest installed match. This does not install a binary or change `~/.clickhouse/default`.

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`.

### Creating and managing ClickHouse servers

Expand Down
20 changes: 20 additions & 0 deletions crates/clickhousectl/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,26 @@ pub enum Error {
#[error("No default version set. Run: clickhousectl local use <version>")]
NoDefaultVersion,

#[error(
"No ClickHouse client versions are installed. Run `clickhousectl local install <version>` before making a direct connection."
)]
NoClientVersionInstalled,

#[error(
"Multiple ClickHouse client versions are installed, but no default is set. Pass `--version <version>` (see `clickhousectl local list`) or run `clickhousectl local use <version>`."
)]
AmbiguousClientVersion,

#[error(
"Default ClickHouse version '{0}' is not installed. Repair it with `clickhousectl local use <version>`, or bypass it for this direct connection with `--version <installed-version>`."
)]
StaleDefaultVersion(String),

#[error(
"ClickHouse client version '{0}' is not installed. Run `clickhousectl local install {0}`, or choose an installed version with `clickhousectl local list`."
)]
ClientVersionNotInstalled(String),

#[error("Version {0} is already installed")]
VersionAlreadyInstalled(String),

Expand Down
149 changes: 145 additions & 4 deletions crates/clickhousectl/src/local/cli.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
use crate::version_manager::{self, VersionSpec};
use clap::{Args, Subcommand};
use clap::{ArgGroup, Args, Subcommand};
use std::str::FromStr;

fn parse_server_name_arg(name: &str) -> Result<String, String> {
Expand Down Expand Up @@ -104,6 +104,37 @@ impl FromStr for ServerVersionArg {
}
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ClientVersionArg(VersionSpec);

impl ClientVersionArg {
pub(crate) fn into_spec(self) -> VersionSpec {
self.0
}
}

impl FromStr for ClientVersionArg {
type Err = String;

fn from_str(input: &str) -> Result<Self, Self::Err> {
if input.starts_with("postgres@") || input.starts_with("postgres:") {
return Err(
"Postgres image selectors are only supported by `local install`; `local client --version` requires an installed ClickHouse version"
.to_string(),
);
}
Comment thread
cursor[bot] marked this conversation as resolved.

let spec = version_manager::parse_version_spec(input).map_err(|error| error.to_string())?;
if matches!(spec, VersionSpec::Latest | VersionSpec::Channel(_)) {
return Err(
"`local client --version` selects an installed numeric version (for example, 25.12 or 25.12.9.61); floating selectors latest, stable, and lts are not supported"
.to_string(),
);
}
Ok(Self(spec))
}
}

#[derive(Args)]
pub struct LocalArgs {
/// Output as JSON
Expand Down Expand Up @@ -208,7 +239,9 @@ CONTEXT FOR AGENTS:
Init,

/// Connect to a running ClickHouse server with clickhouse-client
#[command(after_help = "\
#[command(
group(ArgGroup::new("direct").args(["host", "port"]).multiple(true)),
after_help = "\
CONTEXT FOR AGENTS:
Two connection modes:
1. Named server: `clickhousectl local client --name dev` — looks up port and version from a
Expand All @@ -218,7 +251,8 @@ CONTEXT FOR AGENTS:
connects to localhost. Direct selectors cannot be combined with --name.
--query and --queries-file execute SQL inline or from a file.
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.")]
Related: `clickhousectl local server start` to start a local server, `clickhousectl local server list` to see servers."
)]
Client {
/// Server name to connect to (default: "default")
#[arg(long, short, conflicts_with_all = ["host", "port"])]
Expand All @@ -236,6 +270,10 @@ CONTEXT FOR AGENTS:
)]
port: Option<u16>,

/// Installed local client version for direct host/port mode (e.g. 25, 25.12, or 25.12.9.61). Does not change the default.
#[arg(long, short = 'v', requires = "direct", conflicts_with = "name")]
version: Option<ClientVersionArg>,

/// Execute a SQL query
#[arg(long, short)]
query: Option<String>,
Expand Down Expand Up @@ -699,6 +737,10 @@ mod tests {
assert_version_rejected(&["install", "not.a.version"], expected);
assert_version_rejected(&["use", "not.a.version"], expected);
assert_version_rejected(&["server", "start", "--version", "not.a.version"], expected);
assert_version_rejected(
&["client", "--host", "remote", "--version", "not.a.version"],
expected,
);
}

#[test]
Expand All @@ -707,6 +749,10 @@ mod tests {
assert_version_rejected(&["install", "25.12.9"], expected);
assert_version_rejected(&["use", "25.12.9"], expected);
assert_version_rejected(&["server", "start", "--version", "25.12.9"], expected);
assert_version_rejected(
&["client", "--host", "remote", "--version", "25.12.9"],
expected,
);
}

#[test]
Expand All @@ -715,6 +761,10 @@ mod tests {
assert_version_rejected(&["install", "25.12.9.61.2"], expected);
assert_version_rejected(&["use", "25.12.9.61.2"], expected);
assert_version_rejected(&["server", "start", "--version", "25.12.9.61.2"], expected);
assert_version_rejected(
&["client", "--host", "remote", "--version", "25.12.9.61.2"],
expected,
);
}

#[test]
Expand Down Expand Up @@ -742,6 +792,41 @@ mod tests {
&["server", "start", "--version", " postgres@18 "],
"only supported by `local install`; `local server start --version` requires a ClickHouse version",
);
assert_version_rejected(
&["client", "--host", "remote", "--version", "postgres@18"],
"only supported by `local install`; `local client --version` requires an installed ClickHouse version",
);
}

#[test]
fn clickhouse_client_parses_numeric_installed_version_selectors() {
for input in ["25", "25.12", "25.12.9.61"] {
for selectors in [
vec!["--host", "remote", "--version", input],
vec!["--version", input, "--port", "9000"],
] {
let mut args = vec!["client"];
args.extend(selectors);
let LocalCommands::Client {
version: Some(version),
..
} = local_command(&args)
else {
panic!("expected ClickHouse client version {input}");
};
assert_eq!(version.into_spec().to_string(), input);
}
}
}

#[test]
fn clickhouse_client_rejects_floating_binary_versions() {
for input in ["latest", "stable", "lts"] {
assert_version_rejected(
&["client", "--host", "remote", "--version", input],
"selects an installed numeric version",
);
}
}

#[test]
Expand Down Expand Up @@ -818,6 +903,34 @@ mod tests {
}
}

#[test]
fn clickhouse_client_version_requires_direct_mode_and_conflicts_with_named_mode() {
let missing_direct = local_parse_error(&["client", "--version", "25.12.9.61"]);
assert_eq!(
missing_direct.kind(),
clap::error::ErrorKind::MissingRequiredArgument
);
assert!(
missing_direct.to_string().contains("--host"),
"{missing_direct}"
);
assert!(
missing_direct.to_string().contains("--port"),
"{missing_direct}"
);

for selectors in [
["--name", "dev", "--version", "25.12.9.61"],
["--version", "25.12.9.61", "--name", "dev"],
] {
let args: Vec<&str> = ["client"].into_iter().chain(selectors).collect();
let error = local_parse_error(&args);
assert_eq!(error.kind(), clap::error::ErrorKind::ArgumentConflict);
assert!(error.to_string().contains("--version"), "{error}");
assert!(error.to_string().contains("--name"), "{error}");
}
}

#[test]
fn clickhouse_client_rejects_zero_and_nonnumeric_ports() {
for port in ["0", "not-a-port"] {
Expand All @@ -838,12 +951,40 @@ mod tests {
"child-host",
"--port",
"0",
"--version",
"child-version",
]) else {
panic!("expected ClickHouse client");
};

assert_eq!(name.as_deref(), Some("dev"));
assert_eq!(args, ["--host", "child-host", "--port", "0"]);
assert_eq!(
args,
[
"--host",
"child-host",
"--port",
"0",
"--version",
"child-version"
]
);
}

#[test]
fn clickhouse_client_help_describes_binary_version_selection() {
let error = Cli::try_parse_from(["clickhousectl", "local", "client", "--help"])
.err()
.expect("--help should stop parsing");
assert_eq!(error.kind(), clap::error::ErrorKind::DisplayHelp);
let help = error.to_string();

for text in [
"Installed local client version for direct host/port mode",
"Does not change the default",
] {
assert!(help.contains(text), "missing {text:?} in:\n{help}");
}
}

#[test]
Expand Down
30 changes: 27 additions & 3 deletions crates/clickhousectl/src/local/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ pub mod postgres;
pub mod server;
pub mod symlink;

use cli::{InstallVersionArg, LocalCommands, ServerCommands, ServerVersionArg};
use cli::{ClientVersionArg, InstallVersionArg, LocalCommands, ServerCommands, ServerVersionArg};

use crate::error::{Error, Result};
use crate::{init, paths, version_manager};
Expand Down Expand Up @@ -42,10 +42,11 @@ pub async fn run(cmd: LocalCommands, json: bool) -> Result<()> {
name,
host,
port,
version,
query,
queries_file,
args,
} => run_client(name, host, port, query, queries_file, args),
} => run_client(name, host, port, version, query, queries_file, args),
LocalCommands::Server { command } => run_server_commands(command, json).await,
LocalCommands::Postgres { command } => postgres::run(command, json).await,
}
Expand Down Expand Up @@ -259,6 +260,7 @@ fn run_client(
name: Option<String>,
host: Option<String>,
port: Option<u16>,
version_spec: Option<ClientVersionArg>,
query: Option<String>,
queries_file: Option<String>,
args: Vec<String>,
Expand All @@ -268,7 +270,7 @@ fn run_client(
let (resolved_host, tcp_port, version) = if host.is_some() || port.is_some() {
let h = host.unwrap_or_else(|| "localhost".to_string());
let p = port.unwrap_or(9000);
let v = version_manager::get_default_version()?;
let v = resolve_direct_client_version(version_spec)?;
(h, p, v)
} else {
let server_name = name.as_deref().unwrap_or("default");
Expand Down Expand Up @@ -317,6 +319,28 @@ fn run_client(
Err(Error::Exec(err.to_string()))
}

fn resolve_direct_client_version(version_spec: Option<ClientVersionArg>) -> Result<String> {
if let Some(version_spec) = version_spec {
let spec = version_spec.into_spec();
return version_manager::resolve::try_resolve_local(&spec)?
.ok_or_else(|| Error::ClientVersionNotInstalled(spec.to_string()));
}
Comment thread
cursor[bot] marked this conversation as resolved.

match version_manager::get_default_version() {
Ok(version) => Ok(version),
Err(Error::NoDefaultVersion) => {
let installed = version_manager::list_installed_versions()?;
match installed.as_slice() {
[] => Err(Error::NoClientVersionInstalled),
[version] => Ok(version.clone()),
_ => Err(Error::AmbiguousClientVersion),
}
}
Err(Error::VersionNotFound(version)) => Err(Error::StaleDefaultVersion(version)),
Err(error) => Err(error),
}
}

#[allow(clippy::too_many_arguments)]
async fn start_server(
name: Option<String>,
Expand Down
4 changes: 2 additions & 2 deletions crates/clickhousectl/src/version_manager/install.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ pub async fn install_local_first(
platform: &Platform,
force: bool,
) -> Result<String> {
if !force && let Some(local) = try_resolve_local(spec) {
if !force && let Some(local) = try_resolve_local(spec)? {
eprintln!("ClickHouse {} is already installed as {}", spec, local);
eprintln!("Use --force to re-download the latest build");
return Ok(local);
Expand All @@ -40,7 +40,7 @@ pub async fn ensure_installed_local_first(
spec: &VersionSpec,
platform: &Platform,
) -> Result<String> {
if let Some(local) = try_resolve_local(spec) {
if let Some(local) = try_resolve_local(spec)? {
return Ok(local);
}

Expand Down
8 changes: 4 additions & 4 deletions crates/clickhousectl/src/version_manager/resolve.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,12 @@ pub struct ResolvedVersion {
/// Try to satisfy a spec from already-installed versions, without any network call.
/// Returns `None` for floating specs (`Latest`, `Channel(_)`) — those always need the
/// remote — or when no installed version matches.
pub fn try_resolve_local(spec: &VersionSpec) -> Option<String> {
pub fn try_resolve_local(spec: &VersionSpec) -> Result<Option<String>> {
match spec {
VersionSpec::Latest | VersionSpec::Channel(_) => None,
VersionSpec::Latest | VersionSpec::Channel(_) => Ok(None),
_ => {
let installed = list_installed_versions().ok()?;
find_local_match(spec, &installed)
let installed = list_installed_versions()?;
Ok(find_local_match(spec, &installed))
}
}
}
Expand Down
Loading