Repository: mathesar-foundation/mathesar

Issue:
Implement grouping for Text types.
## Problem
Users may want to group records that contain Text types by first word or first character of the text value.

We need to provide the ability to group by these.

## Solution
We should extend our record grouping mechanism to accept first word or first character as grouping parameters for columns of Text types

This involves:
- Implementing the grouping in the backend
- ~Updating the `/api/v0/databases/<id>/types/` endpoint to store available grouping on this type~ This will be done in #1310 

## Additional context
- Please see these PRs for code related to grouping:
  - #315
  - #353
- Complete implementation of this issue is blocked by implementation of Text types, but we can start work with the `VARCHAR` type which is already implemented.
  - #404 
  - #405 
  - #407
- #557 provides some context on storing groups.


Discussion:

Resolving patch:
diff --git a/db/records/operations/group.py b/db/records/operations/group.py
--- a/db/records/operations/group.py
+++ b/db/records/operations/group.py
@@ -1,7 +1,7 @@
 from enum import Enum
 import json
 import logging
-from sqlalchemy import select, func, and_, case, literal
+from sqlalchemy import select, func, and_, case, literal, cast, TEXT
 
 from db.records import exceptions as records_exceptions
 from db.records.operations import calculation
@@ -18,6 +18,7 @@ class GroupMode(Enum):
     MAGNITUDE = 'magnitude'
     COUNT_BY = 'count_by'
     PERCENTILE = 'percentile'
+    PREFIX = 'prefix'
 
 
 class GroupMetadataField(Enum):
@@ -41,6 +42,7 @@ def __init__(
             count_by=None,
             global_min=None,
             global_max=None,
+            prefix_length=None,
     ):
         self._columns = tuple(columns) if type(columns) != str else tuple([columns])
         self._mode = mode
@@ -49,6 +51,7 @@ def __init__(
         self._count_by = count_by
         self._global_min = global_min
         self._global_max = global_max
+        self._prefix_length = prefix_length
         self._ranged = bool(mode != GroupMode.DISTINCT.value)
         self.validate()
 
@@ -71,6 +74,22 @@ def bound_tuples(self):
         elif self._mode == GroupMode.COUNT_BY.value:
             return [bt for bt in self._bound_tuple_generator()]
 
+    @property
+    def count_by(self):
+        return self._count_by
+
+    @property
+    def global_min(self):
+        return self._count_by
+
+    @property
+    def global_max(self):
+        return self._count_by
+
+    @property
+    def prefix_length(self):
+        return self._prefix_length
+
     @property
     def ranged(self):
         return self._ranged
@@ -103,6 +122,17 @@ def validate(self):
             raise records_exceptions.BadGroupFormat(
                 f'{GroupMode.ENDPOINTS.value} mode requires bound_tuples'
             )
+        elif (
+                self.mode == GroupMode.PREFIX.value
+                and (
+                    not len(self.columns) == 1
+                    or self.prefix_length is None
+                )
+        ):
+            raise records_exceptions.BadGroupFormat(
+                f'{GroupMode.PREFIX.value} mode requires prefix_length,'
+                ' and only works for single columns'
+            )
         elif (
                 self.mode == GroupMode.COUNT_BY.value
                 and (
@@ -178,6 +208,8 @@ def get_group_augmented_records_query(table, group_by):
         query = _get_tens_powers_range_group_select(table, grouping_columns)
     elif group_by.mode == GroupMode.DISTINCT.value:
         query = _get_distinct_group_select(table, grouping_columns)
+    elif group_by.mode == GroupMode.PREFIX.value:
+        query = _get_prefix_group_select(table, grouping_columns, group_by.prefix_length)
     else:
         raise records_exceptions.BadGroupFormat("Unknown error")
     return query
@@ -197,6 +229,21 @@ def _get_distinct_group_select(table, grouping_columns):
     )
 
 
+def _get_prefix_group_select(table, grouping_columns, prefix_length):
+    grouping_column = grouping_columns[0]
+    prefix_expr = func.left(cast(grouping_column, TEXT), prefix_length)
+    window_def = GroupingWindowDefinition(
+        order_by=grouping_columns, partition_by=prefix_expr
+    )
+    group_id_expr = func.dense_rank().over(
+        order_by=window_def.partition_by, range_=window_def.range_
+    )
+    return select(
+        table,
+        _get_group_metadata_definition(window_def, grouping_columns, group_id_expr)
+    )
+
+
 def _get_tens_powers_range_group_select(table, grouping_columns):
     EXTREMA_DIFF = 'extrema_difference'
     POWER = 'power'
diff --git a/mathesar/api/pagination.py b/mathesar/api/pagination.py
--- a/mathesar/api/pagination.py
+++ b/mathesar/api/pagination.py
@@ -112,6 +112,11 @@ def paginate_queryset(
                 'columns': [column_name_id_bidirectional_map[n] for n in group_by.columns],
                 'mode': group_by.mode,
                 'num_groups': group_by.num_groups,
+                'bound_tuples': group_by.bound_tuples,
+                'count_by': group_by.count_by,
+                'global_min': group_by.global_min,
+                'global_max': group_by.global_max,
+                'prefix_length': group_by.prefix_length,
                 'ranged': group_by.ranged,
                 'groups': groups,
             }


Test patch:
diff --git a/db/tests/records/operations/test_group.py b/db/tests/records/operations/test_group.py
--- a/db/tests/records/operations/test_group.py
+++ b/db/tests/records/operations/test_group.py
@@ -131,6 +131,15 @@ def test_GB_validate_passes_valid_kwargs_count_by():
     gb.validate()
 
 
+def test_GB_validate_passes_valid_kwargs_prefix():
+    gb = group.GroupBy(
+        columns=['col1'],
+        mode=group.GroupMode.PREFIX.value,
+        prefix_length=3
+    )
+    gb.validate()
+
+
 def test_GB_validate_fails_invalid_mode():
     with pytest.raises(records_exceptions.InvalidGroupType):
         group.GroupBy(
@@ -165,6 +174,23 @@ def test_GB_validate_fails_missing_bound_tuples():
         )
 
 
+def test_GB_validate_fails_missing_prefix_length():
+    with pytest.raises(records_exceptions.BadGroupFormat):
+        group.GroupBy(
+            columns=['col1'],
+            mode=group.GroupMode.PREFIX.value,
+        )
+
+
+def test_GB_validate_fails_multi_cols_prefix():
+    with pytest.raises(records_exceptions.BadGroupFormat):
+        group.GroupBy(
+            columns=['col1', 'col2'],
+            mode=group.GroupMode.PREFIX.value,
+            prefix_length=3
+        )
+
+
 def test_GB_validate_fails_missing_count_by():
     with pytest.raises(records_exceptions.BadGroupFormat):
         group.GroupBy(
@@ -282,6 +308,25 @@ def test_get_group_augmented_records_query_metadata_fields(roster_table_obj, gro
         )
 
 
+def test_smoke_get_group_augmented_records_query_prefix(roster_table_obj):
+    roster, engine = roster_table_obj
+    group_by = group.GroupBy(
+        ['Student Number'],
+        mode=group.GroupMode.PREFIX.value,
+        prefix_length=1,
+    )
+    augmented_query = group.get_group_augmented_records_query(roster, group_by)
+    with engine.begin() as conn:
+        res = conn.execute(augmented_query).fetchall()
+    for row in res:
+        assert all(
+            [
+                metadata_field.value in row[group.MATHESAR_GROUP_METADATA]
+                for metadata_field in group.GroupMetadataField
+            ]
+        )
+
+
 single_col_number_modes = [
     group.GroupMode.MAGNITUDE.value,
     group.GroupMode.COUNT_BY.value,
@@ -359,7 +404,15 @@ def test_smoke_get_group_augmented_records_query_magnitude(magnitude_table_obj,
             ]
         ),
         4
-    )
+    ),
+    (
+        group.GroupBy(
+            ['Student Number'],
+            mode=group.GroupMode.PREFIX.value,
+            prefix_length=1
+        ),
+        16
+    ),
 ]
 
 
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
@@ -461,6 +461,48 @@ def test_record_list_sort(create_table, client):
             },
         ],
     ),
