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
97 changes: 97 additions & 0 deletions src/command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,46 @@ pub enum Commands {
command: Option<JobsCommands>,
},

/// Manage indexes on a table
Indexes {
/// Workspace ID (defaults to first workspace from login)
#[arg(long, global = true)]
workspace_id: Option<String>,

#[command(subcommand)]
command: IndexesCommands,
},

/// Full-text search across a table column
Search {
/// Search query text
query: String,

/// Table to search (connection.schema.table)
#[arg(long)]
table: String,

/// Column to search
#[arg(long)]
column: String,

/// Columns to display (comma-separated, defaults to all)
#[arg(long)]
select: Option<String>,

/// Maximum number of results
#[arg(long, default_value = "10")]
limit: u32,

/// Workspace ID (defaults to first workspace from login)
#[arg(long)]
workspace_id: Option<String>,

/// Output format
#[arg(long, default_value = "table", value_parser = ["table", "json", "csv"])]
format: String,
},

/// Generate shell completions
Completions {
/// Shell to generate completions for
Expand Down Expand Up @@ -139,6 +179,63 @@ pub enum AuthCommands {
Status,
}

#[derive(Subcommand)]
pub enum IndexesCommands {
/// List indexes on a table
List {
/// Connection ID
#[arg(long)]
connection_id: String,

/// Schema name
#[arg(long)]
schema: String,

/// Table name
#[arg(long)]
table: String,

/// Output format
#[arg(long, default_value = "table", value_parser = ["table", "json", "yaml"])]
format: String,
},

/// Create an index on a table
Create {
/// Connection ID
#[arg(long)]
connection_id: String,

/// Schema name
#[arg(long)]
schema: String,

/// Table name
#[arg(long)]
table: String,

/// Index name
#[arg(long)]
name: String,

/// Columns to index (comma-separated)
#[arg(long)]
columns: String,

/// Index type
#[arg(long, default_value = "sorted", value_parser = ["sorted", "bm25", "vector"])]
r#type: String,

/// Distance metric for vector indexes
#[arg(long, value_parser = ["l2", "cosine", "dot"])]
metric: Option<String>,

/// Create as a background job
#[arg(long)]
r#async: bool,
},
}

#[derive(Subcommand)]
pub enum JobsCommands {
/// List background jobs (shows active jobs by default)
Expand Down
173 changes: 173 additions & 0 deletions src/indexes.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
use crate::config;
use serde::{Deserialize, Serialize};

#[derive(Deserialize, Serialize)]
struct Index {
index_name: String,
index_type: String,
columns: Vec<String>,
metric: Option<String>,
status: String,
created_at: String,
updated_at: String,
}

#[derive(Deserialize)]
struct ListResponse {
indexes: Vec<Index>,
}

pub fn list(
workspace_id: &str,
connection_id: &str,
schema: &str,
table: &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!(
"{}/connections/{}/tables/{}/{}/indexes",
profile_config.api_url, connection_id, schema, table
);
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.indexes).unwrap()),
"yaml" => print!("{}", serde_yaml::to_string(&body.indexes).unwrap()),
"table" => {
if body.indexes.is_empty() {
use crossterm::style::Stylize;
eprintln!("{}", "No indexes found.".dark_grey());
} else {
let rows: Vec<Vec<String>> = body.indexes.iter().map(|i| vec![
i.index_name.clone(),
i.index_type.clone(),
i.columns.join(", "),
i.metric.clone().unwrap_or_default(),
i.status.clone(),
crate::util::format_date(&i.created_at),
]).collect();
crate::table::print(&["NAME", "TYPE", "COLUMNS", "METRIC", "STATUS", "CREATED"], &rows);
}
}
_ => unreachable!(),
}
}

