Repository: mathesar-foundation/mathesar

Issue:
Implement `columns.delete` RPC method
Replaces `DELETE /api/db/v0/tables/{tableId}/columns/{columnId}`


Discussion:

Resolving patch:
diff --git a/db/columns/operations/drop.py b/db/columns/operations/drop.py
--- a/db/columns/operations/drop.py
+++ b/db/columns/operations/drop.py
@@ -1,14 +1,14 @@
-"""The function in this module wraps SQL functions that drop columns."""
+"""The functions in this module wrap SQL functions that drop columns."""
 from db import connection as db_conn
 
 
 def drop_column(table_oid, column_attnum, engine):
     """
-    Drop the given columns from the given table.
+    Drop the given column from the given table.
 
     Args:
-        table_oid: OID of the table whose columns we'll drop.
-        column_attnum: The attnums of the columns to drop.
+        table_oid: OID of the table whose column we'll drop.
+        column_attnum: The attnum of the column to drop.
         engine: SQLAlchemy engine object for connecting.
 
     Returns:
@@ -17,3 +17,17 @@ def drop_column(table_oid, column_attnum, engine):
     return db_conn.execute_msar_func_with_engine(
         engine, 'drop_columns', table_oid, column_attnum
     ).fetchone()[0]
+
+
+def drop_columns_from_table(table_oid, column_attnums, conn):
+    """
+    Drop the given columns from the given table.
+
+    Args:
+        table_oid: OID of the table whose columns we'll drop.
+        column_attnums: The attnums of the columns to drop.
+        conn: A psycopg connection to the relevant database.
+    """
+    return db_conn.exec_msar_func(
+        conn, 'drop_columns', table_oid, *column_attnums
+    ).fetchone()[0]
diff --git a/db/sql/install.py b/db/sql/install.py
--- a/db/sql/install.py
+++ b/db/sql/install.py
@@ -2,8 +2,8 @@
 from db.connection import load_file_with_engine
 
 FILE_DIR = os.path.abspath(os.path.dirname(__file__))
-MSAR_SQL = os.path.join(FILE_DIR, '0_msar.sql')
-MSAR_AGGREGATE_SQL = os.path.join(FILE_DIR, '3_msar_custom_aggregates.sql')
+MSAR_SQL = os.path.join(FILE_DIR, '00_msar.sql')
+MSAR_AGGREGATE_SQL = os.path.join(FILE_DIR, '30_msar_custom_aggregates.sql')
 
 
 def install(engine):
diff --git a/mathesar/rpc/columns.py b/mathesar/rpc/columns.py
--- a/mathesar/rpc/columns.py
+++ b/mathesar/rpc/columns.py
@@ -7,6 +7,7 @@
 from modernrpc.auth.basic import http_basic_auth_login_required
 
 from db.columns.operations.select import get_column_info_for_table
+from db.columns.operations.drop import drop_columns_from_table
 from mathesar.rpc.exceptions.handlers import handle_rpc_exceptions
 from mathesar.rpc.utils import connect
 from mathesar.utils.columns import get_raw_display_options
@@ -155,3 +156,25 @@ def list_(*, table_oid: int, database_id: int, **kwargs) -> ColumnListReturn:
         column_info=column_info,
         display_options=display_options,
     )
+
+
+@rpc_method(name="columns.delete")
+@http_basic_auth_login_required
+@handle_rpc_exceptions
+def delete(
+        *, column_attnums: list[int], table_oid: int, database_id: int, **kwargs
+) -> int:
+    """
+    Delete columns from a table.
+
+    Args:
+        column_attnums: A list of attnums of columns to delete.
+        table_oid: Identity of the table in the user's database.
+        database_id: The Django id of the database containing the table.
+
+    Returns:
+        The number of columns dropped.
+    """
+    user = kwargs.get(REQUEST_KEY).user
+    with connect(database_id, user) as conn:
+        return drop_columns_from_table(table_oid, column_attnums, conn)


Test patch:
diff --git a/db/sql/test_0_msar.sql b/db/sql/test_00_msar.sql
similarity index 99%
rename from db/sql/test_0_msar.sql
rename to db/sql/test_00_msar.sql
--- a/db/sql/test_0_msar.sql
+++ b/db/sql/test_00_msar.sql
@@ -30,6 +30,17 @@ END;
 $$ LANGUAGE plpgsql;
 
 
+CREATE OR REPLACE FUNCTION test_drop_columns_ne_oid() RETURNS SETOF TEXT AS $$
+BEGIN
+  CREATE TABLE "12345" (bleh text, bleh2 numeric);
+  PERFORM msar.drop_columns(12345, 1);
+  RETURN NEXT has_column(
+    '12345', 'bleh', 'Doesn''t drop columns of stupidly-named table'
+  );
+END;
+$$ LANGUAGE plpgsql;
+
+
 CREATE OR REPLACE FUNCTION test_drop_columns_names() RETURNS SETOF TEXT AS $$
 BEGIN
   PERFORM __setup_drop_columns();
diff --git a/db/sql/test_startup.sql b/db/sql/test_startup.sql
--- a/db/sql/test_startup.sql
+++ b/db/sql/test_startup.sql
@@ -8,5 +8,5 @@ $$ LANGUAGE plpgsql;
 CALL raise_notice('Creating testing DB');
 CREATE DATABASE mathesar_testing;
 \c mathesar_testing
-\ir 0_msar.sql
-\ir test_0_msar.sql
+\ir 00_msar.sql
+\ir test_00_msar.sql
diff --git a/db/tests/columns/operations/test_drop.py b/db/tests/columns/operations/test_drop.py
new file mode 100644
--- /dev/null
+++ b/db/tests/columns/operations/test_drop.py
@@ -0,0 +1,18 @@
+from unittest.mock import patch
+from db.columns.operations import drop as col_drop
+
+
+def test_drop_columns():
+    with patch.object(col_drop.db_conn, 'exec_msar_func') as mock_exec:
+        mock_exec.return_value.fetchone = lambda: (3,)
+        result = col_drop.drop_columns_from_table(123, [1, 3, 5], 'conn')
+    mock_exec.assert_called_once_with('conn', 'drop_columns', 123, 1, 3, 5)
+    assert result == 3
+
+
+def test_drop_columns_single():
+    with patch.object(col_drop.db_conn, 'exec_msar_func') as mock_exec:
+        mock_exec.return_value.fetchone = lambda: (1,)
+        result = col_drop.drop_columns_from_table(123, [1], 'conn')
+    mock_exec.assert_called_once_with('conn', 'drop_columns', 123, 1)
+    assert result == 1
diff --git a/mathesar/tests/rpc/test_columns.py b/mathesar/tests/rpc/test_columns.py
--- a/mathesar/tests/rpc/test_columns.py
+++ b/mathesar/tests/rpc/test_columns.py
@@ -136,3 +136,36 @@ def mock_display_options(_database_id, _table_oid, attnums, user):
     }
     actual_col_list = columns.list_(table_oid=23457, database_id=2, request=request)
     assert actual_col_list == expect_col_list
+
+
+def test_columns_delete(rf, monkeypatch):
+    request = rf.post('/api/rpc/v0/', data={})
+    request.user = User(username='alice', password='pass1234')
+    table_oid = 23457
+    database_id = 2
+    column_attnums = [2, 3, 8]
+
+    @contextmanager
+    def mock_connect(_database_id, user):
+        if _database_id == 2 and user.username == 'alice':
+            try:
+                yield True
+            finally:
+                pass
+        else:
+            raise AssertionError('incorrect parameters passed')
+
+    def mock_column_drop(_table_oid, _column_attnums, conn):
+        if _table_oid != table_oid or _column_attnums != column_attnums:
+            raise AssertionError('incorrect parameters passed')
+        return 3
+
+    monkeypatch.setattr(columns, 'connect', mock_connect)
+    monkeypatch.setattr(columns, 'drop_columns_from_table', mock_column_drop)
+    actual_result = columns.delete(
+        column_attnums=column_attnums,
+        table_oid=table_oid,
+        database_id=database_id,
+        request=request
+    )
+    assert actual_result == 3
diff --git a/mathesar/tests/rpc/test_endpoints.py b/mathesar/tests/rpc/test_endpoints.py
--- a/mathesar/tests/rpc/test_endpoints.py
+++ b/mathesar/tests/rpc/test_endpoints.py
@@ -13,6 +13,11 @@
 from mathesar.rpc import schemas
 
 METHODS = [
+    (
+        columns.delete,
+        "columns.delete",
+        [user_is_authenticated]
+    ),
     (
         columns.list_,
         "columns.list",