diff --git a/README.md b/README.md index 5e99246..3200480 100644 --- a/README.md +++ b/README.md @@ -130,14 +130,14 @@ hotdata connections create --name "my-conn" --type postgres --config '{"host":". ## Databases -Managed databases are Hotdata-owned catalogs you create and populate yourself (no remote source to sync). Query them with SQL as `database_name.schema.table` — the database name is the connection name. +Managed databases are Hotdata-owned catalogs you create and populate yourself (no remote source to sync). Query them with SQL as `.schema.table`. ```sh hotdata databases list [-w ] [-o table|json|yaml] -hotdata databases create --name [--table ...] [--schema public] [-o table|json|yaml] +hotdata databases create [--name ] [--catalog ] [--table
...] [--schema public] [--expires-at ] [-o table|json|yaml] hotdata databases [-o table|json|yaml] hotdata databases delete -hotdata databases run [--database ] [--description
...] [--expires-at ] [args...] +hotdata databases run [--database ] [--name
...] [--expires-at ] [args...] hotdata databases run [args...] hotdata databases tables list [--schema ] [-o table|json|yaml] @@ -146,15 +146,15 @@ hotdata databases tables load
--upload-id [--schema publ hotdata databases tables delete
[--schema public] ``` -- `create` registers a managed connection (`source_type: managed`) with no external credentials. Use `--table` to declare tables up front (required before `tables load` on the current API). +- `create` registers a managed connection with no external credentials. `--name` is a human-readable display name; `--catalog` sets the SQL alias used in queries (`SELECT … FROM .schema.table`) and must be `[a-z_][a-z0-9_]*`. Use `--table` to declare tables up front (required before `tables load` on the current API). - `tables load` uploads a **parquet** file (or uses a staged `upload_id` from `POST /v1/files`) and publishes it as the table generation (`replace` mode). -- `run` mints a database-scoped JWT and execs `` with `HOTDATA_DATABASE_TOKEN`, `HOTDATA_DATABASE_REFRESH_TOKEN`, `HOTDATA_DATABASE`, `HOTDATA_WORKSPACE`, and `HOTDATA_API_URL` injected into its environment. Pass a database id (group-positional `` like `sandbox run`, or `--database `) to scope an existing database; omit both to auto-create a scratch one using `--description` / `--schema` / `--table` / `--expires-at`. Useful for launching an agent or child process whose API access is restricted to a single database. +- `run` mints a database-scoped JWT and execs `` with `HOTDATA_DATABASE_TOKEN`, `HOTDATA_DATABASE_REFRESH_TOKEN`, `HOTDATA_DATABASE`, `HOTDATA_WORKSPACE`, and `HOTDATA_API_URL` injected into its environment. Pass a database id (group-positional `` like `sandbox run`, or `--database `) to scope an existing database; omit both to auto-create a scratch one using `--name` / `--schema` / `--table` / `--expires-at`. Useful for launching an agent or child process whose API access is restricted to a single database. - For CSV/JSON uploads without a managed database, use `hotdata datasets create` instead (`datasets.main.*`). Example: ```sh -hotdata databases create --name sales --table orders +hotdata databases create --name "Sales reporting" --catalog sales --table orders hotdata databases tables load sales orders --file ./orders.parquet hotdata query "SELECT count(*) FROM sales.public.orders" ``` diff --git a/skills/hotdata/SKILL.md b/skills/hotdata/SKILL.md index a531d3f..e2d04a1 100644 --- a/skills/hotdata/SKILL.md +++ b/skills/hotdata/SKILL.md @@ -187,28 +187,28 @@ hotdata connections create \ ``` hotdata databases list [--workspace-id ] [--output table|json|yaml] -hotdata databases create [--name ] [--table
...] [--schema public] [--expires-at ] [--workspace-id ] [--output table|json|yaml] -hotdata databases set -hotdata databases [--workspace-id ] [--output table|json|yaml] -hotdata databases delete [--workspace-id ] -hotdata databases run [--database ] [--name ] [--schema public] [--table
...] [--expires-at ] [--workspace-id ] [args...] +hotdata databases create [--name ] [--catalog ] [--table
...] [--schema public] [--expires-at ] [--workspace-id ] [--output table|json|yaml] +hotdata databases set +hotdata databases [--workspace-id ] [--output table|json|yaml] +hotdata databases delete [--workspace-id ] +hotdata databases run [--database ] [--name
...] [--expires-at ] [--workspace-id ] [args...] hotdata databases run [args...] # Dot-notation shorthand for load: database.table or database.schema.table hotdata databases load [--file ./data.parquet] [--url ] [--upload-id ] [--workspace-id ] -hotdata databases tables list [--database ] [--schema ] [--workspace-id ] [--output table|json|yaml] -hotdata databases tables load
[--database ] [--schema public] [--file ./data.parquet] [--url ] [--upload-id ] [--workspace-id ] -hotdata databases tables delete
[--database ] [--schema public] [--workspace-id ] +hotdata databases tables list [--database ] [--schema ] [--workspace-id ] [--output table|json|yaml] +hotdata databases tables load
[--database ] [--schema public] [--file ./data.parquet] [--url ] [--upload-id ] [--workspace-id ] +hotdata databases tables delete
[--database ] [--schema public] [--workspace-id ] ``` - `list` — all managed databases in the workspace. -- `create` — creates a new managed database. `--name` is an optional catalog alias used in queries (`SELECT … FROM .public.
`); must be `[a-z_][a-z0-9_]*`. `--expires-at` accepts relative durations (`24h`, `7d`, `90m`) or an RFC 3339 timestamp; defaults to `24h` when omitted. Repeat `--table` to declare tables up front. -- `set` — saves `` as the active database. Subsequent `databases tables` and `context` commands use it automatically. -- `` — inspect one database (id, description, expires_at). +- `create` — creates a new managed database. `--name` is an optional human-readable display name. `--catalog` sets the SQL alias used in queries (`SELECT … FROM .schema.table`); must be `[a-z_][a-z0-9_]*`. `--expires-at` accepts relative durations (`24h`, `7d`, `90m`) or an RFC 3339 timestamp; omitting means no expiry. Repeat `--table` to declare tables up front. +- `set` — saves `` as the active database. Subsequent `databases tables` and `context` commands use it automatically. +- `` — inspect one database (id, catalog, name, expires_at). - `delete` — removes the managed database; clears the active-database config if it matched. - `load` — shorthand with dot notation (`database.table` or `database.schema.table`). Schema defaults to `public`. -- `tables list` — lists tables with `TABLE` (`..
`), `SYNCED`, `LAST_SYNC`. Uses active database when `--database` is omitted. +- `tables list` — lists tables with `TABLE` (`..
`), `SYNCED`, `LAST_SYNC`. Uses active database when `--database` is omitted. - `tables load` — uploads a local parquet file (`--file`), a remote parquet URL (`--url`), or a pre-staged upload (`--upload-id`) and publishes with **replace** mode. - `tables delete` — drops a table from the managed database. - `run` — mints a database-scoped JWT (via `POST /v1/auth/database`) and execs `` with `HOTDATA_DATABASE_TOKEN`, `HOTDATA_DATABASE_REFRESH_TOKEN`, `HOTDATA_DATABASE`, `HOTDATA_WORKSPACE`, and `HOTDATA_API_URL` injected. Pass a database id as a group positional (`hotdata databases run ...`, sandbox-style) or via `--database `; omit both to auto-create a scratch database using `--name` / `--schema` / `--table` / `--expires-at`. Use this to launch an agent or child process whose API access is scoped to a single database. The minted JWT carries `database`, `workspaces`, `permissions:["read","write"]`, `source:"database_token"`. The session is persisted at `~/.hotdata/database_session.json` (mode `0600`); the child's exit code is propagated. @@ -216,10 +216,10 @@ hotdata databases tables delete
[--database ] [--schema publ Example: ``` -hotdata databases create --name sales --table orders +hotdata databases create --name "Sales reporting" --catalog sales --table orders hotdata databases set hotdata databases tables load orders --file ./orders.parquet -hotdata query "SELECT count(*) FROM .public.orders" +hotdata query "SELECT count(*) FROM sales.public.orders" ``` ### List Tables and Columns diff --git a/src/command.rs b/src/command.rs index 2384764..3b7a1b1 100644 --- a/src/command.rs +++ b/src/command.rs @@ -563,13 +563,15 @@ pub enum DatabasesCommands { /// Create a new managed database Create { - /// SQL catalog alias — becomes the catalog name in queries: - /// SELECT … FROM .public.
. - /// Must be [a-z_][a-z0-9_]*, globally unique. When provided the - /// database defaults to no expiry; omit for an anonymous 24h sandbox. + /// Human-readable display name for the database (e.g. "Sales reporting"). #[arg(long)] name: Option, + /// SQL catalog alias used in queries: SELECT … FROM .schema.table. + /// Must be [a-z_][a-z0-9_]*, globally unique. + #[arg(long)] + catalog: Option, + /// Default schema for bare `--table` entries (default: public). /// Use dot notation in `--table` to target a different schema directly, /// e.g. `--table raw.raw_orders` always goes into the "raw" schema. @@ -583,8 +585,7 @@ pub enum DatabasesCommands { tables: Vec, /// When the database expires. Accepts a relative duration (e.g. 24h, 7d, 90m) - /// or an RFC 3339 timestamp. Omitting with --name means no expiry; omitting - /// without --name defaults to 24h. + /// or an RFC 3339 timestamp. Omitting means no expiry. #[arg(long)] expires_at: Option, diff --git a/src/config.rs b/src/config.rs index a0f680d..01e77a1 100644 --- a/src/config.rs +++ b/src/config.rs @@ -354,7 +354,10 @@ pub mod test_helpers { let guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); let tmp = tempfile::tempdir().unwrap(); // SAFETY: tests are serialized via ENV_LOCK mutex, so no concurrent env mutation. - unsafe { std::env::set_var("HOTDATA_CONFIG_DIR", tmp.path()) }; + unsafe { + std::env::set_var("HOTDATA_CONFIG_DIR", tmp.path()); + std::env::remove_var("HOTDATA_API_KEY"); + } (tmp, guard) } } diff --git a/src/databases.rs b/src/databases.rs index e7f3030..15feedb 100644 --- a/src/databases.rs +++ b/src/databases.rs @@ -24,6 +24,8 @@ pub struct Database { pub id: String, #[serde(default)] pub name: Option, + #[serde(default)] + pub default_catalog: Option, pub default_connection_id: String, #[serde(default)] attachments: Vec, @@ -66,6 +68,8 @@ struct CreateDatabaseResponse { id: String, #[serde(default)] name: Option, + #[serde(default)] + default_catalog: Option, default_connection_id: String, #[serde(default)] expires_at: Option, @@ -143,6 +147,7 @@ fn schema_name(schema: Option<&str>) -> &str { /// Build the request body for `POST /v1/databases`. pub fn create_database_request( name: Option<&str>, + catalog: Option<&str>, schema: &str, tables: &[String], expires_at: Option<&str>, @@ -156,6 +161,13 @@ pub fn create_database_request( ); } + if let Some(c) = catalog { + req.insert( + "default_catalog".to_string(), + serde_json::Value::String(c.to_string()), + ); + } + if !tables.is_empty() { // Group tables by schema, preserving insertion order. // Dot-notation entries (e.g. "raw.raw_orders") use the named schema; @@ -215,11 +227,11 @@ pub fn is_parquet_path(path: &str) -> bool { || Path::new(path).extension().and_then(|e| e.to_str()) == Some("parquet") } -fn table_rows(tables: Vec) -> Vec { +fn table_rows(catalog: &str, tables: Vec) -> Vec { tables .into_iter() .map(|t| TableRow { - full_name: format!("default.{}.{}", t.schema, t.table), + full_name: format!("{catalog}.{}.{}", t.schema, t.table), schema: t.schema, table: t.table, synced: t.synced, @@ -383,7 +395,7 @@ pub fn list(workspace_id: &str, format: &str) { eprintln!("{}", "No databases found.".dark_grey()); eprintln!( "{}", - "Create one with: hotdata databases create --name ".dark_grey() + "Create one with: hotdata databases create --catalog ".dark_grey() ); } else { let rows: Vec> = body @@ -417,12 +429,17 @@ pub fn get(workspace_id: &str, id_or_name: &str, format: &str) { if let Some(n) = &db.name { println!("{}{}", label("name:"), n.clone().cyan()); } + if let Some(c) = &db.default_catalog { + println!("{}{}", label("catalog:"), c.clone().cyan()); + } println!( "{}{}", label("default_connection_id:"), db.default_connection_id.clone().dark_cyan() ); - let catalog = db.name.as_deref().unwrap_or("default"); + let catalog = db.default_catalog.as_deref() + .or(db.name.as_deref()) + .unwrap_or("default"); println!( "{}{}", label("sql_prefix:"), @@ -459,7 +476,7 @@ fn create_and_return_id( expires_at: Option<&str>, ) -> String { use crossterm::style::Stylize; - let body = create_database_request(name, schema, tables, expires_at); + let body = create_database_request(name, None, schema, tables, expires_at); let (status, resp_body) = api.post_raw("/databases", &body); if !status.is_success() { eprintln!("{}", crate::util::api_error(resp_body).red()); @@ -554,6 +571,7 @@ pub fn run( pub fn create( workspace_id: &str, name: Option<&str>, + catalog: Option<&str>, schema: &str, tables: &[String], expires_at: Option<&str>, @@ -561,7 +579,7 @@ pub fn create( ) { use crossterm::style::Stylize; - let body = create_database_request(name, schema, tables, expires_at); + let body = create_database_request(name, catalog, schema, tables, expires_at); let api = ApiClient::new(Some(workspace_id)); let spinner = (format == "table").then(|| crate::util::spinner("Creating database...")); @@ -596,12 +614,17 @@ pub fn create( if let Some(n) = &result.name { println!("name: {}", n.clone().cyan()); } + if let Some(c) = &result.default_catalog { + println!("catalog: {}", c.clone().cyan()); + } println!("id: {}", result.id); if let Some(exp) = &result.expires_at { println!("expires_at: {exp}"); } println!(); - let catalog = result.name.as_deref().unwrap_or("default"); + let catalog = result.default_catalog.as_deref() + .or(result.name.as_deref()) + .unwrap_or("default"); println!( "{}", format!( @@ -678,9 +701,10 @@ pub fn tables_list(workspace_id: &str, database: Option<&str>, schema: Option<&s let database = resolve_current_database(database, workspace_id); let api = ApiClient::new(Some(workspace_id)); let db = resolve_database(&api, &database); + let catalog = db.default_catalog.as_deref().or(db.name.as_deref()).unwrap_or("default"); let tables = collect_tables(&api, &db.default_connection_id, schema); - let rows = table_rows(tables); + let rows = table_rows(catalog, tables); match format { "json" => println!("{}", serde_json::to_string_pretty(&rows).unwrap()), @@ -769,7 +793,8 @@ pub fn tables_load( } }; - let full_name = format!("default.{}.{}", result.schema_name, result.table_name); + let catalog = db.default_catalog.as_deref().or(db.name.as_deref()).unwrap_or("default"); + let full_name = format!("{catalog}.{}.{}", result.schema_name, result.table_name); println!("{}", "Table loaded".green()); println!("full_name: {}", full_name.clone().green()); println!("rows: {}", result.row_count); @@ -805,9 +830,10 @@ pub fn tables_delete(workspace_id: &str, database: Option<&str>, table: &str, sc std::process::exit(1); } + let catalog = db.default_catalog.as_deref().or(db.name.as_deref()).unwrap_or("default"); println!( "{}", - format!("Table 'default.{}.{}' deleted.", schema, table).green() + format!("Table '{catalog}.{schema}.{table}' deleted.").green() ); } @@ -823,13 +849,13 @@ mod tests { #[test] fn create_database_request_empty_without_name_or_tables() { - let req = create_database_request(None, "public", &[], None); + let req = create_database_request(None, None, "public", &[], None); assert_eq!(req, serde_json::json!({})); } #[test] fn create_database_request_includes_name() { - let req = create_database_request(Some("jaffle_shop"), "public", &[], None); + let req = create_database_request(Some("jaffle_shop"), None, "public", &[], None); assert_eq!(req["name"], "jaffle_shop"); assert!(req.get("schemas").is_none()); } @@ -837,6 +863,7 @@ mod tests { #[test] fn create_database_request_includes_schemas_when_tables_declared() { let req = create_database_request( + None, None, "public", &["orders".to_string(), "customers".to_string()], @@ -849,26 +876,27 @@ mod tests { #[test] fn create_database_request_schemas_without_name() { - let req = create_database_request(None, "analytics", &["events".to_string()], None); + let req = create_database_request(None, None, "analytics", &["events".to_string()], None); assert!(req.get("name").is_none()); assert_eq!(req["schemas"][0]["name"], "analytics"); } #[test] fn create_database_request_includes_expires_at_when_provided() { - let req = create_database_request(None, "public", &[], Some("24h")); + let req = create_database_request(None, None, "public", &[], Some("24h")); assert_eq!(req["expires_at"], "24h"); } #[test] fn create_database_request_omits_expires_at_when_none() { - let req = create_database_request(None, "public", &[], None); + let req = create_database_request(None, None, "public", &[], None); assert!(req.get("expires_at").is_none()); } #[test] fn create_database_request_dot_notation_groups_tables_by_schema() { let req = create_database_request( + None, None, "public", &[ @@ -1006,7 +1034,7 @@ mod tests { #[test] fn table_rows_uses_default_prefix() { - let rows = table_rows(vec![InfoTable { + let rows = table_rows("default", vec![InfoTable { connection: "ignored".into(), schema: "public".into(), table: "orders".into(), @@ -1066,6 +1094,7 @@ mod tests { .match_body(mockito::Matcher::JsonString( serde_json::to_string(&create_database_request( Some("mydb"), + None, "public", &["gdp".to_string()], None, @@ -1075,7 +1104,7 @@ mod tests { .create(); let api = ApiClient::test_new(&server.url(), "k", Some("ws-test")); - let body = create_database_request(Some("mydb"), "public", &["gdp".to_string()], None); + let body = create_database_request(Some("mydb"), None, "public", &["gdp".to_string()], None); let (status, resp_body) = api.post_raw("/databases", &body); assert_eq!(status.as_u16(), 201); let parsed: CreateDatabaseResponse = serde_json::from_str(&resp_body).unwrap(); @@ -1184,6 +1213,7 @@ mod tests { .mock("POST", "/databases") .match_body(mockito::Matcher::Json(create_database_request( Some("scratch"), + None, "public", &[], None, diff --git a/src/main.rs b/src/main.rs index 90b9c2d..0f57569 100644 --- a/src/main.rs +++ b/src/main.rs @@ -432,6 +432,7 @@ fn main() { } Some(DatabasesCommands::Create { name, + catalog, schema, tables, expires_at, @@ -439,6 +440,7 @@ fn main() { }) => databases::create( &workspace_id, name.as_deref(), + catalog.as_deref(), &schema, &tables, expires_at.as_deref(),