Repository: mathesar-foundation/mathesar

Issue:
Hide transformation results in a 500 in /run request
## Description
Adding a hide transformation results in a 500 with the following error:
```
Environment:


Request Method: POST
Request URL: http://localhost:8000/api/db/v0/queries/run/

Django Version: 3.1.14
Python Version: 3.9.15
Installed Applications:
['django.contrib.admin',
 'django.contrib.auth',
 'django.contrib.contenttypes',
 'django.contrib.sessions',
 'django.contrib.messages',
 'django.contrib.staticfiles',
 'rest_framework',
 'django_filters',
 'django_property_filter',
 'mathesar']
Installed Middleware:
['django.middleware.security.SecurityMiddleware',
 'django.contrib.sessions.middleware.SessionMiddleware',
 'django.middleware.common.CommonMiddleware',
 'django.middleware.csrf.CsrfViewMiddleware',
 'django.contrib.auth.middleware.AuthenticationMiddleware',
 'django.contrib.messages.middleware.MessageMiddleware',
 'django.middleware.clickjacking.XFrameOptionsMiddleware',
 'mathesar.middleware.CursorClosedHandlerMiddleware',
 'mathesar.middleware.PasswordChangeNeededMiddleware',
 'django_userforeignkey.middleware.UserForeignKeyMiddleware',
 'django_request_cache.middleware.RequestCacheMiddleware']



Traceback (most recent call last):
  File "/usr/local/lib/python3.9/site-packages/django/core/handlers/exception.py", line 47, in inner
    response = get_response(request)
  File "/usr/local/lib/python3.9/site-packages/django/core/handlers/base.py", line 181, in _get_response
    response = wrapped_callback(request, *callback_args, **callback_kwargs)
  File "/usr/local/lib/python3.9/site-packages/django/views/decorators/csrf.py", line 54, in wrapped_view
    return view_func(*args, **kwargs)
  File "/usr/local/lib/python3.9/site-packages/rest_framework/viewsets.py", line 125, in view
    return self.dispatch(request, *args, **kwargs)
  File "/usr/local/lib/python3.9/site-packages/rest_framework/views.py", line 509, in dispatch
    response = self.handle_exception(exc)
  File "/usr/local/lib/python3.9/site-packages/rest_framework/views.py", line 466, in handle_exception
    response = exception_handler(exc, context)
  File "/code/mathesar/exception_handlers.py", line 59, in mathesar_exception_handler
    raise exc
  File "/usr/local/lib/python3.9/site-packages/rest_framework/views.py", line 506, in dispatch
    response = handler(request, *args, **kwargs)
  File "/code/mathesar/api/db/viewsets/queries.py", line 123, in run
    column_metadata = query.all_columns_description_map
  File "/code/mathesar/models/query.py", line 248, in all_columns_description_map
    return {
  File "/code/mathesar/models/query.py", line 249, in <dictcomp>
    alias: self._describe_query_column(sa_col)
  File "/code/mathesar/models/query.py", line 217, in _describe_query_column
    type=sa_col.db_type.id,
  File "/code/db/columns/base.py", line 225, in db_type
    return get_db_type_enum_from_class(self.type.__class__)
  File "/code/db/types/operations/convert.py", line 37, in get_db_type_enum_from_class
    raise UnknownDbTypeId

Exception Type: UnknownDbTypeId at /api/db/v0/queries/run/
Exception Value: 
```

The request:
```json
{
   "base_table":72,
   "initial_columns":[
      {
         "id":225,
         "alias":"Patrons_First Name"
      },
      {
         "id":226,
         "alias":"Patrons_Last Name"
      },
      {
         "id":227,
         "alias":"Patrons_Email"
      }
   ],
   "transformations":[
      {
         "type":"hide",
         "spec":[]
      }
   ],
```


Discussion:
`hide` transformation is not valid when `spec` is an empty list. At least one input alias is expected to be in the list. Not certain that that's the cause for the 500 though. I'll investigate.
@dmos62 The error is thrown even when `spec` contains values. The request was an example.

Additionally, I think it'll be great if the backend _does **not**_ validate the spec to contain atleast one value. It'll save a lot of time on the frontend.

Resolving patch:
diff --git a/db/queries/base.py b/db/queries/base.py
--- a/db/queries/base.py
+++ b/db/queries/base.py
@@ -139,8 +139,12 @@ def all_sa_columns_map(self):
                 name=f'{self.name}_{i}'
             ).transformed_relation.columns
         }
-
-        return initial_columns_map | transforms_columns_map | output_columns_map
+        map_of_alias_to_sa_col = initial_columns_map | transforms_columns_map | output_columns_map
+        for sa_col in map_of_alias_to_sa_col.values():
+            # Below assert will throw an UnknownDbTypeId exception if something is wrong.
+            # Purpose of this is to fail early.
+            assert sa_col.db_type
+        return map_of_alias_to_sa_col
 
     @property
     def sa_output_columns(self):
diff --git a/db/transforms/base.py b/db/transforms/base.py
--- a/db/transforms/base.py
+++ b/db/transforms/base.py
@@ -461,39 +461,55 @@ class SelectSubsetOfColumns(Transform):
     type = "select"
 
     def apply_to_relation(self, relation):
