Repository: mathesar-foundation/mathesar

Issue:
Support for `sum` aggregation function while summarizing columns
## Problem
Currently, two aggregation functions (**list & count**) are supported as transformation steps while summarizing columns. Besides, there are many useful aggregation functions and `sum` is one of them.

## Proposed solution
It would be nice to support `sum` as a transformation step while summarizing columns.

## To Discuss
- `sum` aggregation function should only be available for columns with **Number** data-type (and **Boolean**?).


## 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
@@ -396,6 +396,18 @@ def to_sa_expression(column_expr):
         return array_agg(column_expr)
 
 
+class Sum(DBFunction):
+    id = 'sum'
+    name = 'sum'
+    hints = tuple([
+        hints.aggregation,
+    ])
+
+    @staticmethod
+    def to_sa_expression(column_expr):
+        return sa_call_sql_function('sum', column_expr, return_type=PostgresType.NUMERIC)
+
+
 class Distinct(DBFunction):
     id = 'distinct'
     name = 'distinct'
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
+from db.functions.base import Count, ArrayAgg, Sum
 from db.functions.packed import DistinctArrayAgg
 
 from mathesar.api.exceptions.query_exceptions.exceptions import DeletedColumnAccess
@@ -419,6 +419,7 @@ def _get_default_display_name_for_agg_output_alias(
             DistinctArrayAgg.id: " distinct list",
             ArrayAgg.id: " list",
             Count.id: " count",
+            Sum.id: " sum",
         }
         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
@@ -1,6 +1,238 @@
 display_option_origin = "display_option_origin"
 
 
