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 #321 See merge request bramw/baserow!202
This commit is contained in:
commit
782ba27087
18 changed files with 708 additions and 265 deletions
backend
src/baserow/contrib/database
api/rows
fields
table
views
tests
docs/plugins
|
@ -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):
|
||||
|
|
155
backend/src/baserow/contrib/database/fields/field_filters.py
Normal file
155
backend/src/baserow/contrib/database/fields/field_filters.py
Normal 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())
|
|
@ -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}
|
||||
)
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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):
|
||||
"""
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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):
|
||||
"""
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
5
backend/tests/fixtures/field.py
vendored
5
backend/tests/fixtures/field.py
vendored
|
@ -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)
|
||||
|
|
6
backend/tests/fixtures/table.py
vendored
6
backend/tests/fixtures/table.py
vendored
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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.
|
||||
|
|
Loading…
Add table
Reference in a new issue