Repository: mathesar-foundation/mathesar

Issue:
Support for `percentage-true` aggregation function while summarizing columns
## Problem
When summarizing `Boolean` columns, `percentage of true values` in the column might be helpful for users.
## Proposed solution
It would be nice to support `percentage true` as a transformation step while summarizing columns.


## 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/conftest.py b/conftest.py
--- a/conftest.py
+++ b/conftest.py
@@ -260,6 +260,7 @@ def _get_connection_string(username, password, hostname, database):
 ACADEMICS_SQL = os.path.join(RESOURCES, "academics_create.sql")
 LIBRARY_SQL = os.path.join(RESOURCES, "library_without_checkouts.sql")
 LIBRARY_CHECKOUTS_SQL = os.path.join(RESOURCES, "library_add_checkouts.sql")
+FRAUDULENT_PAYMENTS_SQL = os.path.join(RESOURCES, "fraudulent_payments.sql")
 
 
 @pytest.fixture
@@ -331,3 +332,20 @@ def make_table(table_name):
         in table_names
     }
     return tables
+
+
+@pytest.fixture
+def engine_with_fraudulent_payment(engine_with_schema):
+    engine, schema = engine_with_schema
+    with engine.begin() as conn, open(FRAUDULENT_PAYMENTS_SQL) as f:
+        conn.execute(text(f"SET search_path={schema}"))
+        conn.execute(text(f.read()))
+    yield engine, schema
+
+
+@pytest.fixture
+def payments_db_table(engine_with_fraudulent_payment):
+    engine, schema = engine_with_fraudulent_payment
+    metadata = MetaData(bind=engine)
+    table = Table("Payments", metadata, schema=schema, autoload_with=engine)
+    return table
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
+from sqlalchemy import column, not_, and_, or_, func, literal, cast, distinct, INTEGER
 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
@@ -420,6 +420,19 @@ def to_sa_expression(column_expr):
         return sa_call_sql_function('sum', column_expr, return_type=PostgresType.NUMERIC)
 
 
+class Percentage_True(DBFunction):
+    id = 'percentage_true'
+    name = 'percentage_true'
+    hints = tuple([
+        hints.aggregation,
+    ])
+
+    @staticmethod
+    def to_sa_expression(column_expr):
+        column_expr = cast(column_expr, INTEGER)
+        return sa_call_sql_function('avg', 100 * column_expr, return_type=PostgresType.NUMERIC)
+
+
 class Median(DBFunction):
     id = 'median'
     name = 'median'
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
+from db.functions.base import Count, ArrayAgg, Sum, Median, Mode, Percentage_True
 from db.functions.packed import DistinctArrayAgg
 
 from mathesar.api.exceptions.query_exceptions.exceptions import DeletedColumnAccess
@@ -422,6 +422,7 @@ def _get_default_display_name_for_agg_output_alias(
             Sum.id: " sum",
             Median.id: " median",
             Mode.id: " mode",
+            Percentage_True.id: " percentage true"
         }
         suffix_to_add = map_of_agg_function_to_suffix.get(agg_function)
         if suffix_to_add:


