From ddd4a4d2579a317ee70a2f4d885518b5a581ecb9 Mon Sep 17 00:00:00 2001
From: Petr Stribny <petr@stribny.name>
Date: Tue, 4 Mar 2025 03:43:50 +0000
Subject: [PATCH] Grouped aggregate service group by registry

---
 .../backend/src/baserow_enterprise/apps.py    |  27 +++++
 .../local_baserow/service_types.py            |  34 +++++-
 .../integrations/registries.py                |  23 +++-
 ...est_grouped_aggregate_rows_service_type.py | 107 ++++++++++++++++++
 .../data_source/AggregationGroupByForm.vue    |   7 +-
 .../modules/baserow_enterprise/plugin.js      |  54 ++++++++-
 6 files changed, 247 insertions(+), 5 deletions(-)

diff --git a/enterprise/backend/src/baserow_enterprise/apps.py b/enterprise/backend/src/baserow_enterprise/apps.py
index 7f916b88f..673e935b6 100755
--- a/enterprise/backend/src/baserow_enterprise/apps.py
+++ b/enterprise/backend/src/baserow_enterprise/apps.py
@@ -156,6 +156,33 @@ class BaserowEnterpriseConfig(AppConfig):
         grouped_aggregation_registry.register(VarianceFieldAggregationType())
         grouped_aggregation_registry.register(MedianFieldAggregationType())
 
+        from baserow.contrib.database.fields.field_types import (
+            AutonumberFieldType,
+            BooleanFieldType,
+            EmailFieldType,
+            LongTextFieldType,
+            NumberFieldType,
+            PhoneNumberFieldType,
+            RatingFieldType,
+            SingleSelectFieldType,
+            TextFieldType,
+            URLFieldType,
+        )
+        from baserow_enterprise.integrations.registries import (
+            grouped_aggregation_group_by_registry,
+        )
+
+        grouped_aggregation_group_by_registry.register(TextFieldType())
+        grouped_aggregation_group_by_registry.register(LongTextFieldType())
+        grouped_aggregation_group_by_registry.register(URLFieldType())
+        grouped_aggregation_group_by_registry.register(EmailFieldType())
+        grouped_aggregation_group_by_registry.register(NumberFieldType())
+        grouped_aggregation_group_by_registry.register(RatingFieldType())
+        grouped_aggregation_group_by_registry.register(BooleanFieldType())
+        grouped_aggregation_group_by_registry.register(PhoneNumberFieldType())
+        grouped_aggregation_group_by_registry.register(AutonumberFieldType())
+        grouped_aggregation_group_by_registry.register(SingleSelectFieldType())
+
         from baserow.core.registries import subject_type_registry
 
         subject_type_registry.register(TeamSubjectType())
diff --git a/enterprise/backend/src/baserow_enterprise/integrations/local_baserow/service_types.py b/enterprise/backend/src/baserow_enterprise/integrations/local_baserow/service_types.py
index bd0046f23..52ca8392b 100644
--- a/enterprise/backend/src/baserow_enterprise/integrations/local_baserow/service_types.py
+++ b/enterprise/backend/src/baserow_enterprise/integrations/local_baserow/service_types.py
@@ -3,6 +3,7 @@ from django.db.models import F
 
 from rest_framework.exceptions import ValidationError as DRFValidationError
 
