Repository: mathesar-foundation/mathesar

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


Discussion:

Resolving patch:
diff --git a/db/columns/operations/alter.py b/db/columns/operations/alter.py
--- a/db/columns/operations/alter.py
+++ b/db/columns/operations/alter.py
@@ -29,7 +29,7 @@ def alter_column(engine, table_oid, column_attnum, column_data, connection=None)
         "description": <str>
     }
     """
-    column_alter_def = _process_column_alter_dict(column_data, column_attnum)
+    column_alter_def = _process_column_alter_dict_dep(column_data, column_attnum)
     requested_type = column_alter_def.get("type", {}).get("name")
     if connection is None:
         try:
@@ -154,7 +154,7 @@ def batch_update_columns(table_oid, engine, column_data_list):
     """
     Alter the given columns of the table.
 
-    For details on the column_data_list format, see _process_column_alter_dict.
+    For details on the column_data_list format, see _process_column_alter_dict_dep.
 
     Args:
         table_oid: the OID of the table whose columns we'll alter.
@@ -167,7 +167,7 @@ def batch_update_columns(table_oid, engine, column_data_list):
             engine, 'alter_columns',
             table_oid,
             json.dumps(
-                [_process_column_alter_dict(column) for column in column_data_list]
+                [_process_column_alter_dict_dep(column) for column in column_data_list]
             )
         )
     except InvalidParameterValue:
@@ -180,7 +180,81 @@ def batch_update_columns(table_oid, engine, column_data_list):
         raise InvalidTypeOptionError
 
 
-def _process_column_alter_dict(column_data, column_attnum=None):
+def alter_columns_in_table(table_oid, column_data_list, conn):
+    """
+    Alter columns of the given table in bulk.
+
+    For a description of column_data_list, see _transform_column_alter_dict
+
+    Args:
+        table_oid: The OID of the table whose columns we'll alter.
+        column_data_list: a list of dicts describing the alterations to make.
+    """
+    transformed_column_data = [
+        _transform_column_alter_dict(column) for column in column_data_list
+    ]
+    db_conn.exec_msar_func(
+        conn, 'alter_columns', table_oid, json.dumps(transformed_column_data)
+    )
+    return len(column_data_list)
+
+
+# TODO This function wouldn't be needed if we had the same form in the DB
+# as the RPC API function.
+def _transform_column_alter_dict(data):
+    """
+    Transform the data dict into the form needed for the DB functions.
+
+    Input data form:
+    {
+        "id": <int>,
+        "name": <str>,
+        "type": <str>,
+        "type_options": <dict>,
+        "nullable": <bool>,
+        "default": {"value": <any>}
+        "description": <str>
+    }
+
+    Output form:
+    {
+        "attnum": <int>,
+        "type": {"name": <str>, "options": <dict>},
+        "name": <str>,
+        "not_null": <bool>,
+        "default": <any>,
+        "description": <str>
+    }
+
+    Note that keys with empty values will be dropped, except "default"
+    and "description". Explicitly setting these to None requests dropping
+    the associated property of the underlying column.
+    """
+    type_ = {"name": data.get('type'), "options": data.get('type_options')}
+    new_type = {k: v for k, v in type_.items() if v} or None
+    nullable = data.get(NULLABLE)
+    not_null = not nullable if nullable is not None else None
+    column_name = (data.get(NAME) or '').strip() or None
+    raw_alter_def = {
+        "attnum": data["id"],
+        "type": new_type,
+        "not_null": not_null,
+        "name": column_name,
+        "description": data.get("description")
+    }
+    alter_def = {k: v for k, v in raw_alter_def.items() if v is not None}
+
+    default_dict = data.get("default", {})
+    if default_dict is None:
+        alter_def.update(default=None)
+    elif "value" in default_dict:
+        alter_def.update(default=default_dict["value"])
+
+    return alter_def
+
+
+# TODO This function is deprecated. Remove it when possible.
+def _process_column_alter_dict_dep(column_data, column_attnum=None):
     """
     Transform the column_data dict into the form needed for the DB functions.
 
@@ -221,7 +295,7 @@ def _process_column_alter_dict(column_data, column_attnum=None):
     column_not_null = not column_nullable if column_nullable is not None else None
     column_name = (column_data.get(NAME) or '').strip() or None
     raw_col_alter_def = {
-        "attnum": column_attnum or column_data.get("attnum"),
+        "attnum": column_attnum or column_data.get("attnum") or column_data.get("id"),
         "type": new_type,
         "not_null": column_not_null,
         "name": column_name,
diff --git a/mathesar/rpc/columns.py b/mathesar/rpc/columns.py
--- a/mathesar/rpc/columns.py
+++ b/mathesar/rpc/columns.py
@@ -1,13 +1,14 @@
 """
 Classes and functions exposed to the RPC endpoint for managing table columns.
 """
-from typing import TypedDict
+from typing import Optional, TypedDict
 
 from modernrpc.core import rpc_method, REQUEST_KEY
 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.alter import alter_columns_in_table
 from db.columns.operations.drop import drop_columns_from_table
+from db.columns.operations.select import get_column_info_for_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
@@ -73,9 +74,43 @@ def from_dict(cls, col_default):
             )
 
 
+class SettableColumnInfo(TypedDict):
+    """
+    Information about a column, restricted to settable fields.
+
+    When possible, Passing `null` for a key will clear the underlying
+    setting. E.g.,
+
+    - `default = null` clears the column default setting.
+    - `type_options = null` clears the type options for the column.
+    - `description = null` clears the column description.
+
+    Setting any of `name`, `type`, or `nullable` is a noop.
+
+
+    Only the `id` key is required.
+
+    Attributes:
+        id: The `attnum` of the column in the table.
+        name: The name of the column.
+        type: The type of the column on the database.
+        type_options: The options applied to the column type.
+        nullable: Whether or not the column is nullable.
+        default: The default value.
+        description: The description of the column.
+    """
+    id: int
+    name: Optional[str]
+    type: Optional[str]
+    type_options: Optional[TypeOptions]
+    nullable: Optional[bool]
+    default: Optional[ColumnDefault]
+    description: Optional[str]
+
+
 class ColumnInfo(TypedDict):
     """
