Repository: mathesar-foundation/mathesar

Issue:
Function to handle deleting schemas
**Problem**
<!-- Please provide a clear and concise description of the problem that this feature request is designed to solve.-->
Users might want to delete schemas. 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 schemas in the database. We should raise an error if there is anything outside of the schema referencing the schema.

**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/schemas.py b/db/schemas.py
--- a/db/schemas.py
+++ b/db/schemas.py
@@ -1,7 +1,11 @@
 import logging
 import warnings
-from sqlalchemy.schema import CreateSchema
+from sqlalchemy.schema import CreateSchema, DropSchema
 from sqlalchemy import inspect, MetaData, select, and_, not_, or_, Table
+from sqlalchemy.exc import InternalError
+from sqlalchemy.schema import DDLElement
+from sqlalchemy.ext import compiler
+from psycopg2.errors import DependentObjectsStillExist
 
 from db import types
 
@@ -78,3 +82,42 @@ def create_schema(schema, engine):
     if schema not in get_all_schemas(engine):
         with engine.begin() as connection:
             connection.execute(CreateSchema(schema))
+
+
+def delete_schema(schema, engine, cascade=False, if_exists=False):
+    """
+    This method deletes a Postgres schema.
+    """
+    if if_exists and schema not in get_all_schemas(engine):
+        return
+
+    with engine.begin() as connection:
+        try:
+            connection.execute(DropSchema(schema, cascade=cascade))
+        except InternalError as e:
+            if isinstance(e.orig, DependentObjectsStillExist):
+                raise e.orig
+            else:
+                raise e
+
+
+class RenameSchema(DDLElement):
+    def __init__(self, schema, rename_to):
+        self.schema = schema
+        self.rename_to = rename_to
+
+
+@compiler.compiles(RenameSchema)
+def compile_rename_schema(element, compiler, **_):
+    return "ALTER SCHEMA %s RENAME TO %s" % (
+        element.schema,
+        element.rename_to
+    )
+
+
+def rename_schema(schema, engine, rename_to):
+    """
+    This method renames a Postgres schema.
+    """
+    with engine.begin() as connection:
+        connection.execute(RenameSchema(schema, rename_to))


Test patch:
diff --git a/db/tests/test_schemas.py b/db/tests/test_schemas.py
--- a/db/tests/test_schemas.py
+++ b/db/tests/test_schemas.py
@@ -1,8 +1,13 @@
-from unittest.mock import patch
+import pytest
 import warnings
-from sqlalchemy import create_engine, select, Table, MetaData
-from db import schemas
-from db import types
+from unittest.mock import patch
+from sqlalchemy import (
+    create_engine, select, Table, MetaData, ForeignKey, Column, Integer
+)
+from sqlalchemy.exc import NoSuchTableError, ProgrammingError
+from psycopg2.errors import DependentObjectsStillExist
+
+from db import schemas, tables, types, constants
 
 
 def test_get_mathesar_schemas():
@@ -48,3 +53,127 @@ def test_get_mathesar_schemas_with_oids_gets_correct_oid(engine_with_schema):
     actual_schemata = schemas.get_mathesar_schemas_with_oids(engine)
     actual_oid = [oid for schm, oid in actual_schemata if schm == schema][0]
     assert actual_oid == expect_oid
