Skip to content

Commit 5135e63

Browse files
eddietejedaclaude
andcommitted
feat(search): infer --type and --column from indexes; default schema to public
Make --type and --column optional in hotdata search. When either is omitted, the CLI fetches the table indexes, filters to searchable types (bm25/vector), and resolves them automatically. Exits with a clear error when the result is ambiguous or no index exists. Accept connection.table as shorthand for connection.public.table in --table; schema defaults to public when omitted. Before: hotdata search query --type bm25 --table airbnb.public.listings --column description --limit 5 After: hotdata search query --table airbnb.listings --limit 5 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 647657d commit 5135e63

3 files changed

Lines changed: 137 additions & 11 deletions

File tree

src/command.rs

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -158,24 +158,28 @@ pub enum Commands {
158158
/// Search query text — required for both --type bm25 and --type vector
159159
query: String,
160160

161-
/// Search type — required (no default; choose deliberately)
161+
/// Search type (`bm25` or `vector`). Inferred automatically when the table has exactly
162+
/// one search index — required only when multiple indexes exist.
162163
///
163164
/// `vector` runs server-side `vector_distance(col, 'text')` — the server resolves the
164165
/// embedding column, model, and metric from the index metadata.
165166
///
166167
/// `bm25` runs server-side `bm25_search(table, col, 'text')` and requires a BM25 index
167168
/// on the column.
168169
#[arg(long, value_parser = ["vector", "bm25"])]
169-
r#type: String,
170+
r#type: Option<String>,
170171

171-
/// Table to search (connection.schema.table)
172+
/// Table to search (`connection.table` or `connection.schema.table`).
173+
/// Schema defaults to `public` when omitted.
172174
#[arg(long)]
173175
table: String,
174176

175-
/// Column to search. For `--type vector`, name the source text column — the server
176-
/// resolves the embedding column from the index metadata.
177+
/// Column to search. Inferred automatically when the table has exactly one search index
178+
/// of the resolved type — required only when multiple indexed columns exist.
179+
/// For `--type vector`, name the source text column — the server resolves the embedding
180+
/// column from the index metadata.
177181
#[arg(long)]
178-
column: String,
182+
column: Option<String>,
179183

180184
/// Columns to display (comma-separated, defaults to all)
181185
#[arg(long)]

src/indexes.rs

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,91 @@ fn list_one_table_scan(
147147
}
148148
}
149149

