Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add columns.patch RPC function #3615

Merged
merged 10 commits into from
Jun 10, 2024
84 changes: 79 additions & 5 deletions db/columns/operations/alter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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.
Expand All @@ -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:
Expand All @@ -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.

Expand Down Expand Up @@ -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,
Expand Down
41 changes: 41 additions & 0 deletions db/tests/columns/operations/test_alter.py
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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
Expand Down
2 changes: 2 additions & 0 deletions docs/docs/api/rpc.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,9 +71,11 @@ To use an RPC function:
options:
members:
- list_
- patch
- delete
- ColumnListReturn
- ColumnInfo
- SettableColumnInfo
- TypeOptions
- ColumnDefault

Expand Down
69 changes: 66 additions & 3 deletions mathesar/rpc/columns.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
33 changes: 33 additions & 0 deletions mathesar/tests/rpc/test_columns.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
5 changes: 5 additions & 0 deletions mathesar/tests/rpc/test_endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading