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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion backend/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ dependencies = [
"websockets==15.0.1",
"requests==2.33.0",
"itsdangerous==2.2.0",
"Pillow==12.1.1",
"Pillow==12.2.0",
"drf-spectacular==0.29.0",
"asgiref==3.11.0",
"channels[daphne]==4.3.2",
Expand Down
6 changes: 6 additions & 0 deletions backend/src/baserow/contrib/database/api/views/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,3 +134,9 @@
HTTP_400_BAD_REQUEST,
"This view type does not support setting default row values.",
)
ERROR_VIEW_OWNERSHIP_TYPE_INCOMPATIBLE_WITH_VIEW_TYPE = (
"ERROR_VIEW_OWNERSHIP_TYPE_INCOMPATIBLE_WITH_VIEW_TYPE",
HTTP_400_BAD_REQUEST,
"The ownership type {e.ownership_type} is not compatible with "
"view type {e.view_type}.",
)
11 changes: 10 additions & 1 deletion backend/src/baserow/contrib/database/api/views/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@
ViewGroupByNotSupported,
ViewNotInTable,
ViewOwnershipTypeDoesNotExist,
ViewOwnershipTypeNotCompatibleWithViewType,
ViewSortDoesNotExist,
ViewSortFieldAlreadyExist,
ViewSortFieldNotSupported,
Expand Down Expand Up @@ -164,6 +165,7 @@
ERROR_VIEW_GROUP_BY_NOT_SUPPORTED,
ERROR_VIEW_NOT_IN_TABLE,
ERROR_VIEW_OWNERSHIP_TYPE_DOES_NOT_EXIST,
ERROR_VIEW_OWNERSHIP_TYPE_INCOMPATIBLE_WITH_VIEW_TYPE,
ERROR_VIEW_SORT_DOES_NOT_EXIST,
ERROR_VIEW_SORT_FIELD_ALREADY_EXISTS,
ERROR_VIEW_SORT_FIELD_NOT_SUPPORTED,
Expand Down Expand Up @@ -388,9 +390,15 @@ def get(
"ERROR_USER_NOT_IN_GROUP",
"ERROR_REQUEST_BODY_VALIDATION",
"ERROR_FIELD_NOT_IN_TABLE",
"ERROR_VIEW_OWNERSHIP_TYPE_INCOMPATIBLE_WITH_VIEW_TYPE",
]
),
404: get_error_schema(
[
"ERROR_TABLE_DOES_NOT_EXIST",
"ERROR_VIEW_OWNERSHIP_TYPE_DOES_NOT_EXIST",
]
),
404: get_error_schema(["ERROR_TABLE_DOES_NOT_EXIST"]),
},
)
@transaction.atomic
Expand All @@ -405,6 +413,7 @@ def get(
TableDoesNotExist: ERROR_TABLE_DOES_NOT_EXIST,
UserNotInWorkspace: ERROR_USER_NOT_IN_GROUP,
ViewOwnershipTypeDoesNotExist: ERROR_VIEW_OWNERSHIP_TYPE_DOES_NOT_EXIST,
ViewOwnershipTypeNotCompatibleWithViewType: ERROR_VIEW_OWNERSHIP_TYPE_INCOMPATIBLE_WITH_VIEW_TYPE,
}
)
@allowed_includes("filters", "sortings", "decorations", "group_bys")
Expand Down
10 changes: 10 additions & 0 deletions backend/src/baserow/contrib/database/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -840,13 +840,15 @@ def ready(self):
CreateViewFilterOperationType,
CreateViewGroupByOperationType,
CreateViewOperationType,
CreateViewRowCommentOperationType,
CreateViewRowOperationType,
CreateViewSortOperationType,
DeleteViewDecorationOperationType,
DeleteViewFilterGroupOperationType,
DeleteViewFilterOperationType,
DeleteViewGroupByOperationType,
DeleteViewOperationType,
DeleteViewRowCommentOperationType,
DeleteViewRowOperationType,
DeleteViewSortOperationType,
DuplicateViewOperationType,
Expand All @@ -867,16 +869,19 @@ def ready(self):
ReadViewFilterOperationType,
ReadViewGroupByOperationType,
ReadViewOperationType,
ReadViewRowCommentsOperationType,
ReadViewRowOperationType,
ReadViewsOrderOperationType,
ReadViewSortOperationType,
RestoreViewOperationType,
RestoreViewRowCommentOperationType,
UpdateViewDecorationOperationType,
UpdateViewFilterGroupOperationType,
UpdateViewFilterOperationType,
UpdateViewGroupByOperationType,
UpdateViewOperationType,
UpdateViewPublicOperationType,
UpdateViewRowCommentOperationType,
UpdateViewRowOperationType,
UpdateViewSlugOperationType,
UpdateViewSortOperationType,
Expand All @@ -897,6 +902,11 @@ def ready(self):
operation_type_registry.register(CreateViewRowOperationType())
operation_type_registry.register(UpdateViewRowOperationType())
operation_type_registry.register(DeleteViewRowOperationType())
operation_type_registry.register(ReadViewRowCommentsOperationType())
operation_type_registry.register(CreateViewRowCommentOperationType())
operation_type_registry.register(UpdateViewRowCommentOperationType())
operation_type_registry.register(DeleteViewRowCommentOperationType())
operation_type_registry.register(RestoreViewRowCommentOperationType())
operation_type_registry.register(CreateTableDatabaseTableOperationType())
operation_type_registry.register(ListTablesDatabaseTableOperationType())
operation_type_registry.register(OrderTablesDatabaseTableOperationType())
Expand Down
7 changes: 7 additions & 0 deletions backend/src/baserow/contrib/database/fields/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,13 @@
]
SINGLE_SELECT_SORT_BY_ORDER = "order"

