Repository: mathesar-foundation/mathesar

Issue:
Provide detailed error info for POST to records API when in violation of a not-null constraint
## Current behavior

1. Set up a table which contains a `NOT NULL` column.
1. POST an empty request (`{}`) to the `records` API (e.g. by clicking the `+` icon to add a new row to the table).
1. Observe the following response:

    ```json
    [
      {
        "code": 4999,
        "message": "(psycopg2.errors.NotNullViolation) null value in column \"common_name\" of relation \"trees\" violates not-null constraint\nDETAIL:  Failing row contains (11, null, null, null, null, null, null).\n\n[SQL: INSERT INTO public.trees DEFAULT VALUES RETURNING public.trees.id]\n(Background on this error at: http://sqlalche.me/e/14/gkpj)",
        "field": null,
        "detail": null
      }
    ]
    ```

1. Within the response, observe no easy way to identify _which column_ (or columns) was responsible for the error.

## Expected behavior

- For #775, we need enough detail for the front end to be able to display error messages specific to certain cells. For example, if a POST request has two columns with invalid data (perhaps even invalid in _different ways_), we need to be able to match specific error messages to each of those columns.

- @pavish and I are flexible on the exact response schema from the API here, as long as it satisfies the above goal.

- We should also keep #896 in-mind with this change.

## Status

- blocked by #896



Discussion:
I put this in `[06] Working with Tables` because it's blocking #775.
@kgodey Do you think it's best to wait until #896 is complete (potentially less work overall) or to forge ahead (completing this ticket sooner, because it's blocking work on #775)?
@seancolsen Since there's already a draft for #896, I think it's fine to wait until it's complete.
Cool thanks @kgodey I'm marking this as blocked by #896 then.

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
@@ -43,11 +43,23 @@ def list(self, request, table_pk=None):
                 grouping=serializer.validated_data['grouping'],
             )
         except (BadFilterFormat, FilterFieldNotFound) as e:
-            raise database_api_exceptions.BadFilterAPIException(e, field='filters', status_code=status.HTTP_400_BAD_REQUEST)
+            raise database_api_exceptions.BadFilterAPIException(
+                e,
+                field='filters',
+                status_code=status.HTTP_400_BAD_REQUEST
+            )
         except (BadSortFormat, SortFieldNotFound) as e:
-            raise database_api_exceptions.BadSortAPIException(e, field='order_by', status_code=status.HTTP_400_BAD_REQUEST)
+            raise database_api_exceptions.BadSortAPIException(
+                e,
+                field='order_by',
+                status_code=status.HTTP_400_BAD_REQUEST
+            )
         except (BadGroupFormat, GroupFieldNotFound, InvalidGroupType) as e:
-            raise database_api_exceptions.BadGroupAPIException(e, field='grouping', status_code=status.HTTP_400_BAD_REQUEST)
+            raise database_api_exceptions.BadGroupAPIException(
+                e,
+                field='grouping',
+                status_code=status.HTTP_400_BAD_REQUEST
+            )
 
         serializer = RecordSerializer(records, many=True)
         return paginator.get_paginated_response(serializer.data)
@@ -67,8 +79,12 @@ def create(self, request, table_pk=None):
         try:
             record = table.create_record_or_records(request.data)
         except IntegrityError as e:
-            if e.orig == NotNullViolation:
-                raise database_api_exceptions.NotNullViolationAPIException(e, status_code=status.HTTP_400_BAD_REQUEST)
+            if type(e.orig) == NotNullViolation:
+                raise database_api_exceptions.NotNullViolationAPIException(
+                    e,
+                    status_code=status.HTTP_400_BAD_REQUEST,
+                    table=table
+                )
             else:
                 raise database_api_exceptions.MathesarAPIException(e, status_code=status.HTTP_400_BAD_REQUEST)
         serializer = RecordSerializer(record)
diff --git a/mathesar/api/exceptions/database_exceptions/exceptions.py b/mathesar/api/exceptions/database_exceptions/exceptions.py
--- a/mathesar/api/exceptions/database_exceptions/exceptions.py
+++ b/mathesar/api/exceptions/database_exceptions/exceptions.py
@@ -1,11 +1,13 @@
 from rest_framework import status
 
+from db.columns.operations.select import get_columns_attnum_from_names
 from mathesar.api.exceptions.database_exceptions.base_exceptions import ProgrammingAPIException
 from mathesar.api.exceptions.error_codes import ErrorCodes
 from mathesar.api.exceptions.generic_exceptions.base_exceptions import (
     MathesarAPIException,
     get_default_exception_detail,
 )
+from mathesar.models import Column
 
 
 class UniqueViolationAPIException(MathesarAPIException):
@@ -216,9 +218,19 @@ def __init__(
             self, exception,
             message=None,
             field=None,
-            status_code=status.HTTP_500_INTERNAL_SERVER_ERROR
+            status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+            table=None
     ):
-        message_str, row_detail = exception.orig.args[0].split("DETAIL")
-        message_str = message if message is not None else message_str
-        details = {'row_parameters': exception.params, 'row_detail': row_detail}
+        exception_diagnostics = exception.orig.diag
+        message_str = message if message is not None else exception_diagnostics.message_primary
+        column_attnum = get_columns_attnum_from_names(
+            table.oid,
+            [exception.orig.diag.column_name],
+            table.schema._sa_engine
+        )[0][0]
+        column = Column.objects.get(attnum=column_attnum)
+        details = {
+            'record_detail': exception_diagnostics.message_detail,
+            'column_id': column.id
+        }
         super().__init__(exception, self.error_code, message_str, field, details, status_code)


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
@@ -376,6 +376,7 @@ def test_null_error_record_create(create_table, client):
     record_data = response.json()
     assert response.status_code == 400
     assert 'null value in column "Case Number"' in record_data[0]['message']
+    assert column_id == record_data[0]['detail']['column_id']
 
 
 @pytest.mark.parametrize('table_name,grouping,expected_groups', grouping_params)