Repository: mathesar-foundation/mathesar

Issue:
DB function to handle deleting tables
**Problem**
<!-- Please provide a clear and concise description of the problem that this feature request is designed to solve.-->
Users might want to delete tables. We don't currently support this.

**Proposed solution**
<!-- A clear and concise description of your proposed solution or feature. -->
A function that handles deleting of tables in the database. We should raise an error if there is anything outside of the table referencing the table.

**Additional context**
<!-- Add any other context or screenshots about the feature request here.-->
This should be in the `db` module.


Discussion:

Resolving patch:
diff --git a/db/tables.py b/db/tables.py
--- a/db/tables.py
+++ b/db/tables.py
@@ -4,9 +4,13 @@
     Column, String, Table, MetaData, func, select, ForeignKey, literal, exists,
     join, inspect, and_
 )
-from sqlalchemy.schema import DDLElement
+from sqlalchemy.schema import DDLElement, DropTable
 from sqlalchemy.ext import compiler
 from sqlalchemy_filters import apply_filters
+from alembic.migration import MigrationContext
+from alembic.operations import Operations
+from sqlalchemy.exc import NoSuchTableError, InternalError
+from psycopg2.errors import DependentObjectsStillExist
 
 from db import columns, constants, schemas
 from db.types import inference
@@ -52,6 +56,48 @@ def create_mathesar_table(name, schema, columns_, engine, metadata=None):
     return table
 
 