Test patch:
diff --git a/db/tests/resources/fraudulent_payments.sql b/db/tests/resources/fraudulent_payments.sql
new file mode 100644
--- /dev/null
+++ b/db/tests/resources/fraudulent_payments.sql
@@ -0,0 +1,124 @@
+CREATE TABLE "Payments" (
+  id integer NOT NULL,
+  "Payment Mode" text,
+  "Is Fraudulent" boolean
+);
+
+CREATE SEQUENCE "Payments_id_seq"
+    AS integer
+    START WITH 1
+    INCREMENT BY 1
+    NO MINVALUE
+    NO MAXVALUE
+    CACHE 1;
+
+ALTER SEQUENCE "Payments_id_seq" OWNED BY "Payments".id;
+
+ALTER TABLE ONLY "Payments" ALTER COLUMN id SET DEFAULT nextval('"Payments_id_seq"'::regclass);
+
+INSERT INTO "Payments" (id, "Payment Mode", "Is Fraudulent") VALUES
+(1, 'credit card', false),
+(2, 'debit card', false),
+(3, 'wallet', true),
+(4, 'UPI', false),
+(5, 'credit card', false),
+(6, 'wallet', true),
+(7, 'debit card', false),
+(8, 'pay later', false),
+(9, 'debit card', false),
+(10, 'debit card', false),
+(11, 'pay later', false),
+(12, 'debit card', false),
+(13, 'wallet', true),
+(14, 'wallet', false),
+(15, 'UPI', true),
+(16, 'credit card', false),
+(17, 'wallet', false),
+(18, 'debit card', false),
+(19, 'debit card', false),
+(20, 'credit card', false),
+(21, 'debit card', true),
+(22, 'credit card', false),
+(23, 'debit card', false),
+(24, 'debit card', false),
+(25, 'wallet', false),
+(26, 'wallet', false),
+(27, 'wallet', false),
+(28, 'wallet', false),
+(29, 'credit card', false),
+(30, 'wallet', false),
+(31, 'wallet', false),
+(32, 'debit card', false),
+(33, 'debit card', false),
+(34, 'debit card', false),
+(35, 'pay later', false),
+(36, 'debit card', false),
+(37, 'wallet', false),
+(38, 'debit card', true),
+(39, 'credit card', false),
+(40, 'UPI', false),
+(41, 'debit card', false),
+(42, 'credit card', false),
+(43, 'credit card', false),
+(44, 'wallet', false),
+(45, 'pay later', false),
+(46, 'credit card', true),
+(47, 'debit card', false),
+(48, 'credit card', false),
+(49, 'wallet', false),
+(50, 'UPI', false),
+(51, 'debit card', false),
+(52, 'debit card', false),
+(53, 'wallet', false),
+(54, 'debit card', false),
+(55, 'credit card', false),
+(56, 'pay later', false),
+(57, 'debit card', true),
+(58, 'wallet', false),
+(59, 'credit card', false),
+(60, 'debit card', false),
+(61, 'wallet', true),
+(62, 'wallet', false),
+(63, 'debit card', false),
+(64, 'credit card', false),
+(65, 'wallet', false),
+(66, 'debit card', false),
+(67, 'debit card', false),
+(68, 'pay later', true),
+(69, 'debit card', false),
+(70, 'debit card', false),
+(71, 'wallet', false),
+(72, 'wallet', false),
+(73, 'credit card', false),
+(74, 'debit card', false),
+(75, 'UPI', false),
+(76, 'debit card', false),
+(77, 'wallet', false),
+(78, 'credit card', true),
+(79, 'wallet', true),
+(80, 'debit card', false),
+(81, 'debit card', false),
+(82, 'credit card', false),
+(83, 'debit card', false),
+(84, 'wallet', false),
+(85, 'wallet', true),
+(86, 'UPI', false),
+(87, 'credit card', false),
+(88, 'wallet', false),
+(89, 'debit card', false),
+(90, 'debit card', false),
+(91, 'credit card', false),
+(92, 'pay later', false),
+(93, 'debit card', false),
+(94, 'wallet', false),
+(95, 'debit card', true),
+(96, 'debit card', false),
+(97, 'wallet', false),
+(98, 'wallet', false),
+(99, 'credit card', false),
+(100, 'wallet', true);
+
+SELECT pg_catalog.setval('"Payments_id_seq"', 100, true);
+
+ALTER TABLE ONLY "Payments"
+    ADD CONSTRAINT "Payments_pkey" PRIMARY KEY (id);
\ No newline at end of file
diff --git a/mathesar/tests/api/conftest.py b/mathesar/tests/api/conftest.py
--- a/mathesar/tests/api/conftest.py
+++ b/mathesar/tests/api/conftest.py
@@ -295,3 +295,9 @@ def library_ma_tables(db_table_to_dj_table, library_db_tables):
         for table_name, db_table
         in library_db_tables.items()
     }
+
+
+@pytest.fixture
+def payments_ma_table(db_table_to_dj_table, payments_db_table):
+    reset_reflection()
+    return db_table_to_dj_table(payments_db_table)
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
@@ -357,6 +357,83 @@ def test_mode_aggregation(library_ma_tables, get_uid, client):
     assert sorted(actual_records, key=lambda x: x['Checkout Month']) == expect_records
 
 
+def test_percentage_true_aggregation(payments_ma_table, get_uid, client):
+    _ = payments_ma_table
+    payments = {
+        t["name"]: t for t in client.get("/api/db/v0/tables/").json()["results"]
+    }["Payments"]
+    columns = {
+        c["name"]: c for c in payments["columns"]
+    }
+    request_data = {
+        "name": get_uid(),
+        "base_table": payments["id"],
+        "initial_columns": [
+            {"id": columns["Payment Mode"]["id"], "alias": "Payment Mode"},
+            {"id": columns["Is Fraudulent"]["id"], "alias": "Is Fraudulent"},
+        ],
+        "display_names": {
+            "Payment Mode": "Payment Mode",
+            "Percentage Fraudulent": "Percentage Fraudulent",
+        },
+        "display_options": {
+            "Payment Mode": {
+                display_option_origin: "Payment Mode",
+            },
+            "Is Fraudulent": {
+                display_option_origin: "Is Fraudulent",
+            },
+        },
+        "transformations": [
+            {
+                "spec": {
+                    "grouping_expressions": [
+                        {
+                            "input_alias": "Payment Mode",
+                            "output_alias": "Payment Mode",
+                        }
+                    ],
+                    "aggregation_expressions": [
+                        {
+                            "input_alias": "Is Fraudulent",
+                            "output_alias": "Percentage Fraudulent",
+                            "function": "percentage_true",
+                        }
+                    ]
+                },
+                "type": "summarize",
+            }
+        ]
+    }
+    response = client.post('/api/db/v0/queries/', data=request_data)
+    assert response.status_code == 201
+    query_id = response.json()['id']
+    expect_records = [
+        {
+            'Payment Mode': 'UPI',
+            'Percentage Fraudulent': 16.666666666666668
+        },
+        {
+            'Payment Mode': 'credit card',
+            'Percentage Fraudulent': 10.0
+        },
+        {
+            'Payment Mode': 'debit card',
+            'Percentage Fraudulent': 10.81081081081081
+        },
+        {
+            'Payment Mode': 'pay later',
+            'Percentage Fraudulent': 14.285714285714286
+        },
+        {
+            'Payment Mode': 'wallet',
+            'Percentage Fraudulent': 23.333333333333332
+        }
+    ]
+    actual_records = client.get(f'/api/db/v0/queries/{query_id}/records/').json()['results']
+    assert sorted(actual_records, key=lambda x: x['Payment Mode']) == expect_records
+
+
 def test_Mathesar_money_distinct_list_aggregation(library_ma_tables, get_uid, client):
     _ = library_ma_tables
     items = {