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
42 changes: 42 additions & 0 deletions .githooks/pre-commit
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
#!/usr/bin/env bash
# Run rustfmt and clippy before each commit (same checks as CI).
#
# Install once per clone:
# sh scripts/install-git-hooks.sh
#
# Bypass for a single commit:
# git commit --no-verify
set -euo pipefail

REPO_ROOT="$(git rev-parse --show-toplevel)"
cd "${REPO_ROOT}"

if [[ -f "${HOME}/.cargo/env" ]]; then
# shellcheck disable=SC1091
source "${HOME}/.cargo/env"
fi

if ! command -v cargo >/dev/null 2>&1; then
echo "pre-commit: cargo not found on PATH (install Rust or source ~/.cargo/env)" >&2
exit 1
fi

CHDB_RUST_PATH="$(dirname "${REPO_ROOT}")/chdb-rust"
if [[ ! -f "${CHDB_RUST_PATH}/Cargo.toml" ]]; then
echo "pre-commit: checking out chdb-rust dependency..."
bash "${REPO_ROOT}/scripts/checkout-chdb-rust.sh"
fi

rustup component add rustfmt clippy >/dev/null 2>&1 || true

echo "pre-commit: cargo fmt --check"
if ! cargo fmt --check; then
echo "pre-commit: formatting failed; run 'cargo fmt' and restage" >&2
exit 1
fi

