Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
4 changes: 4 additions & 0 deletions src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,10 @@ impl ApiClient {
}

/// Test-only client (no config load). Used with a local mock HTTP server.
pub fn workspace_id(&self) -> Option<&str> {
self.workspace_id.as_deref()
}
Comment thread
eddietejeda marked this conversation as resolved.
Outdated

/// The refresher returns `None`, so 401s are not retried — matching the
/// behavior of tests that don't exercise the refresh path.
#[cfg(test)]
Expand Down
54 changes: 31 additions & 23 deletions src/command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -329,29 +329,26 @@ pub enum IndexesCommands {
},

/// Create an index on a table or dataset.
///
/// For connection-scoped indexes, pass the table and columns using bracket notation:
/// `connection.table[col1,col2]` or `connection.schema.table[col1,col2]`
/// (schema defaults to `public` when omitted)
///
/// For dataset-scoped indexes, use `--dataset-id` with `--columns`.
Create {
/// Table and columns to index: `connection.table[col1,col2]`
/// or `connection.schema.table[col1,col2]`. Schema defaults to `public`.
///
/// Quote the argument to prevent shell glob expansion:
/// `hotdata indexes create 'airbnb.listings[description]' --type bm25`
#[arg(conflicts_with = "dataset_id")]
target: Option<String>,
/// SQL catalog alias of the target database (e.g. `--catalog airbnb`)
#[arg(long, conflicts_with = "dataset_id")]
catalog: Option<String>,

/// Dataset ID (alternative scope to the positional target)
#[arg(long, conflicts_with = "target")]
dataset_id: Option<String>,
/// Schema name (default: public)
#[arg(long, default_value = "public")]
schema: String,

/// Table name to index
#[arg(long, conflicts_with = "dataset_id")]
table: Option<String>,

/// Columns to index (comma-separated). Required with --dataset-id;
/// for connection scope use bracket notation in the target instead.
/// Column(s) to index (comma-separated)
#[arg(long)]
columns: Option<String>,
column: Option<String>,

/// Dataset ID (alternative scope to --catalog/--table)
#[arg(long, conflicts_with_all = ["catalog", "table"])]
dataset_id: Option<String>,

/// Index name (derived from table, columns, and type if omitted)
#[arg(long)]
Expand Down Expand Up @@ -600,17 +597,28 @@ pub enum DatabasesCommands {
id: String,
},

/// Clear the current database
Unset,

/// Delete a managed database and its tables
Delete {
/// Database name or connection ID
name_or_id: String,
},

/// Load a parquet file into a table using dot notation: `database.table` or `database.schema.table`
/// Load a parquet file into a managed database table
Load {
/// Table to load into: `database.table` or `database.schema.table`.
/// Schema defaults to `public` when omitted.
target: String,
/// SQL catalog alias of the target database (e.g. `--catalog airbnb`)
#[arg(long)]
catalog: String,

/// Schema to load into (default: public)
#[arg(long, default_value = "public")]
schema: String,

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

/// Path to a local parquet file to upload and load
#[arg(long, conflicts_with_all = ["upload_id", "url"])]
Expand Down
36 changes: 27 additions & 9 deletions src/connections.rs
Original file line number Diff line number Diff line change
Expand Up @@ -172,21 +172,39 @@ pub fn resolve_connection_id(api: &ApiClient, name_or_id: &str) -> String {
}
}

// Before listing connections, check if the active database's catalog or name
// matches — prefer it over any stale connection entry with the same name.
if let Some(ws) = api.workspace_id() {
if let Some(active_id) = crate::config::load_current_database("default", ws) {
if let Some(active_db) = api.get_none_if_not_found::<crate::databases::Database>(&format!("/databases/{active_id}")) {
if active_db.default_catalog.as_deref() == Some(name_or_id)
|| active_db.name.as_deref() == Some(name_or_id)
{
return active_db.default_connection_id;
}
}
}
}

let body: ListResponse = api.get("/connections");
match body
if let Some(conn) = body
.connections
.iter()
.find(|c| c.id == name_or_id || c.name == name_or_id)
{
Some(conn) => conn.id.clone(),
None => {
eprintln!(
"{}",
format!("error: no connection named or with id '{name_or_id}'").red()
);
std::process::exit(1);
}
return conn.id.clone();
}

// Fall back to managed databases: treat name_or_id as a catalog alias.
if let Ok(db) = crate::databases::try_resolve_database(api, name_or_id) {
return db.default_connection_id;
}

eprintln!(
"{}",
format!("error: no connection named or with id '{name_or_id}'").red()
);
std::process::exit(1);
}