+class DropTableCascade(DropTable):
+    def __init__(self, table, cascade=False, if_exists=False, **kwargs):
+        print(if_exists)
+        super().__init__(table, if_exists=if_exists, **kwargs)
+        self.cascade = cascade
+
+
+@compiler.compiles(DropTableCascade, "postgresql")
+def compile_drop_table(element, compiler, **_):
+    expression = compiler.visit_drop_table(element)
+    if element.cascade:
+        return expression + " CASCADE"
+    else:
+        return expression
+
+
+def delete_table(name, schema, engine, cascade=False, if_exists=False):
+    try:
+        table = reflect_table(name, schema, engine)
+    except NoSuchTableError:
+        if if_exists:
+            return
+        else:
+            raise
+    with engine.begin() as conn:
+        try:
+            conn.execute(DropTableCascade(table, cascade=cascade))
+        except InternalError as e:
+            if isinstance(e.orig, DependentObjectsStillExist):
+                raise e.orig
+            else:
+                raise e
+
+
+def rename_table(name, schema, engine, rename_to):
+    table = reflect_table(name, schema, engine)
+    with engine.begin() as conn:
+        ctx = MigrationContext.configure(conn)
+        op = Operations(ctx)
+        op.rename_table(table.name, rename_to, schema=table.schema)
+
+
 def extract_columns_from_table(
         old_table_name,
         extracted_column_names,
@@ -424,7 +470,7 @@ def __init__(self, name, selectable):
 
 
 @compiler.compiles(CreateTableAs)
-def compile(element, compiler, **_):
+def compile_create_table_as(element, compiler, **_):
     return "CREATE TABLE %s AS (%s)" % (
         element.name,
         compiler.sql_compiler.process(element.selectable, literal_binds=True),


Test patch:
diff --git a/db/tests/test_tables.py b/db/tests/test_tables.py
--- a/db/tests/test_tables.py
+++ b/db/tests/test_tables.py
@@ -1,6 +1,9 @@
 from unittest.mock import call, patch
 import pytest
-from sqlalchemy import MetaData, select, Column, String
+from sqlalchemy import MetaData, select, Column, String, Table, ForeignKey, Integer
+from sqlalchemy.exc import NoSuchTableError
+from psycopg2.errors import DependentObjectsStillExist
+
 from db import tables, constants, columns
 
 ROSTER = "Roster"
@@ -44,6 +47,100 @@ def test_table_creation_doesnt_reuse_defaults(engine_with_schema):
     )
 
 
+@pytest.mark.parametrize("if_exists", [True, False])
+def test_delete_table(engine_with_schema, if_exists):
+    engine, schema = engine_with_schema
+    table_name = "test_delete_table"
+    tables.create_mathesar_table(table_name, schema, [], engine)
+    tables.delete_table(table_name, schema, engine, if_exists=if_exists)
+    with pytest.raises(NoSuchTableError):
+        tables.reflect_table(table_name, schema, engine)
+
+
+def test_delete_table_no_table_if_exists_true(engine_with_schema):
+    engine, schema = engine_with_schema
+    # Just confirm we don't thrown an error
+    tables.delete_table("test_delete_table", schema, engine, if_exists=True)
+
+
+def test_delete_table_no_table_if_exists_false(engine_with_schema):
+    engine, schema = engine_with_schema
+    with pytest.raises(NoSuchTableError):
+        tables.delete_table("test_delete_table", schema, engine, if_exists=False)
+
+
+def _create_related_table(name, table, schema, engine):
+    metadata = MetaData(schema=schema, bind=engine)
+    related_table = Table(
+        name, metadata,
+        Column('id', Integer, ForeignKey(table.c[constants.ID]))
+    )
+    related_table.create()
+    fk = list(related_table.foreign_keys)[0]
+    assert fk.column.table.name == table.name
+    return related_table
+
+
+def test_delete_table_restricted_foreign_key(engine_with_schema):
+    engine, schema = engine_with_schema
+    table_name = "test_delete_table_restricted_foreign_key"
+    related_table_name = "test_delete_table_restricted_foreign_key_related"
+
+    table = tables.create_mathesar_table(table_name, schema, [], engine)
+    _create_related_table(related_table_name, table, schema, engine)
+
+    with pytest.raises(DependentObjectsStillExist):
+        tables.delete_table(table_name, schema, engine, cascade=False)
+
+
+def test_delete_table_cascade_foreign_key(engine_with_schema):
+    engine, schema = engine_with_schema
+    table_name = "test_delete_table_cascade_foreign_key"
+    related_table_name = "test_delete_table_cascade_foreign_key_related"
+
+    table = tables.create_mathesar_table(table_name, schema, [], engine)
+    related_table = _create_related_table(related_table_name, table, schema, engine)
+
+    tables.delete_table(table_name, schema, engine, cascade=True)
+
+    related_table = tables.reflect_table(related_table.name, schema, engine)
+    assert len(related_table.foreign_keys) == 0
+
+
+def test_rename_table(engine_with_schema):
+    engine, schema = engine_with_schema
+    table_name = "test_rename_table"
+    new_table_name = "test_rename_table_new"
+    old_table = tables.create_mathesar_table(table_name, schema, [], engine)
+    old_oid = tables.get_oid_from_table(old_table.name, old_table.schema, engine)
+
+    tables.rename_table(table_name, schema, engine, new_table_name)
+    new_table = tables.reflect_table(new_table_name, schema, engine)
+    new_oid = tables.get_oid_from_table(new_table.name, new_table.schema, engine)
+
+    assert old_oid == new_oid
+    assert new_table.name == new_table_name
+
+    with pytest.raises(NoSuchTableError):
+        tables.reflect_table(table_name, schema, engine)
+
+
+def test_rename_table_foreign_key(engine_with_schema):
+    engine, schema = engine_with_schema
+    table_name = "test_rename_table_foreign_key"
+    new_table_name = "test_rename_table_foreign_key_new"
+    related_table_name = "test_rename_table_foreign_key_related"
+
+    table = tables.create_mathesar_table(table_name, schema, [], engine)
+    related_table = _create_related_table(related_table_name, table, schema, engine)
+
+    tables.rename_table(table_name, schema, engine, new_table_name)
+
+    related_table = tables.reflect_table(related_table_name, schema, engine)
+    fk = list(related_table.foreign_keys)[0]
+    assert fk.column.table.name == new_table_name
+
+
 def test_extract_columns_from_table_creates_tables(engine_with_roster):
     engine, schema = engine_with_roster
     teachers = "Teachers"