1
0
Fork 0
mirror of https://gitlab.com/bramw/baserow.git synced 2025-04-11 07:51:20 +00:00

Merge branch '321-improve-the-table-model-search_all_fields-method' into 'develop'

Resolve "Improve the table model `search_all_fields` method"

Closes 

See merge request 
This commit is contained in:
Nigel Gott 2021-03-23 12:36:16 +00:00
commit 782ba27087
18 changed files with 708 additions and 265 deletions

View file

@ -1,32 +1,28 @@
from django.db import transaction
from django.conf import settings
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticated
from drf_spectacular.utils import extend_schema
from django.db import transaction
from drf_spectacular.openapi import OpenApiParameter, OpenApiTypes
from drf_spectacular.utils import extend_schema
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from baserow.api.utils import validate_data
from baserow.api.decorators import map_exceptions
from baserow.api.pagination import PageNumberPagination
from baserow.api.errors import ERROR_USER_NOT_IN_GROUP
from baserow.api.pagination import PageNumberPagination
from baserow.api.schemas import get_error_schema
from baserow.api.user_files.errors import ERROR_USER_FILE_DOES_NOT_EXIST
from baserow.core.exceptions import UserNotInGroupError
from baserow.core.user_files.exceptions import UserFileDoesNotExist
from baserow.contrib.database.api.tokens.authentications import TokenAuthentication
from baserow.contrib.database.api.tables.errors import ERROR_TABLE_DOES_NOT_EXIST
from baserow.contrib.database.api.rows.errors import ERROR_ROW_DOES_NOT_EXIST
from baserow.contrib.database.api.rows.serializers import (
example_pagination_row_serializer_class
)
from baserow.contrib.database.api.tokens.errors import ERROR_NO_PERMISSION_TO_TABLE
from baserow.api.utils import validate_data
from baserow.contrib.database.api.fields.errors import (
ERROR_ORDER_BY_FIELD_NOT_POSSIBLE, ERROR_ORDER_BY_FIELD_NOT_FOUND,
ERROR_FILTER_FIELD_NOT_FOUND
)
from baserow.contrib.database.api.rows.errors import ERROR_ROW_DOES_NOT_EXIST
from baserow.contrib.database.api.rows.serializers import (
example_pagination_row_serializer_class
)
from baserow.contrib.database.api.tables.errors import ERROR_TABLE_DOES_NOT_EXIST
from baserow.contrib.database.api.tokens.authentications import TokenAuthentication
from baserow.contrib.database.api.tokens.errors import ERROR_NO_PERMISSION_TO_TABLE
from baserow.contrib.database.api.views.errors import (
ERROR_VIEW_FILTER_TYPE_DOES_NOT_EXIST,
ERROR_VIEW_FILTER_TYPE_NOT_ALLOWED_FOR_FIELD
@ -34,21 +30,23 @@ from baserow.contrib.database.api.views.errors import (
from baserow.contrib.database.fields.exceptions import (
OrderByFieldNotFound, OrderByFieldNotPossible, FilterFieldNotFound
)
from baserow.contrib.database.table.handler import TableHandler
from baserow.contrib.database.table.exceptions import TableDoesNotExist
from baserow.contrib.database.rows.handler import RowHandler
from baserow.contrib.database.rows.exceptions import RowDoesNotExist
from baserow.contrib.database.tokens.handler import TokenHandler
from baserow.contrib.database.rows.handler import RowHandler
from baserow.contrib.database.table.exceptions import TableDoesNotExist
from baserow.contrib.database.table.handler import TableHandler
from baserow.contrib.database.tokens.exceptions import NoPermissionToTable
from baserow.contrib.database.views.models import FILTER_TYPE_AND, FILTER_TYPE_OR
from baserow.contrib.database.tokens.handler import TokenHandler
from baserow.contrib.database.views.exceptions import (
ViewFilterTypeNotAllowedForField, ViewFilterTypeDoesNotExist
)
from baserow.contrib.database.views.registries import view_filter_type_registry
from baserow.core.exceptions import UserNotInGroupError
from baserow.core.user_files.exceptions import UserFileDoesNotExist
from .serializers import (
RowSerializer, get_example_row_serializer_class, get_row_serializer_class
)
from baserow.contrib.database.fields.field_filters import FILTER_TYPE_AND, \
FILTER_TYPE_OR
class RowsView(APIView):

View file

@ -0,0 +1,155 @@
from typing import Dict, Any, Union
from django.db.models import Q, BooleanField
from django.db.models.expressions import RawSQL
FILTER_TYPE_AND = 'AND'
FILTER_TYPE_OR = 'OR'
class AnnotatedQ:
"""
A simple wrapper class combining a params for a Queryset.annotate call with a
django Q object to be used in combination with FilterBuilder to dynamically build up
filters which also require annotations.
"""
def __init__(self, annotation: Dict[str, Any], q: Union[Q, Dict[str, Any]]):
"""
:param annotation: A dictionary which can be unpacked into a django
Queryset.annotate call. This will only happen when using
FilterBuilder.apply_to_queryset.
:param q: a Q object or kwargs which will used to create a Q object.
"""
self.annotation = annotation or {}
if isinstance(q, Q):
self.q = q
else:
self.q = Q(**q)
def __invert__(self):
return AnnotatedQ(self.annotation, ~self.q)
OptionallyAnnotatedQ = Union[Q, AnnotatedQ]
class FilterBuilder:
"""
Combines together multiple Q or AnnotatedQ filters into a single filter which
will AND or OR the provided filters together based on the filter_type
parameter. When applied to a queryset it will also annotate he queryset
prior to filtering with the merged annotations from AnnotatedQ filters.
"""
def __init__(self, filter_type: str):
"""
:param filter_type: Either field_filters.FILTER_TYPE_AND or
field_filters.FILTER_TYPE_OR which dictates how provided Q or AnnotatedQ
filters will be combined together.
For type OR they will be ORed together when applied to a filter set,
for type AND they will be ANDed together.
"""
if filter_type not in [FILTER_TYPE_AND, FILTER_TYPE_OR]:
raise ValueError(f'Unknown filter type {filter_type}.')
self._annotation = {}
self._q_filters = Q()
self._filter_type = filter_type
def filter(self, q: OptionallyAnnotatedQ) -> 'FilterBuilder':
"""
Adds a Q or AnnotatedQ filter into this builder to be joined together with
existing filters based on the builders `filter_type`.
Annotations on provided AnnotatedQ's are merged together with any previously
supplied annotations via dict unpacking and merging.
:param q: A Q or Annotated Q
:return: The updated FilterBuilder with the provided filter applied.
"""
if isinstance(q, AnnotatedQ):
self._annotate(q.annotation)
self._filter(q.q)
else:
self._filter(q)
return self
def apply_to_queryset(self, queryset):
"""
Applies all of the Q and AnnotatedQ filters previously given to this
FilterBuilder by first applying all annotations from AnnotatedQ's and then
filtering with a Q filter resulting from the combination of all filters ANDed or
ORed depending on the filter_type attribute.
:param queryset: The queryset to annotate and filter.
:return: The annotated and filtered queryset.
"""
return queryset.annotate(**self._annotation).filter(self._q_filters)
def _annotate(self, annotation_dict: Dict[str, Any]) -> 'FilterBuilder':
self._annotation = {**self._annotation, **annotation_dict}
def _filter(self, q_filter: Q) -> 'FilterBuilder':
if self._filter_type == FILTER_TYPE_AND:
self._q_filters &= q_filter
elif self._filter_type == FILTER_TYPE_OR:
self._q_filters |= q_filter
else:
raise ValueError(f'Unknown filter type {self._filter_type}.')
def contains_filter(field_name, value, model_field, _) -> OptionallyAnnotatedQ:
value = value.strip()
# If an empty value has been provided we do not want to filter at all.
if value == '':
return Q()
# Check if the model_field accepts the value.
try:
model_field.get_prep_value(value)
return Q(**{f'{field_name}__icontains': value})
except Exception:
pass
return Q()
def filename_contains_filter(field_name, value, _, field) -> OptionallyAnnotatedQ:
value = value.strip()
# If an empty value has been provided we do not want to filter at all.
if value == '':
return Q()
# Check if the model_field has a file which matches the provided filter value.
annotation_query = _build_filename_contains_raw_query(field, value)
return AnnotatedQ(annotation={
f'{field_name}_matches_visible_names': annotation_query
}, q={
f'{field_name}_matches_visible_names': True
})
def _build_filename_contains_raw_query(field, value):
# It is not possible to use Django's ORM to query for if one item in a JSONB
# list has has a key which contains a specified value.
#
# The closest thing the Django ORM provides is:
# queryset.filter(your_json_field__contains=[{"key":"value"}])
# However this is an exact match, so in the above example [{"key":"value_etc"}]
# would not match the filter.
#
# Instead we have to resort to RawSQL to use various built in PostgreSQL JSON
# Array manipulation functions to be able to 'iterate' over a JSONB list
# performing `like` on individual keys in said list.
num_files_with_name_like_value = f"""
EXISTS(
SELECT attached_files ->> 'visible_name'
FROM JSONB_ARRAY_ELEMENTS("field_{field.id}") as attached_files
WHERE UPPER(attached_files ->> 'visible_name') LIKE UPPER(%s)
)
"""
return RawSQL(num_files_with_name_like_value, params=[f"%{value}%"],
output_field=BooleanField())

View file

@ -1,43 +1,43 @@
from datetime import datetime, date
from decimal import Decimal
from pytz import timezone
from random import randrange, randint
from dateutil import parser
from dateutil.parser import ParserError
from datetime import datetime, date
from django.db import models
from django.db.models import Case, When
from django.contrib.postgres.fields import JSONField
from django.core.validators import URLValidator, EmailValidator
from django.core.exceptions import ValidationError
from django.core.validators import URLValidator, EmailValidator
from django.db import models
from django.db.models import Case, When, Q, F, Func, Value, CharField
from django.db.models.expressions import RawSQL
from django.utils.timezone import make_aware
from pytz import timezone
from rest_framework import serializers
from baserow.core.models import UserFile
from baserow.core.user_files.exceptions import UserFileDoesNotExist
from baserow.contrib.database.api.fields.serializers import (
LinkRowValueSerializer, FileFieldRequestSerializer, FileFieldResponseSerializer,
SelectOptionSerializer
)
from baserow.contrib.database.api.fields.errors import (
ERROR_LINK_ROW_TABLE_NOT_IN_SAME_DATABASE, ERROR_LINK_ROW_TABLE_NOT_PROVIDED,
ERROR_INCOMPATIBLE_PRIMARY_FIELD_TYPE
)
from .handler import FieldHandler
from .registries import FieldType, field_type_registry
from .models import (
NUMBER_TYPE_INTEGER, NUMBER_TYPE_DECIMAL, DATE_FORMAT, DATE_TIME_FORMAT,
TextField, LongTextField, URLField, NumberField, BooleanField, DateField,
LinkRowField, EmailField, FileField,
SingleSelectField, SelectOption
from baserow.contrib.database.api.fields.serializers import (
LinkRowValueSerializer, FileFieldRequestSerializer, FileFieldResponseSerializer,
SelectOptionSerializer
)
from baserow.core.models import UserFile
from baserow.core.user_files.exceptions import UserFileDoesNotExist
from .exceptions import (
LinkRowTableNotInSameDatabase, LinkRowTableNotProvided,
IncompatiblePrimaryFieldTypeError
)
from .field_filters import contains_filter, AnnotatedQ, filename_contains_filter
from .fields import SingleSelectForeignKey
from .handler import FieldHandler
from .models import (
NUMBER_TYPE_INTEGER, NUMBER_TYPE_DECIMAL, TextField, LongTextField, URLField,
NumberField, BooleanField, DateField,
LinkRowField, EmailField, FileField,
SingleSelectField, SelectOption
)
from .registries import FieldType, field_type_registry
class TextFieldType(FieldType):
@ -57,6 +57,9 @@ class TextFieldType(FieldType):
def random_value(self, instance, fake, cache):
return fake.name()
def contains_query(self, *args):
return contains_filter(*args)
class LongTextFieldType(FieldType):
type = 'long_text'
@ -72,6 +75,9 @@ class LongTextFieldType(FieldType):
def random_value(self, instance, fake, cache):
return fake.text()
def contains_query(self, *args):
return contains_filter(*args)
class URLFieldType(FieldType):
type = 'url'
@ -108,6 +114,9 @@ class URLFieldType(FieldType):
return super().get_alter_column_prepare_new_value(connection, from_field,
to_field)
def contains_query(self, *args):
return contains_filter(*args)
class NumberFieldType(FieldType):
MAX_DIGITS = 50
@ -207,6 +216,9 @@ class NumberFieldType(FieldType):
f'field_{to_field.id}': 0
})
def contains_query(self, *args):
return contains_filter(*args)
class BooleanFieldType(FieldType):
type = 'boolean'
@ -299,18 +311,24 @@ class DateFieldType(FieldType):
to_field_type = field_type_registry.get_by_model(to_field)
if to_field_type.type != self.type and connection.vendor == 'postgresql':
sql_type = 'date'
sql_format = DATE_FORMAT[from_field.date_format]['sql']
if from_field.date_include_time:
sql_type = 'timestamp'
sql_format += ' ' + DATE_TIME_FORMAT[from_field.date_time_format]['sql']
sql_format = from_field.get_psql_format()
sql_type = from_field.get_psql_type()
return f"""p_in = TO_CHAR(p_in::{sql_type}, '{sql_format}');"""
return super().get_alter_column_prepare_old_value(connection, from_field,
to_field)
def contains_query(self, field_name, value, model_field, field):
return AnnotatedQ(
annotation={f"formatted_date_{field_name}": Func(
F(field_name),
Value(field.get_psql_format()),
function='to_char',
output_field=CharField()
)},
q={f'formatted_date_{field_name}__icontains': value}
)
def get_alter_column_prepare_new_value(self, connection, from_field, to_field):
"""
If the field type has changed into a date field then we want to parse the old
@ -321,14 +339,9 @@ class DateFieldType(FieldType):
from_field_type = field_type_registry.get_by_model(from_field)
if from_field_type.type != self.type and connection.vendor == 'postgresql':
sql_function = 'TO_DATE'
sql_format = DATE_FORMAT[to_field.date_format]['sql']
sql_type = 'date'
if to_field.date_include_time:
sql_function = 'TO_TIMESTAMP'
sql_format += ' ' + DATE_TIME_FORMAT[to_field.date_time_format]['sql']
sql_type = 'timestamp'
sql_function = to_field.get_psql_type_convert_function()
sql_format = to_field.get_psql_format()
sql_type = to_field.get_psql_type()
return f"""
begin
@ -701,6 +714,9 @@ class EmailFieldType(FieldType):
return super().get_alter_column_prepare_new_value(connection, from_field,
to_field)
def contains_query(self, *args):
return contains_filter(*args)
class FileFieldType(FieldType):
type = 'file'
@ -799,6 +815,9 @@ class FileFieldType(FieldType):
return values
def contains_query(self, *args):
return filename_contains_filter(*args)
class SingleSelectFieldType(FieldType):
type = 'single_select'
@ -982,3 +1001,37 @@ class SingleSelectFieldType(FieldType):
random_choice = randint(0, len(select_options) - 1)
return select_options[random_choice]
def contains_query(self, field_name, value, model_field, field):
option_value_mappings = []
option_values = []
# We have to query for all option values here as the user table we are
# constructing a search query for could be in a different database from the
# SingleOption. In such a situation if we just tried to do a cross database
# join django would crash, so we must look up the values in a separate query.
for option in field.select_options.all():
option_values.append(option.value)
option_value_mappings.append(
f"(lower(%s), {int(option.id)})"
)
# If there are no values then there is no way this search could match this
# field.
if len(option_value_mappings) == 0:
return Q()
convert_rows_select_id_to_value_sql = f"""(
SELECT key FROM (
VALUES {','.join(option_value_mappings)}
) AS values (key, value)
WHERE value = "field_{field.id}"
)
"""
query = RawSQL(convert_rows_select_id_to_value_sql, params=option_values,
output_field=models.CharField())
return AnnotatedQ(
annotation={f"select_option_value_{field_name}": query},
q={f'select_option_value_{field_name}__icontains': value}
)

View file

@ -6,7 +6,6 @@ from baserow.core.mixins import (
OrderableMixin, PolymorphicContentTypeMixin, CreatedAndUpdatedOnMixin
)
NUMBER_TYPE_INTEGER = 'INTEGER'
NUMBER_TYPE_DECIMAL = 'DECIMAL'
NUMBER_TYPE_CHOICES = (
@ -205,9 +204,45 @@ class DateField(Field):
:rtype: str
"""
date_format = DATE_FORMAT[self.date_format]['format']
time_format = DATE_TIME_FORMAT[self.date_time_format]['format']
return self._get_format('format')
def get_psql_format(self):
"""
Returns the sql datetime format as a string based on the field's properties.
This could for example be 'YYYY-MM-DD HH12:MIAM'.
:return: The sql datetime format based on the field's properties.
:rtype: str
"""
return self._get_format('sql')
def get_psql_type(self):
"""
Returns the postgresql column type used by this field depending on if it is a
date or datetime.
:return: The postgresql column type either 'timestamp' or 'date'
:rtype: str
"""
return 'timestamp' if self.date_include_time else 'date'
def get_psql_type_convert_function(self):
"""
Returns the postgresql function that can be used to coerce another postgresql
type to the correct type used by this field.
:return: The postgresql type conversion function, either 'TO_TIMESTAMP' or
'TO_DATE'
:rtype: str
"""
return 'TO_TIMESTAMP' if self.date_include_time else 'TO_DATE'
def _get_format(self, format_type):
date_format = DATE_FORMAT[self.date_format][format_type]
time_format = DATE_TIME_FORMAT[self.date_time_format][format_type]
if self.date_include_time:
return f'{date_format} {time_format}'
else:

View file

@ -1,3 +1,5 @@
from django.db.models import Q
from baserow.core.registry import (
Instance, Registry, ModelInstanceMixin, ModelRegistryMixin,
CustomFieldsInstanceMixin, CustomFieldsRegistryMixin, MapAPIExceptionsInstanceMixin,
@ -87,6 +89,26 @@ class FieldType(MapAPIExceptionsInstanceMixin, APIUrlsInstanceMixin,
return queryset
def contains_query(self, field_name, value, model_field, field):
"""
Returns a Q or AnnotatedQ filter which performs a contains filter over the
provided field for this specific type of field.
:param field_name: The name of the field.
:type field_name: str
:param value: The value to check if this field contains or not.
:type value: str
:param model_field: The field's actual django field model instance.
:type model_field: models.Field
:param field: The related field's instance.
:type field: Field
:return: A Q or AnnotatedQ filter.
given value.
:rtype: OptionallyAnnotatedQ
"""
return Q()
def get_serializer_field(self, instance, **kwargs):
"""
Should return the serializer field based on the custom model instance

View file

@ -1,18 +1,16 @@
import re
from decimal import Decimal, DecimalException
from django.db import models
from django.db.models import Q
from baserow.core.mixins import OrderableMixin, CreatedAndUpdatedOnMixin
from baserow.contrib.database.fields.exceptions import (
OrderByFieldNotFound, OrderByFieldNotPossible, FilterFieldNotFound
)
from baserow.contrib.database.views.registries import view_filter_type_registry
from baserow.contrib.database.fields.field_filters import FilterBuilder, \
FILTER_TYPE_AND, FILTER_TYPE_OR
from baserow.contrib.database.fields.registries import field_type_registry
from baserow.contrib.database.views.models import FILTER_TYPE_AND, FILTER_TYPE_OR
from baserow.contrib.database.views.exceptions import ViewFilterTypeNotAllowedForField
from baserow.contrib.database.views.registries import view_filter_type_registry
from baserow.core.mixins import OrderableMixin, CreatedAndUpdatedOnMixin
deconstruct_filter_key_regex = re.compile(
r'filter__field_([0-9]+)__([a-zA-Z0-9_]*)$'
@ -51,39 +49,29 @@ class TableModelQuerySet(models.QuerySet):
:rtype: QuerySet
"""
search_queries = models.Q()
excluded = ('order', 'created_on', 'updated_on')
try:
id_field_filter = models.Q(**{
f'id__contains': int(search)
})
except ValueError:
id_field_filter = models.Q()
for field in self.model._meta.get_fields():
if field.name in excluded:
continue
filter_builder = FilterBuilder(filter_type=FILTER_TYPE_OR).filter(
id_field_filter
)
for field_object in self.model._field_objects.values():
field_name = field_object['name']
model_field = self.model._meta.get_field(field_name)
if (
isinstance(field, models.CharField) or
isinstance(field, models.TextField)
):
search_queries = search_queries | models.Q(**{
f'{field.name}__icontains': search
})
elif (
isinstance(field, models.AutoField) or
isinstance(field, models.IntegerField)
):
try:
search_queries = search_queries | models.Q(**{
f'{field.name}': int(search)
})
except ValueError:
pass
elif isinstance(field, models.DecimalField):
try:
search_queries = search_queries | models.Q(**{
f'{field.name}': Decimal(search)
})
except (ValueError, DecimalException):
pass
sub_filter = field_object['type'].contains_query(
field_name,
search,
model_field,
field_object['field']
)
filter_builder.filter(sub_filter)
return self.filter(search_queries) if len(search_queries) > 0 else self
return filter_builder.apply_to_queryset(self)
def order_by_fields_string(self, order_string):
"""
@ -165,7 +153,7 @@ class TableModelQuerySet(models.QuerySet):
if filter_type not in [FILTER_TYPE_AND, FILTER_TYPE_OR]:
raise ValueError(f'Unknown filter type {filter_type}.')
q_filters = Q()
filter_builder = FilterBuilder(filter_type=filter_type)
for key, values in filter_object.items():
matches = deconstruct_filter_key_regex.match(key)
@ -180,8 +168,9 @@ class TableModelQuerySet(models.QuerySet):
field_id, f'Field {field_id} does not exist.'
)
field_name = self.model._field_objects[field_id]['name']
field_type = self.model._field_objects[field_id]['type'].type
field_object = self.model._field_objects[field_id]
field_name = field_object['name']
field_type = field_object['type'].type
model_field = self.model._meta.get_field(field_name)
view_filter_type = view_filter_type_registry.get(matches[2])
@ -195,27 +184,16 @@ class TableModelQuerySet(models.QuerySet):
values = [values]
for value in values:
q_filter = view_filter_type.get_filter(
field_name,
value,
model_field
filter_builder.filter(
view_filter_type.get_filter(
field_name,
value,
model_field,
field_object['field']
)
)
view_filter_annotation = view_filter_type.get_annotation(
field_name,
value
)
if view_filter_annotation:
self = self.annotate(**view_filter_annotation)
# Depending on filter type we are going to combine the Q either as
# AND or as OR.
if filter_type == FILTER_TYPE_AND:
q_filters &= q_filter
elif filter_type == FILTER_TYPE_OR:
q_filters |= q_filter
return self.filter(q_filters)
return filter_builder.apply_to_queryset(self)
class TableModelManager(models.Manager):

View file

@ -1,24 +1,24 @@
from django.db.models import Q, F
from django.db.models import F
from baserow.core.utils import extract_allowed, set_allowed_attrs
from baserow.contrib.database.fields.registries import field_type_registry
from baserow.contrib.database.fields.models import Field
from baserow.contrib.database.fields.exceptions import FieldNotInTable
from baserow.contrib.database.fields.models import Field
from baserow.contrib.database.fields.registries import field_type_registry
from baserow.core.utils import extract_allowed, set_allowed_attrs
from .exceptions import (
ViewDoesNotExist, UnrelatedFieldError, ViewFilterDoesNotExist,
ViewFilterNotSupported, ViewFilterTypeNotAllowedForField, ViewSortDoesNotExist,
ViewSortNotSupported, ViewSortFieldAlreadyExist, ViewSortFieldNotSupported
)
from .registries import view_type_registry, view_filter_type_registry
from .models import (
View, GridViewFieldOptions, ViewFilter, ViewSort, FILTER_TYPE_AND, FILTER_TYPE_OR
View, GridViewFieldOptions, ViewFilter, ViewSort
)
from .registries import view_type_registry, view_filter_type_registry
from .signals import (
view_created, view_updated, view_deleted, view_filter_created, view_filter_updated,
view_filter_deleted, view_sort_created, view_sort_updated, view_sort_deleted,
grid_view_field_options_updated
)
from baserow.contrib.database.fields.field_filters import FilterBuilder
class ViewHandler:
@ -236,7 +236,7 @@ class ViewHandler:
if view.filters_disabled:
return queryset
q_filters = Q()
filter_builder = FilterBuilder(filter_type=view.filter_type)
for view_filter in view.viewfilter_set.all():
# If the to be filtered field is not present in the `_field_objects` we
@ -245,32 +245,21 @@ class ViewHandler:
raise ValueError(f'The table model does not contain field '
f'{view_filter.field_id}.')
field_name = model._field_objects[view_filter.field_id]['name']
field_object = model._field_objects[view_filter.field_id]
field_name = field_object['name']
model_field = model._meta.get_field(field_name)
view_filter_type = view_filter_type_registry.get(view_filter.type)
q_filter = view_filter_type.get_filter(
field_name,
view_filter.value,
model_field
filter_builder.filter(
view_filter_type.get_filter(
field_name,
view_filter.value,
model_field,
field_object['field']
)
)
view_filter_annotation = view_filter_type.get_annotation(
field_name,
view_filter.value
)
if view_filter_annotation:
queryset = queryset.annotate(**view_filter_annotation)
# Depending on filter type we are going to combine the Q either as AND or
# as OR.
if view.filter_type == FILTER_TYPE_AND:
q_filters &= q_filter
elif view.filter_type == FILTER_TYPE_OR:
q_filters |= q_filter
queryset = queryset.filter(q_filters)
return queryset
return filter_builder.apply_to_queryset(queryset)
def get_filter(self, user, view_filter_id, base_queryset=None):
"""

View file

@ -1,14 +1,14 @@
from django.db import models
from django.contrib.contenttypes.models import ContentType
from django.db import models
from baserow.contrib.database.fields.field_filters import (
FILTER_TYPE_AND, FILTER_TYPE_OR
)
from baserow.contrib.database.fields.models import Field
from baserow.core.mixins import (
OrderableMixin, PolymorphicContentTypeMixin, CreatedAndUpdatedOnMixin
)
from baserow.contrib.database.fields.models import Field
FILTER_TYPE_AND = 'AND'
FILTER_TYPE_OR = 'OR'
FILTER_TYPES = (
(FILTER_TYPE_AND, 'And'),
(FILTER_TYPE_OR, 'Or')

View file

@ -7,6 +7,7 @@ from .exceptions import (
ViewTypeAlreadyRegistered, ViewTypeDoesNotExist, ViewFilterTypeAlreadyRegistered,
ViewFilterTypeDoesNotExist
)
from baserow.contrib.database.fields.field_filters import OptionallyAnnotatedQ
class ViewType(APIUrlsInstanceMixin, CustomFieldsInstanceMixin, ModelInstanceMixin,
@ -102,43 +103,25 @@ class ViewFilterType(Instance):
can be used in combination with the field.
"""
def get_filter(self, field_name, value, model_field):
def get_filter(self, field_name, value, model_field, field) -> OptionallyAnnotatedQ:
"""
Should return a Q object containing the requested filtering based on the
provided arguments.
Should return either a Q object or and AnnotatedQ containing the requested
filtering and annotations based on the provided arguments.
:param field_name: The name of the field that needs to be filtered.
:type field_name: str
:param value: The value that the field must be compared to.
:type value: str
:param model_field: The field extracted form the model.
:param model_field: The field extracted from the model.
:type model_field: models.Field
:return: The Q object that does the filtering. This will later be added to the
queryset in the correct way.
:rtype: Q
:param field: The instance of the underlying baserow field.
:type field: Field
:return: A Q or AnnotatedQ filter for this specific field, which will be then
later combined with other filters to generate the final total view filter.
"""
raise NotImplementedError('Each must have his own get_filter method.')
def get_annotation(self, field_name, value):
"""
Optional method allowing this ViewFilterType to annotate the queryset prior to
the application of any Q filters returned by ViewFilterType.get_filter.
Should return a dictionary which can be unpacked into an annotate call or None
if you do not wish any annotation to be applied by your filter.
:param field_name: The name of the field that needs to be filtered.
:type field_name: str
:param value: The value that the field must be compared to.
:type value: str
:return: The dict object that will be unpacked into an annotate call or None if
no annotation needs to be done.
:rtype: None or dict
"""
return None
class ViewFilterTypeRegistry(Registry):
"""

View file

@ -1,22 +1,21 @@
from math import floor, ceil
from pytz import timezone
from decimal import Decimal
from math import floor, ceil
from dateutil import parser
from dateutil.parser import ParserError
from django.db.models import Q, IntegerField, BooleanField
from django.db.models.expressions import RawSQL
from django.db.models.fields.related import ManyToManyField, ForeignKey
from django.contrib.postgres.fields import JSONField
from django.db.models import Q, IntegerField, BooleanField
from django.db.models.fields.related import ManyToManyField, ForeignKey
from pytz import timezone
from baserow.contrib.database.fields.field_types import (
TextFieldType, LongTextFieldType, URLFieldType, NumberFieldType, DateFieldType,
LinkRowFieldType, BooleanFieldType, EmailFieldType, FileFieldType,
SingleSelectFieldType
)
from .registries import ViewFilterType
from baserow.contrib.database.fields.field_filters import contains_filter, \
filename_contains_filter
class NotViewFilterTypeMixin:
@ -42,7 +41,7 @@ class EqualViewFilterType(ViewFilterType):
EmailFieldType.type
]
def get_filter(self, field_name, value, model_field):
def get_filter(self, field_name, value, model_field, field):
value = value.strip()
# If an empty value has been provided we do not want to filter at all.
@ -75,44 +74,8 @@ class FilenameContainsViewFilterType(ViewFilterType):
FileFieldType.type
]
def get_annotation(self, field_name, value):
value = value.strip()
# If an empty value has been provided we do not want to filter at all.
if value == '':
return None
# It is not possible to use Django's ORM to query for if one item in a JSONB
# list has has a key which contains a specified value.
#
# The closest thing the Django ORM provides is:
# queryset.filter(your_json_field__contains=[{"key":"value"}])
# However this is an exact match, so in the above example [{"key":"value_etc"}]
# would not match the filter.
#
# Instead we have to resort to RawSQL to use various built in PostgreSQL JSON
# Array manipulation functions to be able to 'iterate' over a JSONB list
# performing `like` on individual keys in said list.
num_files_with_name_like_value = f"""
EXISTS(
SELECT attached_files ->> 'visible_name'
FROM JSONB_ARRAY_ELEMENTS("{field_name}") as attached_files
WHERE UPPER(attached_files ->> 'visible_name') LIKE UPPER(%s)
)
"""
query = RawSQL(num_files_with_name_like_value, params=[f"%{value}%"],
output_field=BooleanField())
return {f"{field_name}_matches_visible_names": query}
def get_filter(self, field_name, value, model_field):
value = value.strip()
# If an empty value has been provided we do not want to filter at all.
if value == '':
return Q()
# Check if the model_field has a file which matches the provided filter value.
return Q(**{f'{field_name}_matches_visible_names': True})
def get_filter(self, *args):
return filename_contains_filter(*args)
class ContainsViewFilterType(ViewFilterType):
@ -129,21 +92,8 @@ class ContainsViewFilterType(ViewFilterType):
EmailFieldType.type
]
def get_filter(self, field_name, value, model_field):
value = value.strip()
# If an empty value has been provided we do not want to filter at all.
if value == '':
return Q()
# Check if the model_field accepts the value.
try:
model_field.get_prep_value(value)
return Q(**{f'{field_name}__icontains': value})
except Exception:
pass
return Q()
def get_filter(self, *args):
return contains_filter(*args)
class ContainsNotViewFilterType(NotViewFilterTypeMixin, ContainsViewFilterType):
@ -160,7 +110,7 @@ class HigherThanViewFilterType(ViewFilterType):
type = 'higher_than'
compatible_field_types = [NumberFieldType.type]
def get_filter(self, field_name, value, model_field):
def get_filter(self, field_name, value, model_field, field):
value = value.strip()
# If an empty value has been provided we do not want to filter at all.
@ -191,7 +141,7 @@ class LowerThanViewFilterType(ViewFilterType):
type = 'lower_than'
compatible_field_types = [NumberFieldType.type]
def get_filter(self, field_name, value, model_field):
def get_filter(self, field_name, value, model_field, field):
value = value.strip()
# If an empty value has been provided we do not want to filter at all.
@ -222,7 +172,7 @@ class DateEqualViewFilterType(ViewFilterType):
type = 'date_equal'
compatible_field_types = [DateFieldType.type]
def get_filter(self, field_name, value, model_field):
def get_filter(self, field_name, value, model_field, field):
"""
Parses the provided value string and converts it to an aware datetime object.
That object will used to make a comparison with the provided field name.
@ -267,7 +217,7 @@ class SingleSelectEqualViewFilterType(ViewFilterType):
type = 'single_select_equal'
compatible_field_types = [SingleSelectFieldType.type]
def get_filter(self, field_name, value, model_field):
def get_filter(self, field_name, value, model_field, field):
value = value.strip()
if value == '':
@ -296,7 +246,7 @@ class BooleanViewFilterType(ViewFilterType):
type = 'boolean'
compatible_field_types = [BooleanFieldType.type]
def get_filter(self, field_name, value, model_field):
def get_filter(self, field_name, value, model_field, field):
value = value.strip().lower()
value = value in [
'y',
@ -338,7 +288,7 @@ class EmptyViewFilterType(ViewFilterType):
SingleSelectFieldType.type
]
def get_filter(self, field_name, value, model_field):
def get_filter(self, field_name, value, model_field, field):
# If the model_field is a ManyToMany field we only have to check if it is None.
if (
isinstance(model_field, ManyToManyField) or

View file

@ -169,7 +169,7 @@ def test_list_rows(api_client, data_fixture):
url = reverse('api:database:rows:list', kwargs={'table_id': table.id})
response = api_client.get(
f'{url}?search=1',
f'{url}?search=4',
format='json',
HTTP_AUTHORIZATION=f'JWT {jwt_token}'
)
@ -177,7 +177,7 @@ def test_list_rows(api_client, data_fixture):
assert response.status_code == HTTP_200_OK
assert response_json['count'] == 1
assert len(response_json['results']) == 1
assert response_json['results'][0]['id'] == row_1.id
assert response_json['results'][0]['id'] == row_4.id
url = reverse('api:database:rows:list', kwargs={'table_id': table.id})
response = api_client.get(

View file

@ -0,0 +1,153 @@
import pytest
from django.db.models import Q
from django.db.models.functions import Reverse, Upper
from baserow.contrib.database.fields.field_filters import FilterBuilder, \
FILTER_TYPE_AND, FILTER_TYPE_OR, AnnotatedQ
@pytest.mark.django_db
def test_building_filter_with_and_type_ands_all_provided_qs_together(data_fixture):
user = data_fixture.create_user()
table = data_fixture.create_database_table(user=user)
text_field = data_fixture.create_text_field(table=table, order=1, name='name')
bool_field = data_fixture.create_boolean_field(table=table, order=2,
name='is_active')
model = table.get_model()
row_1 = model.objects.create(**{f'field_{text_field.id}': 'name',
f'field_{bool_field.id}': True})
model.objects.create(**{f'field_{text_field.id}': 'name',
f'field_{bool_field.id}': False})
builder = FilterBuilder(filter_type=FILTER_TYPE_AND)
builder.filter(Q(**{f'field_{text_field.id}': 'name'}))
builder.filter(Q(**{f'field_{bool_field.id}': True}))
queryset = builder.apply_to_queryset(model.objects)
assert queryset.count() == 1
assert row_1 in queryset
@pytest.mark.django_db
def test_building_filter_with_or_type_ors_all_provided_qs_together(data_fixture):
user = data_fixture.create_user()
table = data_fixture.create_database_table(user=user)
text_field = data_fixture.create_text_field(table=table, order=1, name='name')
another_text_field = data_fixture.create_text_field(table=table, order=2,
name='surname')
model = table.get_model()
row_1 = model.objects.create(**{
f'field_{text_field.id}': 'name',
f'field_{another_text_field.id}': 'other'})
row_2 = model.objects.create(**{
f'field_{text_field.id}': 'not_name',
f'field_{another_text_field.id}': 'extra'})
model.objects.create(**{
f'field_{text_field.id}': 'not_name',
f'field_{another_text_field.id}': 'not_other'})
builder = FilterBuilder(filter_type=FILTER_TYPE_OR)
builder.filter(Q(**{f'field_{text_field.id}': 'name'}))
builder.filter(Q(**{f'field_{another_text_field.id}': 'extra'}))
queryset = builder.apply_to_queryset(model.objects)
assert queryset.count() == 2
assert row_1 in queryset
assert row_2 in queryset
@pytest.mark.django_db
def test_building_filter_with_annotated_qs_annotates_prior_to_filter(data_fixture):
user = data_fixture.create_user()
table = data_fixture.create_database_table(user=user)
text_field = data_fixture.create_text_field(table=table, order=1, name='name')
another_text_field = data_fixture.create_text_field(table=table, order=2,
name='surname')
model = table.get_model()
row_1 = model.objects.create(**{
f'field_{text_field.id}': 'name',
f'field_{another_text_field.id}': 'other'})
model.objects.create(**{
f'field_{text_field.id}': 'eman',
f'field_{another_text_field.id}': 'extra'})
model.objects.create(**{
f'field_{text_field.id}': 'not_name',
f'field_{another_text_field.id}': 'not_other'})
builder = FilterBuilder(filter_type=FILTER_TYPE_OR)
builder.filter(AnnotatedQ(annotation={
'reversed_name': Reverse(f'field_{text_field.id}')},
q={f'field_{text_field.id}': 'name'}))
builder.filter(Q(**{f'reversed_name': 'eman'}))
queryset = builder.apply_to_queryset(model.objects)
assert queryset.count() == 1
assert row_1 in queryset
@pytest.mark.django_db
def test_building_filter_with_many_annotated_qs_merges_the_annotations(data_fixture):
user = data_fixture.create_user()
table = data_fixture.create_database_table(user=user)
text_field = data_fixture.create_text_field(table=table, order=1, name='name')
another_text_field = data_fixture.create_text_field(table=table, order=2,
name='surname')
model = table.get_model()
row_1 = model.objects.create(**{
f'field_{text_field.id}': 'name',
f'field_{another_text_field.id}': 'other'})
model.objects.create(**{
f'field_{text_field.id}': 'eman',
f'field_{another_text_field.id}': 'extra'})
model.objects.create(**{
f'field_{text_field.id}': 'not_name',
f'field_{another_text_field.id}': 'not_other'})
builder = FilterBuilder(filter_type=FILTER_TYPE_AND)
builder.filter(AnnotatedQ(annotation={
'reversed_name': Reverse(f'field_{text_field.id}')},
q={f'field_{text_field.id}': 'name'}))
builder.filter(AnnotatedQ(annotation={
'upper_name': Upper(f'field_{text_field.id}')},
q={f'field_{text_field.id}': 'name'}))
builder.filter(Q(reversed_name='eman'))
builder.filter(Q(upper_name='NAME'))
queryset = builder.apply_to_queryset(model.objects)
assert queryset.count() == 1
assert row_1 in queryset
@pytest.mark.django_db
def test_can_invert_an_annotated_q(data_fixture):
user = data_fixture.create_user()
table = data_fixture.create_database_table(user=user)
text_field = data_fixture.create_text_field(table=table, order=1, name='name')
another_text_field = data_fixture.create_text_field(table=table, order=2,
name='surname')
model = table.get_model()
model.objects.create(**{
f'field_{text_field.id}': 'name',
f'field_{another_text_field.id}': 'other'})
row_2 = model.objects.create(**{
f'field_{text_field.id}': 'eman',
f'field_{another_text_field.id}': 'extra'})
row_3 = model.objects.create(**{
f'field_{text_field.id}': 'not_name',
f'field_{another_text_field.id}': 'not_other'})
builder = FilterBuilder(filter_type=FILTER_TYPE_AND)
q_to_invert = AnnotatedQ(
annotation={'reversed_name': Reverse(f'field_{text_field.id}')},
q={f'reversed_name': 'eman'})
builder.filter(~q_to_invert)
queryset = builder.apply_to_queryset(model.objects)
assert queryset.count() == 2
assert row_2 in queryset
assert row_3 in queryset

View file

@ -1,9 +1,12 @@
from datetime import datetime
import pytest
from decimal import Decimal
from unittest.mock import MagicMock
from django.db import models
from django.utils.timezone import make_aware, utc
from baserow.contrib.database.table.models import Table
from baserow.contrib.database.fields.exceptions import (
@ -147,31 +150,53 @@ def test_enhance_by_fields_queryset(data_fixture):
@pytest.mark.django_db
def test_search_all_fields_queryset(data_fixture):
def test_search_all_fields_queryset(data_fixture, user_tables_in_separate_db):
table = data_fixture.create_database_table(name='Cars')
data_fixture.create_text_field(table=table, order=0, name='Name')
data_fixture.create_text_field(table=table, order=1, name='Color')
data_fixture.create_number_field(table=table, order=2, name='Price')
data_fixture.create_long_text_field(table=table, order=3, name='Description')
data_fixture.create_date_field(table=table, order=4, name='Date', date_format="EU")
data_fixture.create_date_field(table=table, order=5, name='DateTime',
date_format="US", date_include_time=True,
date_time_format="24")
data_fixture.create_file_field(table=table, order=6, name='File')
select = data_fixture.create_single_select_field(table=table, order=6,
name='select')
option_a = data_fixture.create_select_option(field=select, value='Option A',
color='blue')
option_b = data_fixture.create_select_option(field=select, value='Option B',
color='red')
model = table.get_model(attribute_names=True)
row_1 = model.objects.create(
name='BMW',
color='Blue',
price=10000,
description='This is the fastest car there is.'
price='10000',
description='This is the fastest car there is.',
date='0005-05-05',
datetime=make_aware(datetime(4006, 7, 8, 0, 0, 0), utc),
file=[{'visible_name': 'test_file.png'}],
select=option_a,
)
row_2 = model.objects.create(
name='Audi',
color='Orange',
price=20000,
description='This is the most expensive car we have.'
price='20500',
description='This is the most expensive car we have.',
date='2005-05-05',
datetime=make_aware(datetime(5, 5, 5, 0, 48, 0), utc),
file=[{'visible_name': 'other_file.png'}],
select=option_b,
)
row_3 = model.objects.create(
name='Volkswagen',
color='White',
price=5000,
description='The oldest car that we have.'
price='5000',
description='The oldest car that we have.',
date='9999-05-05',
datetime=make_aware(datetime(5, 5, 5, 9, 59, 0), utc),
file=[],
)
results = model.objects.all().search_all_fields('FASTEST')
@ -191,14 +216,50 @@ def test_search_all_fields_queryset(data_fixture):
assert len(results) == 1
assert row_2 in results
results = model.objects.all().search_all_fields(row_1.id)
results = model.objects.all().search_all_fields(str(row_1.id))
assert len(results) == 1
assert row_1 in results
results = model.objects.all().search_all_fields(row_3.id)
results = model.objects.all().search_all_fields(str(row_3.id))
assert len(results) == 1
assert row_3 in results
results = model.objects.all().search_all_fields('500')
assert len(results) == 2
assert row_2 in results
assert row_3 in results
results = model.objects.all().search_all_fields('05/05/9999')
assert len(results) == 1
assert row_3 in results
results = model.objects.all().search_all_fields('07/08/4006')
assert len(results) == 1
assert row_1 in results
results = model.objects.all().search_all_fields('00:')
assert len(results) == 2
assert row_1 in results
assert row_2 in results
results = model.objects.all().search_all_fields('.png')
assert len(results) == 2
assert row_1 in results
assert row_2 in results
results = model.objects.all().search_all_fields('test_file')
assert len(results) == 1
assert row_1 in results
results = model.objects.all().search_all_fields('Option')
assert len(results) == 2
assert row_1 in results
assert row_2 in results
results = model.objects.all().search_all_fields('Option B')
assert len(results) == 1
assert row_2 in results
results = model.objects.all().search_all_fields('white car')
assert len(results) == 0

View file

@ -1,4 +1,9 @@
from __future__ import print_function
import psycopg2
import pytest
from django.db import connections
from psycopg2.extensions import ISOLATION_LEVEL_AUTOCOMMIT
import sys
@pytest.fixture
@ -11,3 +16,60 @@ def data_fixture():
def api_client():
from rest_framework.test import APIClient
return APIClient()
def run_non_transactional_raw_sql(sqls, dbinfo):
conn = psycopg2.connect(host=dbinfo['HOST'], user=dbinfo['USER'],
password=dbinfo['PASSWORD'],
port=int(dbinfo['PORT']))
conn.set_isolation_level(ISOLATION_LEVEL_AUTOCOMMIT)
cursor = conn.cursor()
for sql in sqls:
cursor.execute(sql)
conn.close()
# Nicest way of printing to stderr sourced from
# https://stackoverflow.com/questions/5574702/how-to-print-to-stderr-in-python
def eprint(*args, **kwargs):
print(*args, file=sys.stderr, **kwargs)
@pytest.fixture()
def user_tables_in_separate_db(settings):
"""
Creates a temporary database and sets up baserow so it is used to store user tables.
Currently this has only been implemented at a function level scope as adding
databases to settings.DATABASES causes pytest to assume they are extra replica dbs
and spend ages setting them up as mirrors. Instead keeping this at the functional
scope lets us keep it simple and quick.
"""
default_db = settings.DATABASES['default']
user_table_db_name = f'{default_db["NAME"]}_user_tables'
# Print to stderr to match pytest-django's behaviour for logging about test
# database setup and teardown.
eprint(f"Dropping and recreating {user_table_db_name} for test.")
settings.USER_TABLE_DATABASE = 'user_tables_database'
settings.DATABASES['user_tables_database'] = dict(default_db)
settings.DATABASES['user_tables_database']['NAME'] = user_table_db_name
# You cannot drop databases inside transactions and django provides no easy way
# of turning them off temporarily. Instead we need to open our own connection so
# we can turn off transactions to perform the required setup/teardown sql. See:
# https://pytest-django.readthedocs.io/en/latest/database.html#using-a-template
# -database-for-tests
run_non_transactional_raw_sql([f'DROP DATABASE IF EXISTS {user_table_db_name}; ',
f'CREATE DATABASE {user_table_db_name}'],
default_db)
yield
# Close django's connection to the user table db so we can drop it.
connections['user_tables_database'].close()
run_non_transactional_raw_sql([f'DROP DATABASE {user_table_db_name}'], default_db)

View file

@ -1,4 +1,5 @@
from django.db import connection
from django.conf import settings
from django.db import connections
from baserow.contrib.database.fields.models import (
TextField, LongTextField, NumberField, BooleanField, DateField, LinkRowField,
@ -8,7 +9,7 @@ from baserow.contrib.database.fields.models import (
class FieldFixtures:
def create_model_field(self, table, field):
with connection.schema_editor() as schema_editor:
with connections[settings.USER_TABLE_DATABASE].schema_editor() as schema_editor:
to_model = table.get_model(field_ids=[field.id])
model_field = to_model._meta.get_field(field.db_column)
schema_editor.add_field(to_model, model_field)

View file

@ -1,4 +1,5 @@
from django.db import connection
from django.conf import settings
from django.db import connections
from baserow.contrib.database.table.models import Table
@ -17,7 +18,8 @@ class TableFixtures:
table = Table.objects.create(**kwargs)
if create_table:
with connection.schema_editor() as schema_editor:
user_table_db = connections[settings.USER_TABLE_DATABASE]
with user_table_db.schema_editor() as schema_editor:
schema_editor.create_model(table.get_model())
return table

View file

@ -20,6 +20,7 @@
* Fixed 100X backend web socket errors when refreshing the page.
* Fixed SSRF bug in the file upload by URL by blocking urls to the private network.
* Fixed bug where an invalid date could be converted to 0001-01-01.
* The list_database_table_rows search query parameter now searches all possible field types.
## Released (2021-03-01)

View file

@ -26,7 +26,7 @@ class EqualToViewFilterType(ViewFilterType):
type = 'equal_to'
compatible_field_types = ['text']
def get_filter(self, field_name, value, model_field):
def get_filter(self, field_name, value, model_field, field):
value = value.strip()
# If an empty value has been provided we do not want to filter at all.