Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion backend/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ dynamic = ["version"]
classifiers = []

dependencies = [
"django==5.2.13",
"django==5.2.14",
"django-cors-headers==4.9.0",
"djangorestframework==3.16.1",
"djangorestframework-simplejwt==5.5.1",
Expand Down
10 changes: 10 additions & 0 deletions backend/src/baserow/contrib/database/fields/field_aggregations.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ class CountFieldAggregationType(FieldAggregationType):
"""

type = "count"
result_type = "integer"
raw_type = CountViewAggregationType
compatible_field_types = raw_type.compatible_field_types

Expand All @@ -63,6 +64,7 @@ class EmptyCountFieldAggregationType(FieldAggregationType):
"""

type = "empty_count"
result_type = "integer"
raw_type = EmptyCountViewAggregationType
compatible_field_types = raw_type.compatible_field_types

Expand All @@ -73,6 +75,7 @@ class NotEmptyCountFieldAggregationType(FieldAggregationType):
"""

type = "not_empty_count"
result_type = "integer"
raw_type = NotEmptyCountViewAggregationType
compatible_field_types = raw_type.compatible_field_types

Expand All @@ -83,6 +86,7 @@ class CheckedFieldAggregationType(FieldAggregationType):
"""

type = "checked_count"
result_type = "integer"
raw_type = NotEmptyCountViewAggregationType
compatible_field_types = [
BooleanFieldType.type,
Expand All @@ -98,6 +102,7 @@ class NotCheckedFieldAggregationType(FieldAggregationType):
"""

type = "not_checked_count"
result_type = "integer"
raw_type = EmptyCountViewAggregationType
compatible_field_types = [
BooleanFieldType.type,
Expand All @@ -113,6 +118,7 @@ class EmptyPercentageFieldAggregationType(FieldAggregationType):
"""

type = "empty_percentage"
result_type = "float"
raw_type = EmptyCountViewAggregationType
with_total = True
compatible_field_types = [
Expand Down Expand Up @@ -150,6 +156,7 @@ class NotEmptyPercentageFieldAggregationType(FieldAggregationType):
"""

type = "not_empty_percentage"
result_type = "float"
raw_type = NotEmptyCountViewAggregationType
with_total = True
compatible_field_types = [
Expand Down Expand Up @@ -187,6 +194,7 @@ class CheckedPercentageFieldAggregationType(FieldAggregationType):
"""

type = "checked_percentage"
result_type = "float"
raw_type = NotEmptyCountViewAggregationType
with_total = True
compatible_field_types = [
Expand All @@ -203,6 +211,7 @@ class NotCheckedPercentageFieldAggregationType(FieldAggregationType):
"""

type = "not_checked_percentage"
result_type = "float"
raw_type = EmptyCountViewAggregationType
with_total = True
compatible_field_types = [
Expand All @@ -219,6 +228,7 @@ class UniqueCountFieldAggregationType(FieldAggregationType):
"""

type = "unique_count"
result_type = "integer"
raw_type = UniqueCountViewAggregationType
compatible_field_types = raw_type.compatible_field_types

Expand Down
44 changes: 40 additions & 4 deletions backend/src/baserow/contrib/database/fields/registries.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,10 @@
)
from .fields import DurationFieldUsingPostgresFormatting
from .models import Field, FieldConstraint, LinkRowField
from .utils import DeferredForeignKeyUpdater
from .utils import (
DeferredForeignKeyUpdater,
guess_json_type_from_response_serializer_field,
)

if TYPE_CHECKING:
from baserow.contrib.database.fields.dependencies.handler import FieldDependants
Expand Down Expand Up @@ -2394,10 +2397,14 @@ class FieldAggregationType(Instance):
attributes.
"""

result_type = "string"
result_type = "field"
"""Informs features which use field aggregation types what the aggregation
result type be. At the moment the result is always a string, but in the future
if we generated an array for example, the result type would be 'array'."""
result type is.

The value is used as the single source of truth for both schema generation
and serializer selection. Use "field" to reuse the underlying field
serializer, or a primitive result type such as "integer" or "float".
"""

with_total = False
"""Determines if the result of the aggregation needs to
Expand Down Expand Up @@ -2514,6 +2521,35 @@ def _compute_final_aggregation(self, raw_aggregation_result, total_count: int):
return (raw_aggregation_result / total_count) * 100
return raw_aggregation_result

def get_result_schema(self, field: Field) -> Dict[str, Any]:
"""
Returns the JSON schema fragment describing the aggregation result.
"""

serializer_field = self.get_result_serializer_field(field)
return guess_json_type_from_response_serializer_field(serializer_field)

def get_result_serializer_field(self, field: Field):
"""
Returns the DRF serializer field used to serialize the aggregation result.
"""

if self.result_type == "field":
return field.get_type().get_serializer_field(field)
if self.result_type == "integer":
return serializers.IntegerField()
if self.result_type == "float":
return serializers.FloatField()
if self.result_type == "boolean":
return serializers.BooleanField()
if self.result_type == "string":
return serializers.CharField()

raise ValueError(
f"Unsupported aggregation result type '{self.result_type}' "
f"for field aggregation type '{self.type}'."
)


class FieldAggregationTypeRegistry(Registry):
"""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

from .deferred_field_importer import DeferredFieldImporter # noqa: F401
from .deferred_foreign_key_updater import DeferredForeignKeyUpdater # noqa: F401
from .schema import guess_json_type_from_response_serializer_field # noqa: F401

field_pattern = re.compile("^field_([0-9]+)$")

Expand Down
96 changes: 96 additions & 0 deletions backend/src/baserow/contrib/database/fields/utils/schema.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
from typing import Any, Dict, Union

from drf_spectacular.types import OPENAPI_TYPE_MAPPING
from rest_framework.fields import (
BooleanField,
CharField,
ChoiceField,
DateField,
DateTimeField,
DecimalField,
Field,
FloatField,
IntegerField,
ListField,
SerializerMethodField,
TimeField,
UUIDField,
)
from rest_framework.serializers import ListSerializer, Serializer


def guess_json_type_from_response_serializer_field(
serializer_field: Union[Field, Serializer],
) -> Dict[str, Any]:
"""
Responsible for taking a serializer field, and guessing what its JSON
type will be. If the field is a ListSerializer, and it has a child serializer,
we add the child's type as well.

:param serializer_field: The serializer field.
:return: A dictionary to add to our schema.
"""

from baserow.contrib.database.api.fields.serializers import (
DurationFieldSerializer,
)

if isinstance(serializer_field, (UUIDField, CharField, DecimalField, FloatField)):
# DecimalField/FloatField values are returned as strings from the API.
base_type = "string"
elif isinstance(serializer_field, DateField):
return {"type": "string", "format": "date"}
elif isinstance(serializer_field, DateTimeField):
return {"type": "string", "format": "date-time"}
elif isinstance(serializer_field, (TimeField, DurationFieldSerializer)):
base_type = "string"
elif isinstance(serializer_field, ChoiceField):
base_type = "string"
elif isinstance(serializer_field, IntegerField):
base_type = "number"
elif isinstance(serializer_field, BooleanField):
base_type = "boolean"
elif isinstance(serializer_field, ListSerializer):
sub_type = guess_json_type_from_response_serializer_field(
serializer_field.child
)
return {"type": "array", "items": sub_type}
elif isinstance(serializer_field, ListField):
sub_type = guess_json_type_from_response_serializer_field(
serializer_field.child
)
return {"type": "array", "items": sub_type}
elif isinstance(serializer_field, SerializerMethodField):
# Try to guess the json type of SerializerMethodField based on the
# OpenAPI annotations.
#
# When a method serializer uses @extend_schema_field decorator it will
# include a dictionary called "_spectacular_annotation" that contains
# the type of the field to return.
#
# NOTE: This only works for primitive types (e.g, string, boolean, etc.)
# and not for composite ones (e.g, object, lists, etc.).
base_type = None
method = getattr(serializer_field.parent, serializer_field.method_name)
if hasattr(method, "_spectacular_annotation"):
field = method._spectacular_annotation.get("field")
mapping = OPENAPI_TYPE_MAPPING.get(field)
if isinstance(mapping, dict):
base_type = mapping.get("type", None)
elif issubclass(serializer_field.__class__, Serializer):
properties = {}
for name, child_serializer in serializer_field.fields.items():
guessed_type = guess_json_type_from_response_serializer_field(
child_serializer
)
if guessed_type["type"] is not None:
properties[name] = {
"title": name,
**guessed_type,
}

return {"type": "object", "properties": properties}
else:
base_type = None

return {"type": base_type}
Original file line number Diff line number Diff line change
Expand Up @@ -1239,7 +1239,7 @@ def generate_schema(
return {}

# Pluck out the aggregation type which this service uses. We'll use its
# `result_type` to inform the schema what the expected `result` format is.
# declared result metadata to inform the schema and serialization.
aggregation_type = field_aggregation_registry.get(service.aggregation_type)

return {
Expand All @@ -1248,7 +1248,7 @@ def generate_schema(
"properties": {
"result": {
"title": f"{service.field.name} result",
"type": aggregation_type.result_type,
**aggregation_type.get_result_schema(service.field.specific),
}
},
}
Expand Down Expand Up @@ -1511,6 +1511,7 @@ def dispatch_data(
"data": {"result": result},
"baserow_table_model": model,
"field": field,
"service": service,
}
except DjangoFieldDoesNotExist as ex:
raise ServiceImproperlyConfiguredDispatchException(
Expand All @@ -1534,16 +1535,14 @@ def dispatch_transform(
:return: A dictionary containing the aggregation result.
"""

# Use the field type's serializer field to ensure the aggregation result
# is serialized correctly. Some aggregations can return values which are not
# JSON serializable (e.g. Decimal), so we need to use the serializer field
# to convert them into a JSON serializable format.
result = (
data["field"]
.get_type()
.get_serializer_field(data["field"])
.to_representation(data["data"]["result"])
# Use the aggregation type's declared result metadata to serialize the
# aggregation output. This avoids reusing a list-like field serializer for
# count-like aggregations which return scalars.
aggregation_type = field_aggregation_registry.get(
data["service"].aggregation_type
)
serializer_field = aggregation_type.get_result_serializer_field(data["field"])
result = serializer_field.to_representation(data["data"]["result"])

return DispatchResult(data={"result": result})

Expand Down
Loading
Loading