diff --git a/db/databases.py b/db/databases.py index b9b4f04fb3..e72575e2d6 100644 --- a/db/databases.py +++ b/db/databases.py @@ -1,5 +1,5 @@ from psycopg import sql - +from config.database_config import get_internal_database_config from db import connection as db_conn @@ -8,22 +8,40 @@ def get_database(conn): def drop_database(database_oid, conn): - cursor = conn.cursor() + icfg = get_internal_database_config() + conn.commit() conn.autocommit = True - drop_database_query = db_conn.exec_msar_func( - conn, - 'drop_database_query', - database_oid - ).fetchone()[0] - cursor.execute(sql.SQL(drop_database_query)) - cursor.close() - conn.autocommit = False + + with conn.cursor() as c: + c.execute("SELECT datname FROM pg_database WHERE oid = %s", (database_oid,)) + dbname = c.fetchone() + if not dbname: + raise ValueError("Database OID not found") + c.execute(sql.SQL("ALTER DATABASE {} OWNER TO {}") + .format(sql.Identifier(dbname[0]), sql.Identifier(icfg.role))) + + with db_conn.mathesar_connection( + host=icfg.host, port=icfg.port, dbname=icfg.dbname, + user=icfg.role, password=icfg.password, sslmode=icfg.sslmode, + application_name='db.databases.drop_database', + ) as c2: + # Set autocommit immediately after connection + c2.autocommit = True + with c2.cursor() as cur: + cur.execute( + sql.SQL(""" + SELECT pg_terminate_backend(pg_stat_activity.pid) + FROM pg_stat_activity + WHERE pg_stat_activity.datname = %s + AND pid <> pg_backend_pid() + """), + (dbname[0],) + ) + cur.execute(sql.SQL("DROP DATABASE {}").format(sql.Identifier(dbname[0]))) def create_database(database_name, conn): - """Use the given connection to create a database.""" + conn.commit() conn.autocommit = True - conn.execute( - sql.SQL('CREATE DATABASE {}').format(sql.Identifier(database_name)) - ) - conn.autocommit = False + with conn.cursor() as c: + c.execute(sql.SQL('CREATE DATABASE {}').format(sql.Identifier(database_name))) diff --git a/mathesar/rpc/databases/base.py b/mathesar/rpc/databases/base.py index ca4bcdeb4d..5c58e0918d 100644 --- a/mathesar/rpc/databases/base.py +++ b/mathesar/rpc/databases/base.py @@ -1,7 +1,6 @@ from typing import Literal, TypedDict - from modernrpc.core import REQUEST_KEY - +from config.database_config import get_internal_database_config from db.databases import get_database, drop_database from mathesar.models.base import Database from mathesar.rpc.utils import connect @@ -63,8 +62,14 @@ def delete(*, database_oid: int, database_id: int, **kwargs) -> None: database_id: The Django id of the database to connect to. """ user = kwargs.get(REQUEST_KEY).user - with connect(database_id, user) as conn: - drop_database(database_oid, conn) + if not user.is_superuser: + raise Exception("Only Mathesar admins can delete databases") + db = Database.objects.get(id=database_id) + icfg = get_internal_database_config() + if db.server.host != icfg.host or db.server.port != icfg.port: + raise Exception("Only databases on the internal server can be deleted") + with connect(database_id, user) as c: + drop_database(database_oid, c) @mathesar_rpc_method(name="databases.upgrade_sql") diff --git a/mathesar/rpc/databases/configured.py b/mathesar/rpc/databases/configured.py index 0c650b4513..020063ba13 100644 --- a/mathesar/rpc/databases/configured.py +++ b/mathesar/rpc/databases/configured.py @@ -2,9 +2,12 @@ from modernrpc.core import REQUEST_KEY +from config.database_config import get_internal_database_config +from db.databases import drop_database as drop_database_from_server from mathesar.models.base import Database from mathesar.models import exceptions as db_exceptions from mathesar.rpc.decorators import mathesar_rpc_method +from mathesar.rpc.utils import connect class ConfiguredDatabaseInfo(TypedDict): @@ -109,8 +112,10 @@ class DisconnectResult(TypedDict): Attributes: sql_cleaned: Whether Mathesar schemas were successfully removed from the database. False indicates the connection was unavailable and cleanup was skipped. + database_dropped: Whether the database was dropped from the server. """ sql_cleaned: bool + database_dropped: bool @mathesar_rpc_method(name="databases.configured.disconnect") @@ -121,7 +126,9 @@ def disconnect( strict: bool = True, role_name: str = None, password: str = None, - disconnect_db_server: bool = False + disconnect_db_server: bool = False, + drop_database: bool = False, + **kwargs ) -> DisconnectResult: """ Disconnect a configured database, after removing Mathesar SQL from it. @@ -148,14 +155,25 @@ def disconnect( metadata(host, port, role credentials) from Mathesar. This is intended for optional use while disconnecting the last database on the server. + drop_database: If True, will drop the database from the server. + Only works for databases on the internal server and requires + Mathesar admin privileges. Returns: The result of the disconnect operation. """ + user = kwargs.get(REQUEST_KEY).user database = Database.objects.get(id=database_id) + database_dropped = False + + if drop_database: + if not user.is_superuser: + raise Exception("Only Mathesar admins can drop databases") + + icfg = get_internal_database_config() + if database.server.host != icfg.host or database.server.port != icfg.port: + raise Exception("Only databases on the internal server can be dropped") - # Try to uninstall SQL, but if connection is unavailable, skip it - # This allows disconnecting databases with broken connections sql_cleaned = True try: database.uninstall_sql( @@ -165,12 +183,28 @@ def disconnect( password=password, ) except db_exceptions.NoConnectionAvailable: - # Connection is broken, skip SQL cleanup and just remove the database record sql_cleaned = False + if drop_database: + try: + with connect(database_id, user) as conn: + conn.commit() + conn.autocommit = True + with conn.cursor() as c: + c.execute("SELECT oid FROM pg_database WHERE datname = %s", (database.name,)) + result = c.fetchone() + if result: + database_oid = result[0] + drop_database_from_server(database_oid, conn) + database_dropped = True + else: + raise Exception(f"Database {database.name} not found on server") + except Exception as e: + raise Exception(f"Failed to drop database: {str(e)}") + database.delete() server_db_count = len(Database.objects.filter(server=database.server)) if disconnect_db_server and server_db_count == 0: database.server.delete() - return DisconnectResult(sql_cleaned=sql_cleaned) + return DisconnectResult(sql_cleaned=sql_cleaned, database_dropped=database_dropped) diff --git a/mathesar_ui/src/api/rpc/databases.ts b/mathesar_ui/src/api/rpc/databases.ts index 847371f3e2..c576533d70 100644 --- a/mathesar_ui/src/api/rpc/databases.ts +++ b/mathesar_ui/src/api/rpc/databases.ts @@ -92,9 +92,11 @@ export const databases = { role_name?: string; password?: string; disconnect_db_server?: boolean; + drop_database?: boolean; }, { sql_cleaned: boolean; + database_dropped: boolean; } >(), }, diff --git a/mathesar_ui/src/i18n/languages/en/dict.json b/mathesar_ui/src/i18n/languages/en/dict.json index a53bd54942..cb6dcb2d36 100644 --- a/mathesar_ui/src/i18n/languages/en/dict.json +++ b/mathesar_ui/src/i18n/languages/en/dict.json @@ -198,6 +198,8 @@ "database_disconnect_form_into": "This will disconnect the database from Mathesar.", "database_disconnected_successfully": "Database disconnected successfully", "database_disconnected_without_sql_cleanup": "Database disconnected successfully. Mathesar was unable to connect to the database to remove internal schemas.", + "database_dropped_successfully": "Database dropped successfully", + "database_dropped_without_sql_cleanup": "Database dropped successfully. Mathesar was unable to connect to the database to remove internal schemas.", "database_name": "Database Name", "database_new_items_scroll_hint": "Scroll or click here to see the database.", "database_not_found": "Database with id [connectionId] is not found.", @@ -255,6 +257,7 @@ "donate_blurb": "Help us build and maintain Mathesar with a donation to the Mathesar Foundation, a 501(c)(3) nonprofit organization.", "donate_to_mathesar": "Donate to Mathesar", "download": "Download", + "drop_database_warning": "Warning: This will permanently delete the database and all its data from the PostgreSQL server. This action cannot be undone.", "drop_not_yet_implemented": "Dropping databases from within Mathesar is not yet implemented. You can use PostgreSQL directly to drop the database if needed. See this [link](issue) for more information.", "drop_role": "Drop Role", "drop_role_name_question": "Drop role [name]?", diff --git a/mathesar_ui/src/pages/database/DatabasePageWrapper.svelte b/mathesar_ui/src/pages/database/DatabasePageWrapper.svelte index ab76292c21..53271842b7 100644 --- a/mathesar_ui/src/pages/database/DatabasePageWrapper.svelte +++ b/mathesar_ui/src/pages/database/DatabasePageWrapper.svelte @@ -43,14 +43,6 @@ $: ({ database, underlyingDatabase } = $databaseRouteContext); $: void underlyingDatabase.runConservatively(); - // // TODO Allow dropping databases - // const commonData = preloadCommonData(); - // $: currentRoleOwnsDatabase = - // $underlyingDatabase.resolvedValue?.currentAccess.currentRoleOwns; - // $: isDatabaseInInternalServer = - // database.server.host === commonData.internal_db.host && - // database.server.port === commonData.internal_db.port; - const permissionsModal = modal.spawnModalController(); const disconnectModal = modal.spawnModalController(); const reinstallModal = modal.spawnModalController(); @@ -146,19 +138,6 @@ > {$_('reinstall_mathesar_schemas')} - - {/if} @@ -203,7 +182,13 @@ controller={disconnectModal} disconnect={async (opts) => { const result = await databasesStore.disconnectDatabase(opts); - if (result.sql_cleaned) { + if (result.database_dropped) { + toast.success( + result.sql_cleaned + ? $_('database_dropped_successfully') + : $_('database_dropped_without_sql_cleanup'), + ); + } else if (result.sql_cleaned) { toast.success($_('database_disconnected_successfully')); } else { toast.success($_('database_disconnected_without_sql_cleanup')); diff --git a/mathesar_ui/src/pages/database/disconnect/DisconnectDatabaseForm.svelte b/mathesar_ui/src/pages/database/disconnect/DisconnectDatabaseForm.svelte index 43326fa5a2..184c418698 100644 --- a/mathesar_ui/src/pages/database/disconnect/DisconnectDatabaseForm.svelte +++ b/mathesar_ui/src/pages/database/disconnect/DisconnectDatabaseForm.svelte @@ -19,6 +19,8 @@ type DatabaseDisconnectFn, databasesStore, } from '@mathesar/stores/databases'; + import { getUserProfileStoreFromContext } from '@mathesar/stores/userProfile'; + import { preloadCommonData } from '@mathesar/utils/preloadData'; import { Checkbox, Fieldset, @@ -35,6 +37,23 @@ export let disconnect: DatabaseDisconnectFn; export let cancel: () => void; + const userProfileStore = getUserProfileStoreFromContext(); + const commonData = preloadCommonData(); + + $: isAdmin = $userProfileStore?.isMathesarAdmin ?? false; + $: isInternalDatabase = (() => { + if (commonData.routing_context === 'anonymous') { + return false; + } + const internalDb = commonData.internal_db; + return ( + internalDb && + database.server.host === internalDb.host && + database.server.port === internalDb.port + ); + })(); + $: canDropDatabase = isAdmin && isInternalDatabase; + const dropOptions = { keep: $_('keep_the_database'), drop: $_('drop_the_database'), @@ -85,6 +104,7 @@ schemas_to_remove: schemasToRemove, role: $useRole ? { name: $roleName, password: $rolePassword } : undefined, disconnect_db_server: isLastDbInServer && $removeDbServer, + drop_database: $dropOption === 'drop', }); } @@ -192,23 +212,31 @@ /> {#if $dropOption === 'drop'} - - - - {#if slotName === 'link'} - - {translatedArg} - - {/if} - - - + {#if !canDropDatabase} + + + + {#if slotName === 'link'} + + {translatedArg} + + {/if} + + + + {:else} + + + {$_('drop_database_warning')} + + + {/if} {:else if $dropOption === 'keep'} @@ -309,7 +337,8 @@ form.reset(); cancel(); }} - canProceed={$dropOption === 'keep'} + canProceed={$dropOption === 'keep' || + ($dropOption === 'drop' && canDropDatabase)} onProceed={submit} proceedButton={{ label: $_('disconnect') }} cancelButton={{ label: $_('cancel') }} diff --git a/mathesar_ui/src/pages/home/database-card/DatabaseCard.svelte b/mathesar_ui/src/pages/home/database-card/DatabaseCard.svelte index be6e1856d8..629333f432 100644 --- a/mathesar_ui/src/pages/home/database-card/DatabaseCard.svelte +++ b/mathesar_ui/src/pages/home/database-card/DatabaseCard.svelte @@ -58,7 +58,13 @@ controller={disconnectModalController} disconnect={async (opts) => { const result = await databasesStore.disconnectDatabase(opts); - if (result.sql_cleaned) { + if (result.database_dropped) { + toast.success( + result.sql_cleaned + ? $_('database_dropped_successfully') + : $_('database_dropped_without_sql_cleanup'), + ); + } else if (result.sql_cleaned) { toast.success($_('database_disconnected_successfully')); } else { toast.success($_('database_disconnected_without_sql_cleanup')); diff --git a/mathesar_ui/src/stores/databases.ts b/mathesar_ui/src/stores/databases.ts index ec7f4b75a9..c2887631a8 100644 --- a/mathesar_ui/src/stores/databases.ts +++ b/mathesar_ui/src/stores/databases.ts @@ -103,7 +103,8 @@ class DatabasesStore { schemas_to_remove?: SystemSchema[]; role?: { name: string; password: string }; disconnect_db_server: boolean; - }): Promise<{ sql_cleaned: boolean }> { + drop_database?: boolean; + }): Promise<{ sql_cleaned: boolean; database_dropped: boolean }> { const result = await api.databases.configured .disconnect({ database_id: p.database.id, @@ -112,6 +113,7 @@ class DatabasesStore { role_name: p.role?.name, password: p.role?.password, disconnect_db_server: p.disconnect_db_server, + drop_database: p.drop_database, }) .run(); this.unsortedDatabases.delete(p.database.id);