-    Information about a column.
+    Information about a column. Extends the settable fields.
 
     Attributes:
         id: The `attnum` of the column in the table.
@@ -158,6 +193,34 @@ def list_(*, table_oid: int, database_id: int, **kwargs) -> ColumnListReturn:
     )
 
 
+@rpc_method(name="columns.patch")
+@http_basic_auth_login_required
+@handle_rpc_exceptions
+def patch(
+        *,
+        column_data_list: list[SettableColumnInfo],
+        table_oid: int,
+        database_id: int,
+        **kwargs
+) -> int:
+    """
+    Alter details of preexisting columns in a table.
+
+    Does not support altering the type or type options of array columns.
+
+    Args:
+        column_data_list: A list describing desired column alterations.
+        table_oid: Identity of the table whose columns we'll modify.
+        database_id: The Django id of the database containing the table.
+
+    Returns:
+        The number of columns altered.
+    """
+    user = kwargs.get(REQUEST_KEY).user
+    with connect(database_id, user) as conn:
+        return alter_columns_in_table(table_oid, column_data_list, conn)
+
+
 @rpc_method(name="columns.delete")
 @http_basic_auth_login_required
 @handle_rpc_exceptions


Test patch:
diff --git a/db/tests/columns/operations/test_alter.py b/db/tests/columns/operations/test_alter.py
--- a/db/tests/columns/operations/test_alter.py
+++ b/db/tests/columns/operations/test_alter.py
@@ -1,6 +1,9 @@
+import json
+from unittest.mock import patch
 from sqlalchemy import Column, select, Table, MetaData, VARCHAR, INTEGER
 
 from db import constants
+from db.columns.operations import alter as col_alt
 from db.columns.operations.alter import batch_update_columns, rename_column
 from db.columns.operations.select import (
     get_column_attnum_from_name, get_column_name_from_attnum,
@@ -18,6 +21,44 @@
 from db.schemas.utils import get_schema_oid_from_name
 
 
+def test_alter_columns_in_table_basic():
+    with patch.object(col_alt.db_conn, 'exec_msar_func') as mock_exec:
+        col_alt.alter_columns_in_table(
+            123,
+            [
+                {
+                    "id": 3, "name": "colname3", "type": "numeric",
+                    "type_options": {"precision": 8}, "nullable": True,
+                    "default": {"value": 8, "is_dynamic": False},
+                    "description": "third column"
+                }, {
+                    "id": 6, "name": "colname6", "type": "character varying",
+                    "type_options": {"length": 32}, "nullable": True,
+                    "default": {"value": "blahblah", "is_dynamic": False},
+                    "description": "textual column"
+                }
+            ],
+            'conn'
+        )
+        expect_json_arg = [
+            {
+                "attnum": 3, "name": "colname3",
+                "type": {"name": "numeric", "options": {"precision": 8}},
+                "not_null": False, "default": 8, "description": "third column",
+            }, {
+                "attnum": 6, "name": "colname6",
+                "type": {
+                    "name": "character varying", "options": {"length": 32},
+                },
+                "not_null": False, "default": "blahblah",
+                "description": "textual column"
+            }
+        ]
+        assert mock_exec.call_args.args[:3] == ('conn', 'alter_columns', 123)
+        # Necessary since `json.dumps` mangles dict ordering, but we don't care.
+        assert json.loads(mock_exec.call_args.args[3]) == expect_json_arg
+
+
 def _rename_column_and_assert(table, old_col_name, new_col_name, engine):
     """
     Renames the colum of a table and assert the change went through
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
@@ -138,6 +138,39 @@ def mock_display_options(_database_id, _table_oid, attnums, user):
     assert actual_col_list == expect_col_list
 
 
+def test_columns_patch(rf, monkeypatch):
+    request = rf.post('/api/rpc/v0/', data={})
+    request.user = User(username='alice', password='pass1234')
+    table_oid = 23457
+    database_id = 2
+    column_data_list = [{"id": 3, "name": "newname"}]
+
+    @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_alter(_table_oid, _column_data_list, conn):
+        if _table_oid != table_oid or _column_data_list != column_data_list:
+            raise AssertionError('incorrect parameters passed')
+        return 1
+
+    monkeypatch.setattr(columns, 'connect', mock_connect)
+    monkeypatch.setattr(columns, 'alter_columns_in_table', mock_column_alter)
+    actual_result = columns.patch(
+        column_data_list=column_data_list,
+        table_oid=table_oid,
+        database_id=database_id,
+        request=request
+    )
+    assert actual_result == 1
+
+
 def test_columns_delete(rf, monkeypatch):
     request = rf.post('/api/rpc/v0/', data={})
     request.user = User(username='alice', password='pass1234')
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
@@ -24,6 +24,11 @@
         "columns.list",
         [user_is_authenticated]
     ),
+    (
+        columns.patch,
+        "columns.patch",
+        [user_is_authenticated]
+    ),
     (
         connections.add_from_known_connection,
         "connections.add_from_known_connection",