Repository: mathesar-foundation/mathesar

Issue:
Insert broken when there's not primary key
## Description
<!-- A clear and concise description of what the bug is. -->

When a table does not have a primary key, attempting to use the UI to create a new record results in the backend throwing an unhandled 500 error.

## Expected behavior
<!-- A clear and concise description of what you expected to happen. -->

When a `POST` request representing a new empty record is sent for a table with no primary key, the server should respond with a 405 error, since we can't allow creating (or editing) rows on tables with no primary key. The error should be associated with some informational `APIException` stating that you can't insert into tables without a primary key.

The implementer should add an API test to validate their solution.

## To Reproduce
<!-- How can we recreate this bug? Please try to provide a Minimal, Complete, and Verifiable (http://stackoverflow.com/help/mcve) example if code-related. -->

1. Using psql or some other client, create a table without a primary key.
2. In the UI, navigate to that table.
3. Click the `+` to add a record. 
![image](https://github.com/centerofci/mathesar/assets/6829750/897e36a2-4b39-4d1a-a87d-83ad0ca1d169)
4. Observe the 500 response.
5. See the error (via mouse-over) in the UI: 
![image](https://github.com/centerofci/mathesar/assets/6829750/95dcb4d1-bc7e-4633-aff4-35d6e3c3e972)
6. See the `tuple index out of range` error in the devtools.


Discussion:
Hi Everyone, I'm taking up this issue and have started working on it. I'll keep you updated on my progress.
Hey, I observed that the Mathesar web interface displays 'mathesar_tables' as a database. However, I couldn't connect to mathesar_tables database. I successfully connected to 'mathesar_django' database and made a table there using psql but I cannot find that table in UI because I think table needs to be created in mathesar_tables database and not mathesar_django database? Can you please clarify if 'mathesar_tables' is an alias for another database specified in my project's configuration, or if there's a different method to access it? 

However, I successfully reproduced the issue, by making table through UI and then deleting the id column from it.

Resolving patch:
diff --git a/mathesar/api/db/viewsets/records.py b/mathesar/api/db/viewsets/records.py
--- a/mathesar/api/db/viewsets/records.py
+++ b/mathesar/api/db/viewsets/records.py
@@ -1,7 +1,7 @@
 from psycopg2.errors import ForeignKeyViolation, InvalidDatetimeFormat
 from rest_access_policy import AccessViewSetMixin
 from rest_framework import status, viewsets
-from rest_framework.exceptions import NotFound
+from rest_framework.exceptions import NotFound, MethodNotAllowed
 from rest_framework.renderers import BrowsableAPIRenderer
 from rest_framework.response import Response
 from rest_framework.permissions import IsAuthenticatedOrReadOnly
@@ -145,17 +145,26 @@ def retrieve(self, request, pk=None, table_pk=None):
 
     def create(self, request, table_pk=None):
         table = get_table_or_404(table_pk)
+        primary_key_column_name = None
+        try:
+            primary_key_column_name = table.primary_key_column_name
+        except AssertionError:
+            raise generic_api_exceptions.MethodNotAllowedAPIException(
+                MethodNotAllowed,
+                error_code=ErrorCodes.MethodNotAllowed.value,
+                message="You cannot insert into tables without a primary key"
+            )
         serializer = RecordSerializer(data=request.data, context=self.get_serializer_context(table))
         serializer.is_valid(raise_exception=True)
         serializer.save()
         # TODO refactor to use serializer for more DRY response logic
         column_name_id_map = table.get_column_name_id_bidirectional_map()
-        table_pk_column_id = column_name_id_map[table.primary_key_column_name]
+        table_pk_column_id = column_name_id_map[primary_key_column_name]
         pk_value = serializer.data[table_pk_column_id]
         paginator = TableLimitOffsetPagination()
         record_filters = {
             "equal": [
-                {"column_name": [table.primary_key_column_name]},
+                {"column_name": [primary_key_column_name]},
                 {"literal": [pk_value]}
             ]
         }
diff --git a/mathesar/api/exceptions/generic_exceptions/base_exceptions.py b/mathesar/api/exceptions/generic_exceptions/base_exceptions.py
--- a/mathesar/api/exceptions/generic_exceptions/base_exceptions.py
+++ b/mathesar/api/exceptions/generic_exceptions/base_exceptions.py
@@ -88,6 +88,22 @@ def __init__(
         self.status_code = status_code
 
 
+class MethodNotAllowedAPIException(MathesarAPIException):
+
+    def __init__(
+            self,
+            exception,
+            error_code=ErrorCodes.MethodNotAllowed.value,
+            message=None,
+            field=None,
+            details=None,
+            status_code=status.HTTP_405_METHOD_NOT_ALLOWED
+    ):
+        exception_detail = get_default_exception_detail(exception, error_code, message, field, details)._asdict()
+        self.detail = [exception_detail]
+        self.status_code = status_code
+
+
 class ValueAPIException(MathesarAPIException):
     # Default message is not needed as the exception string provides enough details
 


Test patch:
diff --git a/mathesar/tests/api/test_record_api.py b/mathesar/tests/api/test_record_api.py
--- a/mathesar/tests/api/test_record_api.py
+++ b/mathesar/tests/api/test_record_api.py
@@ -1055,6 +1055,38 @@ def test_record_create(create_patents_table, client):
             assert data[column_name] == record_data[column_id_str]
 
 
+def test_record_create_without_primary_key(create_patents_table, client):
+    table_name = 'NASA Record Create Without PK'
+    table = create_patents_table(table_name)
+    column_test_table = table
+    num_columns = len(column_test_table.sa_columns)
+    col_one_name = column_test_table.sa_columns[0].name
+    column = column_test_table.get_columns_by_name(['id'])[0]
+    response = client.delete(
+        f"/api/db/v0/tables/{column_test_table.id}/columns/{column.id}/"
+    )
+    assert response.status_code == 204
+    new_columns_response = client.get(
+        f"/api/db/v0/tables/{column_test_table.id}/columns/"
+    )
+    new_data = new_columns_response.json()
+    assert col_one_name not in [col["name"] for col in new_data["results"]]
+    assert new_data["count"] == num_columns - 1
+    columns_name_id_map = table.get_column_name_id_bidirectional_map()
+    data = {
+        columns_name_id_map['Center']: 'NASA Example Space Center',
+        columns_name_id_map['Status']: 'Application',
+        columns_name_id_map['Case Number']: 'ESC-0000',
+        columns_name_id_map['Patent Number']: '01234',
+        columns_name_id_map['Application SN']: '01/000,001',
+        columns_name_id_map['Title']: 'Example Patent Name',
+        columns_name_id_map['Patent Expiration Date']: ''
+    }
+    response = client.post(f'/api/db/v0/tables/{table.id}/records/', data=data)
+    assert response.status_code == 405
+    assert response.json()[0]['message'] == "You cannot insert into tables without a primary key"
+
+
 @pytest.mark.parametrize('client_name, expected_status_code', update_client_with_status_code)
 def test_record_partial_update_based_on_permission(create_patents_table, request, client_name, expected_status_code):
     table_name = 'NASA Record Patch'