diff --git a/Cargo.lock b/Cargo.lock index 23799da..0903652 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -682,6 +682,7 @@ dependencies = [ "serde_json", "serde_yaml", "sha2", + "sqlformat", "tabled", "tar", "tiny_http", @@ -1771,6 +1772,16 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "sqlformat" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0705994df478b895f05b8e290e0d46e53187b26f8d889d37b2a0881234922d94" +dependencies = [ + "unicode_categories", + "winnow", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -2099,6 +2110,12 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "unicode_categories" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e" + [[package]] name = "unsafe-libyaml" version = "0.2.11" @@ -2525,6 +2542,15 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + [[package]] name = "wit-bindgen" version = "0.51.0" diff --git a/Cargo.toml b/Cargo.toml index f94f357..2d508b1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,6 +34,7 @@ nix = { version = "0.29", features = ["fs"] } flate2 = "1" tar = "0.4" semver = "1" +sqlformat = "0.5.0" [package.metadata.release] pre-release-hook = ["git", "cliff", "-o", "CHANGELOG.md", "--tag", "{{version}}" ] diff --git a/README.md b/README.md index 3d30c82..9ce9fd6 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,9 @@ API key priority (lowest to highest): config file → `HOTDATA_API_KEY` env var | `tables` | `list` | List tables and columns | | `datasets` | `list`, `create` | Manage uploaded datasets | | `query` | | Execute a SQL query | +| `queries` | `list`, `create`, `update`, `run` | Manage saved queries | +| `search` | | Full-text search across a table column | +| `indexes` | `list`, `create` | Manage indexes on a table | | `results` | `list` | Retrieve stored query results | | `jobs` | `list` | Manage background jobs | | `skills` | `install`, `status` | Manage the hotdata-cli agent skill | @@ -123,10 +126,12 @@ hotdata datasets list [--workspace-id ] [--limit ] [--offset ] [--form hotdata datasets [--workspace-id ] [--format table|json|yaml] hotdata datasets create --file data.csv [--label "My Dataset"] [--table-name my_dataset] hotdata datasets create --sql "SELECT ..." --label "My Dataset" +hotdata datasets create --url "https://example.com/data.parquet" --label "My Dataset" ``` - Datasets are queryable as `datasets.main.`. -- `--file`, `--sql`, and `--query-id` are mutually exclusive. +- `--file`, `--sql`, `--query-id`, and `--url` are mutually exclusive. +- `--url` imports data directly from a URL (supports csv, json, parquet). - Format is auto-detected from file extension or content. - Piped stdin is supported: `cat data.csv | hotdata datasets create --label "My Dataset"` @@ -139,6 +144,44 @@ hotdata query "" [--workspace-id ] [--connection ] [--fo - Default format is `table`, which prints results with row count and execution time. - Use `--connection` to scope the query to a specific connection. +## Saved Queries + +```sh +hotdata queries list [--limit ] [--offset ] [--format table|json|yaml] +hotdata queries [--format table|json|yaml] +hotdata queries create --name "My Query" --sql "SELECT ..." [--description "..."] [--tags "tag1,tag2"] +hotdata queries update [--name "New Name"] [--sql "SELECT ..."] [--description "..."] [--tags "tag1,tag2"] +hotdata queries run [--format table|json|csv] +``` + +- `list` shows saved queries with name, description, tags, and version. +- View a query by ID to see its formatted and syntax-highlighted SQL. +- `create` requires `--name` and `--sql`. Tags are comma-separated. +- `update` accepts any combination of fields to change. +- `run` executes a saved query and displays results like the `query` command. + +## Search + +```sh +hotdata search "" --table --column [--select ] [--limit ] [--format table|json|csv] +``` + +- Full-text search using BM25 across a table column. +- Requires a BM25 index on the target column (see `indexes create`). +- Results are ordered by relevance score (descending). +- `--select` specifies which columns to return (comma-separated, defaults to all). The `score` column is automatically appended when `--select` is used. + +## Indexes + +```sh +hotdata indexes list --connection-id --schema --table [--workspace-id ] [--format table|json|yaml] +hotdata indexes create --connection-id --schema --table
--name --columns [--type sorted|bm25|vector] [--metric l2|cosine|dot] [--async] +``` + +- `list` shows indexes on a table with name, type, columns, status, and creation date. +- `create` creates an index. Use `--type bm25` for full-text search, `--type vector` for vector search (requires `--metric`). +- `--async` submits index creation as a background job. + ## Results ```sh diff --git a/skills/hotdata-cli/SKILL.md b/skills/hotdata-cli/SKILL.md index 642dd77..49d43a9 100644 --- a/skills/hotdata-cli/SKILL.md +++ b/skills/hotdata-cli/SKILL.md @@ -1,6 +1,6 @@ --- name: hotdata-cli -description: Use this skill when the user wants to run hotdata CLI commands, query the Hotdata API, list workspaces, list connections, create connections, list tables, manage datasets, execute SQL queries, or interact with the hotdata service. Activate when the user says "run hotdata", "query hotdata", "list workspaces", "list connections", "create a connection", "list tables", "list datasets", "create a dataset", "upload a dataset", "execute a query", or asks you to use the hotdata CLI. +description: Use this skill when the user wants to run hotdata CLI commands, query the Hotdata API, list workspaces, list connections, create connections, list tables, manage datasets, execute SQL queries, manage saved queries, search tables, manage indexes, or interact with the hotdata service. Activate when the user says "run hotdata", "query hotdata", "list workspaces", "list connections", "create a connection", "list tables", "list datasets", "create a dataset", "upload a dataset", "execute a query", "search a table", "list indexes", "create an index", "list saved queries", "run a saved query", or asks you to use the hotdata CLI. version: 0.1.5 --- @@ -138,11 +138,13 @@ hotdata datasets [--workspace-id ] [--format table|js hotdata datasets create --label "My Dataset" --file data.csv [--table-name my_dataset] [--workspace-id ] hotdata datasets create --label "My Dataset" --sql "SELECT * FROM ..." [--table-name my_dataset] [--workspace-id ] hotdata datasets create --label "My Dataset" --query-id [--table-name my_dataset] [--workspace-id ] +hotdata datasets create --label "My Dataset" --url "https://example.com/data.parquet" [--table-name my_dataset] [--workspace-id ] ``` - `--file` uploads a local file. Omit to pipe data via stdin: `cat data.csv | hotdata datasets create --label "My Dataset"` - `--sql` creates a dataset from a SQL query result. - `--query-id` creates a dataset from a previously saved query. -- `--file`, `--sql`, and `--query-id` are mutually exclusive. +- `--url` imports data directly from a URL (supports csv, json, parquet). +- `--file`, `--sql`, `--query-id`, and `--url` are mutually exclusive. - Format is auto-detected from file extension (`.csv`, `.json`, `.parquet`) or file content. - `--label` is optional when `--file` is provided — defaults to the filename without extension. Required for `--sql` and `--query-id`. - `--table-name` is optional — derived from the label if omitted. @@ -176,6 +178,41 @@ hotdata results [--workspace-id ] [--format table|json - Query results include a `result-id` in the footer (e.g. `[result-id: rslt...]`). - **Always use this command to retrieve past query results rather than re-running the same query.** Re-running queries wastes resources and may return different results. +### Saved Queries +``` +hotdata queries list [--limit ] [--offset ] [--format table|json|yaml] +hotdata queries [--format table|json|yaml] +hotdata queries create --name "My Query" --sql "SELECT ..." [--description "..."] [--tags "tag1,tag2"] [--format table|json|yaml] +hotdata queries update [--name "New Name"] [--sql "SELECT ..."] [--description "..."] [--tags "tag1,tag2"] [--format table|json|yaml] +hotdata queries run [--format table|json|csv] +``` +- `list` shows saved queries with name, description, tags, and version. +- View a query by ID to see its formatted and syntax-highlighted SQL. +- `create` requires `--name` and `--sql`. Tags are comma-separated. +- `update` accepts any combination of fields to change. +- `run` executes a saved query and displays results like the `query` command. +- **Use `queries run` instead of re-typing SQL when a saved query exists.** + +### Search +``` +hotdata search "" --table --column [--select ] [--limit ] [--format table|json|csv] +``` +- Full-text search using BM25 across a table column. +- Requires a BM25 index on the target column (see `indexes create`). +- Results are ordered by relevance score (descending). +- `--select` specifies which columns to return (comma-separated, defaults to all). The `score` column is automatically appended when `--select` is used. +- Default limit is 10. + +### Indexes +``` +hotdata indexes list --connection-id --schema --table
[--workspace-id ] [--format table|json|yaml] +hotdata indexes create --connection-id --schema --table
--name --columns [--type sorted|bm25|vector] [--metric l2|cosine|dot] [--async] +``` +- `list` shows indexes on a table with name, type, columns, status, and creation date. +- `create` creates an index. Use `--type bm25` for full-text search, `--type vector` for vector search (requires `--metric`). +- `--async` submits index creation as a background job. Use `hotdata jobs ` to check status. +- **Before using `hotdata search`, create a BM25 index on the target column.** + ### Jobs ``` hotdata jobs list [--workspace-id ] [--job-type ] [--status ] [--all] [--format table|json|yaml] diff --git a/src/command.rs b/src/command.rs index a87f95d..486888c 100644 --- a/src/command.rs +++ b/src/command.rs @@ -145,6 +145,19 @@ pub enum Commands { format: String, }, + /// Manage saved queries + Queries { + /// Query ID to show details + id: Option, + + /// Output format (used with query ID) + #[arg(long, default_value = "table", value_parser = ["table", "json", "yaml"])] + format: String, + + #[command(subcommand)] + command: Option, + }, + /// Generate shell completions Completions { /// Shell to generate completions for @@ -306,12 +319,16 @@ pub enum DatasetsCommands { format: String, /// SQL query to create the dataset from - #[arg(long, conflicts_with_all = ["file", "upload_id", "query_id"])] + #[arg(long, conflicts_with_all = ["file", "upload_id", "query_id", "url"])] sql: Option, /// Saved query ID to create the dataset from - #[arg(long, conflicts_with_all = ["file", "upload_id", "sql"])] + #[arg(long, conflicts_with_all = ["file", "upload_id", "sql", "url"])] query_id: Option, + + /// URL to import data from + #[arg(long, conflicts_with_all = ["file", "upload_id", "sql", "query_id"])] + url: Option, }, } @@ -503,6 +520,91 @@ pub enum ResultsCommands { }, } +#[derive(Subcommand)] +pub enum QueriesCommands { + /// List saved queries + List { + /// Maximum number of results + #[arg(long)] + limit: Option, + + /// Pagination offset + #[arg(long)] + offset: Option, + + /// Output format + #[arg(long, default_value = "table", value_parser = ["table", "json", "yaml"])] + format: String, + }, + + /// Create a new saved query + Create { + /// Query name + #[arg(long)] + name: String, + + /// SQL query string + #[arg(long)] + sql: String, + + /// Query description + #[arg(long)] + description: Option, + + /// Comma-separated tags + #[arg(long)] + tags: Option, + + /// Output format + #[arg(long, default_value = "table", value_parser = ["table", "json", "yaml"])] + format: String, + }, + + /// Execute a saved query + Run { + /// Saved query ID + id: String, + + /// Output format + #[arg(long, default_value = "table", value_parser = ["table", "json", "csv"])] + format: String, + }, + + /// Update a saved query + Update { + /// Saved query ID + id: String, + + /// New query name + #[arg(long)] + name: Option, + + /// New SQL query string + #[arg(long)] + sql: Option, + + /// New description + #[arg(long)] + description: Option, + + /// Comma-separated tags + #[arg(long)] + tags: Option, + + /// Override the auto-detected category (pass empty string to clear) + #[arg(long)] + category: Option, + + /// User annotation for table size (pass empty string to clear) + #[arg(long)] + table_size: Option, + + /// Output format + #[arg(long, default_value = "table", value_parser = ["table", "json", "yaml"])] + format: String, + }, +} + #[derive(Subcommand)] pub enum TablesCommands { /// List all tables in a workspace diff --git a/src/datasets.rs b/src/datasets.rs index 205e893..e4b69b8 100644 --- a/src/datasets.rs +++ b/src/datasets.rs @@ -388,6 +388,22 @@ pub fn create_from_upload( create_dataset(workspace_id, label, table_name, source, on_failure); } +pub fn create_from_url( + workspace_id: &str, + url: &str, + label: Option<&str>, + table_name: Option<&str>, +) { + let label = match label { + Some(l) => l, + None => { + eprintln!("error: --label is required when using --url"); + std::process::exit(1); + } + }; + create_dataset(workspace_id, label, table_name, json!({ "Url": { "url": url } }), None); +} + pub fn create_from_query( workspace_id: &str, sql: &str, diff --git a/src/main.rs b/src/main.rs index 56ca4f5..9d4eb65 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,6 +6,7 @@ mod connections_new; mod datasets; mod indexes; mod jobs; +mod queries; mod query; mod results; mod skill; @@ -16,7 +17,7 @@ mod workspace; use anstyle::AnsiColor; use clap::{Parser, builder::Styles}; -use command::{AuthCommands, Commands, ConnectionsCommands, ConnectionsCreateCommands, DatasetsCommands, IndexesCommands, JobsCommands, ResultsCommands, SkillCommands, TablesCommands, WorkspaceCommands}; +use command::{AuthCommands, Commands, ConnectionsCommands, ConnectionsCreateCommands, DatasetsCommands, IndexesCommands, JobsCommands, QueriesCommands, ResultsCommands, SkillCommands, TablesCommands, WorkspaceCommands}; #[derive(Parser)] #[command(name = "hotdata", version, about = concat!("Hotdata CLI - Command line interface for Hotdata (v", env!("CARGO_PKG_VERSION"), ")"), long_about = None, disable_version_flag = true)] @@ -79,11 +80,13 @@ fn main() { Some(DatasetsCommands::List { limit, offset, format }) => { datasets::list(&workspace_id, limit, offset, &format) } - Some(DatasetsCommands::Create { label, table_name, file, upload_id, format, sql, query_id }) => { + Some(DatasetsCommands::Create { label, table_name, file, upload_id, format, sql, query_id, url }) => { if let Some(sql) = sql { datasets::create_from_query(&workspace_id, &sql, label.as_deref(), table_name.as_deref()) } else if let Some(query_id) = query_id { datasets::create_from_saved_query(&workspace_id, &query_id, label.as_deref(), table_name.as_deref()) + } else if let Some(url) = url { + datasets::create_from_url(&workspace_id, &url, label.as_deref(), table_name.as_deref()) } else { datasets::create_from_upload(&workspace_id, label.as_deref(), table_name.as_deref(), file.as_deref(), upload_id.as_deref(), &format) } @@ -223,6 +226,33 @@ fn main() { ); query::execute(&sql, &workspace_id, None, &format) } + Commands::Queries { id, format, command } => { + let workspace_id = resolve_workspace(None); + if let Some(id) = id { + queries::get(&id, &workspace_id, &format) + } else { + match command { + Some(QueriesCommands::List { limit, offset, format }) => { + queries::list(&workspace_id, limit, offset, &format) + } + Some(QueriesCommands::Run { id, format }) => { + queries::run(&id, &workspace_id, &format) + } + Some(QueriesCommands::Create { name, sql, description, tags, format }) => { + queries::create(&workspace_id, &name, &sql, description.as_deref(), tags.as_deref(), &format) + } + Some(QueriesCommands::Update { id, name, sql, description, tags, category, table_size, format }) => { + queries::update(&workspace_id, &id, name.as_deref(), sql.as_deref(), description.as_deref(), tags.as_deref(), category.as_deref(), table_size.as_deref(), &format) + } + None => { + use clap::CommandFactory; + let mut cmd = Cli::command(); + cmd.build(); + cmd.find_subcommand_mut("queries").unwrap().print_help().unwrap(); + } + } + } + } Commands::Completions { shell } => { use clap::CommandFactory; use clap_complete::generate; diff --git a/src/queries.rs b/src/queries.rs new file mode 100644 index 0000000..2e7a81d --- /dev/null +++ b/src/queries.rs @@ -0,0 +1,517 @@ +use crate::config; +use crossterm::style::Stylize; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +const SQL_KEYWORDS: &[&str] = &[ + "SELECT", "FROM", "WHERE", "AND", "OR", "NOT", "IN", "IS", "NULL", "AS", + "ON", "JOIN", "LEFT", "RIGHT", "INNER", "OUTER", "FULL", "CROSS", + "ORDER", "BY", "GROUP", "HAVING", "LIMIT", "OFFSET", "UNION", "ALL", + "INSERT", "INTO", "VALUES", "UPDATE", "SET", "DELETE", "CREATE", "DROP", + "ALTER", "TABLE", "INDEX", "VIEW", "WITH", "DISTINCT", "BETWEEN", "LIKE", + "CASE", "WHEN", "THEN", "ELSE", "END", "EXISTS", "ASC", "DESC", "TRUE", "FALSE", + "COUNT", "SUM", "AVG", "MIN", "MAX", "CAST", "COALESCE", "NULLIF", +]; + +fn highlight_sql(sql: &str) -> String { + let mut result = String::with_capacity(sql.len() * 2); + let chars: Vec = sql.chars().collect(); + let len = chars.len(); + let mut i = 0; + + while i < len { + let ch = chars[i]; + + // Single-line comment + if ch == '-' && i + 1 < len && chars[i + 1] == '-' { + let start = i; + while i < len && chars[i] != '\n' { + i += 1; + } + let comment: String = chars[start..i].iter().collect(); + result.push_str(&comment.dark_grey().to_string()); + continue; + } + + // Block comment + if ch == '/' && i + 1 < len && chars[i + 1] == '*' { + let start = i; + i += 2; + while i + 1 < len && !(chars[i] == '*' && chars[i + 1] == '/') { + i += 1; + } + if i + 1 < len { i += 2; } + let comment: String = chars[start..i].iter().collect(); + result.push_str(&comment.dark_grey().to_string()); + continue; + } + + // String literal (handles '' escaped quotes in SQL) + if ch == '\'' { + let start = i; + i += 1; + loop { + if i >= len { break; } + if chars[i] == '\'' { + i += 1; + // '' is an escaped quote, continue the string + if i < len && chars[i] == '\'' { + i += 1; + } else { + break; + } + } else { + i += 1; + } + } + let s: String = chars[start..i].iter().collect(); + result.push_str(&s.yellow().to_string()); + continue; + } + + // Number + if ch.is_ascii_digit() || (ch == '.' && i + 1 < len && chars[i + 1].is_ascii_digit()) { + let start = i; + while i < len && (chars[i].is_ascii_digit() || chars[i] == '.') { + i += 1; + } + // Don't highlight if it's part of an identifier + if start > 0 && (chars[start - 1].is_alphanumeric() || chars[start - 1] == '_') { + let s: String = chars[start..i].iter().collect(); + result.push_str(&s); + } else { + let s: String = chars[start..i].iter().collect(); + result.push_str(&s.cyan().to_string()); + } + continue; + } + + // Word (keyword or identifier) + if ch.is_alphanumeric() || ch == '_' { + let start = i; + while i < len && (chars[i].is_alphanumeric() || chars[i] == '_') { + i += 1; + } + let word: String = chars[start..i].iter().collect(); + if SQL_KEYWORDS.contains(&word.to_uppercase().as_str()) { + result.push_str(&word.blue().to_string()); + } else { + result.push_str(&word); + } + continue; + } + + result.push(ch); + i += 1; + } + + result +} + +#[derive(Deserialize, Serialize)] +struct SavedQuery { + id: String, + name: String, + description: String, + tags: Vec, + latest_version: u64, + created_at: String, + updated_at: String, +} + +#[derive(Deserialize, Serialize)] +struct SavedQueryDetail { + id: String, + name: String, + description: String, + sql: String, + sql_hash: String, + tags: Vec, + latest_version: u64, + #[serde(default)] + category: Value, + #[serde(default)] + has_aggregation: Value, + #[serde(default)] + has_group_by: Value, + #[serde(default)] + has_join: Value, + #[serde(default)] + has_limit: Value, + #[serde(default)] + has_order_by: Value, + #[serde(default)] + has_predicate: Value, + #[serde(default)] + num_tables: Value, + #[serde(default)] + table_size: Value, + created_at: String, + updated_at: String, +} + +#[derive(Deserialize)] +struct ListResponse { + queries: Vec, + count: u64, + has_more: bool, +} + +pub fn list(workspace_id: &str, limit: Option, offset: Option, format: &str) { + let profile_config = match config::load("default") { + Ok(c) => c, + Err(e) => { + eprintln!("{e}"); + std::process::exit(1); + } + }; + + let api_key = match &profile_config.api_key { + Some(key) if key != "PLACEHOLDER" => key.clone(), + _ => { + eprintln!("error: not authenticated. Run 'hotdata auth' to log in."); + std::process::exit(1); + } + }; + + let mut url = format!("{}/queries", profile_config.api_url); + let mut params = vec![]; + if let Some(l) = limit { params.push(format!("limit={l}")); } + if let Some(o) = offset { params.push(format!("offset={o}")); } + if !params.is_empty() { url = format!("{url}?{}", params.join("&")); } + + let client = reqwest::blocking::Client::new(); + let resp = match client + .get(&url) + .header("Authorization", format!("Bearer {api_key}")) + .header("X-Workspace-Id", workspace_id) + .send() + { + Ok(r) => r, + Err(e) => { + eprintln!("error connecting to API: {e}"); + std::process::exit(1); + } + }; + + if !resp.status().is_success() { + use crossterm::style::Stylize; + eprintln!("{}", crate::util::api_error(resp.text().unwrap_or_default()).red()); + std::process::exit(1); + } + + let body: ListResponse = match resp.json() { + Ok(v) => v, + Err(e) => { + eprintln!("error parsing response: {e}"); + std::process::exit(1); + } + }; + + match format { + "json" => println!("{}", serde_json::to_string_pretty(&body.queries).unwrap()), + "yaml" => print!("{}", serde_yaml::to_string(&body.queries).unwrap()), + "table" => { + if body.queries.is_empty() { + use crossterm::style::Stylize; + eprintln!("{}", "No saved queries found.".dark_grey()); + } else { + let rows: Vec> = body.queries.iter().map(|q| vec![ + q.id.clone(), + q.name.clone(), + q.description.clone(), + q.tags.join(", "), + q.latest_version.to_string(), + crate::util::format_date(&q.updated_at), + ]).collect(); + crate::table::print(&["ID", "NAME", "DESCRIPTION", "TAGS", "VERSION", "UPDATED"], &rows); + } + if body.has_more { + let next = offset.unwrap_or(0) + body.count as u32; + use crossterm::style::Stylize; + eprintln!("{}", format!("showing {} results — use --offset {next} for more", body.count).dark_grey()); + } + } + _ => unreachable!(), + } +} + +pub fn get(query_id: &str, workspace_id: &str, format: &str) { + let profile_config = match config::load("default") { + Ok(c) => c, + Err(e) => { + eprintln!("{e}"); + std::process::exit(1); + } + }; + + let api_key = match &profile_config.api_key { + Some(key) if key != "PLACEHOLDER" => key.clone(), + _ => { + eprintln!("error: not authenticated. Run 'hotdata auth' to log in."); + std::process::exit(1); + } + }; + + let url = format!("{}/queries/{query_id}", profile_config.api_url); + let client = reqwest::blocking::Client::new(); + + let resp = match client + .get(&url) + .header("Authorization", format!("Bearer {api_key}")) + .header("X-Workspace-Id", workspace_id) + .send() + { + Ok(r) => r, + Err(e) => { + eprintln!("error connecting to API: {e}"); + std::process::exit(1); + } + }; + + if !resp.status().is_success() { + use crossterm::style::Stylize; + eprintln!("{}", crate::util::api_error(resp.text().unwrap_or_default()).red()); + std::process::exit(1); + } + + let q: SavedQueryDetail = match resp.json() { + Ok(v) => v, + Err(e) => { + eprintln!("error parsing response: {e}"); + std::process::exit(1); + } + }; + + print_detail(&q, format); +} + +fn print_detail(q: &SavedQueryDetail, format: &str) { + match format { + "json" => println!("{}", serde_json::to_string_pretty(q).unwrap()), + "yaml" => print!("{}", serde_yaml::to_string(q).unwrap()), + "table" => { + let label = |l: &str| format!("{:<12}", l).dark_grey().to_string(); + println!("{}{}", label("id:"), q.id); + println!("{}{}", label("name:"), q.name); + println!("{}{}", label("description:"), q.description); + println!("{}{}", label("version:"), q.latest_version); + if !q.tags.is_empty() { + println!("{}{}", label("tags:"), q.tags.join(", ")); + } + println!("{}{}", label("created:"), crate::util::format_date(&q.created_at)); + println!("{}{}", label("updated:"), crate::util::format_date(&q.updated_at)); + println!(); + println!("{}", "SQL:".dark_grey()); + let formatted = sqlformat::format( + &q.sql, + &sqlformat::QueryParams::None, + &sqlformat::FormatOptions { + indent: sqlformat::Indent::Spaces(2), + uppercase: Some(true), + lines_between_queries: 1, + ..Default::default() + }, + ); + println!("{}", highlight_sql(&formatted)); + } + _ => unreachable!(), + } +} + +fn parse_tags(tags: Option<&str>) -> Option> { + tags.map(|t| t.split(',').map(str::trim).collect()) +} + +pub fn create( + workspace_id: &str, + name: &str, + sql: &str, + description: Option<&str>, + tags: Option<&str>, + format: &str, +) { + let profile_config = match config::load("default") { + Ok(c) => c, + Err(e) => { + eprintln!("{e}"); + std::process::exit(1); + } + }; + + let api_key = match &profile_config.api_key { + Some(key) if key != "PLACEHOLDER" => key.clone(), + _ => { + eprintln!("error: not authenticated. Run 'hotdata auth' to log in."); + std::process::exit(1); + } + }; + + let mut body = serde_json::json!({ "name": name, "sql": sql }); + if let Some(d) = description { body["description"] = serde_json::json!(d); } + if let Some(tags) = parse_tags(tags) { body["tags"] = serde_json::json!(tags); } + + let url = format!("{}/queries", profile_config.api_url); + let client = reqwest::blocking::Client::new(); + + let resp = match client + .post(&url) + .header("Authorization", format!("Bearer {api_key}")) + .header("X-Workspace-Id", workspace_id) + .json(&body) + .send() + { + Ok(r) => r, + Err(e) => { + eprintln!("error connecting to API: {e}"); + std::process::exit(1); + } + }; + + if !resp.status().is_success() { + eprintln!("{}", crate::util::api_error(resp.text().unwrap_or_default()).red()); + std::process::exit(1); + } + + let q: SavedQueryDetail = match resp.json() { + Ok(v) => v, + Err(e) => { + eprintln!("error parsing response: {e}"); + std::process::exit(1); + } + }; + + println!("{}", "Query created".green()); + print_detail(&q, format); +} + +pub fn update( + workspace_id: &str, + id: &str, + name: Option<&str>, + sql: Option<&str>, + description: Option<&str>, + tags: Option<&str>, + category: Option<&str>, + table_size: Option<&str>, + format: &str, +) { + let profile_config = match config::load("default") { + Ok(c) => c, + Err(e) => { + eprintln!("{e}"); + std::process::exit(1); + } + }; + + let api_key = match &profile_config.api_key { + Some(key) if key != "PLACEHOLDER" => key.clone(), + _ => { + eprintln!("error: not authenticated. Run 'hotdata auth' to log in."); + std::process::exit(1); + } + }; + + if name.is_none() && sql.is_none() && description.is_none() && tags.is_none() && category.is_none() && table_size.is_none() { + eprintln!("error: no fields to update. Provide at least one of --name, --sql, --description, --tags, --category, or --table-size."); + std::process::exit(1); + } + + let mut body = serde_json::json!({}); + if let Some(n) = name { body["name"] = serde_json::json!(n); } + if let Some(s) = sql { body["sql"] = serde_json::json!(s); } + if let Some(d) = description { body["description"] = serde_json::json!(d); } + if let Some(tags) = parse_tags(tags) { body["tags"] = serde_json::json!(tags); } + match category { + Some("") => { body["category_override"] = serde_json::json!(null); } + Some(c) => { body["category_override"] = serde_json::json!(c); } + None => {} + } + match table_size { + Some("") => { body["table_size_override"] = serde_json::json!(null); } + Some(ts) => { body["table_size_override"] = serde_json::json!(ts); } + None => {} + } + + let url = format!("{}/queries/{id}", profile_config.api_url); + let client = reqwest::blocking::Client::new(); + + let resp = match client + .put(&url) + .header("Authorization", format!("Bearer {api_key}")) + .header("X-Workspace-Id", workspace_id) + .json(&body) + .send() + { + Ok(r) => r, + Err(e) => { + eprintln!("error connecting to API: {e}"); + std::process::exit(1); + } + }; + + if !resp.status().is_success() { + eprintln!("{}", crate::util::api_error(resp.text().unwrap_or_default()).red()); + std::process::exit(1); + } + + let q: SavedQueryDetail = match resp.json() { + Ok(v) => v, + Err(e) => { + eprintln!("error parsing response: {e}"); + std::process::exit(1); + } + }; + + println!("{}", "Query updated".green()); + print_detail(&q, format); +} + +pub fn run(query_id: &str, workspace_id: &str, format: &str) { + let profile_config = match config::load("default") { + Ok(c) => c, + Err(e) => { + eprintln!("{e}"); + std::process::exit(1); + } + }; + + let api_key = match &profile_config.api_key { + Some(key) if key != "PLACEHOLDER" => key.clone(), + _ => { + eprintln!("error: not authenticated. Run 'hotdata auth' to log in."); + std::process::exit(1); + } + }; + + let url = format!("{}/queries/{query_id}/execute", profile_config.api_url); + let client = reqwest::blocking::Client::new(); + + let resp = match client + .post(&url) + .header("Authorization", format!("Bearer {api_key}")) + .header("X-Workspace-Id", workspace_id) + .send() + { + Ok(r) => r, + Err(e) => { + eprintln!("error connecting to API: {e}"); + std::process::exit(1); + } + }; + + if !resp.status().is_success() { + eprintln!("{}", crate::util::api_error(resp.text().unwrap_or_default()).red()); + std::process::exit(1); + } + + let result: crate::query::QueryResponse = match resp.json() { + Ok(v) => v, + Err(e) => { + eprintln!("error parsing response: {e}"); + std::process::exit(1); + } + }; + + crate::query::print_result(&result, format); +} diff --git a/src/query.rs b/src/query.rs index 7f63c05..da9298e 100644 --- a/src/query.rs +++ b/src/query.rs @@ -3,13 +3,13 @@ use serde::Deserialize; use serde_json::Value; #[derive(Deserialize)] -struct QueryResponse { - result_id: Option, - columns: Vec, - rows: Vec>, - row_count: u64, - execution_time_ms: u64, - warning: Option, +pub struct QueryResponse { + pub result_id: Option, + pub columns: Vec, + pub rows: Vec>, + pub row_count: u64, + pub execution_time_ms: u64, + pub warning: Option, } fn value_to_string(v: &Value) -> String { @@ -81,6 +81,10 @@ pub fn execute(sql: &str, workspace_id: &str, connection: Option<&str>, format: } }; + print_result(&result, format); +} + +pub fn print_result(result: &QueryResponse, format: &str) { if let Some(ref warning) = result.warning { eprintln!("warning: {warning}"); } diff --git a/src/table.rs b/src/table.rs index 448f430..7dc4306 100644 --- a/src/table.rs +++ b/src/table.rs @@ -75,11 +75,12 @@ pub fn print(headers: &[&str], rows: &[Vec]) { } /// Print a table with JSON-typed data. Numbers, bools, and nulls get per-cell coloring. -/// Uses simple word-wrapping without ID column priority (for user-generated query results). +/// Uses fair column width distribution (for user-generated query results). pub fn print_json(headers: &[String], rows: &[Vec]) { use tabled::settings::object::Cell; let tw = term_width(); + let ncols = headers.len(); let mut builder = tabled::builder::Builder::new(); builder.push_record(headers.iter().map(|h| h.to_string())); @@ -87,6 +88,8 @@ pub fn print_json(headers: &[String], rows: &[Vec]) { // Track cells that need coloring: (row_index, col_index, color) let mut colored_cells: Vec<(usize, usize, Color)> = Vec::new(); + let mut string_rows: Vec> = Vec::with_capacity(rows.len()); + for (ri, row) in rows.iter().enumerate() { let string_row: Vec = row .iter() @@ -109,13 +112,22 @@ pub fn print_json(headers: &[String], rows: &[Vec]) { } }) .collect(); - builder.push_record(string_row); + builder.push_record(&string_row); + string_rows.push(string_row); } + // Calculate fair column widths: each column gets its natural width capped + // at a fair share, then surplus space is redistributed to columns that need more. + let col_widths = fair_column_widths(headers, &string_rows, ncols, tw); + let mut table = builder.build(); + table.with(Style::modern_rounded()); + + for (i, &w) in col_widths.iter().enumerate() { + table.with(Modify::new(Columns::new(i..=i)).with(Width::wrap(w))); + } + table - .with(Style::modern_rounded()) - .with(Width::wrap(tw).keep_words(true)) .with(Modify::new(Segment::all()).with(BorderColor::filled(Color::FG_BRIGHT_BLACK))) .with(Modify::new(Rows::first()).with(Color::FG_GREEN)); @@ -125,3 +137,63 @@ pub fn print_json(headers: &[String], rows: &[Vec]) { println!("{table}"); } + +/// Distribute terminal width fairly across columns. +/// Each column gets at least its natural width (header or content), up to +/// an equal share. Surplus from narrow columns is redistributed to wider ones. +fn fair_column_widths(headers: &[String], rows: &[Vec], ncols: usize, tw: usize) -> Vec { + if ncols == 0 { return vec![]; } + + // borders + padding: 1 left border + (3 per column: pad+border) => ncols*3 + 1 + let overhead = ncols * 3 + 1; + let available = tw.saturating_sub(overhead); + + // Natural width based on content, with header allowed to add up to 3 extra chars + let natural: Vec = (0..ncols).map(|i| { + let content_w = rows.iter() + .filter_map(|r| r.get(i)) + .map(|s| s.len()) + .max() + .unwrap_or(1); + let header_w = headers.get(i).map(|h| h.len()).unwrap_or(0); + let header_cap = content_w + 3; + content_w.max(header_w.min(header_cap)) + }).collect(); + + // Iteratively distribute: cap at fair share, give surplus to remaining columns + let mut widths = vec![0usize; ncols]; + let mut remaining = available; + let mut unsettled: Vec = (0..ncols).collect(); + + while !unsettled.is_empty() { + let fair_share = remaining / unsettled.len(); + let mut newly_settled = vec![]; + let mut used = 0; + + for &i in &unsettled { + if natural[i] <= fair_share { + widths[i] = natural[i]; + used += natural[i]; + newly_settled.push(i); + } + } + + if newly_settled.is_empty() { + // All remaining columns exceed fair share — give each the fair share + for &i in &unsettled { + widths[i] = fair_share; + } + break; + } + + remaining -= used; + unsettled.retain(|i| !newly_settled.contains(i)); + } + + // Ensure minimum width of 1 + for w in &mut widths { + if *w == 0 { *w = 1; } + } + + widths +}