+def test_alias(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["id"]["id"], "alias": "id"},
+            {"id": columns["Checkout Time"]["id"], "alias": "Checkout Time"},
+            {"id": columns["Patron"]["id"], "alias": "Patron"},
+        ],
+        "display_names": {
+            "Checkout Month": "Month",
+            "Count": "Number of Checkouts",
+            "Sum": "Sum of patron",
+        },
+        "display_options": {
+            "Checkout Time": {
+                display_option_origin: "Checkout Time",
+            },
+            "id": {
+                display_option_origin: "id",
+            },
+            "Patron": {
+                display_option_origin: "Patron",
+            },
+        },
+        "transformations": [
+            {
+                "spec": {
+                    "grouping_expressions": [
+                        {
+                            "input_alias": "Checkout Time",
+                            "output_alias": "Checkout Month",
+                            "preproc": "truncate_to_month",
+                        }
+                    ],
+                    "aggregation_expressions": [
+                        {
+                            "input_alias": "id",
+                            "output_alias": "Count",
+                            "function": "count",
+                        },
+                        {
+                            "input_alias": "Patron",
+                            "output_alias": "Sum",
+                            "function": "sum",
+                        }
+                    ]
+                },
+                "type": "summarize",
+            }
+        ]
+    }
+    response = client.post('/api/db/v0/queries/', data=request_data)
+    assert response.status_code == 201
+    query_id = response.json()["id"]
+    expect_repsonse_data = [
+        {
+            'alias': 'Checkout Month',
+            'display_name': 'Month',
+            'type': 'text',
+            'type_options': None,
+            'display_options': {
+                display_option_origin: "Checkout Time",
+            },
+            'is_initial_column': False,
+            'input_table_name': None,
+            'input_table_id': None,
+            'input_column_name': None,
+            'input_alias': 'Checkout Time',
+        }, {
+            'alias': 'Count',
+            'display_name': 'Number of Checkouts',
+            'type': 'integer',
+            'type_options': None,
+            'display_options': {
+                display_option_origin: "id",
+            },
+            'is_initial_column': False,
+            'input_table_name': None,
+            'input_table_id': None,
+            'input_column_name': None,
+            'input_alias': 'id',
+        }, {
+            'alias': 'Sum',
+            'display_name': 'Sum of patron',
+            'type': 'numeric',
+            'type_options': None,
+            'display_options': {
+                display_option_origin: "Patron",
+            },
+            'is_initial_column': False,
+            'input_table_name': None,
+            'input_table_id': None,
+            'input_column_name': None,
+            'input_alias': 'Patron',
+        }
+    ]
+    actual_response_data = client.get(f'/api/db/v0/queries/{query_id}/columns/').json()
+    assert sorted(actual_response_data, key=lambda x: x['alias']) == expect_repsonse_data
+
+
+def test_count_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["id"]["id"], "alias": "id"},
+            {"id": columns["Checkout Time"]["id"], "alias": "Checkout Time"},
+        ],
+        "display_names": {
+            "Checkout Month": "Month",
+            "Count": "Number of Checkouts",
+        },
+        "display_options": {
+            "Checkout Time": {
+                display_option_origin: "Checkout Time",
+            },
+            "id": {
+                display_option_origin: "id",
+            },
+        },
+        "transformations": [
+            {
+                "spec": {
+                    "grouping_expressions": [
+                        {
+                            "input_alias": "Checkout Time",
+                            "output_alias": "Checkout Month",
+                            "preproc": "truncate_to_month",
+                        }
+                    ],
+                    "aggregation_expressions": [
+                        {
+                            "input_alias": "id",
+                            "output_alias": "Count",
+                            "function": "count",
+                        }
+                    ]
+                },
+                "type": "summarize",
+            }
+        ]
+    }
+    response = client.post('/api/db/v0/queries/', data=request_data)
+    assert response.status_code == 201
+    query_id = response.json()['id']
+    expect_records = [
+        {'Checkout Month': '2022-05', 'Count': 39},
+        {'Checkout Month': '2022-06', 'Count': 26},
+        {'Checkout Month': '2022-07', 'Count': 29},
+        {'Checkout Month': '2022-08', 'Count': 10},
+    ]
+    actual_records = client.get(f'/api/db/v0/queries/{query_id}/records/').json()['results']
+    assert sorted(actual_records, key=lambda x: x['Checkout Month']) == expect_records
+
+
+def test_sum_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 Month": "Month",
+            "Sum": "Sum of patron",
+        },
+        "display_options": {
+            "Checkout Time": {
+                display_option_origin: "Checkout Time",
+            },
+            "Patron": {
+                display_option_origin: "Patron",
+            },
+        },
+        "transformations": [
+            {
+                "spec": {
+                    "grouping_expressions": [
+                        {
+                            "input_alias": "Checkout Time",
+                            "output_alias": "Checkout Month",
+                            "preproc": "truncate_to_month",
+                        }
+                    ],
+                    "aggregation_expressions": [
+                        {
+                            "input_alias": "Patron",
+                            "output_alias": "Sum",
+                            "function": "sum",
+                        }
+                    ]
+                },
+                "type": "summarize",
+            }
+        ]
+    }
+    response = client.post('/api/db/v0/queries/', data=request_data)
+    assert response.status_code == 201
+    query_id = response.json()['id']
+    expect_records = [
+        {'Checkout Month': '2022-05', 'Sum': 649},
+        {'Checkout Month': '2022-06', 'Sum': 298},
+        {'Checkout Month': '2022-07', 'Sum': 524},
+        {'Checkout Month': '2022-08', 'Sum': 126},
+    ]
+    actual_records = client.get(f'/api/db/v0/queries/{query_id}/records/').json()['results']
+    assert sorted(actual_records, key=lambda x: x['Checkout Month']) == expect_records
+
+
 def test_Mathesar_money_distinct_list_aggregation(library_ma_tables, get_uid, client):
     _ = library_ma_tables
     items = {
diff --git a/mathesar/tests/api/query/test_library_demo_reports.py b/mathesar/tests/api/query/test_library_demo_reports.py
--- a/mathesar/tests/api/query/test_library_demo_reports.py
+++ b/mathesar/tests/api/query/test_library_demo_reports.py
@@ -139,8 +139,7 @@ def check_overdue_books_columns(library_ma_tables, create_overdue_books_query, c
     return query_id
 
 
-@pytest.fixture
-def run_overdue_books_scenario(check_overdue_books_columns, client):
+def test_run_overdue_books_scenario(check_overdue_books_columns, client):
     query_id = check_overdue_books_columns
     expect_records = [
         {
@@ -173,107 +172,3 @@ def run_overdue_books_scenario(check_overdue_books_columns, client):
     for rec_pair in zip(actual_records, expect_records):
         assert rec_pair[0]['email'] == rec_pair[1]['email']
         assert sorted(rec_pair[0]['Title List']) == sorted(rec_pair[1]['Title List'])
-
-
-@pytest.fixture
-def create_monthly_checkouts_query(run_overdue_books_scenario, get_uid, client):
-    _ = run_overdue_books_scenario
-    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["id"]["id"], "alias": "id"},
-            {"id": columns["Checkout Time"]["id"], "alias": "Checkout Time"},
-        ],
-        "display_names": {
-            "Checkout Month": "Month",
-            "Count": "Number of Checkouts",
-        },
-        "display_options": {
-            "Checkout Time": {
-                display_option_origin: "Checkout Time",
-            },
-            "id": {
-                display_option_origin: "id",
-            },
-        },
-        "transformations": [
-            {
-                "spec": {
-                    "grouping_expressions": [
-                        {
-                            "input_alias": "Checkout Time",
-                            "output_alias": "Checkout Month",
-                            "preproc": "truncate_to_month",
-                        }
-                    ],
-                    "aggregation_expressions": [
-                        {
-                            "input_alias": "id",
-                            "output_alias": "Count",
-                            "function": "count",
-                        }
-                    ]
-                },
-                "type": "summarize",
-            }
-        ]
-    }
-    response = client.post('/api/db/v0/queries/', data=request_data)
-    assert response.status_code == 201
-    return response
-
-
-@pytest.fixture
-def check_monthly_checkouts_columns(create_monthly_checkouts_query, client):
-    query_id = create_monthly_checkouts_query.json()['id']
-    expect_repsonse_data = [
-        {
-            'alias': 'Checkout Month',
-            'display_name': 'Month',
-            'type': 'text',
-            'type_options': None,
-            'display_options': {
-                display_option_origin: "Checkout Time",
-            },
-            'is_initial_column': False,
-            'input_table_name': None,
-            'input_table_id': None,
-            'input_column_name': None,
-            'input_alias': 'Checkout Time',
-        }, {
-            'alias': 'Count',
-            'display_name': 'Number of Checkouts',
-            'type': 'integer',
-            'type_options': None,
-            'display_options': {
-                display_option_origin: "id",
-            },
-            'is_initial_column': False,
-            'input_table_name': None,
-            'input_table_id': None,
-            'input_column_name': None,
-            'input_alias': 'id',
-        }
-    ]
-    actual_response_data = client.get(f'/api/db/v0/queries/{query_id}/columns/').json()
-    assert sorted(actual_response_data, key=lambda x: x['alias']) == expect_repsonse_data
-    return query_id
-
-
-def test_monthly_checkouts_scenario(check_monthly_checkouts_columns, client):
-    query_id = check_monthly_checkouts_columns
-    expect_records = [
-        {'Checkout Month': '2022-05', 'Count': 39},
-        {'Checkout Month': '2022-06', 'Count': 26},
-        {'Checkout Month': '2022-07', 'Count': 29},
-        {'Checkout Month': '2022-08', 'Count': 10},
-    ]
-    actual_records = client.get(f'/api/db/v0/queries/{query_id}/records/').json()['results']
-    assert sorted(actual_records, key=lambda x: x['Checkout Month']) == expect_records