Repository: mathesar-foundation/mathesar

Issue:
Support for `Peak time` aggregation function while summarizing columns
## Problem
We need to create an aggregate function for `Time` which imitates `average`, the only difference will be it will take modular mathematics into consieration.

## Proposed solution
It would be nice to support such an aggregate as a transformation step while summarizing Time columns.

## To Discuss
- `Peak time` aggregation function should only be available for columns with **Time** and **DateTime** data type.

## Additional context
This feature request is a part of GSoC '23 project, [Add More Summarization Functions](https://summerofcode.withgoogle.com/programs/2023/projects/D9XvkQCN).



Discussion:

Resolving patch:
diff --git a/db/functions/base.py b/db/functions/base.py
--- a/db/functions/base.py
+++ b/db/functions/base.py
@@ -15,7 +15,7 @@
 from abc import ABC, abstractmethod
 import warnings
 
-from sqlalchemy import column, not_, and_, or_, func, literal, cast, distinct, INTEGER
+from sqlalchemy import column, not_, and_, or_, func, literal, cast, distinct, INTEGER, TIME
 from sqlalchemy.dialects.postgresql import array_agg, TEXT, array
 from sqlalchemy.sql import quoted_name
 from sqlalchemy.sql.functions import GenericFunction, concat, percentile_disc, mode, max, min
@@ -407,6 +407,19 @@ def to_sa_expression(column_expr):
         return mode().within_group(column_expr)
 
 
+class PeakTime(DBFunction):
+    id = 'peak_time'
+    name = 'peak_time'
+    hints = tuple([
+        hints.aggregation
+    ])
+
+    @staticmethod
+    def to_sa_expression(column_expr):
+        column_expr = cast(column_expr, TIME)
+        return sa_call_sql_function('msar.peak_time', column_expr, return_type=PostgresType.TIME_WITHOUT_TIME_ZONE)
+
+
 class Min(DBFunction):
     id = 'min'
     name = 'min'
diff --git a/db/sql/install.py b/db/sql/install.py
--- a/db/sql/install.py
+++ b/db/sql/install.py
@@ -3,9 +3,12 @@
 
 FILE_DIR = os.path.abspath(os.path.dirname(__file__))
 MSAR_SQL = os.path.join(FILE_DIR, '0_msar.sql')
+MSAR_AGGREGATE_SQL = os.path.join(FILE_DIR, '3_msar_custom_aggregates.sql')
 
 
 def install(engine):
     """Install SQL pieces using the given engine."""
     with open(MSAR_SQL) as file_handle:
         load_file_with_engine(engine, file_handle)
+    with open(MSAR_AGGREGATE_SQL) as custom_aggregates:
+        load_file_with_engine(engine, custom_aggregates)
diff --git a/mathesar/models/query.py b/mathesar/models/query.py
--- a/mathesar/models/query.py
+++ b/mathesar/models/query.py
@@ -6,7 +6,7 @@
 from db.transforms.operations.deserialize import deserialize_transformation
 from db.transforms.operations.serialize import serialize_transformation
 from db.transforms.base import Summarize
-from db.functions.base import Count, ArrayAgg, Sum, Median, Mode, Percentage_True, Max, Min, Mean
+from db.functions.base import Count, ArrayAgg, Sum, Median, Mode, Percentage_True, Max, Min, Mean, PeakTime
 from db.functions.packed import DistinctArrayAgg
 
 from mathesar.api.exceptions.query_exceptions.exceptions import DeletedColumnAccess
@@ -424,8 +424,9 @@ def _get_default_display_name_for_agg_output_alias(
             Median.id: " median",
             Mode.id: " mode",
             Percentage_True.id: " percentage true",
-            Min.id: "min",
+            Min.id: " min",
             Mean.id: " mean",
+            PeakTime.id: " peak_time"
         }
         suffix_to_add = map_of_agg_function_to_suffix.get(agg_function)
         if suffix_to_add:


Test patch:
diff --git a/mathesar/tests/api/query/test_aggregation_functions.py b/mathesar/tests/api/query/test_aggregation_functions.py
--- a/mathesar/tests/api/query/test_aggregation_functions.py
+++ b/mathesar/tests/api/query/test_aggregation_functions.py
@@ -481,6 +481,179 @@ def test_max_aggregation(library_ma_tables, get_uid, client):
     assert sorted(actual_records, key=lambda x: x['Checkout Month']) == expect_records
 
 
