Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
15 changes: 9 additions & 6 deletions src/command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -563,13 +563,16 @@ pub enum DatabasesCommands {

/// Create a new managed database
Create {
/// SQL catalog alias — becomes the catalog name in queries:
/// SELECT … FROM <name>.public.<table>.
/// 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<String>,

/// SQL catalog alias used in queries: SELECT … FROM <catalog>.schema.table.
/// Must be [a-z_][a-z0-9_]*, globally unique. When provided the database
/// defaults to no expiry; omit for an anonymous 24h sandbox.
#[arg(long)]
catalog: Option<String>,

/// 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.
Expand All @@ -583,8 +586,8 @@ pub enum DatabasesCommands {
tables: Vec<String>,

/// 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 with --catalog means no expiry; omitting
/// without --catalog defaults to 24h.
#[arg(long)]
expires_at: Option<String>,

Expand Down
5 changes: 4 additions & 1 deletion src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Expand Down
38 changes: 29 additions & 9 deletions src/databases.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ struct CreateDatabaseResponse {
id: String,
#[serde(default)]
name: Option<String>,
#[serde(default)]
default_catalog: Option<String>,
default_connection_id: String,
#[serde(default)]
expires_at: Option<String>,
Expand Down Expand Up @@ -143,6 +145,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>,
Expand All @@ -156,6 +159,13 @@ pub fn create_database_request(
);
}

if let Some(c) = catalog {
req.insert(
"default_catalog".to_string(),
serde_json::Value::String(c.to_string()),
);
}

Comment on lines +164 to +170

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.

nit: the new default_catalog request branch has no test coverage. Every create_database_request test call passes None for the catalog arg, so this insert is never exercised. Consider adding a test like create_database_request(None, Some("analytics"), "public", &[], None) asserting req["default_catalog"] == "analytics". (not blocking)

if !tables.is_empty() {
// Group tables by schema, preserving insertion order.
// Dot-notation entries (e.g. "raw.raw_orders") use the named schema;
Expand Down Expand Up @@ -459,7 +469,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());
Expand Down Expand Up @@ -554,14 +564,15 @@ pub fn run(
pub fn create(
workspace_id: &str,
name: Option<&str>,
catalog: Option<&str>,
schema: &str,
tables: &[String],
expires_at: Option<&str>,
format: &str,
) {
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..."));
Expand Down Expand Up @@ -596,12 +607,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");
Comment on lines +625 to +627

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.

nit: now that name is a free-form display string (e.g. "Sales reporting"), this fallback can put an invalid catalog into the query hint. If the server returns a name but no default_catalog, the hint becomes SELECT * FROM Sales reporting.public.<table> — which won't parse. Falling back to "default" (or omitting the hint) would be safer than falling back to the display name.

Same conflation still exists in get (line 435, let catalog = db.name.as_deref()... for sql_prefix) and the list empty-state hint (line 396, --name <catalog_name>), which now read as stale given this PR's name/catalog split. Worth a follow-up if DatabaseInfo can surface default_catalog. (not blocking)

println!(
"{}",
format!(
Expand Down Expand Up @@ -823,20 +839,21 @@ 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());
}

#[test]
fn create_database_request_includes_schemas_when_tables_declared() {
let req = create_database_request(
None,
None,
"public",
&["orders".to_string(), "customers".to_string()],
Expand All @@ -849,26 +866,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",
&[
Expand Down Expand Up @@ -1066,6 +1084,7 @@ mod tests {
.match_body(mockito::Matcher::JsonString(
serde_json::to_string(&create_database_request(
Some("mydb"),
None,
"public",
&["gdp".to_string()],
None,
Expand All @@ -1075,7 +1094,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();
Expand Down Expand Up @@ -1184,6 +1203,7 @@ mod tests {
.mock("POST", "/databases")
.match_body(mockito::Matcher::Json(create_database_request(
Some("scratch"),
None,
"public",
&[],
None,
Expand Down
2 changes: 2 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -432,13 +432,15 @@ fn main() {
}
Some(DatabasesCommands::Create {
name,
catalog,
schema,
tables,
expires_at,
output,
}) => databases::create(
&workspace_id,
name.as_deref(),
catalog.as_deref(),
&schema,
&tables,
expires_at.as_deref(),
Expand Down
Loading