diff --git a/docs/src/js/classes/Catalog.md b/docs/src/js/classes/Catalog.md index 8351380e2..31d3aabc6 100644 --- a/docs/src/js/classes/Catalog.md +++ b/docs/src/js/classes/Catalog.md @@ -70,7 +70,9 @@ Create a database, or open an existing database when existOk is true. dropDatabase(name, options): Promise ``` -Drop an empty database. The server rejects nonempty databases. +Drop a database. A nonempty database is an error unless `cascade` is set, +which drops everything in it first; the name stays taken until the server +finishes cleaning up their storage. #### Parameters @@ -78,6 +80,8 @@ Drop an empty database. The server rejects nonempty databases. * **options** = `{}` +* **options.cascade?**: `boolean` + * **options.ignoreMissing?**: `boolean` #### Returns diff --git a/nodejs/__test__/catalog.test.ts b/nodejs/__test__/catalog.test.ts index f8b14ca6f..3d647695c 100644 --- a/nodejs/__test__/catalog.test.ts +++ b/nodejs/__test__/catalog.test.ts @@ -86,7 +86,10 @@ describe("remote catalog", () => { value: "team/search", }); expect(names.pageToken()).toBe("next"); - await catalog.dropDatabase("team/search", { ignoreMissing: true }); + await catalog.dropDatabase("team/search", { + ignoreMissing: true, + cascade: true, + }); expect(requests[0].url).toBe("/v1/namespace/team%2Fsearch/create"); expect(requests[0].body).toEqual({ mode: "ExistOk" }); expect(requests[5].url).toBe( @@ -94,7 +97,7 @@ describe("remote catalog", () => { ); expect(requests[6].body).toEqual({ mode: "Skip", - behavior: "Restrict", + behavior: "Cascade", }); for (const [i, request] of requests.entries()) { expect(request.headers["x-lancedb-database"]).toBe( diff --git a/nodejs/lancedb/catalog.ts b/nodejs/lancedb/catalog.ts index e344d8187..c91e5a70f 100644 --- a/nodejs/lancedb/catalog.ts +++ b/nodejs/lancedb/catalog.ts @@ -82,12 +82,16 @@ export class Catalog { return new LocalConnection(await this.inner.connectDatabase(name)); } - /** Drop an empty database. The server rejects nonempty databases. */ + /** + * Drop a database. A nonempty database is an error unless `cascade` is set, + * which drops everything in it first; the name stays taken until the server + * finishes cleaning up their storage. + */ async dropDatabase( name: string, - options: { ignoreMissing?: boolean } = {}, + options: { ignoreMissing?: boolean; cascade?: boolean } = {}, ): Promise { - await this.inner.dropDatabase(name, options.ignoreMissing); + await this.inner.dropDatabase(name, options.ignoreMissing, options.cascade); } /** diff --git a/nodejs/src/catalog.rs b/nodejs/src/catalog.rs index 82edda5da..d55bb3508 100644 --- a/nodejs/src/catalog.rs +++ b/nodejs/src/catalog.rs @@ -124,10 +124,17 @@ impl Catalog { .default_error() } #[napi] - pub async fn drop_database(&self, name: String, ignore_missing: Option) -> Result<()> { + pub async fn drop_database( + &self, + name: String, + ignore_missing: Option, + cascade: Option, + ) -> Result<()> { self.inner .drop_database( - DropDatabaseRequest::new(name).ignore_missing(ignore_missing.unwrap_or(false)), + DropDatabaseRequest::new(name) + .ignore_missing(ignore_missing.unwrap_or(false)) + .cascade(cascade.unwrap_or(false)), ) .await .default_error() diff --git a/python/python/lancedb/_lancedb.pyi b/python/python/lancedb/_lancedb.pyi index d5fcac30f..66fdd3a7c 100644 --- a/python/python/lancedb/_lancedb.pyi +++ b/python/python/lancedb/_lancedb.pyi @@ -990,7 +990,7 @@ class Catalog: ) -> Connection: ... async def connect_database(self, name: str) -> Connection: ... async def drop_database( - self, name: str, *, ignore_missing: bool = False + self, name: str, *, ignore_missing: bool = False, cascade: bool = False ) -> None: ... def list_databases( self, *, page_token: Optional[str] = None, page_limit: Optional[int] = None diff --git a/python/python/lancedb/catalog.py b/python/python/lancedb/catalog.py index 666d414a4..9a0dc3412 100644 --- a/python/python/lancedb/catalog.py +++ b/python/python/lancedb/catalog.py @@ -130,9 +130,15 @@ class AsyncCatalog: self._inner.list_databases(page_token=page_token, page_limit=page_limit) ) - async def drop_database(self, name: str, *, ignore_missing: bool = False) -> None: - """Drop an empty database. A nonempty database is an error.""" - await self._inner.drop_database(name, ignore_missing=ignore_missing) + async def drop_database( + self, name: str, *, ignore_missing: bool = False, cascade: bool = False + ) -> None: + """Drop a database. A nonempty database is an error unless ``cascade`` is set, + which drops everything in it first; the name stays taken until the server + finishes cleaning up their storage.""" + await self._inner.drop_database( + name, ignore_missing=ignore_missing, cascade=cascade + ) class Catalog: @@ -207,9 +213,17 @@ class Catalog: self._inner.list_databases(page_token=page_token, page_limit=page_limit) ) - def drop_database(self, name: str, *, ignore_missing: bool = False) -> None: - """Drop an empty database. A nonempty database is an error.""" - LOOP.run(self._inner.drop_database(name, ignore_missing=ignore_missing)) + def drop_database( + self, name: str, *, ignore_missing: bool = False, cascade: bool = False + ) -> None: + """Drop a database. A nonempty database is an error unless ``cascade`` is set, + which drops everything in it first; the name stays taken until the server + finishes cleaning up their storage.""" + LOOP.run( + self._inner.drop_database( + name, ignore_missing=ignore_missing, cascade=cascade + ) + ) async def connect_catalog_async( diff --git a/python/python/tests/test_catalog.py b/python/python/tests/test_catalog.py index b2036b7a6..b259778dd 100644 --- a/python/python/tests/test_catalog.py +++ b/python/python/tests/test_catalog.py @@ -86,12 +86,12 @@ def test_catalog_sync_scope_and_serialization(catalog_server): connection.execute_query_async("SELECT 1") assert restored.table_names() == [] assert list(catalog.list_databases()) == ["team/search"] - catalog.drop_database("team/search", ignore_missing=True) + catalog.drop_database("team/search", ignore_missing=True, cascade=True) assert requests[0][0] == "/v1/namespace/team%2Fsearch/create" assert requests[0][2] == {"mode": "ExistOk"} assert requests[1][0] == "/v1/namespace/team%2Fsearch/describe" assert requests[4][0] == "/v1/namespace/$/list" - assert requests[5][2] == {"mode": "Skip", "behavior": "Restrict"} + assert requests[5][2] == {"mode": "Skip", "behavior": "Cascade"} for i, (_, headers, _) in enumerate(requests): headers = {key.lower(): value for key, value in headers.items()} assert headers.get("x-lancedb-database") == ( diff --git a/python/src/catalog.rs b/python/src/catalog.rs index 9e375b314..fb2e7e8a0 100644 --- a/python/src/catalog.rs +++ b/python/src/catalog.rs @@ -60,16 +60,21 @@ impl Catalog { }) } - #[pyo3(signature = (name, *, ignore_missing=false))] + #[pyo3(signature = (name, *, ignore_missing=false, cascade=false))] fn drop_database<'py>( self_: PyRef<'py, Self>, name: String, ignore_missing: bool, + cascade: bool, ) -> PyResult> { let inner = self_.inner.clone(); future_into_py(self_.py(), async move { inner - .drop_database(DropDatabaseRequest::new(name).ignore_missing(ignore_missing)) + .drop_database( + DropDatabaseRequest::new(name) + .ignore_missing(ignore_missing) + .cascade(cascade), + ) .await .infer_error() }) diff --git a/rust/lancedb/src/catalog.rs b/rust/lancedb/src/catalog.rs index ad8674b77..18373ed5a 100644 --- a/rust/lancedb/src/catalog.rs +++ b/rust/lancedb/src/catalog.rs @@ -59,7 +59,7 @@ impl> From for CreateDatabaseRequest { } } -/// Options for restricted database deletion. Tables must be removed first. +/// Options for database deletion. The database must be empty unless `cascade` is set. #[derive(Clone, Debug)] #[non_exhaustive] pub struct DropDatabaseRequest { @@ -67,6 +67,8 @@ pub struct DropDatabaseRequest { pub name: String, /// Succeed if the database is absent. pub ignore_missing: bool, + /// Drop everything in the database first. + pub cascade: bool, } impl DropDatabaseRequest { @@ -75,6 +77,7 @@ impl DropDatabaseRequest { Self { name: name.into(), ignore_missing: false, + cascade: false, } } @@ -83,6 +86,13 @@ impl DropDatabaseRequest { self.ignore_missing = value; self } + + /// Drop every table, view, function, secret and namespace in the database first. The + /// server cleans up their storage in the background, and the name stays taken until then. + pub fn cascade(mut self, value: bool) -> Self { + self.cascade = value; + self + } } impl> From for DropDatabaseRequest { @@ -204,7 +214,7 @@ pub trait Catalog: Send + Sync + std::fmt::Debug + 'static { fn uri(&self) -> &str; /// Create a database, or open it when `exist_ok` permits. async fn create_database(&self, request: CreateDatabaseRequest) -> Result>; - /// Drop an empty database. This must not cascade to tables. + /// Drop a database, which must be empty unless the request cascades. async fn drop_database(&self, request: DropDatabaseRequest) -> Result<()>; /// List a page of database names. async fn list_databases(&self, request: ListDatabasesRequest) -> Result; @@ -267,7 +277,7 @@ impl CatalogConnection { )) } - /// Drop an empty database; pass a name or [`DropDatabaseRequest`] for additional options. + /// Drop a database; pass a name or [`DropDatabaseRequest`] for options such as `cascade`. pub async fn drop_database(&self, request: impl Into) -> Result<()> { self.catalog.drop_database(request.into()).await } diff --git a/rust/lancedb/src/remote/catalog.rs b/rust/lancedb/src/remote/catalog.rs index df3782368..95facd04b 100644 --- a/rust/lancedb/src/remote/catalog.rs +++ b/rust/lancedb/src/remote/catalog.rs @@ -226,7 +226,14 @@ impl Catalog for RemoteCatalog { } .into(), ), - behavior: Some("Restrict".into()), + behavior: Some( + if request.cascade { + "Cascade" + } else { + "Restrict" + } + .into(), + ), ..Default::default() }) .await; @@ -474,7 +481,7 @@ mod tests { } #[tokio::test] - async fn catalog_preserves_errors_and_never_cascades() { + async fn catalog_preserves_errors_and_restricts_drops_by_default() { let (endpoint, task) = server(vec![ (404, json!({"error": "missing"})), (409, json!({"error": "exists"})), @@ -522,6 +529,26 @@ mod tests { ); } + #[tokio::test] + async fn drop_database_cascades_only_when_asked() { + let (endpoint, task) = server(vec![(200, json!({})), (200, json!({}))]).await; + let catalog = RemoteCatalog::try_new(endpoint, RemoteCatalogOptions::default()).unwrap(); + catalog.drop_database("db".into()).await.unwrap(); + catalog + .drop_database(DropDatabaseRequest::new("db").cascade(true)) + .await + .unwrap(); + let requests = task.await.unwrap(); + assert_eq!( + requests[0].body, + json!({"mode": "Fail", "behavior": "Restrict"}) + ); + assert_eq!( + requests[1].body, + json!({"mode": "Fail", "behavior": "Cascade"}) + ); + } + #[tokio::test] async fn database_names_follow_pages_lazily() { use futures::TryStreamExt;