# Maximum number of characters per text column to include in sort expressions used
# for view index creation and ORDER BY clauses. This prevents PostgreSQL btree index
# row size errors (max ~8191 bytes) when sorting on text/long_text fields with large
# values. Values differing only after this prefix will fall through to the "order"
# and "id" tiebreakers.
SORT_INDEX_TEXT_MAX_CHARS = 200

UNIQUE_WITH_EMPTY_CONSTRAINT_NAME = "unique_with_empty"


Expand Down
6 changes: 4 additions & 2 deletions backend/src/baserow/contrib/database/fields/field_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@
)
from django.db.models.fields import NOT_PROVIDED
from django.db.models.fields.related import ManyToManyField
from django.db.models.functions import Cast, Coalesce, RowNumber
from django.db.models.functions import Cast, Coalesce, Left, RowNumber

from dateutil import parser
from dateutil.parser import ParserError
Expand Down Expand Up @@ -279,7 +279,9 @@ class CollationSortMixin:
def get_order(
self, field, field_name, order_direction, sort_type, table_model=None
) -> OptionallyAnnotatedOrderBy:
field_expr = collate_expression(F(field_name))
from baserow.contrib.database.fields.constants import SORT_INDEX_TEXT_MAX_CHARS

field_expr = collate_expression(Left(F(field_name), SORT_INDEX_TEXT_MAX_CHARS))

if order_direction == "ASC":
field_order_by = field_expr.asc(nulls_first=True)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from django.db import models
from django.db.models import Expression, F, Func, Q, QuerySet, TextField, Value
from django.db.models import Field as DjangoField
from django.db.models.functions import Cast, Concat
from django.db.models.functions import Cast, Concat, Left