+    (
+        'NASA Record List Group Prefix',
+        {'columns': ['Case Number'], 'mode': 'prefix', 'prefix_length': 3},
+        [
+            {
+                'count': 87,
+                'first_value': {'Case Number': 'KSC-11641'},
+                'last_value': {'Case Number': 'KSC-13689'},
+                'less_than_eq_value': None,
+                'greater_than_eq_value': None,
+                'less_than_value': None,
+                'greater_than_value': None,
+                'result_indices': [0]
+            }, {
+                'count': 138,
+                'first_value': {'Case Number': 'ARC-14048-1'},
+                'last_value': {'Case Number': 'ARC-16942-2'},
+                'less_than_eq_value': None,
+                'greater_than_eq_value': None,
+                'less_than_value': None,
+                'greater_than_value': None,
+                'result_indices': [
+                    1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17,
+                    18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 31, 32, 33,
+                    34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48,
+                    49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63,
+                    64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78,
+                    79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93,
+                    94, 95, 96, 97, 98, 99
+                ]
+            }, {
+                'count': 21,
+                'first_value': {'Case Number': 'DRC-001-049'},
+                'last_value': {'Case Number': 'DRC-098-001'},
+                'less_than_eq_value': None,
+                'greater_than_eq_value': None,
+                'less_than_value': None,
+                'greater_than_value': None,
+                'result_indices': [30]
+            },
+        ],
+    ),
 ]
 
 
@@ -543,6 +585,11 @@ def _test_group_equality(actual_groups, expect_groups):
     ]
     assert grouping_dict['mode'] == group_by.mode
     assert grouping_dict['num_groups'] == group_by.num_groups
+    assert grouping_dict['bound_tuples'] == group_by.bound_tuples
+    assert grouping_dict['count_by'] == group_by.count_by
+    assert grouping_dict['global_min'] == group_by.global_min
+    assert grouping_dict['global_max'] == group_by.global_max
+    assert grouping_dict['prefix_length'] == group_by.prefix_length
     assert grouping_dict['ranged'] == group_by.ranged
     _test_group_equality(grouping_dict['groups'], expected_groups)