Skip to content

Commit 2c6b10b

Browse files
committed
feat(databases): add --if-not-exists to create
Get-or-create by name, the call the Python SDK already exposes and customers bind to on start-up. Sent only when set, so an older server that rejects unknown keys is unaffected. Table output says whether the database was created or already existed; json/yaml carry `created`.
1 parent b05b3cf commit 2c6b10b

3 files changed

Lines changed: 93 additions & 15 deletions

File tree

‎skills/hotdata/SKILL.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ Returns workspaces with `current`, `public_id`, `name`, `active`, `favorite`, `p
104104
```
105105
hotdata databases list [--workspace-id <workspace_id>] [--output table|json|yaml]
106106
hotdata databases count [--workspace-id <workspace_id>] [--output table|json|yaml]
107-
hotdata databases create [--name <display_name>] [--catalog <alias>] [--table <table> ...] [--schema public] [--expires-at <duration|timestamp>] [--workspace-id <workspace_id>] [--output table|json|yaml]
107+
hotdata databases create [--name <display_name>] [--catalog <alias>] [--table <table> ...] [--schema public] [--expires-at <duration|timestamp>] [--if-not-exists] [--workspace-id <workspace_id>] [--output table|json|yaml]
108108
hotdata databases fork [<id>] [--name <display_name>] [--expires-at <duration|timestamp>] [--description <text>] [--workspace-id <workspace_id>] [--output table|json|yaml]
109109
hotdata databases lineage [<id>] [--forks-limit <n>] [--workspace-id <workspace_id>] [--output table|json|yaml]
110110
hotdata databases use <id>
@@ -130,6 +130,7 @@ hotdata databases tables remove <table> [--database <id>] [--schema public] [--w
130130
- `list` — all instant databases in the workspace. Current database is marked with `*` under the CURRENT column; CREATED shows when each database was made.
131131
- `count` — the total number of instant databases in the workspace, across **all** pages (`list` shows one page). Prints a bare integer by default so it drops straight into scripts (`$(hotdata databases count)`); `--output json|yaml` render `{"count": N}` / `count: N`.
132132
- `create` — creates a new instant database. `--name` is an optional human-readable display name. `--catalog` sets the SQL alias used in queries (`SELECT … FROM <catalog>.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. **Does not switch the current database** — run `hotdata databases use <id>` with the printed id (or pass `-d <id>`) to work in it.
133+
- `create --if-not-exists --name <name>` — get-or-create: when a database already carries that name it is returned instead of a new one being created, so a script can bind to its database on every run without looking it up first. The table output says `Database already exists` in that case, and `-o json`/`yaml` carry `created: true|false`. Requires `--name`; names are otherwise not unique, so without the flag a second `create` with the same name makes a second database.
133134
- `fork` — creates a new instant database that is an independent deep copy of an existing one (same schemas, tables, and data); the source is left unchanged and the two diverge freely afterwards. The source defaults to the current database; pass the database `<id>` to fork another. `--name` defaults to `<source>-fork` (so the two stay distinguishable in `list`); `--expires-at` accepts a relative duration or RFC 3339 timestamp, and when omitted a still-future source expiry is carried over. `--description` records why the fork was taken (for example `"what-if on Q3 pricing"`); it can be set only at fork time. **Does not switch the current database** — run `hotdata databases use <fork_id>` to work in the fork, or pass `-d <fork_id>` per command. The fork answers to the **same catalog alias** as its source inside its own scope; catalogs attached to the source are **re-attached** to the fork, but indexes are **not** carried over. Only databases created with the current (DuckLake) storage engine can be forked — older parquet-backed databases return an error. The fork's output (and its `databases <id>` view) records **`forked_from`** provenance: source id, the source's name at fork time, the copied snapshot, when, and the description if one was given.
134135
- `lineage` — renders a database's **whole fork family tree**, walked from the root down (defaults to the current database; one lineage request per database, fine for the small families forks produce in practice): every reachable generation, with the queried database marked `← this database`. A fork taken with `--description` shows the first line of it on its row; `-o json`/`yaml` carry the full text. Lineage is a historical record, not a live link — the databases stay independent, and a **deleted** generation stays in the chain (marked `deleted`), though its own fork list can't be enumerated: such branches end with `⋯ forks unknown`. Forks made before the server recorded lineage carry none. `--forks-limit <n>` pages each database's direct-fork list (server clamps to 1–100); a truncated branch closes with `⋯ N more`. `-o json`/`yaml` return `{database_id, root_id, tree}` with a recursive `tree` node.
135136
- `use` — saves the database as the current database (accepts an id, catalog alias, or name; prefer the id). Subsequent `query`, `databases tables`, and `databases context` commands use it automatically. This is the **only** command that changes the current database.

‎src/commands/databases.rs‎

Lines changed: 89 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,12 @@ pub enum DatabasesCommands {
7373
#[arg(long = "attach")]
7474
attach: Vec<String>,
7575

76+
/// Get-or-create: when a database with this `--name` already exists,
77+
/// return it instead of creating another. Lets a script bind to its
78+
/// database on every run without looking it up first. Requires `--name`.
79+
#[arg(long, requires = "name")]
80+
if_not_exists: bool,
81+
7682
/// Output format
7783
#[arg(long = "output", short = 'o', default_value = "table", value_parser = ["table", "json", "yaml"])]
7884
output: String,
@@ -627,6 +633,10 @@ struct CreateDatabaseResponse {
627633
/// responses never carry it.
628634
#[serde(default, skip_serializing_if = "Option::is_none")]
629635
forked_from: Option<ForkedFrom>,
636+
/// Only with `--if-not-exists`: `true` when this call created the database,
637+
/// `false` when an existing one was returned.
638+
#[serde(default, skip_serializing_if = "Option::is_none")]
639+
created: Option<bool>,
630640
}
631641

632642
#[derive(Deserialize)]
@@ -824,13 +834,20 @@ pub fn create_database_request(
824834
schema: &str,
825835
tables: &[String],
826836
expires_at: Option<&str>,
837+
if_not_exists: bool,
827838
) -> serde_json::Value {
828839
let mut req = serde_json::Map::new();
829840

830841
if let Some(n) = name {
831842
req.insert("name".to_string(), serde_json::Value::String(n.to_string()));
832843
}
833844

845+
// Omitted rather than `false`: the field is new on the server, and an older
846+
// deployment rejects unknown keys it would otherwise never see.
847+
if if_not_exists {
848+
req.insert("if_not_exists".to_string(), serde_json::Value::Bool(true));
849+
}
850+
834851
if let Some(c) = catalog {
835852
req.insert(
836853
"default_catalog".to_string(),
@@ -933,9 +950,15 @@ fn create_database_typed_request(
933950
schema: &str,
934951
tables: &[String],
935952
expires_at: Option<&str>,
953+
if_not_exists: bool,
936954
) -> hotdata::models::CreateDatabaseRequest {
937955
serde_json::from_value(create_database_request(
938-
name, catalog, schema, tables, expires_at,
956+
name,
957+
catalog,
958+
schema,
959+
tables,
960+
expires_at,
961+
if_not_exists,
939962
))
940963
.expect("create_database_request always emits a valid CreateDatabaseRequest body")
941964
}
@@ -2072,11 +2095,13 @@ pub fn create(
20722095
tables: &[String],
20732096
expires_at: Option<&str>,
20742097
attach: &[String],
2098+
if_not_exists: bool,
20752099
format: &str,
20762100
) {
20772101
use crossterm::style::Stylize;
20782102

2079-
let request = create_database_typed_request(name, catalog, schema, tables, expires_at);
2103+
let request =
2104+
create_database_typed_request(name, catalog, schema, tables, expires_at, if_not_exists);
20802105

20812106
let api = Api::new(Some(workspace_id));
20822107
let resp = if format == "table" {
@@ -2090,6 +2115,11 @@ pub fn create(
20902115
}
20912116
.unwrap_or_else(|e| e.exit());
20922117

2118+
// With `--if-not-exists` the server says whether it created or found the
2119+
// database; `created` is absent on older servers, which never return an
2120+
// existing one here, so absent reads as created.
2121+
let created = resp.created.flatten().unwrap_or(true);
2122+
20932123
// Attach requested connection catalogs onto the fresh database so a single
20942124
// `databases create --attach …` lands a cross-source-ready context. A failed
20952125
// attach is surfaced but non-fatal: the database exists and is usable.
@@ -2122,13 +2152,19 @@ pub fn create(
21222152
// Never set on a plain create; mapped anyway so the shape can't drift
21232153
// from fork's if the server ever returns an existing fork here.
21242154
forked_from: resp.forked_from.flatten().map(|f| ForkedFrom::from(*f)),
2155+
// Only meaningful with `--if-not-exists`, where a script branches on it.
2156+
created: if if_not_exists { Some(created) } else { None },
21252157
};
21262158

21272159
match format {
21282160
"json" => println!("{}", serde_json::to_string_pretty(&result).unwrap()),
21292161
"yaml" => print!("{}", serde_yaml::to_string(&result).unwrap()),
21302162
"table" => {
2131-
println!("{}", "Database created".green());
2163+
if created {
2164+
println!("{}", "Database created".green());
2165+
} else {
2166+
println!("{}", "Database already exists".green());
2167+
}
21322168
if let Some(n) = &result.name {
21332169
println!("name: {}", n.clone().cyan());
21342170
}
@@ -2236,6 +2272,7 @@ pub fn fork(
22362272
default_connection_id: resp.default_connection_id,
22372273
expires_at: resp.expires_at.flatten(),
22382274
forked_from: resp.forked_from.flatten().map(|f| ForkedFrom::from(*f)),
2275+
created: None,
22392276
};
22402277

22412278
match format {
@@ -3229,9 +3266,23 @@ mod tests {
32293266
);
32303267
}
32313268

3269+
#[test]
3270+
fn create_database_request_sends_if_not_exists_only_when_set() {
3271+
let req = create_database_request(Some("app"), None, "public", &[], None, true);
3272+
assert_eq!(req["if_not_exists"], serde_json::json!(true));
3273+
let req = create_database_request(Some("app"), None, "public", &[], None, false);
3274+
assert!(
3275+
req.get("if_not_exists").is_none(),
3276+
"an older server rejects unknown keys, so false is omitted, not sent"
3277+
);
3278+
// The typed request carries it through to the SDK model.
3279+
let typed = create_database_typed_request(Some("app"), None, "public", &[], None, true);
3280+
assert_eq!(typed.if_not_exists, Some(true));
3281+
}
3282+
32323283
#[test]
32333284
fn create_database_request_declares_schema_without_name_or_tables() {
3234-
let req = create_database_request(None, None, "public", &[], None);
3285+
let req = create_database_request(None, None, "public", &[], None, false);
32353286
assert_eq!(
32363287
req,
32373288
serde_json::json!({"schemas": [{"name": "public"}]}),
@@ -3241,7 +3292,7 @@ mod tests {
32413292

32423293
#[test]
32433294
fn create_database_request_declares_custom_schema_without_tables() {
3244-
let req = create_database_request(None, None, "analytics", &[], None);
3295+
let req = create_database_request(None, None, "analytics", &[], None, false);
32453296
assert_eq!(req["schemas"][0]["name"], "analytics");
32463297
// An empty schema declaration omits `tables` entirely — the server
32473298
// treats that as "declare the schema, add tables later".
@@ -3252,7 +3303,7 @@ mod tests {
32523303
fn create_database_request_typed_round_trip_without_tables() {
32533304
// The typed builder deserializes the JSON body and panics on a shape the
32543305
// SDK model rejects, so the tables-less schema declaration must survive it.
3255-
let req = create_database_typed_request(None, None, "public", &[], None);
3306+
let req = create_database_typed_request(None, None, "public", &[], None, false);
32563307
let schemas = req.schemas.expect("schemas is always sent");
32573308
assert_eq!(schemas.len(), 1);
32583309
assert_eq!(schemas[0].name, "public");
@@ -3261,7 +3312,7 @@ mod tests {
32613312

32623313
#[test]
32633314
fn create_database_request_includes_name() {
3264-
let req = create_database_request(Some("jaffle_shop"), None, "public", &[], None);
3315+
let req = create_database_request(Some("jaffle_shop"), None, "public", &[], None, false);
32653316
assert_eq!(req["name"], "jaffle_shop");
32663317
assert_eq!(req["schemas"][0]["name"], "public");
32673318
}
@@ -3274,6 +3325,7 @@ mod tests {
32743325
"public",
32753326
&["orders".to_string(), "customers".to_string()],
32763327
None,
3328+
false,
32773329
);
32783330
assert_eq!(req["schemas"][0]["name"], "public");
32793331
assert_eq!(req["schemas"][0]["tables"][0]["name"], "orders");
@@ -3282,7 +3334,14 @@ mod tests {
32823334

32833335
#[test]
32843336
fn create_database_request_schemas_without_name() {
3285-
let req = create_database_request(None, None, "analytics", &["events".to_string()], None);
3337+
let req = create_database_request(
3338+
None,
3339+
None,
3340+
"analytics",
3341+
&["events".to_string()],
3342+
None,
3343+
false,
3344+
);
32863345
assert!(req.get("name").is_none());
32873346
assert_eq!(req["schemas"][0]["name"], "analytics");
32883347
}
@@ -3292,7 +3351,14 @@ mod tests {
32923351
// Every --table dot-qualified elsewhere: --schema still has to be
32933352
// declared, or a later bare-name declaration into it fails the same way
32943353
// an empty --table list used to.
3295-
let req = create_database_request(None, None, "public", &["raw.orders".to_string()], None);
3354+
let req = create_database_request(
3355+
None,
3356+
None,
3357+
"public",
3358+
&["raw.orders".to_string()],
3359+
None,
3360+
false,
3361+
);
32963362
assert_eq!(req["schemas"][0]["name"], "public");
32973363
assert!(
32983364
req["schemas"][0].get("tables").is_none(),
@@ -3312,6 +3378,7 @@ mod tests {
33123378
"public",
33133379
&["orders".to_string(), "public.customers".to_string()],
33143380
None,
3381+
false,
33153382
);
33163383
assert_eq!(req["schemas"].as_array().unwrap().len(), 1);
33173384
assert_eq!(req["schemas"][0]["name"], "public");
@@ -3321,13 +3388,13 @@ mod tests {
33213388

33223389
#[test]
33233390
fn create_database_request_includes_expires_at_when_provided() {
3324-
let req = create_database_request(None, None, "public", &[], Some("24h"));
3391+
let req = create_database_request(None, None, "public", &[], Some("24h"), false);
33253392
assert_eq!(req["expires_at"], "24h");
33263393
}
33273394

33283395
#[test]
33293396
fn create_database_request_omits_expires_at_when_none() {
3330-
let req = create_database_request(None, None, "public", &[], None);
3397+
let req = create_database_request(None, None, "public", &[], None, false);
33313398
assert!(req.get("expires_at").is_none());
33323399
}
33333400

@@ -3343,6 +3410,7 @@ mod tests {
33433410
"raw.raw_customers".to_string(),
33443411
],
33453412
None,
3413+
false,
33463414
);
33473415
// bare "orders" → default schema "public"
33483416
assert_eq!(req["schemas"][0]["name"], "public");
@@ -4331,14 +4399,21 @@ mod tests {
43314399
"public",
43324400
&["gdp".to_string()],
43334401
None,
4334-
))
4402+
false,
4403+
))
43354404
.unwrap(),
43364405
))
43374406
.create();
43384407

43394408
let api = Api::test_new(&server.url(), "k", Some("ws-test"));
4340-
let body =
4341-
create_database_request(Some("mydb"), None, "public", &["gdp".to_string()], None);
4409+
let body = create_database_request(
4410+
Some("mydb"),
4411+
None,
4412+
"public",
4413+
&["gdp".to_string()],
4414+
None,
4415+
false,
4416+
);
43424417
let (status, resp_body) = api.post_raw("/databases", &body).unwrap();
43434418
assert_eq!(status.as_u16(), 201);
43444419
let parsed: CreateDatabaseResponse = serde_json::from_str(&resp_body).unwrap();

‎src/main.rs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,7 @@ fn main() {
255255
tables,
256256
expires_at,
257257
attach,
258+
if_not_exists,
258259
output,
259260
}) => databases::create(
260261
&workspace_id,
@@ -264,6 +265,7 @@ fn main() {
264265
&tables,
265266
expires_at.as_deref(),
266267
&attach,
268+
if_not_exists,
267269
&output,
268270
),
269271
Some(DatabasesCommands::Fork {

0 commit comments

Comments
 (0)