pub fn get(workspace_id: &str, connection_id: &str, format: &str) {
Expand Down
113 changes: 96 additions & 17 deletions src/databases.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ struct DatabaseSummary {
id: String,
#[serde(default)]
name: Option<String>,
#[serde(default)]
default_catalog: Option<String>,
}

#[derive(Deserialize)]
Expand Down Expand Up @@ -109,8 +111,26 @@ pub fn try_resolve_database(api: &ApiClient, id_or_name: &str) -> Result<Databas
return Ok(db);
}

// Fall back to listing and matching by name.
// Fall back to listing — prefer catalog alias match, then name.
let body: ListDatabasesResponse = api.get("/databases");

let catalog_matches: Vec<&DatabaseSummary> = body
.databases
.iter()
.filter(|d| d.default_catalog.as_deref() == Some(id_or_name))
.collect();

if !catalog_matches.is_empty() {
return match catalog_matches.len() {
1 => Ok(fetch_database(api, &catalog_matches[0].id)),
_ => Err(format!(
"multiple databases have catalog '{}' — use the database id instead",
id_or_name
)),
};
}


let name_matches: Vec<&DatabaseSummary> = body
.databases
.iter()
Expand All @@ -119,7 +139,7 @@ pub fn try_resolve_database(api: &ApiClient, id_or_name: &str) -> Result<Databas

match name_matches.len() {
0 => Err(format!(
"no database with id or name '{id_or_name}'"
"no database with id, catalog, or name '{id_or_name}'"
)),
1 => Ok(fetch_database(api, &name_matches[0].id)),
_ => Err(format!(
Expand Down Expand Up @@ -398,17 +418,20 @@ pub fn list(workspace_id: &str, format: &str) {
"Create one with: hotdata databases create --catalog <alias>".dark_grey()
);
} else {
let current = crate::config::load_current_database("default", workspace_id);
let rows: Vec<Vec<String>> = body
.databases
.iter()
.map(|d| {
let marker = if current.as_deref() == Some(d.id.as_str()) { "*" } else { "" };
vec![
marker.to_string(),
d.id.clone(),
d.name.as_deref().unwrap_or("-").to_string(),
]
})
.collect();
crate::table::print(&["ID", "NAME"], &rows);
crate::table::print(&["", "ID", "NAME"], &rows);
}
}
_ => unreachable!(),
Expand Down Expand Up @@ -644,6 +667,15 @@ pub fn create(
}
}

pub fn unset(workspace_id: &str) {
use crossterm::style::Stylize;
if let Err(e) = crate::config::clear_current_database("default", workspace_id) {
eprintln!("{}", format!("error clearing current database: {e}").red());
std::process::exit(1);
}
println!("{}", "Current database cleared.".green());
}