echo "pre-commit: cargo clippy --all-targets -- -D warnings"
if ! cargo clippy --all-targets -- -D warnings; then
echo "pre-commit: clippy failed; fix warnings before committing" >&2
exit 1
fi
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -113,4 +113,5 @@ Desktop.ini
.cursorrules
.cursor/
hyperbytedb-operator
influx-multiplay
influx-multiplay
chdb-rust
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 6 additions & 4 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ FROM debian:bookworm-slim AS builder
RUN apt-get update && apt-get install -y --no-install-recommends \
curl ca-certificates build-essential pkg-config \
clang llvm-dev libclang-dev \
pkg-config libssl-dev \
pkg-config libssl-dev git \
&& rm -rf /var/lib/apt/lists/*

# Install rustup itself, but not a toolchain — the actual rustc/cargo version
Expand All @@ -26,9 +26,11 @@ RUN curl -sL https://lib.chdb.io | bash
# docker build -f hyperbytedb/Dockerfile <parent>
WORKDIR /build

# chdb-rust path dependency. Copied to /build/chdb-rust so `../../chdb-rust`
# from /build/hyperbytedb/hyperbytedb resolves correctly.
COPY chdb-rust /build/chdb-rust
# chdb-rust path dependency (`../../chdb-rust` from hyperbytedb/hyperbytedb).
# Clone during the image build so `docker compose up` works without a local checkout.
ARG CHDB_RUST_REPO=https://github.com/hyperbyte-cloud/chdb-rust.git
ARG CHDB_RUST_REF=feat_arrow_insert
RUN git clone --depth 1 --branch "${CHDB_RUST_REF}" "${CHDB_RUST_REPO}" /build/chdb-rust

# Pinned toolchain spec must be in place before any cargo invocation so
# rustup auto-installs the right version on first use.
Expand Down
1 change: 1 addition & 0 deletions hyperbytedb-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ serde = { version = "1", features = ["derive"] }
serde_json = "1"
serde_urlencoded = "0.7"
rustyline = { version = "15", features = ["derive"] }
parking_lot = "0.12"
comfy-table = "7"
anyhow = "1"
thiserror = "2"
Expand Down
233 changes: 207 additions & 26 deletions hyperbytedb-cli/src/output/mod.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,31 @@
use comfy_table::{Cell, Table};
use std::collections::HashMap;
use std::io::IsTerminal;

use comfy_table::presets::UTF8_NO_BORDERS;
use comfy_table::{Attribute, Cell, Color, ContentArrangement, Table};
use serde_json::Value;

use crate::client::QueryResponse;
use crate::session::OutputFormat;

struct DisplayStyle {
color: bool,
}

impl DisplayStyle {
fn detect() -> Self {
Self {
color: stdout_supports_color(),
}
}
}

fn stdout_supports_color() -> bool {
std::io::stdout().is_terminal()
&& std::env::var("NO_COLOR").is_err()
&& std::env::var("HYPERBYTEDB_NO_COLOR").is_err()
}

pub fn format_response(response: &QueryResponse, format: OutputFormat, pretty: bool) -> String {
match format {
OutputFormat::Json => format_json(response, pretty),
Expand All @@ -26,52 +48,184 @@ pub fn format_json(response: &QueryResponse, pretty: bool) -> String {
}

pub fn format_column(response: &QueryResponse) -> String {
let style = DisplayStyle::detect();
let mut out = String::new();
for result in &response.results {

for (result_idx, result) in response.results.iter().enumerate() {
if result_idx > 0 {
out.push('\n');
}

if let Some(ref err) = result.error {
out.push_str(&format!("ERR: {err}\n"));
out.push_str(&format_error(err, &style));
continue;
}

let Some(ref series_list) = result.series else {
continue;
};

if series_list.is_empty() {
out.push('\n');
out.push_str(&format_empty_result(&style));
continue;
}
for series in series_list {
if !series.name.is_empty() {
out.push_str(&format!("name: {}\n", series.name));
}
if let Some(ref tags) = series.tags
&& !tags.is_empty()
{
let tag_str: Vec<String> = tags.iter().map(|(k, v)| format!("{k}={v}")).collect();
out.push_str(&format!("tags: {}\n", tag_str.join(", ")));

for (series_idx, series) in series_list.iter().enumerate() {
if series_idx > 0 {
out.push('\n');
}
out.push_str(&format_series_header(
&series.name,
series.tags.as_ref(),
&style,
));
out.push('\n');

let mut table = Table::new();
table.set_header(series.columns.iter().map(|c| c.as_str()));
table
.load_preset(UTF8_NO_BORDERS)
.set_content_arrangement(ContentArrangement::Dynamic);
table.set_header(
series
.columns
.iter()
.map(|column| styled_column_header(column, &style)),
);
for row in &series.values {
table.add_row(row.iter().map(value_cell));
table.add_row(
series
.columns
.iter()
.zip(row.iter())
.map(|(column, value)| value_cell(value, column, &style)),
);
}
out.push_str(&format!("{table}\n"));
out.push_str(&format!("{table}"));

if series.partial == Some(true) {
out.push_str(
"(partial results: response was truncated — increase chunk size or narrow the query)\n",
);
out.push('\n');
out.push_str(&format_partial_notice(&style));
}
}
}

if !out.is_empty() && !out.ends_with('\n') {
out.push('\n');
}
out
}

fn format_series_header(
name: &str,
tags: Option<&HashMap<String, String>>,
style: &DisplayStyle,
) -> String {
let mut out = String::new();
if style.color {
out.push_str("\x1b[1;34m");
}
if name.is_empty() {
out.push('·');
} else {
out.push_str(name);
}
if style.color {
out.push_str("\x1b[0m");
}

if let Some(tags) = tags
&& !tags.is_empty()
{
let mut pairs: Vec<_> = tags.iter().collect();
pairs.sort_by(|(a, _), (b, _)| a.cmp(b));
if style.color {
out.push_str("\x1b[2;36m ");
} else {
out.push_str(" ");
}
let rendered: Vec<String> = pairs
.into_iter()
.map(|(key, value)| {
if style.color {
format!("\x1b[1;36m{key}\x1b[0m\x1b[2;36m={value}\x1b[0m")
} else {
format!("{key}={value}")
}
})
.collect();
out.push_str(&rendered.join(", "));
if style.color {
out.push_str("\x1b[0m");
}
}
out
}

fn value_cell(v: &Value) -> Cell {
match v {
Value::Null => Cell::new(""),
Value::String(s) => Cell::new(s),
Value::Number(n) => Cell::new(n.to_string()),
Value::Bool(b) => Cell::new(b.to_string()),
other => Cell::new(other.to_string()),
fn styled_column_header(column: &str, style: &DisplayStyle) -> Cell {
let mut cell = Cell::new(column);
if style.color {
cell = cell.fg(Color::Cyan).add_attribute(Attribute::Bold);
}
cell
}

fn value_cell(value: &Value, column: &str, style: &DisplayStyle) -> Cell {
let text = value_text(value);
let mut cell = Cell::new(&text);

if !style.color {
return cell;
}

match value {
Value::Null => cell = cell.fg(Color::DarkGrey),
Value::Bool(_) => cell = cell.fg(Color::Yellow),
Value::Number(_) => cell = cell.fg(Color::Green),
Value::String(_) if is_time_column(column) => cell = cell.fg(Color::Magenta),
Value::String(_) => {}
_ => cell = cell.fg(Color::Grey),
}

cell
}

fn is_time_column(column: &str) -> bool {
column.eq_ignore_ascii_case("time") || column.ends_with("_time")
}

fn value_text(value: &Value) -> String {
match value {
Value::Null => String::new(),
Value::String(s) => s.clone(),
Value::Number(n) => n.to_string(),
Value::Bool(b) => b.to_string(),
other => other.to_string(),
}
}

fn format_error(err: &str, style: &DisplayStyle) -> String {
if style.color {
format!("\x1b[1;31m✗ {err}\x1b[0m\n")
} else {
format!("ERR: {err}\n")
}
}

fn format_empty_result(style: &DisplayStyle) -> String {
if style.color {
"\x1b[2m(no results)\x1b[0m\n".to_string()
} else {
"(no results)\n".to_string()
}
}

fn format_partial_notice(style: &DisplayStyle) -> String {
let message =
"(partial results: response was truncated — increase chunk size or narrow the query)";
if style.color {
format!("\x1b[33m{message}\x1b[0m\n")
} else {
format!("{message}\n")
}
}

Expand All @@ -98,6 +252,33 @@ mod tests {
let out = format_column(&resp);
assert!(out.contains("mydb"));
assert!(out.contains("name"));
assert!(!out.contains("││"));
}

#[test]
fn column_format_includes_series_header() {
let mut tags = HashMap::new();
tags.insert("host".to_string(), "srv1".to_string());
let resp = QueryResponse {
results: vec![StatementResult {
statement_id: 1,
series: Some(vec![SeriesResult {
name: "cpu".to_string(),
tags: Some(tags),
columns: vec!["time".to_string(), "value".to_string()],
values: vec![vec![
Value::String("2024-01-01T00:00:00Z".to_string()),
Value::Number(42.into()),
]],
partial: None,
}]),
error: None,
}],
};
let out = format_column(&resp);
assert!(out.contains("cpu"));
assert!(out.contains("host=srv1"));
assert!(out.contains("42"));
}

#[test]
Expand Down
Loading
Loading