diff --git a/config/settings/common_settings.py b/config/settings/common_settings.py index 7697ed053c..c37d6d23ca 100644 --- a/config/settings/common_settings.py +++ b/config/settings/common_settings.py @@ -67,6 +67,7 @@ def pipe_delim(pipe_string): MODERNRPC_METHODS_MODULES = [ 'mathesar.rpc.connections', 'mathesar.rpc.columns', + 'mathesar.rpc.columns.metadata', 'mathesar.rpc.schemas', 'mathesar.rpc.tables' ] diff --git a/docs/docs/api/rpc.md b/docs/docs/api/rpc.md index 1c5cb1611c..76c2791903 100644 --- a/docs/docs/api/rpc.md +++ b/docs/docs/api/rpc.md @@ -37,9 +37,9 @@ To use an RPC function: } ``` ---- +## Connections -::: mathesar.rpc.connections +::: connections options: members: - add_from_known_connection @@ -47,9 +47,9 @@ To use an RPC function: - grant_access_to_user - DBModelReturn ---- +## Schemas -::: mathesar.rpc.schemas +::: schemas options: members: - list_ @@ -59,9 +59,9 @@ To use an RPC function: - SchemaInfo - SchemaPatch ---- +## Tables -::: mathesar.rpc.tables +::: tables options: members: - list_ @@ -73,20 +73,27 @@ To use an RPC function: - TableInfo - SettableTableInfo ---- +## Columns -::: mathesar.rpc.columns +::: columns options: members: - list_ - add - patch - delete - - ColumnListReturn - ColumnInfo - SettableColumnInfo - TypeOptions - ColumnDefault + +## Column Metadata + +::: columns.metadata + options: + members: + - list_ + - ColumnMetaData ## Responses diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index 58875327c4..e2488e3424 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -57,14 +57,14 @@ plugins: - mkdocstrings: handlers: python: - paths: [..] + paths: [../mathesar/rpc/] options: + heading_level: 3 docstring_style: google separate_signature: true - show_root_heading: true - show_root_full_path: false + show_root_toc_entry: false + show_root_members_full_path: true show_source: false - show_symbol_type_heading: true group_by_category: false theme: diff --git a/mathesar/migrations/0008_add_metadata_models.py b/mathesar/migrations/0008_add_metadata_models.py new file mode 100644 index 0000000000..13dd921ad3 --- /dev/null +++ b/mathesar/migrations/0008_add_metadata_models.py @@ -0,0 +1,46 @@ +# Generated by Django 4.2.11 on 2024-06-20 08:02 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('mathesar', '0007_users_permissions_remodel'), + ] + + operations = [ + migrations.CreateModel( + name='ColumnMetaData', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('table_oid', models.PositiveIntegerField()), + ('attnum', models.PositiveIntegerField()), + ('bool_input', models.CharField(blank=True, choices=[('dropdown', 'dropdown'), ('checkbox', 'checkbox')])), + ('bool_true', models.CharField(default='True')), + ('bool_false', models.CharField(default='False')), + ('num_min_frac_digits', models.PositiveIntegerField(blank=True)), + ('num_max_frac_digits', models.PositiveIntegerField(blank=True)), + ('num_show_as_perc', models.BooleanField(default=False)), + ('mon_currency_symbol', models.CharField(default='$')), + ('mon_currency_location', models.CharField(choices=[('after-minus', 'after-minus'), ('end-with-space', 'end-with-space')], default='after-minus')), + ('time_format', models.CharField(blank=True)), + ('date_format', models.CharField(blank=True)), + ('duration_min', models.CharField(blank=True, max_length=255)), + ('duration_max', models.CharField(blank=True, max_length=255)), + ('duration_show_units', models.BooleanField(default=True)), + ('database', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='mathesar.database')), + ], + ), + migrations.AddConstraint( + model_name='columnmetadata', + constraint=models.UniqueConstraint(fields=('database', 'table_oid', 'attnum'), name='unique_column_metadata'), + ), + migrations.AddConstraint( + model_name='columnmetadata', + constraint=models.CheckConstraint(check=models.Q(('num_max_frac_digits__lte', 20), ('num_min_frac_digits__lte', 20), ('num_min_frac_digits__lte', models.F('num_max_frac_digits'))), name='frac_digits_integrity'), + ), + ] diff --git a/mathesar/models/base.py b/mathesar/models/base.py index de7454dd6d..43b9e1607e 100644 --- a/mathesar/models/base.py +++ b/mathesar/models/base.py @@ -80,3 +80,44 @@ def connection(self): user=self.role.name, password=self.role.password, ) + + +class ColumnMetaData(BaseModel): + database = models.ForeignKey('Database', on_delete=models.CASCADE) + table_oid = models.PositiveIntegerField() + attnum = models.PositiveIntegerField() + bool_input = models.CharField( + choices=[("dropdown", "dropdown"), ("checkbox", "checkbox")], + blank=True + ) + bool_true = models.CharField(default='True') + bool_false = models.CharField(default='False') + num_min_frac_digits = models.PositiveIntegerField(blank=True) + num_max_frac_digits = models.PositiveIntegerField(blank=True) + num_show_as_perc = models.BooleanField(default=False) + mon_currency_symbol = models.CharField(default="$") + mon_currency_location = models.CharField( + choices=[("after-minus", "after-minus"), ("end-with-space", "end-with-space")], + default="after-minus" + ) + time_format = models.CharField(blank=True) + date_format = models.CharField(blank=True) + duration_min = models.CharField(max_length=255, blank=True) + duration_max = models.CharField(max_length=255, blank=True) + duration_show_units = models.BooleanField(default=True) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["database", "table_oid", "attnum"], + name="unique_column_metadata" + ), + models.CheckConstraint( + check=( + models.Q(num_max_frac_digits__lte=20) + & models.Q(num_min_frac_digits__lte=20) + & models.Q(num_min_frac_digits__lte=models.F("num_max_frac_digits")) + ), + name="frac_digits_integrity" + ) + ] diff --git a/mathesar/rpc/columns/__init__.py b/mathesar/rpc/columns/__init__.py new file mode 100644 index 0000000000..4b40b38c84 --- /dev/null +++ b/mathesar/rpc/columns/__init__.py @@ -0,0 +1 @@ +from .base import * # noqa diff --git a/mathesar/rpc/columns.py b/mathesar/rpc/columns/base.py similarity index 89% rename from mathesar/rpc/columns.py rename to mathesar/rpc/columns/base.py index 6f198d8553..f7e53704c5 100644 --- a/mathesar/rpc/columns.py +++ b/mathesar/rpc/columns/base.py @@ -12,7 +12,6 @@ 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 class TypeOptions(TypedDict, total=False): @@ -171,49 +170,24 @@ def from_dict(cls, col_info): ) -class ColumnListReturn(TypedDict): - """ - Information about the columns of a table. - - Attributes: - column_info: Column information from the user's database. - display_options: Display metadata managed by Mathesar. - """ - column_info: list[ColumnInfo] - display_options: list[dict] - - @rpc_method(name="columns.list") @http_basic_auth_login_required @handle_rpc_exceptions -def list_(*, table_oid: int, database_id: int, **kwargs) -> ColumnListReturn: +def list_(*, table_oid: int, database_id: int, **kwargs) -> list[ColumnInfo]: """ List information about columns for a table. Exposed as `list`. - Also return display options for each column, if they're defined. - Args: table_oid: Identity of the table in the user's database. database_id: The Django id of the database containing the table. Returns: - A list of column details, and a separate list of display options. + A list of column details. """ user = kwargs.get(REQUEST_KEY).user with connect(database_id, user) as conn: raw_column_info = get_column_info_for_table(table_oid, conn) - column_info, attnums = tuple( - zip( - *[(ColumnInfo.from_dict(col), col['id']) for col in raw_column_info] - ) - ) - display_options = get_raw_display_options( - database_id, table_oid, attnums, user - ) - return ColumnListReturn( - column_info=column_info, - display_options=display_options, - ) + return [ColumnInfo.from_dict(col) for col in raw_column_info] @rpc_method(name="columns.add") diff --git a/mathesar/rpc/columns/metadata.py b/mathesar/rpc/columns/metadata.py new file mode 100644 index 0000000000..561355e04e --- /dev/null +++ b/mathesar/rpc/columns/metadata.py @@ -0,0 +1,93 @@ +""" +Classes and functions exposed to the RPC endpoint for managing column metadata. +""" +from typing import Literal, Optional, TypedDict + +from modernrpc.core import rpc_method +from modernrpc.auth.basic import http_basic_auth_login_required + +from mathesar.rpc.exceptions.handlers import handle_rpc_exceptions +from mathesar.utils.columns import get_columns_meta_data + + +class ColumnMetaData(TypedDict): + """ + Metadata for a column in a table. + + Only the `database`, `table_oid`, and `attnum` keys are required. + + Attributes: + database_id: The Django id of the database containing the table. + table_oid: The OID of the table containing the column + attnum: The attnum of the column in the table. + bool_input: How the input for a boolean column should be shown. + bool_true: A string to display for `true` values. + bool_false: A string to display for `false` values. + num_min_frac_digits: Minimum digits shown after the decimal point. + num_max_frac_digits: Maximum digits shown after the decimal point. + num_show_as_perc: Whether to show a numeric value as a percentage. + mon_currency_symbol: The currency symbol shown for money value. + mon_currency_location: Where the currency symbol should be shown. + time_format: A string representing the format of time values. + date_format: A string representing the format of date values. + duration_min: The smallest unit for displaying durations. + duration_max: The largest unit for displaying durations. + duration_show_units: Whether to show the units for durations. + """ + database_id: int + table_oid: int + attnum: int + bool_input: Optional[Literal["dropdown", "checkbox"]] + bool_true: Optional[str] + bool_false: Optional[str] + num_min_frac_digits: Optional[int] + num_max_frac_digits: Optional[int] + num_show_as_perc: Optional[bool] + mon_currency_symbol: Optional[str] + mon_currency_location: Optional[Literal["after-minus", "end-with-space"]] + time_format: Optional[str] + date_format: Optional[str] + duration_min: Optional[str] + duration_max: Optional[str] + duration_show_units: Optional[bool] + + @classmethod + def from_model(cls, model): + return cls( + database_id=model.database.id, + table_oid=model.table_oid, + attnum=model.attnum, + bool_input=model.bool_input, + bool_true=model.bool_true, + bool_false=model.bool_false, + num_min_frac_digits=model.num_min_frac_digits, + num_max_frac_digits=model.num_max_frac_digits, + num_show_as_perc=model.num_show_as_perc, + mon_currency_symbol=model.mon_currency_symbol, + mon_currency_location=model.mon_currency_location, + time_format=model.time_format, + date_format=model.date_format, + duration_min=model.duration_min, + duration_max=model.duration_max, + duration_show_units=model.duration_show_units, + ) + + +@rpc_method(name="columns.metadata.list") +@http_basic_auth_login_required +@handle_rpc_exceptions +def list_(*, table_oid: int, database_id: int, **kwargs) -> list[ColumnMetaData]: + """ + List metadata associated with columns for a table. Exposed as `list`. + + Args: + table_oid: Identity of the table in the user's database. + database_id: The Django id of the database containing the table. + + Returns: + A list of column meta data objects. + """ + columns_meta_data = get_columns_meta_data(table_oid, database_id) + return [ + ColumnMetaData.from_model(model) for model in columns_meta_data + ] diff --git a/mathesar/tests/rpc/test_columns.py b/mathesar/tests/rpc/columns/test_base.py similarity index 62% rename from mathesar/tests/rpc/test_columns.py rename to mathesar/tests/rpc/columns/test_base.py index 1afd813a33..657145f514 100644 --- a/mathesar/tests/rpc/test_columns.py +++ b/mathesar/tests/rpc/columns/test_base.py @@ -1,5 +1,5 @@ """ -This file tests the column listing function. +This file tests the column RPC functions. Fixtures: rf(pytest-django): Provides mocked `Request` objects. @@ -19,7 +19,7 @@ def test_columns_list(rf, monkeypatch): @contextmanager def mock_connect(_database_id, user): - if _database_id == 2 and user.username == 'alice': + if _database_id == database_id and user.username == 'alice': try: yield True finally: @@ -66,75 +66,44 @@ def mock_column_info(_table_oid, conn): }, ] - def mock_display_options(_database_id, _table_oid, attnums, user): - if ( - database_id != 2 - or table_oid != 23457 - or attnums != (1, 2, 4, 8, 10) - or user.username != 'alice' - ): - raise AssertionError("incorrect parameters passed") - return [ - { - 'id': 4, - 'use_grouping': 'true', - 'number_format': 'english', - 'show_as_percentage': False, - 'maximum_fraction_digits': 2, - 'minimum_fraction_digits': 2 - } - ] - monkeypatch.setattr(columns, 'connect', mock_connect) - monkeypatch.setattr(columns, 'get_column_info_for_table', mock_column_info) - monkeypatch.setattr(columns, 'get_raw_display_options', mock_display_options) - expect_col_list = { - 'column_info': ( - { - 'id': 1, 'name': 'id', 'type': 'integer', - 'default': {'value': 'identity', 'is_dynamic': True}, - 'nullable': False, 'description': None, 'primary_key': True, - 'type_options': None, - 'has_dependents': True - }, { - 'id': 2, 'name': 'numcol', 'type': 'numeric', - 'default': {'value': "'8'::numeric", 'is_dynamic': False}, - 'nullable': True, - 'description': 'My super numeric column', - 'primary_key': False, - 'type_options': None, - 'has_dependents': False - }, { - 'id': 4, 'name': 'numcolmod', 'type': 'numeric', - 'default': None, - 'nullable': True, 'description': None, 'primary_key': False, - 'type_options': {'scale': 3, 'precision': 5}, - 'has_dependents': False - }, { - 'id': 8, 'name': 'ivlcolmod', 'type': 'interval', - 'default': None, - 'nullable': True, 'description': None, 'primary_key': False, - 'type_options': {'fields': 'day to second'}, - 'has_dependents': False - }, { - 'id': 10, 'name': 'arrcol', 'type': '_array', - 'default': None, - 'nullable': True, 'description': None, 'primary_key': False, - 'type_options': {'item_type': 'character varying', 'length': 3}, - 'has_dependents': False - } - ), - 'display_options': [ - { - 'id': 4, - 'use_grouping': 'true', - 'number_format': 'english', - 'show_as_percentage': False, - 'maximum_fraction_digits': 2, - 'minimum_fraction_digits': 2 - } - ] - } - actual_col_list = columns.list_(table_oid=23457, database_id=2, request=request) + monkeypatch.setattr(columns.base, 'connect', mock_connect) + monkeypatch.setattr(columns.base, 'get_column_info_for_table', mock_column_info) + expect_col_list = [ + { + 'id': 1, 'name': 'id', 'type': 'integer', + 'default': {'value': 'identity', 'is_dynamic': True}, + 'nullable': False, 'description': None, 'primary_key': True, + 'type_options': None, + 'has_dependents': True + }, { + 'id': 2, 'name': 'numcol', 'type': 'numeric', + 'default': {'value': "'8'::numeric", 'is_dynamic': False}, + 'nullable': True, + 'description': 'My super numeric column', + 'primary_key': False, + 'type_options': None, + 'has_dependents': False + }, { + 'id': 4, 'name': 'numcolmod', 'type': 'numeric', + 'default': None, + 'nullable': True, 'description': None, 'primary_key': False, + 'type_options': {'scale': 3, 'precision': 5}, + 'has_dependents': False + }, { + 'id': 8, 'name': 'ivlcolmod', 'type': 'interval', + 'default': None, + 'nullable': True, 'description': None, 'primary_key': False, + 'type_options': {'fields': 'day to second'}, + 'has_dependents': False + }, { + 'id': 10, 'name': 'arrcol', 'type': '_array', + 'default': None, + 'nullable': True, 'description': None, 'primary_key': False, + 'type_options': {'item_type': 'character varying', 'length': 3}, + 'has_dependents': False + } + ] + actual_col_list = columns.list_(table_oid=23457, database_id=database_id, request=request) assert actual_col_list == expect_col_list @@ -160,8 +129,8 @@ def mock_column_alter(_table_oid, _column_data_list, conn): raise AssertionError('incorrect parameters passed') return 1 - monkeypatch.setattr(columns, 'connect', mock_connect) - monkeypatch.setattr(columns, 'alter_columns_in_table', mock_column_alter) + monkeypatch.setattr(columns.base, 'connect', mock_connect) + monkeypatch.setattr(columns.base, 'alter_columns_in_table', mock_column_alter) actual_result = columns.patch( column_data_list=column_data_list, table_oid=table_oid, @@ -193,8 +162,8 @@ def mock_column_create(_table_oid, _column_data_list, conn): raise AssertionError('incorrect parameters passed') return [3, 4] - monkeypatch.setattr(columns, 'connect', mock_connect) - monkeypatch.setattr(columns, 'add_columns_to_table', mock_column_create) + monkeypatch.setattr(columns.base, 'connect', mock_connect) + monkeypatch.setattr(columns.base, 'add_columns_to_table', mock_column_create) actual_result = columns.add( column_data_list=column_data_list, table_oid=table_oid, @@ -226,8 +195,8 @@ def mock_column_drop(_table_oid, _column_attnums, conn): raise AssertionError('incorrect parameters passed') return 3 - monkeypatch.setattr(columns, 'connect', mock_connect) - monkeypatch.setattr(columns, 'drop_columns_from_table', mock_column_drop) + monkeypatch.setattr(columns.base, 'connect', mock_connect) + monkeypatch.setattr(columns.base, 'drop_columns_from_table', mock_column_drop) actual_result = columns.delete( column_attnums=column_attnums, table_oid=table_oid, diff --git a/mathesar/tests/rpc/columns/test_metadata.py b/mathesar/tests/rpc/columns/test_metadata.py new file mode 100644 index 0000000000..f9b6e087c0 --- /dev/null +++ b/mathesar/tests/rpc/columns/test_metadata.py @@ -0,0 +1,59 @@ +""" +This file tests the column metadata RPC functions. + +Fixtures: + monkeypatch(pytest): Lets you monkeypatch an object for testing. +""" +from mathesar.models.base import ColumnMetaData, Database, Server +from mathesar.rpc.columns import metadata + + +# TODO consider mocking out ColumnMetaData queryset for this test +def test_columns_meta_data_list(monkeypatch): + database_id = 2 + table_oid = 123456 + + def mock_get_columns_meta_data(_table_oid, _database_id): + server_model = Server(id=2, host='example.com', port=5432) + db_model = Database(id=_database_id, name='mymathesardb', server=server_model) + return [ + ColumnMetaData( + database=db_model, table_oid=_table_oid, attnum=2, + bool_input="dropdown", bool_true="TRUE", bool_false="FALSE", + num_min_frac_digits=5, num_max_frac_digits=10, num_show_as_perc=False, + mon_currency_symbol="EUR", mon_currency_location="end-with-space", + time_format=None, date_format=None, + duration_min=None, duration_max=None, duration_show_units=True, + ), + ColumnMetaData( + database=db_model, table_oid=_table_oid, attnum=8, + bool_input="checkbox", bool_true="true", bool_false="false", + num_min_frac_digits=2, num_max_frac_digits=8, num_show_as_perc=True, + mon_currency_symbol="$", mon_currency_location="after-minus", + time_format=None, date_format=None, + duration_min=None, duration_max=None, duration_show_units=True, + ) + ] + + monkeypatch.setattr(metadata, "get_columns_meta_data", mock_get_columns_meta_data) + + expect_metadata_list = [ + metadata.ColumnMetaData( + database_id=database_id, table_oid=table_oid, attnum=2, + bool_input="dropdown", bool_true="TRUE", bool_false="FALSE", + num_min_frac_digits=5, num_max_frac_digits=10, num_show_as_perc=False, + mon_currency_symbol="EUR", mon_currency_location="end-with-space", + time_format=None, date_format=None, + duration_min=None, duration_max=None, duration_show_units=True, + ), + metadata.ColumnMetaData( + database_id=database_id, table_oid=table_oid, attnum=8, + bool_input="checkbox", bool_true="true", bool_false="false", + num_min_frac_digits=2, num_max_frac_digits=8, num_show_as_perc=True, + mon_currency_symbol="$", mon_currency_location="after-minus", + time_format=None, date_format=None, + duration_min=None, duration_max=None, duration_show_units=True, + ), + ] + actual_metadata_list = metadata.list_(table_oid=table_oid, database_id=database_id) + assert actual_metadata_list == expect_metadata_list diff --git a/mathesar/tests/rpc/test_endpoints.py b/mathesar/tests/rpc/test_endpoints.py index 5e38425161..5141281a93 100644 --- a/mathesar/tests/rpc/test_endpoints.py +++ b/mathesar/tests/rpc/test_endpoints.py @@ -34,6 +34,11 @@ "columns.add", [user_is_authenticated] ), + ( + columns.metadata.list_, + "columns.metadata.list", + [user_is_authenticated] + ), ( connections.add_from_known_connection, "connections.add_from_known_connection", diff --git a/mathesar/utils/columns.py b/mathesar/utils/columns.py index 97075a7d52..7c48cd914e 100644 --- a/mathesar/utils/columns.py +++ b/mathesar/utils/columns.py @@ -1,16 +1,5 @@ -from mathesar.models.deprecated import Column +from mathesar.models.base import ColumnMetaData -# This should be replaced once we have the ColumnMetadata model sorted out. -def get_raw_display_options(database_id, table_oid, attnums, user): - """Get display options for the columns from Django.""" - if user.metadata_privileges(database_id) is not None: - return [ - {"id": c.attnum} | c.display_options - for c in Column.current_objects.filter( - table__schema__database__id=database_id, - table__oid=table_oid, - attnum__in=attnums - ) - if c.display_options is not None - ] +def get_columns_meta_data(table_oid, database_id): + return ColumnMetaData.filter(database__id=database_id, table_oid=table_oid)