from dateutil import parser
from rest_framework import serializers
Expand Down Expand Up @@ -144,7 +144,9 @@ def placeholder_empty_baserow_expression(
return literal("")

def _get_order_field_expression(self, field_name: str) -> Expression | F:
return collate_expression(F(field_name))
from baserow.contrib.database.fields.constants import SORT_INDEX_TEXT_MAX_CHARS

return collate_expression(Left(F(field_name), SORT_INDEX_TEXT_MAX_CHARS))


class BaserowFormulaTextType(
Expand Down
162 changes: 111 additions & 51 deletions backend/src/baserow/contrib/database/rows/handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@
PermissionDenied,
)
from baserow.core.handler import CoreHandler
from baserow.core.psycopg import is_unique_violation_error, sql
from baserow.core.psycopg import is_index_row_size_error, is_unique_violation_error, sql
from baserow.core.registries import OperationType
from baserow.core.telemetry.utils import baserow_trace_methods
from baserow.core.trash.handler import TrashHandler
Expand Down Expand Up @@ -667,7 +667,7 @@ def get_row_names(
return {row.id: str(row) for row in queryset}

# noinspection PyMethodMayBeStatic
def has_row(self, user, table, row_id, raise_error=False, model=None):
def has_row(self, user, table, row_id, raise_error=False, model=None, view=None):
"""
Checks if a row with the given id exists and is not trashed in the table.

Expand All @@ -676,30 +676,28 @@ def has_row(self, user, table, row_id, raise_error=False, model=None):
do a much more efficient query to check only if the row exists or not.

:param user: The user of whose behalf the row is being checked.
:type user: User
:param table: The table where the row must be checked in.
:type table: Table
:param row_id: The id of the row that must be checked.
:type row_id: int
:param raise_error: Whether or not to raise an Exception if the row does not
exist or just return a boolean instead.
:type raise_error: bool
:param model: If the correct model has already been generated it can be
provided so that it does not have to be generated for a second time.
:type model: Model
:param view: Optionally provide view, if the row is checked in the view.
This can result in different permissions checks.
:raises RowDoesNotExist: When the row with the provided id does not exist
and raise_error is set to True.
:raises UserNotInWorkspace: If the user does not belong to the workspace.
:return: If raise_error is False then a boolean indicating if the row does or
does not exist.
:rtype: bool
"""

CoreHandler().check_permissions(
user,
self._check_permissions_with_view_fallback(
ReadDatabaseRowOperationType.type,
workspace=table.database.workspace,
context=table,
ReadViewRowOperationType.type,
user,
table,
view,
[row_id],
)

if model is None:
Expand Down Expand Up @@ -911,12 +909,27 @@ def force_create_row(
instance = model(**row_values)
field_rules_handler.validate_row(instance)

def safe_save_instance():
try:
with transaction.atomic():
instance.save(force_insert=True)
rows_created_counter.add(1)
except Exception as exc:
if is_unique_violation_error(exc):
raise FieldDataConstraintException()
else:
raise exc

try:
instance.save(force_insert=True)
rows_created_counter.add(1)
safe_save_instance()
except Exception as exc:
if is_unique_violation_error(exc):
raise FieldDataConstraintException()
if is_index_row_size_error(exc):
from baserow.contrib.database.views.handler import (
ViewIndexingHandler,
)

ViewIndexingHandler.handle_index_row_size_error(model.baserow_table_id)
safe_save_instance()
else:
raise exc

Expand Down Expand Up @@ -1156,11 +1169,26 @@ def update_row(
setattr(row, LAST_MODIFIED_BY_COLUMN_NAME, user if user.id else None)
always_updated_fields.append(LAST_MODIFIED_BY_COLUMN_NAME)

def safe_save_row():
try:
with transaction.atomic():
row.save(update_fields=update_row_fields + always_updated_fields)
except Exception as exc:
if is_unique_violation_error(exc):
raise FieldDataConstraintException()
else:
raise exc

try:
row.save(update_fields=update_row_fields + always_updated_fields)
safe_save_row()
except Exception as exc:
if is_unique_violation_error(exc):
raise FieldDataConstraintException()
if is_index_row_size_error(exc):
from baserow.contrib.database.views.handler import (
ViewIndexingHandler,
)

ViewIndexingHandler.handle_index_row_size_error(model.baserow_table_id)
safe_save_row()
else:
raise exc
rows_updated_counter.add(1)
Expand Down Expand Up @@ -1365,21 +1393,35 @@ def force_create_rows(

rows = [row for (row, _) in rows_relationships]

def safe_bulk_create():
try:
with transaction.atomic():
return model.objects.bulk_create(rows)
except Exception as exc:
if is_unique_violation_error(exc):
if not generate_error_report:
raise FieldDataConstraintException()

for index, (row, _) in enumerate(rows_relationships):
report[index] = {
"non_field_errors": [
"Row was not inserted due to conflicts or constraints"
]
}
return []
else:
raise exc

try:
with transaction.atomic():
inserted_rows = model.objects.bulk_create(rows)
inserted_rows = safe_bulk_create()
except Exception as exc:
inserted_rows = []
if is_unique_violation_error(exc):
if not generate_error_report:
raise FieldDataConstraintException()
if is_index_row_size_error(exc):
from baserow.contrib.database.views.handler import (
ViewIndexingHandler,
)

for index, (row, _) in enumerate(rows_relationships):
report[index] = {
"non_field_errors": [
"Row was not inserted due to conflicts or constraints"
]
}
ViewIndexingHandler.handle_index_row_size_error(model.baserow_table_id)
inserted_rows = safe_bulk_create()
else:
raise exc

Expand Down Expand Up @@ -2432,28 +2474,46 @@ def force_update_rows(
bulk_update_fields.append(field_name)

if len(bulk_update_fields) > 0:

def safe_bulk_update():
try:
with transaction.atomic():
model.objects.bulk_update(
rows_to_update, bulk_update_fields, batch_size=2000
)
except Exception as exc:
if is_unique_violation_error(exc):
if generate_error_report:
for idx, row in enumerate(rows_to_update):
report[idx] = {
"non_field_errors": [
"Row was not updated due to conflicts or constraints"
]
}
return UpdatedRowsData(
[],
[],
original_row_values_by_id,
fields_metadata_by_row_id,
report,
[],
)
raise FieldDataConstraintException()
else:
raise exc

try:
model.objects.bulk_update(
rows_to_update, bulk_update_fields, batch_size=2000
)
safe_bulk_update()
except Exception as exc:
if is_unique_violation_error(exc):
if generate_error_report:
for idx, row in enumerate(rows_to_update):
report[idx] = {
"non_field_errors": [
"Row was not updated due to conflicts or constraints"
]
}
return UpdatedRowsData(
[],
[],
original_row_values_by_id,
fields_metadata_by_row_id,
report,
[],
)
raise FieldDataConstraintException()
if is_index_row_size_error(exc):
from baserow.contrib.database.views.handler import (
ViewIndexingHandler,
)

ViewIndexingHandler.handle_index_row_size_error(
model.baserow_table_id
)
safe_bulk_update()
else:
raise exc

Expand Down
Loading
Loading