+
+
+def _create_related_table(schema, related_schema, table, related_table, engine):
+    schemas.create_schema(schema, engine)
+    table = tables.create_mathesar_table(table, schema, [], engine)
+
+    schemas.create_schema(related_schema, engine)
+    metadata = MetaData(schema=related_schema, bind=engine)
+    related_table = Table(
+        related_table, metadata,
+        Column('id', Integer, ForeignKey(table.c[constants.ID]))
+    )
+    related_table.create()
+
+    related_table = tables.reflect_table(related_table.name, related_schema, engine)
+    fk = list(related_table.foreign_keys)[0]
+    assert fk.column.table.schema == schema
+
+    return related_table
+
+
+@pytest.mark.parametrize("if_exists", [True, False])
+def test_delete_schema(engine, if_exists):
+    test_schema = "test_delete_schema"
+
+    schemas.create_schema(test_schema, engine)
+    current_schemas = schemas.get_mathesar_schemas(engine)
+    assert test_schema in current_schemas
+
+    schemas.delete_schema(test_schema, engine, if_exists=if_exists)
+    current_schemas = schemas.get_mathesar_schemas(engine)
+    assert test_schema not in current_schemas
+
+
+def test_delete_schema_missing_if_exists_true(engine):
+    # Just ensure there is no error
+    schemas.delete_schema("test_delete_schema_missing", engine, if_exists=True)
+
+
+def test_delete_schema_missing_if_exists_false(engine):
+    with pytest.raises(ProgrammingError):
+        schemas.delete_schema("test_delete_schema_missing", engine, if_exists=False)
+
+
+def test_delete_schema_restricted(engine):
+    test_schema = "test_delete_schema_restricted"
+    test_table = "test_delete_schema_restricted_table"
+
+    schemas.create_schema(test_schema, engine)
+    tables.create_mathesar_table(test_table, test_schema, [], engine)
+
+    with pytest.raises(DependentObjectsStillExist):
+        schemas.delete_schema(test_schema, engine)
+
+    current_schemas = schemas.get_mathesar_schemas(engine)
+    assert test_schema in current_schemas
+
+
+def test_delete_schema_cascade(engine):
+    test_schema = "test_delete_schema_cascade"
+    test_table = "test_delete_schema_cascade_table"
+
+    schemas.create_schema(test_schema, engine)
+    table = tables.create_mathesar_table(test_table, test_schema, [], engine)
+
+    schemas.delete_schema(test_schema, engine, cascade=True)
+
+    current_schemas = schemas.get_mathesar_schemas(engine)
+    assert test_schema not in current_schemas
+    with pytest.raises(NoSuchTableError):
+        tables.reflect_table(table.name, test_schema, engine)
+
+
+def test_delete_schema_cascade_foreign_key(engine):
+    test_schema = "test_delete_schema_cascade_foreign_key"
+    related_schema = "test_delete_schema_cascade_foreign_key_related"
+    test_table = "test_delete_schema_cascade_foreign_key_table"
+    test_related_table = "test_delete_schema_cascade_foreign_key_related_table"
+
+    related_table = _create_related_table(
+        test_schema, related_schema, test_table, test_related_table, engine
+    )
+
+    schemas.delete_schema(test_schema, engine, cascade=True)
+
+    related_table = tables.reflect_table(related_table.name, related_schema, engine)
+    assert len(related_table.foreign_keys) == 0
+
+
+def test_rename_schema(engine):
+    test_schema = "test_rename_schema"
+    new_test_schema = "test_rename_schema_new"
+
+    schemas.create_schema(test_schema, engine)
+    current_schemas = schemas.get_mathesar_schemas(engine)
+    assert test_schema in current_schemas
+
+    schemas.rename_schema(test_schema, engine, new_test_schema)
+    current_schemas = schemas.get_mathesar_schemas(engine)
+    assert test_schema not in current_schemas
+    assert new_test_schema in current_schemas
+
+
+def test_rename_schema_missing(engine):
+    with pytest.raises(ProgrammingError):
+        schemas.rename_schema("test_rename_schema_missing", engine, "new_name")
+
+
+def test_rename_schema_foreign_key(engine):
+    test_schema = "test_rename_schema_foreign_key"
+    related_schema = "test_rename_schema_foreign_key_related"
+    new_test_schema = "test_rename_schema_foreign_key_new"
+    test_table = "test_rename_schema_foreign_key_table"
+    test_related_table = "test_rename_schema_foreign_key_related_table"
+
+    related_table = _create_related_table(
+        test_schema, related_schema, test_table, test_related_table, engine
+    )
+
+    schemas.rename_schema(test_schema, engine, new_test_schema)
+
+    related_table = tables.reflect_table(related_table.name, related_schema, engine)
+    fk = list(related_table.foreign_keys)[0]
+    assert fk.column.table.schema == new_test_schema