From 05855b6bb6d8a22ceb1f8b25332a146eed912660 Mon Sep 17 00:00:00 2001
From: "Victor Piryns (pivi)" <pivi@odoo.com>
Date: Fri, 3 Mar 2023 13:44:52 +0000
Subject: [PATCH] [FIX] project: speed up name_search of tags on a task

Description:
The pop-up that shows suggestions when adding a tag on a task is
really slow (300~500ms per request on prod).

Cause:
`project.tag` has an override of `_name_search()`, which adds a
domain of the form
```python
['|', ('task_ids.project_id', '=', project_id),
 ('project_ids', 'in', project_id)]
```
which is expensive to compute.

Functional requirements:
- Suggest tags of the "project" first (those that are on tasks in the
  same project), then all the rest.
- Tag's name should be sorted alphabetically per result set (tags on tasks of
project first, sorted alphabetically, then all other tags, sorted
alphabetically)

Solution:
Construct a custom query that makes use of CTE and `UNION ALL` to sort
only on a subsets of the tags to speed up. Also `UNION ALL` with a
`LIMIT` is lazy evaluated, so we gain on speed of all tags are comming
from tags on tasks in the current project.

Affected version:
- 16.0
- saas-16.1
- master

perf-3209468

closes odoo/odoo#114318

Signed-off-by: Xavier Bol (xbo) <xbo@odoo.com>
---
 addons/project/models/project.py | 66 ++++++++++++++++++++++++++------
 1 file changed, 55 insertions(+), 11 deletions(-)

diff --git a/addons/project/models/project.py b/addons/project/models/project.py
index 305c2b55fc1c..8962b88ba70b 100644
--- a/addons/project/models/project.py
+++ b/addons/project/models/project.py
@@ -2702,14 +2702,58 @@ class ProjectTags(models.Model):
 
     @api.model
     def _name_search(self, name='', args=None, operator='ilike', limit=100, name_get_uid=None):
-        domain = args
-        if 'project_id' in self.env.context:
-            domain = self._get_project_tags_domain(domain, self.env.context.get('project_id'))
-        return super()._name_search(name, domain, operator, limit, name_get_uid)
-
-    @api.model
-    def name_create(self, name):
-        existing_tag = self.search([('name', '=ilike', name.strip())], limit=1)
-        if existing_tag:
-            return existing_tag.name_get()[0]
-        return super().name_create(name)
+        if 'project_id' in self.env.context and operator == 'ilike':
+            # `args` has the form of the default filter ['!', ['id', 'in', <ids>]]
+            # passed to exclude already selected tags -> exclude them in our query too
+            excluded_ids = list(args[1][2]) \
+                if args and len(args) == 2 and args[0] == '!' and len(args[1]) == 3 and args[1][:2] == ["id", "in"] \
+                else []
+            # UNION ALL is lazy evaluated, if the first query has enough results,
+            # the second is not executed (just planned).
+            query = """
+                WITH query_tags_in_tasks AS (
+                    SELECT tags.id, COALESCE(tags.name ->> %(lang)s, tags.name ->> 'en_US') AS name, 1 AS sequence
+                    FROM project_tags AS tags
+                    JOIN (
+                        SELECT project_tags_id
+                        FROM project_tags_project_task_rel AS rel
+                        JOIN project_task AS task
+                            ON task.project_id = %(project_id)s
+                            AND task.id = rel.project_task_id
+                        ORDER BY task.id DESC
+                        LIMIT 1000 -- arbitrary limit to speed up lookup on huge projects (fallback below on global scope)
+                    ) AS tags__tasks_ids
+                        ON tags__tasks_ids.project_tags_id = tags.id
+                    WHERE tags.id != ALL(%(excluded_ids)s)
+                    AND COALESCE(tags.name ->> %(lang)s, tags.name ->> 'en_US') ILIKE %(search_term)s
+                    GROUP BY 1, 2, 3  -- faster than a distinct
+                    LIMIT %(limit)s
+                ), query_all_tags AS (
+                    SELECT tags.id, COALESCE(tags.name ->> %(lang)s, tags.name ->> 'en_US') AS name, 2 AS sequence
+                    FROM project_tags AS tags
+                    WHERE tags.id != ALL(%(excluded_ids)s)
+                    AND tags.id NOT IN (SELECT id FROM query_tags_in_tasks)
+                    AND COALESCE(tags.name ->> %(lang)s, tags.name ->> 'en_US') ILIKE %(search_term)s
+                    LIMIT %(limit)s
+                )
+                SELECT id FROM (
+                    SELECT id, name, sequence
+                    FROM query_tags_in_tasks
+                    UNION ALL
+                    SELECT id, name, sequence
+                    FROM query_all_tags
+                    LIMIT %(limit)s
+                ) AS tags
+                ORDER BY sequence, name
+            """
+            params = {
+                'project_id': self.env.context.get('project_id'),
+                'excluded_ids': excluded_ids,
+                'limit': limit,
+                'lang': self.env.context.get('lang', 'en_US'),
+                'search_term': '%' + name + '%',
+            }
+            self.env.cr.execute(query, params)
+            return [row[0] for row in self.env.cr.fetchall()]
+        else:
+            return super()._name_search(name, args, operator, limit, name_get_uid)
-- 
GitLab