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)