150+
/// Infers `(index_type, column)` for `hotdata search` when `--type` or `--column` are omitted.
151+
///
152+
/// Fetches the indexes on `connection_name.schema.table`, filters to searchable types
153+
/// (`bm25`, `vector`), and narrows further by `hint_type` / `hint_column` when provided.
154+
/// Exits with an error when the result is ambiguous (multiple matches) or no index exists.
155+
pub fn infer_for_search(
156+
workspace_id: &str,
157+
connection_name: &str,
158+
schema: &str,
159+
table: &str,
160+
hint_type: Option<&str>,
161+
hint_column: Option<&str>,
162+
) -> (String, String) {
163+
use crossterm::style::Stylize;
164+
165+
let api = ApiClient::new(Some(workspace_id));
166+
167+
// Resolve connection name → ID
168+
let conn_map = connection_lookup(&api);
169+
let connection_id = match conn_map.get(connection_name) {
170+
Some(id) => id.clone(),
171+
None => {
172+
eprintln!(
173+
"{}",
174+
format!("Connection '{}' not found.", connection_name).red()
175+
);
176+
std::process::exit(1);
177+
}
178+
};
179+
180+
// Fetch indexes for this table
181+
let indexes = list_one_table(&api, &connection_id, schema, table);
182+
183+
// Filter to searchable indexes, honouring any hints
184+
let matches: Vec<&Index> = indexes
185+
.iter()
186+
.filter(|i| {
187+
let t = i.index_type.as_str();
188+
(t == "bm25" || t == "vector")
189+
&& hint_type.map_or(true, |ht| ht == t)
190+
&& hint_column.map_or(true, |hc| i.columns.iter().any(|c| c == hc))
191+
})
192+
.collect();
193+
194+
match matches.as_slice() {
195+
[] => {
196+
let what = match hint_type {
197+
Some(t) => format!("{} index", t),
198+
None => "BM25 or vector index".to_string(),
199+
};
200+
eprintln!(
201+
"{}",
202+
format!(
203+
"No {} found on {}.{}.{} — run 'hotdata indexes create' first.",
204+
what, connection_name, schema, table
205+
)
206+
.red()
207+
);
208+
std::process::exit(1);
209+
}
210+
[one] => {
211+
let index_type = one.index_type.clone();
212+
let column = one.columns.first().cloned().unwrap_or_default();
213+
(index_type, column)
214+
}
215+
_ => {
216+
let types: Vec<&str> = matches.iter().map(|i| i.index_type.as_str()).collect();
217+
let cols: Vec<String> = matches
218+
.iter()
219+
.flat_map(|i| i.columns.iter().cloned())
220+
.collect();
221+
eprintln!(
222+
"{}",
223+
format!(
224+
"Multiple search indexes found (types: {}, columns: {}) — specify --type and --column.",
225+
types.join(", "),
226+
cols.join(", ")
227+
)
228+
.red()
229+
);
230+
std::process::exit(1);
231+
}
232+
}
233+
}
234+
150235
pub fn list(
151236
workspace_id: &str,
152237
connection_id: Option<&str>,

src/main.rs

Lines changed: 42 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -706,9 +706,46 @@ fn main() {
706706
output,
707707
} => {
708708
let workspace_id = resolve_workspace(workspace_id);
709+
710+
// Parse `connection.table` or `connection.schema.table`.
711+
// Schema defaults to `public` when omitted.
712+
let parts: Vec<&str> = table.splitn(4, '.').collect();
713+
let (conn_name, schema, table_name) = match parts.as_slice() {
714+
[conn, schema, tbl] => {
715+
(conn.to_string(), schema.to_string(), tbl.to_string())
716+
}
717+
[conn, tbl] => (conn.to_string(), "public".to_string(), tbl.to_string()),
718+
_ => {
719+
eprintln!(
720+
"error: --table must be 'connection.table' or 'connection.schema.table'"
721+
);
722+
std::process::exit(1);
723+
}
724+
};
725+
let normalized_table = format!("{}.{}.{}", conn_name, schema, table_name);
726+
727+
// Infer --type and --column from the table's indexes when either is omitted.
728+
let (resolved_type, resolved_column) =
729+
if r#type.is_some() && column.is_some() {
730+
(r#type.unwrap(), column.unwrap())
731+
} else {
732+
let (inferred_type, inferred_column) = indexes::infer_for_search(
733+
&workspace_id,
734+
&conn_name,
735+
&schema,
736+
&table_name,
737+
r#type.as_deref(),
738+
column.as_deref(),
739+
);
740+
(
741+
r#type.unwrap_or(inferred_type),
742+
column.unwrap_or(inferred_column),
743+
)
744+
};
745+
709746
let select_cols = select.as_deref().unwrap_or("*");
710747

711-
let sql = match r#type.as_str() {
748+
let sql = match resolved_type.as_str() {
712749
"bm25" => {
713750
let bm25_columns = match select.as_deref() {
714751
Some(cols) => format!("{}, score", cols),
@@ -717,8 +754,8 @@ fn main() {
717754
format!(
718755
"SELECT {} FROM bm25_search('{}', '{}', '{}') ORDER BY score DESC LIMIT {}",
719756
bm25_columns,
720-
table.replace('\'', "''"),
721-
column.replace('\'', "''"),
757+
normalized_table.replace('\'', "''"),
758+
resolved_column.replace('\'', "''"),
722759
query.replace('\'', "''"),
723760
limit,
724761
)
@@ -728,9 +765,9 @@ fn main() {
728765
"vector" => format!(
729766
"SELECT {}, vector_distance({}, '{}') AS dist FROM {} ORDER BY dist LIMIT {}",
730767
select_cols,
731-
column,
768+
resolved_column,
732769
query.replace('\'', "''"),
733-
table,
770+
normalized_table,
734771
limit,
735772
),
736773
_ => unreachable!(),

0 commit comments

Comments
 (0)