pub fn create(
workspace_id: &str,
connection_id: &str,
schema: &str,
table: &str,
name: &str,
columns: &str,
index_type: &str,
metric: Option<&str>,
async_mode: bool,
) {
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 cols: Vec<&str> = columns.split(',').map(str::trim).collect();
let mut body = serde_json::json!({
"index_name": name,
"columns": cols,
"index_type": index_type,
"async": async_mode,
});
if let Some(m) = metric {
body["metric"] = serde_json::json!(m);
}

let url = format!(
"{}/connections/{}/tables/{}/{}/indexes",
profile_config.api_url, connection_id, schema, table
);
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() {
use crossterm::style::Stylize;
eprintln!("{}", crate::util::api_error(resp.text().unwrap_or_default()).red());
std::process::exit(1);
}

use crossterm::style::Stylize;
if async_mode {
let body: serde_json::Value = resp.json().unwrap_or_default();
let job_id = body["job_id"].as_str().unwrap_or("unknown");
println!("{}", "Index creation submitted.".green());
println!("job_id: {}", job_id);
println!("{}", "Use 'hotdata jobs <job_id>' to check status.".dark_grey());
} else {
println!("{}", "Index created.".green());
}
}
4 changes: 1 addition & 3 deletions src/jobs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -174,9 +174,7 @@ pub fn list(

let jobs = if !all && status.is_none() {
// Default: show only active jobs (pending + running)
let mut jobs = fetch_jobs(&client, &api_key, &api_url, workspace_id, job_type, Some("pending"), limit, offset);
jobs.extend(fetch_jobs(&client, &api_key, &api_url, workspace_id, job_type, Some("running"), limit, offset));
jobs
fetch_jobs(&client, &api_key, &api_url, workspace_id, job_type, Some("pending,running"), limit, offset)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This collapses two explicit API calls (one for "pending", one for "running") into a single call with "pending,running". That's cleaner, but it implicitly depends on the API accepting comma-separated status values. If the API doesn't support that syntax it will silently return an empty or wrong result set rather than erroring.

Worth either confirming the API contract supports this, or adding a note/test that exercises the combined-status path.

} else {
fetch_jobs(&client, &api_key, &api_url, workspace_id, job_type, status, limit, offset)
};
Expand Down
30 changes: 29 additions & 1 deletion src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ mod config;
mod connections;
mod connections_new;
mod datasets;
mod indexes;
mod jobs;
mod query;
mod results;
Expand All @@ -15,7 +16,7 @@ mod workspace;

use anstyle::AnsiColor;
use clap::{Parser, builder::Styles};
use command::{AuthCommands, Commands, ConnectionsCommands, ConnectionsCreateCommands, DatasetsCommands, JobsCommands, ResultsCommands, SkillCommands, TablesCommands, WorkspaceCommands};
use command::{AuthCommands, Commands, ConnectionsCommands, ConnectionsCreateCommands, DatasetsCommands, IndexesCommands, JobsCommands, 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)]
Expand Down Expand Up @@ -195,6 +196,33 @@ fn main() {
}
}
}
Commands::Indexes { workspace_id, command } => {
let workspace_id = resolve_workspace(workspace_id);
match command {
IndexesCommands::List { connection_id, schema, table, format } => {
indexes::list(&workspace_id, &connection_id, &schema, &table, &format)
}
IndexesCommands::Create { connection_id, schema, table, name, columns, r#type, metric, r#async } => {
indexes::create(&workspace_id, &connection_id, &schema, &table, &name, &columns, &r#type, metric.as_deref(), r#async)
}
}
}
Commands::Search { query, table, column, select, limit, workspace_id, format } => {
let workspace_id = resolve_workspace(workspace_id);
let columns = match select.as_deref() {
Some(cols) => format!("{}, score", cols),
Comment on lines +212 to +213
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The columns value built from --select is interpolated directly into the SQL SELECT list without any sanitization, while table, column, and query are all escaped with replace('\'', "''"). Those three are escaped because they're SQL string literals inside bm25_search(...). columns is in the identifier position (SELECT clause) and needs different handling — either validate against a known-safe pattern (e.g., only [a-zA-Z0-9_, ]) or quote each identifier individually.

As-is, --select "1 FROM other_table --" produces:

SELECT 1 FROM other_table --, score FROM bm25_search(...)

None => "*".to_string(),
};
let sql = format!(
"SELECT {} FROM bm25_search('{}', '{}', '{}') ORDER BY score DESC LIMIT {}",
columns,
table.replace('\'', "''"),
column.replace('\'', "''"),
query.replace('\'', "''"),
limit,
);
query::execute(&sql, &workspace_id, None, &format)
}
Commands::Completions { shell } => {
use clap::CommandFactory;
use clap_complete::generate;
Expand Down
Loading
Loading