Skip to content

Commit ac9a9c7

Browse files
authored
feat(search): Add indexes and text search commands
1 parent bb0dfb3 commit ac9a9c7

5 files changed

Lines changed: 354 additions & 10 deletions

File tree

src/command.rs

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,46 @@ pub enum Commands {
105105
command: Option<JobsCommands>,
106106
},
107107

108+
/// Manage indexes on a table
109+
Indexes {
110+
/// Workspace ID (defaults to first workspace from login)
111+
#[arg(long, global = true)]
112+
workspace_id: Option<String>,
113+
114+
#[command(subcommand)]
115+
command: IndexesCommands,
116+
},
117+
118+
/// Full-text search across a table column
119+
Search {
120+
/// Search query text
121+
query: String,
122+
123+
/// Table to search (connection.schema.table)
124+
#[arg(long)]
125+
table: String,
126+
127+
/// Column to search
128+
#[arg(long)]
129+
column: String,
130+
131+
/// Columns to display (comma-separated, defaults to all)
132+
#[arg(long)]
133+
select: Option<String>,
134+
135+
/// Maximum number of results
136+
#[arg(long, default_value = "10")]
137+
limit: u32,
138+
139+
/// Workspace ID (defaults to first workspace from login)
140+
#[arg(long)]
141+
workspace_id: Option<String>,
142+
143+
/// Output format
144+
#[arg(long, default_value = "table", value_parser = ["table", "json", "csv"])]
145+
format: String,
146+
},
147+
108148
/// Generate shell completions
109149
Completions {
110150
/// Shell to generate completions for
@@ -139,6 +179,63 @@ pub enum AuthCommands {
139179
Status,
140180
}
141181

182+
#[derive(Subcommand)]
183+
pub enum IndexesCommands {
184+
/// List indexes on a table
185+
List {
186+
/// Connection ID
187+
#[arg(long)]
188+
connection_id: String,
189+
190+
/// Schema name
191+
#[arg(long)]
192+
schema: String,
193+
194+
/// Table name
195+
#[arg(long)]
196+
table: String,
197+
198+
/// Output format
199+
#[arg(long, default_value = "table", value_parser = ["table", "json", "yaml"])]
200+
format: String,
201+
},
202+
203+
/// Create an index on a table
204+
Create {
205+
/// Connection ID
206+
#[arg(long)]
207+
connection_id: String,
208+
209+
/// Schema name
210+
#[arg(long)]
211+
schema: String,
212+
213+
/// Table name
214+
#[arg(long)]
215+
table: String,
216+
217+
/// Index name
218+
#[arg(long)]
219+
name: String,
220+
221+
/// Columns to index (comma-separated)
222+
#[arg(long)]
223+
columns: String,
224+
225+
/// Index type
226+
#[arg(long, default_value = "sorted", value_parser = ["sorted", "bm25", "vector"])]
227+
r#type: String,
228+
229+
/// Distance metric for vector indexes
230+
#[arg(long, value_parser = ["l2", "cosine", "dot"])]
231+
metric: Option<String>,
232+
233+
/// Create as a background job
234+
#[arg(long)]
235+
r#async: bool,
236+
},
237+
}
238+
142239
#[derive(Subcommand)]
143240
pub enum JobsCommands {
144241
/// List background jobs (shows active jobs by default)

src/indexes.rs

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
use crate::config;
2+
use serde::{Deserialize, Serialize};
3+
4+
#[derive(Deserialize, Serialize)]
5+
struct Index {
6+
index_name: String,
7+
index_type: String,
8+
columns: Vec<String>,
9+
metric: Option<String>,
10+
status: String,
11+
created_at: String,
12+
updated_at: String,
13+
}
14+
15+
#[derive(Deserialize)]
16+
struct ListResponse {
17+
indexes: Vec<Index>,
18+
}
19+
20+
pub fn list(
21+
workspace_id: &str,
22+
connection_id: &str,
23+
schema: &str,
24+
table: &str,
25+
format: &str,
26+
) {
27+
let profile_config = match config::load("default") {
28+
Ok(c) => c,
29+
Err(e) => {
30+
eprintln!("{e}");
31+
std::process::exit(1);
32+
}
33+
};
34+
35+
let api_key = match &profile_config.api_key {
36+
Some(key) if key != "PLACEHOLDER" => key.clone(),
37+
_ => {
38+
eprintln!("error: not authenticated. Run 'hotdata auth' to log in.");
39+
std::process::exit(1);
40+
}
41+
};
42+
43+
let url = format!(
44+
"{}/connections/{}/tables/{}/{}/indexes",
45+
profile_config.api_url, connection_id, schema, table
46+
);
47+
let client = reqwest::blocking::Client::new();
48+
49+
let resp = match client
50+
.get(&url)
51+
.header("Authorization", format!("Bearer {api_key}"))
52+
.header("X-Workspace-Id", workspace_id)
53+
.send()
54+
{
55+
Ok(r) => r,
56+
Err(e) => {
57+
eprintln!("error connecting to API: {e}");
58+
std::process::exit(1);
59+
}
60+
};
61+
62+
if !resp.status().is_success() {
63+
use crossterm::style::Stylize;
64+
eprintln!("{}", crate::util::api_error(resp.text().unwrap_or_default()).red());
65+
std::process::exit(1);
66+
}
67+
68+
let body: ListResponse = match resp.json() {
69+
Ok(v) => v,
70+
Err(e) => {
71+
eprintln!("error parsing response: {e}");
72+
std::process::exit(1);
73+
}
74+
};
75+
76+
match format {
77+
"json" => println!("{}", serde_json::to_string_pretty(&body.indexes).unwrap()),
78+
"yaml" => print!("{}", serde_yaml::to_string(&body.indexes).unwrap()),
79+
"table" => {
80+
if body.indexes.is_empty() {
81+
use crossterm::style::Stylize;
82+
eprintln!("{}", "No indexes found.".dark_grey());
83+
} else {
84+
let rows: Vec<Vec<String>> = body.indexes.iter().map(|i| vec![
85+
i.index_name.clone(),
86+
i.index_type.clone(),
87+
i.columns.join(", "),
88+
i.metric.clone().unwrap_or_default(),
89+
i.status.clone(),
90+
crate::util::format_date(&i.created_at),
91+
]).collect();
92+
crate::table::print(&["NAME", "TYPE", "COLUMNS", "METRIC", "STATUS", "CREATED"], &rows);
93+
}
94+
}
95+
_ => unreachable!(),
96+
}
97+
}
98+
99+
pub fn create(
100+
workspace_id: &str,
101+
connection_id: &str,
102+
schema: &str,
103+
table: &str,
104+
name: &str,
105+
columns: &str,
106+
index_type: &str,
107+
metric: Option<&str>,
108+
async_mode: bool,
109+
) {
110+
let profile_config = match config::load("default") {
111+
Ok(c) => c,
112+
Err(e) => {
113+
eprintln!("{e}");
114+
std::process::exit(1);
115+
}
116+
};
117+
118+
let api_key = match &profile_config.api_key {
119+
Some(key) if key != "PLACEHOLDER" => key.clone(),
120+
_ => {
121+
eprintln!("error: not authenticated. Run 'hotdata auth' to log in.");
122+
std::process::exit(1);
123+
}
124+
};
125+
126+
let cols: Vec<&str> = columns.split(',').map(str::trim).collect();
127+
let mut body = serde_json::json!({
128+
"index_name": name,
129+
"columns": cols,
130+
"index_type": index_type,
131+
"async": async_mode,
132+
});
133+
if let Some(m) = metric {
134+
body["metric"] = serde_json::json!(m);
135+
}
136+
137+
let url = format!(
138+
"{}/connections/{}/tables/{}/{}/indexes",
139+
profile_config.api_url, connection_id, schema, table
140+
);
141+
let client = reqwest::blocking::Client::new();
142+
143+
let resp = match client
144+
.post(&url)
145+
.header("Authorization", format!("Bearer {api_key}"))
146+
.header("X-Workspace-Id", workspace_id)
147+
.json(&body)
148+
.send()
149+
{
150+
Ok(r) => r,
151+
Err(e) => {
152+
eprintln!("error connecting to API: {e}");
153+
std::process::exit(1);
154+
}
155+
};
156+
157+
if !resp.status().is_success() {
158+
use crossterm::style::Stylize;
159+
eprintln!("{}", crate::util::api_error(resp.text().unwrap_or_default()).red());
160+
std::process::exit(1);
161+
}
162+
163+
use crossterm::style::Stylize;
164+
if async_mode {
165+
let body: serde_json::Value = resp.json().unwrap_or_default();
166+
let job_id = body["job_id"].as_str().unwrap_or("unknown");
167+
println!("{}", "Index creation submitted.".green());
168+
println!("job_id: {}", job_id);
169+
println!("{}", "Use 'hotdata jobs <job_id>' to check status.".dark_grey());
170+
} else {
171+
println!("{}", "Index created.".green());
172+
}
173+
}

src/jobs.rs

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -174,9 +174,7 @@ pub fn list(
174174

175175
let jobs = if !all && status.is_none() {
176176
// Default: show only active jobs (pending + running)
177-
let mut jobs = fetch_jobs(&client, &api_key, &api_url, workspace_id, job_type, Some("pending"), limit, offset);
178-
jobs.extend(fetch_jobs(&client, &api_key, &api_url, workspace_id, job_type, Some("running"), limit, offset));
179-
jobs
177+
fetch_jobs(&client, &api_key, &api_url, workspace_id, job_type, Some("pending,running"), limit, offset)
180178
} else {
181179
fetch_jobs(&client, &api_key, &api_url, workspace_id, job_type, status, limit, offset)
182180
};

src/main.rs

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ mod config;
44
mod connections;
55
mod connections_new;
66
mod datasets;
7+
mod indexes;
78
mod jobs;
89
mod query;
910
mod results;
@@ -15,7 +16,7 @@ mod workspace;
1516

1617
use anstyle::AnsiColor;
1718
use clap::{Parser, builder::Styles};
18-
use command::{AuthCommands, Commands, ConnectionsCommands, ConnectionsCreateCommands, DatasetsCommands, JobsCommands, ResultsCommands, SkillCommands, TablesCommands, WorkspaceCommands};
19+
use command::{AuthCommands, Commands, ConnectionsCommands, ConnectionsCreateCommands, DatasetsCommands, IndexesCommands, JobsCommands, ResultsCommands, SkillCommands, TablesCommands, WorkspaceCommands};
1920

2021
#[derive(Parser)]
2122
#[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)]
@@ -195,6 +196,33 @@ fn main() {
195196
}
196197
}
197198
}
199+
Commands::Indexes { workspace_id, command } => {
200+
let workspace_id = resolve_workspace(workspace_id);
201+
match command {
202+
IndexesCommands::List { connection_id, schema, table, format } => {
203+
indexes::list(&workspace_id, &connection_id, &schema, &table, &format)
204+
}
205+
IndexesCommands::Create { connection_id, schema, table, name, columns, r#type, metric, r#async } => {
206+
indexes::create(&workspace_id, &connection_id, &schema, &table, &name, &columns, &r#type, metric.as_deref(), r#async)
207+
}
208+
}
209+
}
210+
Commands::Search { query, table, column, select, limit, workspace_id, format } => {
211+
let workspace_id = resolve_workspace(workspace_id);
212+
let columns = match select.as_deref() {
213+
Some(cols) => format!("{}, score", cols),
214+
None => "*".to_string(),
215+
};
216+
let sql = format!(
217+
"SELECT {} FROM bm25_search('{}', '{}', '{}') ORDER BY score DESC LIMIT {}",
218+
columns,
219+
table.replace('\'', "''"),
220+
column.replace('\'', "''"),
221+
query.replace('\'', "''"),
222+
limit,
223+
);
224+
query::execute(&sql, &workspace_id, None, &format)
225+
}
198226
Commands::Completions { shell } => {
199227
use clap::CommandFactory;
200228
use clap_complete::generate;

0 commit comments

Comments
 (0)