Skip to content

Commit 4cfd24c

Browse files
authored
Do not show trashed rows in search results (baserow#4526)
1 parent b1c57da commit 4cfd24c

File tree

5 files changed

+111
-2
lines changed

5 files changed

+111
-2
lines changed
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
from django.db import migrations
2+
3+
forward_sql = """
4+
CREATE OR REPLACE FUNCTION row_exists_not_trashed(p_table_id integer, p_row_id integer)
5+
RETURNS boolean AS $$
6+
DECLARE
7+
table_name text;
8+
result boolean;
9+
BEGIN
10+
table_name := 'database_table_' || p_table_id;
11+
12+
IF NOT EXISTS (
13+
SELECT 1 FROM pg_class WHERE relname = table_name AND relkind = 'r'
14+
) THEN
15+
RETURN false;
16+
END IF;
17+
18+
EXECUTE format(
19+
'SELECT EXISTS(SELECT 1 FROM %I WHERE id = $1 AND trashed = false)',
20+
table_name
21+
) INTO result USING p_row_id;
22+
23+
RETURN COALESCE(result, false);
24+
END;
25+
$$ LANGUAGE plpgsql;
26+
"""
27+
28+
reverse_sql = """
29+
DROP FUNCTION IF EXISTS row_exists_not_trashed(integer, integer);
30+
"""
31+
32+
33+
class Migration(migrations.Migration):
34+
dependencies = [
35+
("database", "0203_alter_field_field_dependencies"),
36+
]
37+
38+
operations = [
39+
migrations.RunSQL(forward_sql, reverse_sql),
40+
]

backend/src/baserow/contrib/database/search_types.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
from baserow.contrib.database.models import Database
2828
from baserow.contrib.database.search.handler import SearchHandler
2929
from baserow.contrib.database.search_base import DatabaseSearchableItemType
30+
from baserow.contrib.database.table.expressions import RowNotTrashedDynamicTable
3031
from baserow.contrib.database.table.models import Table
3132
from baserow.contrib.database.table.operations import ReadDatabaseTableOperationType
3233
from baserow.core.db import specific_iterator
@@ -352,9 +353,14 @@ def get_union_values_queryset(self, user, workspace, context) -> QuerySet:
352353
self.type, getattr(self, "priority", 10)
353354
)
354355

356+
field_ids_by_table_id = defaultdict(list)
357+
for f_id, t_id in base_fields:
358+
field_ids_by_table_id[t_id].append(f_id)
359+
355360
# Build field_id -> table_id mapping for CASE expression
356361
when_clauses = [
357-
When(field_id=f_id, then=Value(t_id)) for (f_id, t_id) in base_fields
362+
When(field_id__in=f_ids, then=Value(t_id))
363+
for (t_id, f_ids) in field_ids_by_table_id.items()
358364
]
359365
table_id_case = Case(
360366
*when_clauses, default=Value(0), output_field=IntegerField()
@@ -377,6 +383,7 @@ def get_union_values_queryset(self, user, workspace, context) -> QuerySet:
377383
.filter(rn=1) # Only keep the best field per row
378384
.annotate(
379385
search_type=Value(self.type, output_field=TextField()),
386+
is_valid=RowNotTrashedDynamicTable(F("table_id"), F("row_id")),
380387
object_id=Concat(
381388
Cast(F("table_id"), output_field=TextField()),
382389
Value("_", output_field=TextField()),
@@ -398,6 +405,7 @@ def get_union_values_queryset(self, user, workspace, context) -> QuerySet:
398405
query=Value(context.query),
399406
),
400407
)
408+
.filter(is_valid=True)
401409
.values(
402410
"search_type",
403411
"object_id",
@@ -547,6 +555,7 @@ def postprocess(self, rows: Iterable[Dict]) -> List[SearchResult]:
547555

548556
field_id_int = int(field_id)
549557
table_id_int = field_id_to_table_id.get(field_id_int) or int(table_id)
558+
550559
database_id = table_id_to_database_id.get(table_id_int)
551560
database_name = database_id_to_name.get(database_id)
552561
workspace_id = database_id_to_workspace_id.get(database_id)

backend/src/baserow/contrib/database/table/expressions.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from django.contrib.postgres.fields import ArrayField
2-
from django.db.models import Func, IntegerField, TextField
2+
from django.db.models import BooleanField, Func, IntegerField, TextField
33

44

55
class BaserowTableRowCount(Func):
@@ -13,3 +13,13 @@ class BaserowTableFileUniques(Func):
1313
function = "get_distinct_baserow_table_file_uniques"
1414
output_field = ArrayField(TextField())
1515
arity = 1
16+
17+
18+
class RowNotTrashedDynamicTable(Func):
19+
"""
20+
Check if a row exists and is not trashed in a dynamically named table.
21+
Calls the `row_exists_not_trashed(table_id, row_id)` PL/pgSQL function.
22+
"""
23+
24+
function = "row_exists_not_trashed"
25+
output_field = BooleanField()

backend/tests/baserow/contrib/database/search/test_database_search_types.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -357,3 +357,44 @@ def test_row_search_multiple_fields(data_fixture):
357357
assert len(results) >= 2
358358
assert results[0].id == f"{table.id}_{row1.id}"
359359
assert results[1].id == f"{table.id}_{row2.id}"
360+
361+
362+
@pytest.mark.workspace_search
363+
@pytest.mark.django_db(transaction=True)
364+
def test_row_search_excludes_trashed_rows(data_fixture):
365+
user = data_fixture.create_user()
366+
workspace = data_fixture.create_workspace(user=user)
367+
database = data_fixture.create_database_application(workspace=workspace)
368+
table = data_fixture.create_database_table(database=database)
369+
text_field = data_fixture.create_text_field(table=table, name="Name", primary=True)
370+
371+
from baserow.contrib.database.rows.handler import RowHandler
372+
from baserow.core.search.handler import WorkspaceSearchHandler
373+
374+
row_handler = RowHandler()
375+
row1 = row_handler.create_rows(
376+
user=user,
377+
table=table,
378+
rows_values=[{f"field_{text_field.id}": "Searchable normal"}],
379+
).created_rows[0]
380+
381+
row2 = row_handler.create_rows(
382+
user=user,
383+
table=table,
384+
rows_values=[{f"field_{text_field.id}": "Searchable trashed"}],
385+
).created_rows[0]
386+
387+
SearchHandler.create_workspace_search_table_if_not_exists(workspace.id)
388+
SearchHandler.initialize_missing_search_data(table)
389+
SearchHandler.process_search_data_updates(table)
390+
391+
model = table.get_model()
392+
model.objects_and_trash.filter(id=row2.id).update(trashed=True)
393+
394+
context = SearchContext(query="Searchable", limit=10, offset=0)
395+
results, _ = WorkspaceSearchHandler().search_all_types(user, workspace, context)
396+
397+
row_results = [r for r in results if r.type == "database_row"]
398+
assert len(row_results) == 1
399+
assert row_results[0].id == f"{table.id}_{row1.id}"
400+
assert "normal" in row_results[0].title.lower()
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"type": "bug",
3+
"message": "Do not show trashed rows in search results",
4+
"issue_origin": "github",
5+
"issue_number": 4525,
6+
"domain": "database",
7+
"bullet_points": [],
8+
"created_at": "2026-01-09"
9+
}

0 commit comments

Comments
 (0)