diff --git a/chromadb/api/__init__.py b/chromadb/api/__init__.py index 81218bbce4a..77d908c8e70 100644 --- a/chromadb/api/__init__.py +++ b/chromadb/api/__init__.py @@ -516,6 +516,17 @@ def get_database(self, name: str, tenant: str = DEFAULT_TENANT) -> Database: """ pass + @abstractmethod + def delete_database(self, name: str, tenant: str = DEFAULT_TENANT) -> None: + """Delete a database. Raises an error if the database does not exist. + + Args: + database: The name of the database to delete. + tenant: The tenant of the database to delete. + + """ + pass + @abstractmethod def list_databases( self, diff --git a/chromadb/api/async_api.py b/chromadb/api/async_api.py index a2b147ddf74..eb206d9303f 100644 --- a/chromadb/api/async_api.py +++ b/chromadb/api/async_api.py @@ -508,6 +508,17 @@ async def get_database(self, name: str, tenant: str = DEFAULT_TENANT) -> Databas """ pass + @abstractmethod + async def delete_database(self, name: str, tenant: str = DEFAULT_TENANT) -> None: + """Delete a database. Raises an error if the database does not exist. + + Args: + database: The name of the database to delete. + tenant: The tenant of the database to delete. + + """ + pass + @abstractmethod async def list_databases( self, diff --git a/chromadb/api/async_client.py b/chromadb/api/async_client.py index ac83ba60f4c..6e2bd1e8b04 100644 --- a/chromadb/api/async_client.py +++ b/chromadb/api/async_client.py @@ -445,6 +445,10 @@ async def create_database(self, name: str, tenant: str = DEFAULT_TENANT) -> None async def get_database(self, name: str, tenant: str = DEFAULT_TENANT) -> Database: return await self._server.get_database(name=name, tenant=tenant) + @override + async def delete_database(self, name: str, tenant: str = DEFAULT_TENANT) -> None: + return await self._server.delete_database(name=name, tenant=tenant) + @override async def list_databases( self, diff --git a/chromadb/api/async_fastapi.py b/chromadb/api/async_fastapi.py index 38c00225b30..bd566503231 100644 --- a/chromadb/api/async_fastapi.py +++ b/chromadb/api/async_fastapi.py @@ -168,6 +168,18 @@ async def get_database( id=response["id"], name=response["name"], tenant=response["tenant"] ) + @trace_method("AsyncFastAPI.delete_database", OpenTelemetryGranularity.OPERATION) + @override + async def delete_database( + self, + name: str, + tenant: str = DEFAULT_TENANT, + ) -> None: + await self._make_request( + "delete", + f"/tenants/{tenant}/databases/{name}", + ) + @trace_method("AsyncFastAPI.list_databases", OpenTelemetryGranularity.OPERATION) @override async def list_databases( diff --git a/chromadb/api/client.py b/chromadb/api/client.py index e63751f0035..38980c82ff2 100644 --- a/chromadb/api/client.py +++ b/chromadb/api/client.py @@ -452,6 +452,10 @@ def create_database(self, name: str, tenant: str = DEFAULT_TENANT) -> None: def get_database(self, name: str, tenant: str = DEFAULT_TENANT) -> Database: return self._server.get_database(name=name, tenant=tenant) + @override + def delete_database(self, name: str, tenant: str = DEFAULT_TENANT) -> None: + return self._server.delete_database(name=name, tenant=tenant) + @override def list_databases( self, diff --git a/chromadb/api/fastapi.py b/chromadb/api/fastapi.py index 04a48e81593..1cb6922e0d7 100644 --- a/chromadb/api/fastapi.py +++ b/chromadb/api/fastapi.py @@ -127,6 +127,19 @@ def get_database( id=resp_json["id"], name=resp_json["name"], tenant=resp_json["tenant"] ) + @trace_method("FastAPI.delete_database", OpenTelemetryGranularity.OPERATION) + @override + def delete_database( + self, + name: str, + tenant: str = DEFAULT_TENANT, + ) -> None: + """Deletes a database""" + self._make_request( + "delete", + f"/tenants/{tenant}/databases/{name}", + ) + @trace_method("FastAPI.list_databases", OpenTelemetryGranularity.OPERATION) @override def list_databases( diff --git a/chromadb/api/segment.py b/chromadb/api/segment.py index 173a64a8e61..d10cbdaabf7 100644 --- a/chromadb/api/segment.py +++ b/chromadb/api/segment.py @@ -157,6 +157,11 @@ def create_database(self, name: str, tenant: str = DEFAULT_TENANT) -> None: def get_database(self, name: str, tenant: str = DEFAULT_TENANT) -> t.Database: return self._sysdb.get_database(name=name, tenant=tenant) + @trace_method("SegmentAPI.delete_database", OpenTelemetryGranularity.OPERATION) + @override + def delete_database(self, name: str, tenant: str = DEFAULT_TENANT) -> None: + self._sysdb.delete_database(name=name, tenant=tenant) + @trace_method("SegmentAPI.list_databases", OpenTelemetryGranularity.OPERATION) @override def list_databases( diff --git a/chromadb/auth/__init__.py b/chromadb/auth/__init__.py index bbf7b235de1..3a5964dee25 100644 --- a/chromadb/auth/__init__.py +++ b/chromadb/auth/__init__.py @@ -164,6 +164,7 @@ class AuthzAction(str, Enum): GET_TENANT = "tenant:get_tenant" CREATE_DATABASE = "db:create_database" GET_DATABASE = "db:get_database" + DELETE_DATABASE = "db:delete_database" LIST_DATABASES = "db:list_databases" LIST_COLLECTIONS = "db:list_collections" COUNT_COLLECTIONS = "db:count_collections" diff --git a/chromadb/db/impl/grpc/client.py b/chromadb/db/impl/grpc/client.py index 0a0c7107e1f..43131c0c667 100644 --- a/chromadb/db/impl/grpc/client.py +++ b/chromadb/db/impl/grpc/client.py @@ -126,6 +126,10 @@ def get_database(self, name: str, tenant: str = DEFAULT_TENANT) -> Database: raise NotFoundError() raise InternalError() + @overrides + def delete_database(self, name: str, tenant: str = DEFAULT_TENANT) -> None: + raise NotImplementedError() + @overrides def list_databases( self, diff --git a/chromadb/db/mixins/sysdb.py b/chromadb/db/mixins/sysdb.py index 5901dbd764e..c31b7388016 100644 --- a/chromadb/db/mixins/sysdb.py +++ b/chromadb/db/mixins/sysdb.py @@ -116,6 +116,35 @@ def get_database(self, name: str, tenant: str = DEFAULT_TENANT) -> Database: tenant=tenant, ) + @override + def delete_database(self, name: str, tenant: str = DEFAULT_TENANT) -> None: + with self.tx() as cur: + databases = Table("databases") + q = ( + self.querybuilder() + .from_(databases) + .where(databases.name == ParameterValue(name)) + .where(databases.tenant_id == ParameterValue(tenant)) + .delete() + ) + sql, params = get_sql(q, self.parameter_format()) + sql = sql + " RETURNING id" + result = cur.execute(sql, params).fetchone() + if not result: + raise NotFoundError(f"Database {name} not found for tenant {tenant}") + + # As of 01/09/2025, cascading deletes don't work because foreign keys are not enabled. + # See https://github.com/chroma-core/chroma/issues/3456. + collections = Table("collections") + q = ( + self.querybuilder() + .from_(collections) + .where(collections.database_id == ParameterValue(result[0])) + .delete() + ) + sql, params = get_sql(q, self.parameter_format()) + cur.execute(sql, params) + @override def list_databases( self, diff --git a/chromadb/db/system.py b/chromadb/db/system.py index 9a39b93d4fb..40bc4ab468e 100644 --- a/chromadb/db/system.py +++ b/chromadb/db/system.py @@ -34,6 +34,11 @@ def get_database(self, name: str, tenant: str = DEFAULT_TENANT) -> Database: exist.""" pass + @abstractmethod + def delete_database(self, name: str, tenant: str = DEFAULT_TENANT) -> None: + """Delete a database.""" + pass + @abstractmethod def list_databases( self, diff --git a/chromadb/server/fastapi/__init__.py b/chromadb/server/fastapi/__init__.py index f8f023b745b..0cede0cddc7 100644 --- a/chromadb/server/fastapi/__init__.py +++ b/chromadb/server/fastapi/__init__.py @@ -288,6 +288,13 @@ def setup_v2_routes(self) -> None: response_model=None, ) + self.router.add_api_route( + "/api/v2/tenants/{tenant}/databases/{database_name}", + self.delete_database, + methods=["DELETE"], + response_model=None, + ) + self.router.add_api_route( "/api/v2/tenants", self.create_tenant, @@ -559,6 +566,28 @@ async def get_database( ), ) + @trace_method("FastAPI.delete_database", OpenTelemetryGranularity.OPERATION) + async def delete_database( + self, + request: Request, + database_name: str, + tenant: str, + ) -> None: + self.auth_request( + request.headers, + AuthzAction.DELETE_DATABASE, + tenant, + database_name, + None, + ) + + await to_thread.run_sync( + self._api.delete_database, + database_name, + tenant, + limiter=self._capacity_limiter, + ) + @trace_method("FastAPI.create_tenant", OpenTelemetryGranularity.OPERATION) async def create_tenant( self, diff --git a/chromadb/test/api/test_delete_database.py b/chromadb/test/api/test_delete_database.py new file mode 100644 index 00000000000..b6b6f972114 --- /dev/null +++ b/chromadb/test/api/test_delete_database.py @@ -0,0 +1,76 @@ +import pytest +from chromadb.api.client import AdminClient, Client +from chromadb.config import System +from chromadb.db.impl.sqlite import SqliteDB +from chromadb.errors import InvalidCollectionException, NotFoundError +from chromadb.test.conftest import NOT_CLUSTER_ONLY, ClientFactories + + +def test_deletes_database(client_factories: ClientFactories) -> None: + if not NOT_CLUSTER_ONLY: + pytest.skip("This API is not yet supported by distributed") + + admin_client = client_factories.create_admin_client_from_system() + + admin_client.create_database("test_delete_database") + + client = client_factories.create_client(database="test_delete_database") + collection = client.create_collection("foo") + + admin_client.delete_database("test_delete_database") + + with pytest.raises(NotFoundError): + admin_client.get_database("test_delete_database") + + with pytest.raises(InvalidCollectionException): + client.get_collection("foo") + + with pytest.raises(InvalidCollectionException): + collection.upsert(["foo"], [0.0, 0.0, 0.0]) + + +def test_does_not_affect_other_databases(client_factories: ClientFactories) -> None: + if not NOT_CLUSTER_ONLY: + pytest.skip("This API is not yet supported by distributed") + + admin_client = client_factories.create_admin_client_from_system() + + admin_client.create_database("first") + admin_client.create_database("second") + + client = client_factories.create_client(database="second") + collection = client.create_collection("test") + + admin_client.delete_database("first") + + assert client.get_collection("test").id == collection.id + + +def test_collection_was_removed(sqlite_persistent: System) -> None: + sqlite = sqlite_persistent.instance(SqliteDB) + + admin_client = AdminClient.from_system(sqlite_persistent) + admin_client.create_database("test_delete_database") + + client = Client.from_system(sqlite_persistent, database="test_delete_database") + client.create_collection("foo") + + admin_client.delete_database("test_delete_database") + + with pytest.raises(InvalidCollectionException): + client.get_collection("foo") + + # Check table + with sqlite.tx() as cur: + row = cur.execute("SELECT COUNT(*) from collections").fetchone() + assert row[0] == 0 + + +def test_errors_when_database_does_not_exist(client_factories: ClientFactories) -> None: + if not NOT_CLUSTER_ONLY: + pytest.skip("This API is not yet supported by distributed") + + admin_client = client_factories.create_admin_client_from_system() + + with pytest.raises(NotFoundError): + admin_client.delete_database("foo") diff --git a/clients/js/src/AdminClient.ts b/clients/js/src/AdminClient.ts index 27f697463a4..d66e635d97f 100644 --- a/clients/js/src/AdminClient.ts +++ b/clients/js/src/AdminClient.ts @@ -243,6 +243,26 @@ export class AdminClient { return { name: getDatabase.name } as Database; } + /** + * Deletes a database. + * + * @param {Object} params - The parameters for deleting a database. + * @param {string} params.name - The name of the database. + * @param {string} params.tenantName - The name of the tenant. + * + * @returns {Promise} A promise that returns nothing. + * @throws {Error} If there is an issue deleting the database. + */ + public async deleteDatabase({ + name, + tenantName, + }: { + name: string; + tenantName: string; + }): Promise { + await this.api.deleteDatabase(name, tenantName, this.api.options); + } + /** * Lists database for a specific tenant. * diff --git a/clients/js/src/generated/api.ts b/clients/js/src/generated/api.ts index dddfee58ba8..ff6e97b64c4 100644 --- a/clients/js/src/generated/api.ts +++ b/clients/js/src/generated/api.ts @@ -723,6 +723,44 @@ export const ApiApiFetchParamCreator = function (configuration?: Configuration) options: localVarRequestOptions, }; }, + /** + * @summary Delete Database + * @param {string} databaseName + * @param {string} tenant + * @param {RequestInit} [options] Override http request option. + * @throws {RequiredError} + */ + deleteDatabase(databaseName: string, tenant: string, options: RequestInit = {}): FetchArgs { + // verify required parameter 'databaseName' is not null or undefined + if (databaseName === null || databaseName === undefined) { + throw new RequiredError('databaseName', 'Required parameter databaseName was null or undefined when calling deleteDatabase.'); + } + // verify required parameter 'tenant' is not null or undefined + if (tenant === null || tenant === undefined) { + throw new RequiredError('tenant', 'Required parameter tenant was null or undefined when calling deleteDatabase.'); + } + let localVarPath = `/api/v2/tenants/{tenant}/databases/{database_name}` + .replace('{database_name}', encodeURIComponent(String(databaseName))) + .replace('{tenant}', encodeURIComponent(String(tenant))); + const localVarPathQueryStart = localVarPath.indexOf("?"); + const localVarRequestOptions: RequestInit = Object.assign({ method: 'DELETE' }, options); + const localVarHeaderParameter: Headers = options.headers ? new Headers(options.headers) : new Headers(); + const localVarQueryParameter = new URLSearchParams(localVarPathQueryStart !== -1 ? localVarPath.substring(localVarPathQueryStart + 1) : ""); + if (localVarPathQueryStart !== -1) { + localVarPath = localVarPath.substring(0, localVarPathQueryStart); + } + + localVarRequestOptions.headers = localVarHeaderParameter; + + const localVarQueryParameterString = localVarQueryParameter.toString(); + if (localVarQueryParameterString) { + localVarPath += "?" + localVarQueryParameterString; + } + return { + url: localVarPath, + options: localVarRequestOptions, + }; + }, /** * @summary Delete V1 * @param {string} collectionId @@ -2333,6 +2371,36 @@ export const ApiApiFp = function(configuration?: Configuration) { }); }; }, + /** + * @summary Delete Database + * @param {string} databaseName + * @param {string} tenant + * @param {RequestInit} [options] Override http request option. + * @throws {RequiredError} + */ + deleteDatabase(databaseName: string, tenant: string, options?: RequestInit): (fetch?: FetchAPI, basePath?: string) => Promise { + const localVarFetchArgs = ApiApiFetchParamCreator(configuration).deleteDatabase(databaseName, tenant, options); + return (fetch: FetchAPI = defaultFetch, basePath: string = BASE_PATH) => { + return fetch(basePath + localVarFetchArgs.url, localVarFetchArgs.options).then((response) => { + const contentType = response.headers.get('Content-Type'); + const mimeType = contentType ? contentType.replace(/;.*/, '') : undefined; + + if (response.status === 200) { + if (mimeType === 'application/json') { + return response.json() as any; + } + throw response; + } + if (response.status === 422) { + if (mimeType === 'application/json') { + throw response; + } + throw response; + } + throw response; + }); + }; + }, /** * @summary Delete V1 * @param {string} collectionId @@ -3360,6 +3428,17 @@ export class ApiApi extends BaseAPI { return ApiApiFp(this.configuration).deleteCollectionV1(collectionName, tenant, database, options)(this.fetch, this.basePath); } + /** + * @summary Delete Database + * @param {string} databaseName + * @param {string} tenant + * @param {RequestInit} [options] Override http request option. + * @throws {RequiredError} + */ + public deleteDatabase(databaseName: string, tenant: string, options?: RequestInit) { + return ApiApiFp(this.configuration).deleteDatabase(databaseName, tenant, options)(this.fetch, this.basePath); + } + /** * @summary Delete V1 * @param {string} collectionId diff --git a/clients/js/src/generated/models.ts b/clients/js/src/generated/models.ts index 5c9a65eac25..b959113305d 100644 --- a/clients/js/src/generated/models.ts +++ b/clients/js/src/generated/models.ts @@ -209,6 +209,9 @@ export namespace Api { export interface DeleteCollectionV1200Response { } + export interface DeleteDatabase200Response { + } + export interface DeleteV1200Response { } diff --git a/clients/js/test/admin.test.ts b/clients/js/test/admin.test.ts index 567a2136f8f..c46285bfe26 100644 --- a/clients/js/test/admin.test.ts +++ b/clients/js/test/admin.test.ts @@ -42,6 +42,19 @@ describe("AdminClient", () => { expect(getDatabase.name).toBe("test"); }); + test("it should delete a database", async () => { + await adminClient.createTenant({ name: "test4" }); + await adminClient.createDatabase({ + name: "test", + tenantName: "test4", + }); + await adminClient.deleteDatabase({ name: "test", tenantName: "test4" }); + + await expect( + adminClient.getDatabase({ name: "test", tenantName: "test4" }), + ).rejects.toThrow(); + }); + test("it should list databases for a tenant", async () => { await adminClient.createTenant({ name: "test2" }); diff --git a/docs/docs.trychroma.com/markdoc/content/reference/python/client.md b/docs/docs.trychroma.com/markdoc/content/reference/python/client.md index 61df475941f..5231d7766fd 100644 --- a/docs/docs.trychroma.com/markdoc/content/reference/python/client.md +++ b/docs/docs.trychroma.com/markdoc/content/reference/python/client.md @@ -471,6 +471,19 @@ Get a database. Raises an error if the database does not exist. - `database` - The name of the database to get. - `tenant` - The tenant of the database to get. +## delete_database + +```python +def delete_database(name: str, tenant: str = DEFAULT_TENANT) -> None +``` + +Delete a database and all associated collections. Raises an error if the database does not exist. + +**Arguments**: + +- `database` - The name of the database to delete. +- `tenant` - The tenant of the database to delete. + ## list_databases ```python