+from baserow.contrib.database.fields.exceptions import FieldTypeDoesNotExist
 from baserow.contrib.database.views.exceptions import AggregationTypeDoesNotExist
 from baserow.contrib.database.views.utils import AnnotatedAggregation
 from baserow.contrib.integrations.local_baserow.integration_types import (
@@ -28,7 +29,10 @@ from baserow_enterprise.api.integrations.local_baserow.serializers import (
 from baserow_enterprise.integrations.local_baserow.models import (
     LocalBaserowGroupedAggregateRows,
 )
-from baserow_enterprise.integrations.registries import grouped_aggregation_registry
+from baserow_enterprise.integrations.registries import (
+    grouped_aggregation_group_by_registry,
+    grouped_aggregation_registry,
+)
 from baserow_enterprise.services.types import (
     ServiceAggregationGroupByDict,
     ServiceAggregationSeriesDict,
@@ -186,7 +190,8 @@ class LocalBaserowGroupedAggregateRowsUserServiceType(
         group_bys: list[ServiceAggregationGroupByDict] | None = None,
     ):
         with atomic_if_not_already():
-            table_field_ids = service.table.field_set.values_list("id", flat=True)
+            table_fields = service.table.field_set.all()
+            table_field_ids = [field.id for field in table_fields]
 
             def validate_agg_group_by(group_by):
                 if (
@@ -199,6 +204,23 @@ class LocalBaserowGroupedAggregateRowsUserServiceType(
                         code="invalid_field",
                     )
 
+                field = next(
+                    (
+                        field
+                        for field in table_fields
+                        if field.id == group_by["field_id"]
+                    )
+                )
+
+                try:
+                    grouped_aggregation_group_by_registry.get_by_type(field.get_type())
+                except FieldTypeDoesNotExist:
+                    raise DRFValidationError(
+                        detail=f"The field with ID {group_by['field_id']} cannot "
+                        "be used as a group by field.",
+                        code="invalid_field",
+                    )
+
                 return True
 
             service.service_aggregation_group_bys.all().delete()
@@ -405,6 +427,14 @@ class LocalBaserowGroupedAggregateRowsUserServiceType(
                 raise ServiceImproperlyConfigured(
                     f"The field with ID {group_by.field.id} is trashed."
                 )
+            try:
+                grouped_aggregation_group_by_registry.get_by_type(
+                    group_by.field.get_type()
+                )
+            except FieldTypeDoesNotExist:
+                raise ServiceImproperlyConfigured(
+                    f"The field with ID {group_by.field.id} cannot be used for group by."
+                )
             group_by_values.append(group_by.field.db_column)
 
         if len(group_by_values) > 0:
diff --git a/enterprise/backend/src/baserow_enterprise/integrations/registries.py b/enterprise/backend/src/baserow_enterprise/integrations/registries.py
index f0c197275..d392ff547 100644
--- a/enterprise/backend/src/baserow_enterprise/integrations/registries.py
+++ b/enterprise/backend/src/baserow_enterprise/integrations/registries.py
@@ -1,4 +1,8 @@
-from baserow.contrib.database.fields.registries import FieldAggregationType
+from baserow.contrib.database.fields.exceptions import (
+    FieldTypeAlreadyRegistered,
+    FieldTypeDoesNotExist,
+)
+from baserow.contrib.database.fields.registries import FieldAggregationType, FieldType
 from baserow.contrib.database.views.exceptions import (
     AggregationTypeAlreadyRegistered,
     AggregationTypeDoesNotExist,
@@ -20,3 +24,20 @@ class GroupedAggregationTypeRegistry(Registry[FieldAggregationType]):
 grouped_aggregation_registry: GroupedAggregationTypeRegistry = (
     GroupedAggregationTypeRegistry()
 )
+
+
+class GroupedAggregationGroupByRegistry(Registry[FieldType]):
+    """
+    The main registry for storing field types compatible
+    with the grouped aggregate service to be used as group by
+    fields.
+    """
+
+    name = "grouped_aggregations_group_by"
+    does_not_exist_exception_class = FieldTypeDoesNotExist
+    already_registered_exception_class = FieldTypeAlreadyRegistered
+
+
+grouped_aggregation_group_by_registry: GroupedAggregationGroupByRegistry = (
+    GroupedAggregationGroupByRegistry()
+)
diff --git a/enterprise/backend/tests/baserow_enterprise_tests/integrations/local_baserow/service_types/test_grouped_aggregate_rows_service_type.py b/enterprise/backend/tests/baserow_enterprise_tests/integrations/local_baserow/service_types/test_grouped_aggregate_rows_service_type.py
index e1481ecff..6eb5cd213 100644
--- a/enterprise/backend/tests/baserow_enterprise_tests/integrations/local_baserow/service_types/test_grouped_aggregate_rows_service_type.py
+++ b/enterprise/backend/tests/baserow_enterprise_tests/integrations/local_baserow/service_types/test_grouped_aggregate_rows_service_type.py
@@ -220,6 +220,41 @@ def test_create_grouped_aggregate_rows_service_group_by_field_not_in_table(
         ServiceHandler().create_service(service_type, **values)
 
 
+@pytest.mark.django_db
+def test_create_grouped_aggregate_rows_service_group_by_field_not_compatible(
+    data_fixture,
+):
+    user = data_fixture.create_user()
+    dashboard = data_fixture.create_dashboard_application(user=user)
+    table = data_fixture.create_database_table(user=user)
+    table_2 = data_fixture.create_database_table(user=user)
+    field = data_fixture.create_number_field(table=table)
+    field_2 = data_fixture.create_uuid_field(table=table)
+    view = data_fixture.create_grid_view(user=user, table=table)
+    integration = data_fixture.create_local_baserow_integration(
+        application=dashboard, user=user
+    )
+    service_type = service_type_registry.get("local_baserow_grouped_aggregate_rows")
+    values = service_type.prepare_values(
+        {
+            "view_id": view.id,
+            "table_id": view.table_id,
+            "integration_id": integration.id,
+            "service_aggregation_series": [
+                {"field_id": field.id, "aggregation_type": "sum"},
+            ],
+            "service_aggregation_group_bys": [{"field_id": field_2.id}],
+        },
+        user,
+    )
+
+    with pytest.raises(
+        ValidationError,
+        match=f"The field with ID {field_2.id} cannot be used as a group by field.",
+    ):
+        ServiceHandler().create_service(service_type, **values)
+
+
 @pytest.mark.django_db
 def test_create_grouped_aggregate_rows_service_max_series_exceeded(
     data_fixture,
@@ -662,6 +697,46 @@ def test_update_grouped_aggregate_rows_service_group_by_field_not_in_table(
         ServiceHandler().update_service(service_type, service=service, **values)
 
 
+@pytest.mark.django_db
+def test_update_grouped_aggregate_rows_service_group_by_field_not_in_compatible(
+    data_fixture,
+):
+    user = data_fixture.create_user()
+    dashboard = data_fixture.create_dashboard_application(user=user)
+    table = data_fixture.create_database_table(user=user)
+    field = data_fixture.create_number_field(table=table)
+    field_2 = data_fixture.create_uuid_field(table=table)
+    view = data_fixture.create_grid_view(user=user, table=table)
+    integration = data_fixture.create_local_baserow_integration(
+        application=dashboard, user=user
+    )
+    service_type = service_type_registry.get("local_baserow_grouped_aggregate_rows")
+    service = data_fixture.create_service(
+        LocalBaserowGroupedAggregateRows,
+        integration=integration,
+        table=table,
+        view=view,
+    )
+
+    values = service_type.prepare_values(
+        {
+            "table_id": table.id,
+            "service_aggregation_series": [
+                {"field_id": field.id, "aggregation_type": "sum"},
+            ],
+            "service_aggregation_group_bys": [{"field_id": field_2.id}],
+        },
+        user,
+        service,
+    )
+
+    with pytest.raises(
+        ValidationError,
+        match=f"The field with ID {field_2.id} cannot be used as a group by field.",
+    ):
+        ServiceHandler().update_service(service_type, service=service, **values)
+
+
 @pytest.mark.django_db
 def test_update_grouped_aggregate_rows_service_max_series_exceeded(
     data_fixture,
@@ -1298,6 +1373,38 @@ def test_grouped_aggregate_rows_service_group_by_field_trashed(data_fixture):
     assert exc.value.args[0] == f"The field with ID {field_2.id} is trashed."
 
 
+@pytest.mark.django_db
+def test_grouped_aggregate_rows_service_group_by_field_not_compatible(data_fixture):
+    user = data_fixture.create_user()
+    dashboard = data_fixture.create_dashboard_application(user=user)
+    table = data_fixture.create_database_table(user=user)
+    field = data_fixture.create_number_field(table=table)
+    field_2 = data_fixture.create_uuid_field(table=table)
+    integration = data_fixture.create_local_baserow_integration(
+        application=dashboard, user=user
+    )
+    service = data_fixture.create_service(
+        LocalBaserowGroupedAggregateRows,
+        integration=integration,
+        table=table,
+    )
+    LocalBaserowTableServiceAggregationSeries.objects.create(
+        service=service, field=field, aggregation_type="sum", order=1
+    )
+    LocalBaserowTableServiceAggregationGroupBy.objects.create(
+        service=service, field=field_2, order=1
+    )
+
+    dispatch_context = FakeDispatchContext()
+
+    with pytest.raises(ServiceImproperlyConfigured) as exc:
+        ServiceHandler().dispatch_service(service, dispatch_context)
+    assert (
+        exc.value.args[0]
+        == f"The field with ID {field_2.id} cannot be used for group by."
+    )
+
+
 @pytest.mark.django_db
 def test_grouped_aggregate_rows_service_table_trashed(data_fixture):
     user = data_fixture.create_user()
diff --git a/enterprise/web-frontend/modules/baserow_enterprise/dashboard/components/data_source/AggregationGroupByForm.vue b/enterprise/web-frontend/modules/baserow_enterprise/dashboard/components/data_source/AggregationGroupByForm.vue
index 7ad791a5a..b33aee0df 100644
--- a/enterprise/web-frontend/modules/baserow_enterprise/dashboard/components/data_source/AggregationGroupByForm.vue
+++ b/enterprise/web-frontend/modules/baserow_enterprise/dashboard/components/data_source/AggregationGroupByForm.vue
@@ -40,8 +40,13 @@ export default {
     }
   },
   computed: {
+    compatibleFields() {
+      return this.tableFields.filter((field) =>
+        this.$registry.exists('groupedAggregationGroupedBy', field.type)
+      )
+    },
     groupByOptions() {
-      const tableFieldOptions = this.tableFields.map((field) => {
+      const tableFieldOptions = this.compatibleFields.map((field) => {
         return {
           name: field.name,
           value: field.id,
diff --git a/enterprise/web-frontend/modules/baserow_enterprise/plugin.js b/enterprise/web-frontend/modules/baserow_enterprise/plugin.js
index 36190bc13..3ee15aa9d 100644
--- a/enterprise/web-frontend/modules/baserow_enterprise/plugin.js
+++ b/enterprise/web-frontend/modules/baserow_enterprise/plugin.js
@@ -72,7 +72,18 @@ import {
   MedianViewAggregationType,
 } from '@baserow/modules/database/viewAggregationTypes'
 import { PeriodicDataSyncDeactivatedNotificationType } from '@baserow_enterprise/notificationTypes'
-
+import {
+  TextFieldType,
+  LongTextFieldType,
+  URLFieldType,
+  EmailFieldType,
+  NumberFieldType,
+  RatingFieldType,
+  BooleanFieldType,
+  SingleSelectFieldType,
+  PhoneNumberFieldType,
+  AutonumberFieldType,
+} from '@baserow/modules/database/fieldTypes'
 import {
   FF_AB_SSO,
   FF_DASHBOARDS,
@@ -244,6 +255,47 @@ export default (context) => {
     new UniqueCountViewAggregationType(context)
   )
 
+  app.$registry.register(
+    'groupedAggregationGroupedBy',
+    new TextFieldType(context)
+  )
+  app.$registry.register(
+    'groupedAggregationGroupedBy',
+    new LongTextFieldType(context)
+  )
+  app.$registry.register(
+    'groupedAggregationGroupedBy',
+    new NumberFieldType(context)
+  )
+  app.$registry.register(
+    'groupedAggregationGroupedBy',
+    new URLFieldType(context)
+  )
+  app.$registry.register(
+    'groupedAggregationGroupedBy',
+    new RatingFieldType(context)
+  )
+  app.$registry.register(
+    'groupedAggregationGroupedBy',
+    new BooleanFieldType(context)
+  )
+  app.$registry.register(
+    'groupedAggregationGroupedBy',
+    new EmailFieldType(context)
+  )
+  app.$registry.register(
+    'groupedAggregationGroupedBy',
+    new SingleSelectFieldType(context)
+  )
+  app.$registry.register(
+    'groupedAggregationGroupedBy',
+    new PhoneNumberFieldType(context)
+  )
+  app.$registry.register(
+    'groupedAggregationGroupedBy',
+    new AutonumberFieldType(context)
+  )
+
   app.$registry.register(
     'notification',
     new PeriodicDataSyncDeactivatedNotificationType(context)