Repository: mathesar-foundation/mathesar

Issue:
Unhandled error for exclusion constraint violation
## Description
<!-- A clear and concise description of what the bug is. -->

When there's a violation of an exclusion constraint, we show a python error in the front end (in the hover box thing).

## Expected behavior
<!-- A clear and concise description of what you expected to happen. -->

We should handle this error better in the service layer to give the front end a nicer error to display.

## 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. -->

Start with the library demo set. Add an exclusion constraint to the "Checkouts" table using 

```sql
CREATE EXTENSION IF NOT EXISTS btree_gist;
ALTER TABLE "Checkouts" DROP CONSTRAINT IF EXISTS sim_chck_excl;
ALTER TABLE "Checkouts"
  ADD CONSTRAINT sim_chck_excl
  EXCLUDE USING gist
    ("Item" WITH =, tsrange("Checkout Time", "Check In Time", '[]') WITH &&);
```

Go to the front end, try to add a checkout for a book that hasn't yet been checked in. See the bug.

## Additional context
<!-- Add any other context about the problem or screenshots here. -->

It's worth considering whether we should wrap all such errors into some kind of "Unknown error occured" catch-all. 


Discussion:
Moving this to the Backlog – this isn't critical enough to block launch.
Hello @dmos62, i see this issue hasn't been fixed yet, I would like to take it up
@SafeEHA sure, go ahead. Submit a PR. 
@mathemancer , @rajatvijay , I noticed that no one is currently working on this issue. Can i pick this up ? 
@manthan-jsharma definitely!
Hi, 
Can i start working on this ?

@raisachatterjee you've already been assigned to another issue and someone else has said that they'd like to work on this.
Hi Everyone I am new to open source,
Is anyone currently working on it or I can try to address this and submit pr


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
@@ -400,11 +400,32 @@ class ExclusionViolationAPIException(MathesarAPIException):
     def __init__(
             self,
             exception,
-            message=None,
+            message="The requested update violates an exclusion constraint",
             field=None,
             details=None,
+            table=None,
             status_code=status.HTTP_500_INTERNAL_SERVER_ERROR
     ):
+        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(table=table, oid=constraint_oid)
+                details = {
+                    "constraint": constraint.id,
+                    "constraint_columns": [c.id for c in constraint.columns],
+                }
+            except Exception:
+                warnings.warn("Could not enrich Exception")
+            details.update(
+                {
+                    "original_details": exception.orig.diag.message_detail,
+                }
+            )
         super().__init__(exception, self.error_code, message, field, details, 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, UniqueViolation, CheckViolation
+from psycopg2.errors import NotNullViolation, UniqueViolation, CheckViolation, ExclusionViolation
 from rest_framework import serializers
 from rest_framework import status
 from sqlalchemy.exc import IntegrityError
@@ -53,6 +53,12 @@ def update(self, instance, validated_data):
                     e,
                     status_code=status.HTTP_400_BAD_REQUEST
                 )
+            elif type(e.orig) is ExclusionViolation:
+                raise database_api_exceptions.ExclusionViolationAPIException(
+                    e,
+                    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
@@ -80,6 +86,12 @@ def create(self, validated_data):
                     e,
                     status_code=status.HTTP_400_BAD_REQUEST
                 )
+            elif type(e.orig) is ExclusionViolation:
+                raise database_api_exceptions.ExclusionViolationAPIException(
+                    e,
+                    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
@@ -1,5 +1,6 @@
 import json
 import pytest
+from sqlalchemy import text
 from copy import deepcopy
 from unittest.mock import patch
 
@@ -9,6 +10,7 @@
 from db.records.operations.group import GroupBy
 from db.records.operations.sort import BadSortFormat, SortFieldNotFound
 
+from mathesar.state import reset_reflection
 from mathesar.api.exceptions.error_codes import ErrorCodes
 from mathesar.api.utils import follows_json_number_spec
 from mathesar.functions.operations.convert import rewrite_db_function_spec_column_ids_to_names
@@ -1464,3 +1466,58 @@ def test_record_patch_unique_violation(create_patents_table, client):
         f'/api/db/v0/tables/{table.id}/constraints/{constraint_id}/'
     ).json()
     assert actual_constraint_details['name'] == 'NASA unique record PATCH_pkey'