pub fn set(workspace_id: &str, id: &str) {
use crossterm::style::Stylize;
let api = ApiClient::new(Some(workspace_id));
Expand Down Expand Up @@ -747,7 +779,26 @@ pub fn tables_load(

let database = resolve_current_database(database, workspace_id);
let api = ApiClient::new(Some(workspace_id));
let db = resolve_database(&api, &database);
// Prefer the active database when its catalog or name matches the lookup key,
// avoiding ambiguity when multiple databases share the same catalog name.
let active_id = crate::config::load_current_database("default", workspace_id);
let lookup_key = match active_id.as_deref() {
Some(id) => {
if let Some(active) = api.get_none_if_not_found::<Database>(&format!("/databases/{id}")) {
if active.default_catalog.as_deref() == Some(database.as_str())
|| active.name.as_deref() == Some(database.as_str())
{
id.to_string()
} else {
database.clone()
}
} else {
database.clone()
}
}
None => database.clone(),
};
let db = resolve_database(&api, &lookup_key);
let schema = schema_name(schema);

// clap enforces mutual exclusion; only one of these is ever Some.
Expand All @@ -769,19 +820,47 @@ pub fn tables_load(
let (status, resp_body) = api.post_raw(&path, &body);
spinner.finish_and_clear();

if !status.is_success() {
let msg = crate::util::api_error(resp_body);
if msg.contains("not declared") {
eprintln!("{}", msg.red());
eprintln!(
"{}",
"Declare the table when creating the database, e.g.:\n \
hotdata databases create --table <table>"
.dark_grey()
);
} else {
eprintln!("{}", msg.red());
let (status, resp_body) = if !status.is_success()
&& crate::util::api_error(resp_body.clone()).contains("not declared")
{
// The table wasn't declared at create time. Delete the database and
// recreate it with the table declared, then retry the load.
let (del_status, del_body) = api.delete_raw(&format!("/databases/{}", db.id));
if !del_status.is_success() {
eprintln!("{}", crate::util::api_error(del_body).red());
std::process::exit(1);
}
Comment thread
eddietejeda marked this conversation as resolved.
let create_body = create_database_request(
db.name.as_deref(),
db.default_catalog.as_deref(),
schema,
&[table.to_string()],
None,
);
Comment thread
eddietejeda marked this conversation as resolved.
Outdated
let (create_status, create_body_resp) = api.post_raw("/databases", &create_body);
if !create_status.is_success() {
eprintln!("{}", crate::util::api_error(create_body_resp).red());
std::process::exit(1);
}
let new_db: CreateDatabaseResponse = match serde_json::from_str(&create_body_resp) {
Ok(v) => v,
Err(e) => {
eprintln!("error parsing create response: {e}");
std::process::exit(1);
}
};
let _ = crate::config::save_current_database("default", workspace_id, &new_db.id);
let new_path = managed_table_load_path(&new_db.default_connection_id, schema, table);
let spinner = crate::util::spinner("Loading table...");
let result = api.post_raw(&new_path, &body);
spinner.finish_and_clear();
result
Comment thread
eddietejeda marked this conversation as resolved.
} else {
(status, resp_body)
};

if !status.is_success() {
eprintln!("{}", crate::util::api_error(resp_body).red());
std::process::exit(1);
}

Expand Down Expand Up @@ -978,7 +1057,7 @@ mod tests {

let api = ApiClient::test_new(&server.url(), "k", None);
let err = try_resolve_database(&api, "missing").unwrap_err();
assert!(err.contains("no database with id or name"));
assert!(err.contains("no database with id"));
}

#[test]
Expand Down
14 changes: 2 additions & 12 deletions src/indexes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -219,18 +219,8 @@ pub fn infer_for_search(

let api = ApiClient::new(Some(workspace_id));

// Resolve connection name → ID
let conn_map = connection_lookup(&api);
let connection_id = match conn_map.get(connection_name) {
Some(id) => id.clone(),
None => {
eprintln!(
"{}",
format!("Connection '{}' not found.", connection_name).red()
);
std::process::exit(1);
}
};
// Resolve connection name → ID (falls back to managed database catalog lookup)
let connection_id = crate::connections::resolve_connection_id(&api, connection_name);

// Fetch indexes for this table
let indexes = list_one_table(&api, &connection_id, schema, table);
Expand Down
Loading
Loading