Repository: mathesar-foundation/mathesar

Issue:
Provide detailed error info for POST to records API when in violation of a unique constraint
## Current behavior

1. Begin with the following data:

    ```sql
    DROP TABLE IF EXISTS "musician_roles";
    CREATE TABLE "musician_roles" (
        "id" serial NOT NULL,
        "band_name" character varying(255),
        "person_name" character varying(255),
        "instrument" character varying(255),
        "start_year" integer,
        "end_year" integer,
        CONSTRAINT "person_pkey" PRIMARY KEY ("id"),
        CONSTRAINT "musician_roles_unique_key"
            UNIQUE ("band_name", "person_name", "instrument")
    );

    INSERT INTO "musician_roles" 
        ( "band_name"   , "person_name"     , "instrument", "start_year", "end_year" )
    VALUES
        ( 'The Beatles' , 'John Lennon'     , 'guitar'    , 1960        , 1970       ),
        ( 'The Beatles' , 'John Lennon'     , 'vocals'    , 1960        , 1970       ),
        ( 'The Beatles' , 'Paul McCartney'  , 'bass'      , 1960        , 1970       ),
        ( 'The Beatles' , 'Paul McCartney'  , 'vocals'    , 1960        , 1970       ),
        ( 'The Beatles' , 'George Harrison' , 'guitar'    , 1960        , 1970       ),
        ( 'The Beatles' , 'George Harrison' , 'vocals'    , 1960        , 1970       ),
        ( 'The Beatles' , 'Ringo Starr'     , 'drums'     , 1960        , 1970       ),
        ( 'The Beatles' , 'Ringo Starr'     , 'vocals'    , 1960        , 1970       );
    ```

1. View the table in Mathesar.

1. On row 8, edit the value for `instrument`, changing "vocals" to "drums".

1. Observe a PATCH request to the records API.

1. Observe the response to have HTTP status code 500. Expect a 4xx code instead.

1. Observe the response body as:

    ```json
    [
      {
        "code": 4208,
        "message": "This column has non-unique values so a unique constraint cannot be set",
        "field": null,
        "detail": null
      }
    ]
    ```

1. Expect the response to give the client a way to determine which columns were in violation of the unique constraint.

## Desired response body

We could take a variety of different approach here to satisfy the goal of giving the client more info about the columns.

I've marked this ticket as "draft" so that we may discuss various response body schemas and come to an agreement.

Some ideas thus far:

- In a separate [discussion](https://github.com/centerofci/mathesar/pull/1053#issuecomment-1033313114), @pavish said:

    > if the server could respond with the name of the constraint that failed, we can use that to highlight all associated columns.

- We could also respond with an array of column ids



Discussion:
@kgodey I stuck this in `[06] Working with Tables` because it fit well with other work there. I know we're trying to close out that milestone though, so feel free to re-prioritize this.

I'm going to return the violated constraint rather than fields. The reason is, the violation may have to do with a union of fields (this is different from each field being violated individually).

Unfortunately, PostgreSQL only gives _one_ violation per error. That is, if there are two uniqueness constraints on the table, and we violate both, the error can only tell us about one.

Resolving patch:
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,16 +1,23 @@
 from rest_framework import status
 
 from db.columns.operations.select import get_column_attnum_from_name
+from db.constraints.operations.select import get_constraint_oid_by_name_and_table_oid
 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.base import Column
+from mathesar.models.base import Column, Constraint
 
 
 class UniqueViolationAPIException(MathesarAPIException):
+    """
+    Exception raised when trying to:
+
+    - Add unique constraint to column with non-unique values, or
+    - trying to add non-unique value to a column with unique constraint, or
+    """
     error_code = ErrorCodes.UniqueViolation.value
 
     def __init__(
@@ -19,9 +26,35 @@ def __init__(
             message="This column has non-unique values so a unique constraint cannot be set",
             field=None,
             details=None,
+            table=None,
             status_code=status.HTTP_500_INTERNAL_SERVER_ERROR
     ):
-        exception_detail = get_default_exception_detail(exception, self.error_code, message, field, details)._asdict()
+        if details is None and table is not None:
+            details = {}
+            try:
+                constraint_oid = get_constraint_oid_by_name_and_table_oid(
+                    exception.orig.diag.constraint_name,
+                    table.oid,
+                    table._sa_engine
+                )
+                constraint = Constraint.objects.get(oid=constraint_oid)
+                details = {
+                    "constraint": constraint.id,
+                    "constraint_columns": [c.id for c in constraint.columns],
+                }
+            except TypeError:
+                details = {
+                    "constraint": None,
+                }
+            details.update(
+                {
+                    "original_details": exception.orig.diag.message_detail,
+                }
+            )
+
+        exception_detail = get_default_exception_detail(
+            exception, self.error_code, message, field, details
+        )._asdict()
         self.detail = [exception_detail]
         self.status_code = status_code
 
diff --git a/mathesar/api/serializers/records.py b/mathesar/api/serializers/records.py
--- a/mathesar/api/serializers/records.py
+++ b/mathesar/api/serializers/records.py
@@ -1,4 +1,4 @@
-from psycopg2.errors import NotNullViolation
+from psycopg2.errors import NotNullViolation, UniqueViolation
 from rest_framework import serializers
 from rest_framework import status
 from sqlalchemy.exc import IntegrityError
@@ -34,6 +34,22 @@ def update(self, instance, validated_data):
                 e,
                 status_code=status.HTTP_400_BAD_REQUEST
             )
+        except IntegrityError as e:
+            if type(e.orig) == NotNullViolation:
+                raise database_api_exceptions.NotNullViolationAPIException(
+                    e,
+                    status_code=status.HTTP_400_BAD_REQUEST,
+                    table=table
+                )
+            elif type(e.orig) == UniqueViolation:
+                raise database_api_exceptions.UniqueViolationAPIException(
+                    e,
+                    message="The requested update violates a uniqueness constraint",
+                    table=table,
+                    status_code=status.HTTP_400_BAD_REQUEST,
+                )
+            else:
+                raise database_api_exceptions.MathesarAPIException(e, status_code=status.HTTP_400_BAD_REQUEST)
         return record
 
     def create(self, validated_data):
@@ -47,6 +63,13 @@ def create(self, validated_data):
                     status_code=status.HTTP_400_BAD_REQUEST,
                     table=table
                 )
