Repository: WeblateOrg/weblate

Issue:
ProgrammingError in Bulk Edit
### Describe the issue

When testing the bulk edit feature in Weblate version 5.1 by navigating to "My Component" -> "Tool" -> "Bulk Edit" -> filling out the form -> and clicking "Apply," I encountered a UI message:

```
Server Error
The server had serious problems serving your request. You might want to contact server admins in case the error persists.
```

### I already tried

- [X] I've read and searched [the documentation](https://docs.weblate.org/).
- [X] I've searched for similar issues in this repository.

### Steps to reproduce the behavior

Navigating to "My Component" -> "Tool" -> "Bulk Edit" -> filling out the form -> and clicking "Apply,"
![image](https://github.com/WeblateOrg/weblate/assets/133736755/e991374b-d0d5-4d36-a938-4a1558a233f7)


### Expected behavior

The expected behavior is that the bulk edit feature should run smoothly, and the target string should change its state to "needs editing."

### Screenshots

![image](https://github.com/WeblateOrg/weblate/assets/133736755/dfd5a3ed-6cd4-4a3d-bdb5-641351557323)


### Exception traceback

```pytb
gunicorn stderr | [2023-12-14 07:25:42,161: ERROR/463] Internal Server Error: /bulk-edit/ipension-translation-de/ipension-events/
gunicorn stderr | Traceback (most recent call last):
gunicorn stderr |   File "/usr/local/lib/python3.11/site-packages/django/db/backends/utils.py", line 89, in _execute
gunicorn stderr |     return self.cursor.execute(sql, params)
gunicorn stderr |            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
gunicorn stderr |   File "/usr/local/lib/python3.11/site-packages/psycopg/cursor.py", line 737, in execute
gunicorn stderr |     raise ex.with_traceback(None)
gunicorn stderr | psycopg.errors.UndefinedTable: invalid reference to FROM-clause entry for table "trans_unit"
gunicorn stderr | LINE 1: ..." = U1."id") WHERE (U1."component_id" IN (13) AND "trans_uni...
gunicorn stderr |                                                              ^
gunicorn stderr | HINT:  Perhaps you meant to reference the table alias "u0".
gunicorn stderr | 
gunicorn stderr | The above exception was the direct cause of the following exception:
gunicorn stderr | 
gunicorn stderr | Traceback (most recent call last):
gunicorn stderr |   File "/usr/local/lib/python3.11/site-packages/django/core/handlers/exception.py", line 55, in inner
gunicorn stderr |     response = get_response(request)
gunicorn stderr |                ^^^^^^^^^^^^^^^^^^^^^
gunicorn stderr |   File "/usr/local/lib/python3.11/site-packages/django/core/handlers/base.py", line 197, in _get_response
gunicorn stderr |     response = wrapped_callback(request, *callback_args, **callback_kwargs)
gunicorn stderr |                ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
gunicorn stderr |   File "/usr/local/lib/python3.11/site-packages/django/contrib/auth/decorators.py", line 23, in _wrapper_view
gunicorn stderr |     return view_func(request, *args, **kwargs)
gunicorn stderr |            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
gunicorn stderr |   File "/usr/local/lib/python3.11/site-packages/django/views/decorators/http.py", line 43, in inner
gunicorn stderr |     return func(request, *args, **kwargs)
gunicorn stderr |            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
gunicorn stderr |   File "/usr/local/lib/python3.11/site-packages/django/views/decorators/cache.py", line 62, in _wrapper_view_func
gunicorn stderr |     response = view_func(request, *args, **kwargs)
gunicorn stderr |                ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
gunicorn stderr |   File "/usr/local/lib/python3.11/site-packages/weblate/trans/views/search.py", line 206, in bulk_edit
gunicorn stderr |     updated = bulk_perform(
gunicorn stderr |               ^^^^^^^^^^^^^
gunicorn stderr |   File "/usr/local/lib/python3.11/site-packages/weblate/trans/bulk.py", line 42, in bulk_perform
gunicorn stderr |     for component in components:
gunicorn stderr |   File "/usr/local/lib/python3.11/site-packages/django/db/models/query.py", line 398, in __iter__
gunicorn stderr |     self._fetch_all()
gunicorn stderr |   File "/usr/local/lib/python3.11/site-packages/django/db/models/query.py", line 1881, in _fetch_all
gunicorn stderr |     self._result_cache = list(self._iterable_class(self))
gunicorn stderr |                          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
gunicorn stderr |   File "/usr/local/lib/python3.11/site-packages/django/db/models/query.py", line 91, in __iter__
gunicorn stderr |     results = compiler.execute_sql(
gunicorn stderr |               ^^^^^^^^^^^^^^^^^^^^^
gunicorn stderr |   File "/usr/local/lib/python3.11/site-packages/django/db/models/sql/compiler.py", line 1562, in execute_sql
gunicorn stderr |     cursor.execute(sql, params)
gunicorn stderr |   File "/usr/local/lib/python3.11/site-packages/django/db/backends/utils.py", line 67, in execute
gunicorn stderr |     return self._execute_with_wrappers(
gunicorn stderr |            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
gunicorn stderr |   File "/usr/local/lib/python3.11/site-packages/django/db/backends/utils.py", line 80, in _execute_with_wrappers
gunicorn stderr |     return executor(sql, params, many, context)
gunicorn stderr |            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
gunicorn stderr |   File "/usr/local/lib/python3.11/site-packages/django/db/backends/utils.py", line 84, in _execute
gunicorn stderr |     with self.db.wrap_database_errors:
gunicorn stderr |   File "/usr/local/lib/python3.11/site-packages/django/db/utils.py", line 91, in __exit__
gunicorn stderr |     raise dj_exc_value.with_traceback(traceback) from exc_value
gunicorn stderr |   File "/usr/local/lib/python3.11/site-packages/django/db/backends/utils.py", line 89, in _execute
gunicorn stderr |     return self.cursor.execute(sql, params)
gunicorn stderr |            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
gunicorn stderr |   File "/usr/local/lib/python3.11/site-packages/psycopg/cursor.py", line 737, in execute
gunicorn stderr |     raise ex.with_traceback(None)
gunicorn stderr | django.db.utils.ProgrammingError: invalid reference to FROM-clause entry for table "trans_unit"
gunicorn stderr | LINE 1: ..." = U1."id") WHERE (U1."component_id" IN (13) AND "trans_uni...
gunicorn stderr |                                                              ^
gunicorn stderr | HINT:  Perhaps you meant to reference the table alias "u0".
```


### How do you run Weblate?

Docker container

### Weblate versions

5.1

### Weblate deploy checks

_No response_

### Additional context

_No response_


Discussion:
This merely looks like a bug in Django. Can you try upgrading the container to get a newer Django version?
Sentry issue: [WEBLATE-65M](https://sentry.weblate.org/organizations/weblate/issues/6820/?referrer=github_integration)

Resolving patch:
diff --git a/weblate/utils/db.py b/weblate/utils/db.py
--- a/weblate/utils/db.py
+++ b/weblate/utils/db.py
@@ -6,7 +6,7 @@
 
 from django.db import connections, models
 from django.db.models import Case, IntegerField, Sum, When
-from django.db.models.lookups import Contains, Exact, PatternLookup, Regex
+from django.db.models.lookups import PatternLookup, Regex
 
 from .inv_regex import invert_re
 
@@ -75,71 +75,40 @@ def count_alnum(string):
     return sum(map(str.isalnum, string))
 
 
-class PostgreSQLFallbackLookup(PatternLookup):
-    def __init__(self, lhs, rhs):
-        self.orig_lhs = lhs
-        self.orig_rhs = rhs
-        super().__init__(lhs, rhs)
-
-    def needs_fallback(self):
-        return isinstance(self.orig_rhs, str) and count_alnum(self.orig_rhs) <= 3
-
-
-class FallbackStringMixin:
-    """Avoid using index for lhs by concatenating to a string."""
-
+class PostgreSQLFallbackLookupMixin:
     def process_lhs(self, compiler, connection, lhs=None):
-        lhs_sql, params = super().process_lhs(compiler, connection, lhs)
-        return f"{lhs_sql} || ''", params
-
+        if self._needs_fallback:
+            lhs_sql, params = super().process_lhs(compiler, connection, lhs)
+            return f"{lhs_sql} || ''", params
+        return super().process_lhs(compiler, connection, lhs)
 
-class PostgreSQLRegexFallbackLookup(FallbackStringMixin, Regex):
-    pass
 
-
-class PostgreContainsFallbackLookup(FallbackStringMixin, Contains):
-    pass
-
-
-class PostgreExactFallbackLookup(FallbackStringMixin, Exact):
-    pass
-
-
-class PostgreSQLRegexLookup(Regex):
+class PostgreSQLFallbackLookup(PostgreSQLFallbackLookupMixin, PatternLookup):
     def __init__(self, lhs, rhs):
-        self.orig_lhs = lhs
-        self.orig_rhs = rhs
+        self._needs_fallback = isinstance(rhs, str) and count_alnum(rhs) <= 3
         super().__init__(lhs, rhs)
 
-    def needs_fallback(self):
-        if not isinstance(self.orig_rhs, str):
-            return False
-        return (
-            min((count_alnum(match) for match in invert_re(self.orig_rhs)), default=0)
-            < 3
-        )
 
-    def as_sql(self, compiler, connection):
-        if self.needs_fallback():
-            return PostgreSQLRegexFallbackLookup(self.orig_lhs, self.orig_rhs).as_sql(
-                compiler, connection
-            )
-        return super().as_sql(compiler, connection)
+class PostgreSQLRegexLookup(PostgreSQLFallbackLookupMixin, Regex):
+    def __init__(self, lhs, rhs):
+        self._needs_fallback = isinstance(rhs, str) and (
+            min((count_alnum(match) for match in invert_re(rhs)), default=0) < 3
+        )
+        super().__init__(lhs, rhs)
 
 
 class PostgreSQLSearchLookup(PostgreSQLFallbackLookup):
     lookup_name = "search"
-    param_pattern = "%s"
 
-    def as_sql(self, compiler, connection):
-        if self.needs_fallback():
-            return PostgreContainsFallbackLookup(self.orig_lhs, self.orig_rhs).as_sql(
-                compiler, connection
-            )
-        lhs, lhs_params = self.process_lhs(compiler, connection)
-        rhs, rhs_params = self.process_rhs(compiler, connection)
-        params = lhs_params + rhs_params
-        return f"{lhs} %% {rhs} = true", params
+    def process_rhs(self, qn, connection):
+        if not self._needs_fallback:
+            self.param_pattern = "%s"
+        return super().process_rhs(qn, connection)
+
+    def get_rhs_op(self, connection, rhs):
+        if self._needs_fallback:
+            return connection.operators["contains"] % rhs
+        return "%%%% %s = true" % rhs
 
 
 class MySQLSearchLookup(models.Lookup):
@@ -162,15 +131,10 @@ class PostgreSQLSubstringLookup(PostgreSQLFallbackLookup):
 
     lookup_name = "substring"
 
-    def as_sql(self, compiler, connection):
-        if self.needs_fallback():
-            return PostgreContainsFallbackLookup(self.orig_lhs, self.orig_rhs).as_sql(
-                compiler, connection
-            )
-        lhs, lhs_params = self.process_lhs(compiler, connection)
-        rhs, rhs_params = self.process_rhs(compiler, connection)
-        params = lhs_params + rhs_params
-        return f"{lhs} ILIKE {rhs}", params
+    def get_rhs_op(self, connection, rhs):
+        if self._needs_fallback:
+            return connection.operators["contains"] % rhs
+        return "ILIKE %s" % rhs
 
 
 def re_escape(pattern):


Test patch:
diff --git a/weblate/utils/tests/test_db.py b/weblate/utils/tests/test_db.py
--- a/weblate/utils/tests/test_db.py
+++ b/weblate/utils/tests/test_db.py
@@ -5,6 +5,7 @@
 from unittest import SkipTest, TestCase
 
 from weblate.trans.models import Unit
+from weblate.trans.tests.test_views import FixtureTestCase
 from weblate.utils.db import re_escape, using_postgresql
 
 BASE_SQL = 'SELECT "trans_unit"."id" FROM "trans_unit" WHERE '
@@ -16,7 +17,7 @@ def test_re_escape(self):
         self.assertEqual(re_escape("a{1,4}"), "a\\{1,4\\}")
 
 
-class PostgreSQLOperatorTesT(TestCase):
+class PostgreSQLOperatorTest(TestCase):
     def setUp(self):
         if not using_postgresql():
             raise SkipTest("PostgreSQL only test.")
@@ -30,7 +31,7 @@ def test_search(self):
         queryset = Unit.objects.filter(source__search="'''").only("id")
         self.assertEqual(
             str(queryset.query),
-            BASE_SQL + """"trans_unit"."source"::text || '' LIKE %'''%""",
+            BASE_SQL + """"trans_unit"."source" || '' LIKE %'''%""",
         )
 
     def test_substring(self):
@@ -42,5 +43,20 @@ def test_substring(self):
         queryset = Unit.objects.filter(source__substring="'''").only("id")
         self.assertEqual(
             str(queryset.query),
-            BASE_SQL + """"trans_unit"."source"::text || '' LIKE %'''%""",
+            BASE_SQL + """"trans_unit"."source" || '' LIKE %'''%""",
         )
+
+
+class SearchSQLOperatorTest(FixtureTestCase):
+    def test_search(self):
+        # Verifies that even complex query with a fallback is built properly
+        # This is essentially what bulk edit does with such search
+        from weblate.trans.models import Component, Project, Unit
+
+        obj = Project.objects.first()
+        unit_set = Unit.objects.filter(translation__component__project=obj).prefetch()
+        matching = unit_set.search("10°", project=obj)
+        components = Component.objects.filter(
+            id__in=matching.values_list("translation__component_id", flat=True)
+        )
+        self.assertEqual(len(components), 0)