+def test_peak_time_aggregation(library_ma_tables, get_uid, client):
+    _ = library_ma_tables
+    checkouts = {
+        t["name"]: t for t in client.get("/api/db/v0/tables/").json()["results"]
+    }["Checkouts"]
+    columns = {
+        c["name"]: c for c in checkouts["columns"]
+    }
+    request_data = {
+        "name": get_uid(),
+        "base_table": checkouts["id"],
+        "initial_columns": [
+            {"id": columns["Checkout Time"]["id"], "alias": "Checkout Time"},
+            {"id": columns["Patron"]["id"], "alias": "Patron"},
+        ],
+        "display_names": {
+            "Checkout Time": "Checkout Time",
+            "Patron": "Patron",
+        },
+        "display_options": {
+            "Checkout Time": {
+                display_option_origin: "Checkout Time",
+            },
+            "Patron": {
+                display_option_origin: "Patron",
+            },
+        },
+        "transformations": [
+            {
+                "spec": {
+                    "grouping_expressions": [
+                        {
+                            "input_alias": "Patron",
+                            "output_alias": "Patron",
+                        }
+                    ],
+                    "aggregation_expressions": [
+                        {
+                            "input_alias": "Checkout Time",
+                            "output_alias": "Checkout Time",
+                            "function": "peak_time",
+                        }
+                    ]
+                },
+                "type": "summarize",
+            }
+        ]
+    }
+    response = client.post('/api/db/v0/queries/', data=request_data)
+    assert response.status_code == 201
+    query_id = response.json()['id']
+    expect_records = [
+        {
+            "Patron": 1,
+            "Checkout Time": "12:54:00.286569"
+        },
+        {
+            "Patron": 2,
+            "Checkout Time": "12:33:56.911007"
+        },
+        {
+            "Patron": 3,
+            "Checkout Time": "15:59:15.468421"
+        },
+        {
+            "Patron": 4,
+            "Checkout Time": "19:32:29.142471"
+        },
+        {
+            "Patron": 5,
+            "Checkout Time": "13:14:47.698064"
+        },
+        {
+            "Patron": 6,
+            "Checkout Time": "14:03:59.331127"
+        },
+        {
+            "Patron": 7,
+            "Checkout Time": "12:39:06.036969"
+        },
+        {
+            "Patron": 8,
+            "Checkout Time": "13:11:37.322141"
+        },
+        {
+            "Patron": 9,
+            "Checkout Time": "15:42:14.208165"
+        },
+        {
+            "Patron": 10,
+            "Checkout Time": "15:34:02.558857"
+        },
+        {
+            "Patron": 11,
+            "Checkout Time": "14:25:18.50151"
+        },
+        {
+            "Patron": 12,
+            "Checkout Time": "19:38:12.268677"
+        },
+        {
+            "Patron": 13,
+            "Checkout Time": "12:31:00.403794"
+        },
+        {
+            "Patron": 14,
+            "Checkout Time": "13:26:25.293263"
+        },
+        {
+            "Patron": 15,
+            "Checkout Time": "13:34:53.582087"
+        },
+        {
+            "Patron": 16,
+            "Checkout Time": "15:23:23.148845"
+        },
+        {
+            "Patron": 17,
+            "Checkout Time": "16:39:21.51814"
+        },
+        {
+            "Patron": 18,
+            "Checkout Time": "15:56:47.170539"
+        },
+        {
+            "Patron": 19,
+            "Checkout Time": "13:05:33.506587"
+        },
+        {
+            "Patron": 20,
+            "Checkout Time": "15:45:13.753633"
+        },
+        {
+            "Patron": 21,
+            "Checkout Time": "11:40:36.809586"
+        },
+        {
+            "Patron": 22,
+            "Checkout Time": "13:25:09.374102"
+        },
+        {
+            "Patron": 23,
+            "Checkout Time": "14:18:54.097847"
+        },
+        {
+            "Patron": 24,
+            "Checkout Time": "15:30:34.310875"
+        },
+        {
+            "Patron": 25,
+            "Checkout Time": "13:03:00.9767"
+        },
+        {
+            "Patron": 26,
+            "Checkout Time": "17:14:35.469216"
+        },
+        {
+            "Patron": 27,
+            "Checkout Time": "13:41:13.814894"
+        },
+        {
+            "Patron": 28,
+            "Checkout Time": "15:51:15.074412"
+        },
+        {
+            "Patron": 29,
+            "Checkout Time": "16:03:31.517422"
+        }
+    ]
+    actual_records = client.get(f'/api/db/v0/queries/{query_id}/records/').json()['results']
+    assert sorted(actual_records, key=lambda x: x['Patron']) == expect_records
+
+
 def test_min_aggregation(library_ma_tables, get_uid, client):
     _ = library_ma_tables
     checkouts = {