+            elif type(e.orig) == UniqueViolation:
+                raise database_api_exceptions.UniqueViolationAPIException(
+                    e,
+                    message="The requested insert violates a uniqueness constraint",
+                    table=table,
+                    status_code=status.HTTP_400_BAD_REQUEST,
+                )
             else:
                 raise database_api_exceptions.MathesarAPIException(e, status_code=status.HTTP_400_BAD_REQUEST)
         return record


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
@@ -1070,3 +1070,37 @@ def test_record_patch_invalid_date_format(create_patents_table, client):
     assert response.status_code == 400
     assert response_data[0]['code'] == ErrorCodes.InvalidDateFormatError.value
     assert response_data[0]['message'] == 'Invalid date format'
+
+
+def test_record_post_unique_violation(create_patents_table, client):
+    table_name = 'NASA unique record POST'
+    table = create_patents_table(table_name)
+    id_column_id = table.get_column_name_id_bidirectional_map()['id']
+    data = {str(id_column_id): 1}
+    response = client.post(f'/api/db/v0/tables/{table.id}/records/', data=data)
+    actual_exception = response.json()[0]
+    assert actual_exception['code'] == ErrorCodes.UniqueViolation.value
+    assert actual_exception['message'] == 'The requested insert violates a uniqueness constraint'
+    assert actual_exception['detail']['constraint_columns'] == [id_column_id]
+    constraint_id = actual_exception['detail']['constraint']
+    actual_constraint_details = client.get(
+        f'/api/db/v0/tables/{table.id}/constraints/{constraint_id}/'
+    ).json()
+    assert actual_constraint_details['name'] == 'NASA unique record POST_pkey'
+
+
+def test_record_patch_unique_violation(create_patents_table, client):
+    table_name = 'NASA unique record PATCH'
+    table = create_patents_table(table_name)
+    id_column_id = table.get_column_name_id_bidirectional_map()['id']
+    data = {str(id_column_id): 1}
+    response = client.patch(f'/api/db/v0/tables/{table.id}/records/{2}/', data=data)
+    actual_exception = response.json()[0]
+    assert actual_exception['code'] == ErrorCodes.UniqueViolation.value
+    assert actual_exception['message'] == 'The requested update violates a uniqueness constraint'
+    assert actual_exception['detail']['constraint_columns'] == [id_column_id]
+    constraint_id = actual_exception['detail']['constraint']
+    actual_constraint_details = client.get(
+        f'/api/db/v0/tables/{table.id}/constraints/{constraint_id}/'
+    ).json()
+    assert actual_constraint_details['name'] == 'NASA unique record PATCH_pkey'