-        columns_to_select = self._columns_to_select
-        if columns_to_select:
-            processed_columns_to_select = [
-                _make_sure_column_expression(column)
-                for column
-                in columns_to_select
-            ]
-            executable = select(*processed_columns_to_select).select_from(relation)
+        sa_columns_to_select = self._get_sa_columns_to_select(relation)
+        if sa_columns_to_select:
+            executable = select(*sa_columns_to_select).select_from(relation)
             return _to_non_executable(executable)
         else:
             return relation
 
     def get_unique_constraint_mappings(self, _):
-        columns_to_select = self._columns_to_select
+        # We presume that when we're looking at uc mappings, the raw spec will always be string
+        # names.
+        column_names_to_select = self._raw_columns_to_select
         return [
             UniqueConstraintMapping(
-                column_to_select,
-                column_to_select,
+                column_names_to_select,
+                column_names_to_select,
             )
-            for column_to_select
-            in columns_to_select
+            for column_names_to_select
+            in column_names_to_select
         ]
 
+    def _get_sa_columns_to_select(self, relation):
+        return tuple(
+            _make_sure_sa_col_expr(raw_col, relation)
+            for raw_col
+            in self._raw_columns_to_select
+        )
+
     @property
-    def _columns_to_select(self):
-        return self.spec
+    def _raw_columns_to_select(self):
+        """
+        The spec will be a list whose items will be SQLAlchemy column expressions and/or string
+        names.
+
+        This accepts SQLAlchemy column expressions so that we can count records by doing
+        SelectSubsetOfColumns(count(1).label("_count")).
+        """
+        return self.spec or []
 
 
-def _make_sure_column_expression(input):
-    if isinstance(input, str):
-        return sqlalchemy.column(input)
+def _make_sure_sa_col_expr(raw_col, relation):
+    if isinstance(raw_col, str):
+        # If raw_col is a string, we consider it a column name and look up an SQL column using it
+        col_name = raw_col
+        sa_col = relation.c[col_name]
+        return sa_col
     else:
-        return input
+        # If raw_col isn't a string, we presume it's an SA column expression
+        sa_col = raw_col
+        return sa_col
 
 
 def _to_executable(relation):


Test patch:
diff --git a/mathesar/tests/api/query/test_query_run.py b/mathesar/tests/api/query/test_query_run.py
--- a/mathesar/tests/api/query/test_query_run.py
+++ b/mathesar/tests/api/query/test_query_run.py
@@ -183,3 +183,103 @@ def test_queries_run_deleted_column(create_patents_table, client):
     assert response.status_code == 400
     assert response_data[0]['code'] == ErrorCodes.DeletedColumnAccess.value
     assert response_data[0]['detail']['column_id'] == to_be_deleted_column_id
+
+
+def test_queries_run_with_transforms(create_patents_table, client):
+    base_table = create_patents_table(table_name='patent_query_run_minimal_table')
+    display_names = {
+        'col1': 'Column 1',
+        'col2': 'Column 2',
+    }
+    initial_columns = [
+        {
+            'id': base_table.get_column_by_name('Center').id,
+            'alias': 'col1',
+        },
+        {
+            'id': base_table.get_column_by_name('Case Number').id,
+            'alias': 'col2',
+        },
+    ]
+    data = {
+        'base_table': base_table.id,
+        'initial_columns': initial_columns,
+        'display_names': display_names,
+        'transformations': [
+            {
+                "type": "hide",
+                "spec": ['col1']
+            },
+        ],
+        'parameters': {
+            'order_by': [
+                {'field': 'col2', 'direction': 'desc'}
+            ],
+            'limit': 2,
+            'offset': 3
+        }
+    }
+
+    expect_query = {
+        k: v
+        for k, v
+        in data.items()
+        if k != 'parameters'
+    } | {
+        'schema': base_table.schema.id,
+        'transformations': [
+            {
+                "type": "hide",
+                "spec": ['col1'],
+            }
+        ]
+    }
+
+    expect_response_json = {
+        'query': expect_query,
+        'records': {
+            'count': 1393,
+            'grouping': None,
+            'preview_data': None,
+            'results': [
+                {'col2': 'SSC-00050'},
+                {'col2': 'SSC-00040'},
+            ]
+        },
+        'output_columns': ['col2'],
+        'column_metadata': {
+            'col1': {
+                'alias': 'col1',
+                'display_name': 'Column 1',
+                'type': 'text',
+                'type_options': None,
+                'display_options': None,
+                'is_initial_column': True,
+                'input_table_name': 'patent_query_run_minimal_table',
+                'input_column_name': 'Center',
+                'input_alias': None,
+            },
+            'col2': {
+                'alias': 'col2',
+                'display_name': 'Column 2',
+                'type': 'text',
+                'type_options': None,
+                'display_options': None,
+                'is_initial_column': True,
+                'input_table_name': 'patent_query_run_minimal_table',
+                'input_column_name': 'Case Number',
+                'input_alias': None,
+            }
+        },
+        'parameters': {
+            'order_by': [
+                {'field': 'col2', 'direction': 'desc'}
+            ],
+            'limit': 2,
+            'offset': 3,
+        }
+    }
+
+    response = client.post('/api/db/v0/queries/run/', data, format='json')
+    assert response.status_code == 200
+    assert response.json() == expect_response_json