+
+
+def test_record_post_exclusion_violation(reservations_table, engine, client):
+    table_name = 'Exclusion test post'
+    table = reservations_table(table_name)
+    room_number_column_id = table.get_column_name_id_bidirectional_map()['room_number']
+    columns_name_id_map = table.get_column_name_id_bidirectional_map()
+    query = text(
+        f"""CREATE EXTENSION IF NOT EXISTS btree_gist;
+        ALTER TABLE "Reservations"."{table_name}" DROP CONSTRAINT IF EXISTS room_overlap;
+        ALTER TABLE "Reservations"."{table_name}"
+        ADD CONSTRAINT room_overlap
+        EXCLUDE USING gist
+        ("room_number" WITH =, TSRANGE("check_in_date", "check_out_date", '[]') WITH &&);"""
+    )
+    with engine.begin() as conn:
+        conn.execute(query)
+    reset_reflection(db_name=table.schema.database.name)
+    response = client.post(f'/api/db/v0/tables/{table.id}/records/', data={
+        str(columns_name_id_map['id']): 3,
+        str(columns_name_id_map['room_number']): 1,
+        str(columns_name_id_map['check_in_date']): '11/12/2023',
+        str(columns_name_id_map['check_out_date']): '11/21/2023',
+    })
+    actual_exception = response.json()[0]
+    assert actual_exception['code'] == ErrorCodes.ExclusionViolation.value
+    assert actual_exception['message'] == 'The requested update violates an exclusion constraint'
+    assert actual_exception['detail']['constraint_columns'] == [room_number_column_id]
+
+
+def test_record_patch_exclusion_violation(reservations_table, engine, client):
+    table_name = 'Exclusion test patch'
+    table = reservations_table(table_name)
+    room_number_column_id = table.get_column_name_id_bidirectional_map()['room_number']
+    columns_name_id_map = table.get_column_name_id_bidirectional_map()
+    query = text(
+        f"""CREATE EXTENSION IF NOT EXISTS btree_gist;
+        ALTER TABLE "Reservations"."{table_name}" DROP CONSTRAINT IF EXISTS room_overlap;
+        ALTER TABLE "Reservations"."{table_name}"
+        ADD CONSTRAINT room_overlap
+        EXCLUDE USING gist
+        ("room_number" WITH =, TSRANGE("check_in_date", "check_out_date", '[]') WITH &&);"""
+    )
+    with engine.begin() as conn:
+        conn.execute(query)
+    reset_reflection(db_name=table.schema.database.name)
+    response = client.patch(f'/api/db/v0/tables/{table.id}/records/{2}/', data={
+        str(columns_name_id_map['room_number']): 1,
+        str(columns_name_id_map['check_in_date']): '11/12/2023',
+        str(columns_name_id_map['check_out_date']): '11/21/2023',
+    })
+    actual_exception = response.json()[0]
+    assert actual_exception['code'] == ErrorCodes.ExclusionViolation.value
+    assert actual_exception['message'] == 'The requested update violates an exclusion constraint'
+    assert actual_exception['detail']['constraint_columns'] == [room_number_column_id]
diff --git a/mathesar/tests/conftest.py b/mathesar/tests/conftest.py
--- a/mathesar/tests/conftest.py
+++ b/mathesar/tests/conftest.py
@@ -11,7 +11,7 @@
 from django.conf import settings
 from rest_framework.test import APIClient
 
-from sqlalchemy import Column, MetaData, Integer
+from sqlalchemy import Column, MetaData, Integer, Date
 from sqlalchemy import Table as SATable
 
 from db.tables.operations.select import get_oid_from_table
@@ -30,6 +30,7 @@
 from mathesar.state import reset_reflection
 from mathesar.state.base import set_initial_reflection_happened
 from db.metadata import get_empty_metadata
+from db.tests.columns.utils import create_test_table
 
 
 @pytest.fixture
@@ -329,6 +330,12 @@ def patent_schema(create_schema):
     yield create_schema(PATENT_SCHEMA)
 
 
+@pytest.fixture
+def reservations_schema(create_schema):
+    RESERVATIONS_SCHEMA = 'Reservations'
+    yield create_schema(RESERVATIONS_SCHEMA)
+
+
 # TODO rename to create_ma_schema
 @pytest.fixture
 def create_schema(test_db_model, create_db_schema):
@@ -362,6 +369,30 @@ def _create_mathesar_table(
     yield _create_mathesar_table
 
 
+@pytest.fixture
+def reservations_table(engine, reservations_schema):
+    schema_name = reservations_schema.name
+
+    def _create_test_table(table_name, schema_name=schema_name):
+        table_name = table_name or 'Exclusion Check'
+        schema_name = reservations_schema.name
+        cols = [
+            Column('id', Integer, primary_key=True),
+            Column('room_number', Integer),
+            Column('check_in_date', Date),
+            Column('check_out_date', Date)
+        ]
+        insert_data = [
+            (1, 1, '11/10/2023', '11/15/2023'),
+            (2, 1, '11/16/2023', '11/20/2023')
+        ]
+        sa_table = create_test_table(table_name, cols, insert_data, schema_name, engine)
+        table_oid = get_oid_from_table(sa_table.name, schema_name, engine)
+        table = Table.current_objects.create(oid=table_oid, schema=reservations_schema)
+        return table
+    return _create_test_table
+
+
 @pytest.fixture
 def create_patents_table(patents_csv_filepath, patent_schema, create_table):
     schema_name = patent_schema.name