Projet

Général

Profil

0001-general-use-tree-queries-for-handling-page-hierarchy.patch

Valentin Deniaud, 11 janvier 2022 11:17

Télécharger (10,2 ko)

Voir les différences:

Subject: [PATCH 1/2] general: use tree queries for handling page hierarchy
 (#60018)

 combo/data/models.py   |  26 +++------
 combo/data/query.py    | 125 +++++++++++++++++++++++++++++++++++++++++
 combo/manager/forms.py |   4 +-
 tests/test_manager.py  |   4 +-
 tests/test_pages.py    |   1 +
 tests/test_public.py   |   2 +-
 6 files changed, 139 insertions(+), 23 deletions(-)
 create mode 100644 combo/data/query.py
combo/data/models.py
60 60

  
61 61
from .fields import RichTextField, TemplatableURLField
62 62
from .library import get_cell_class, get_cell_classes, register_cell_class
63
from .query import TreeManager
63 64

  
64 65

  
65 66
class PostException(Exception):
......
151 152
        return self.name
152 153

  
153 154

  
154
class PageManager(models.Manager):
155
class PageManager(TreeManager):
155 156
    snapshots = False
156 157

  
157 158
    def __init__(self, *args, **kwargs):
......
272 273
        return super().save(*args, **kwargs)
273 274

  
274 275
    def get_parents_and_self(self):
275
        pages = [self]
276
        page = self
277
        while page.parent_id:
278
            page = page._parent if hasattr(page, '_parent') else page.parent
279
            pages.append(page)
280
        return list(reversed(pages))
276
        if not self.parent_id:
277
            return [self]
278

  
279
        return list(Page.objects.ancestors(self, include_self=False)) + [self]
281 280

  
282 281
    def get_online_url(self, follow_redirection=True):
283 282
        if (
......
323 322
        return Page.objects.filter(parent_id=self.id).exists()
324 323

  
325 324
    def get_descendants(self, include_myself=False):
326
        def get_descendant_pages(page, include_page=True):
327
            if include_page:
328
                descendants = [page]
329
            else:
330
                descendants = []
331
            for item in page.get_children():
332
                descendants.extend(get_descendant_pages(item))
333
            return descendants
334

  
335
        return Page.objects.filter(
336
            id__in=[x.id for x in get_descendant_pages(self, include_page=include_myself)]
337
        )
325
        return Page.objects.descendants(self, include_self=include_myself)
338 326

  
339 327
    def get_descendants_and_me(self):
340 328
        return self.get_descendants(include_myself=True)
combo/data/query.py
1
# Copyright (c) 2018, Feinheit AG and individual contributors.
2
# All rights reserved.
3
#
4
# Redistribution and use in source and binary forms, with or without modification,
5
# are permitted provided that the following conditions are met:
6
#
7
#     1. Redistributions of source code must retain the above copyright notice,
8
#        this list of conditions and the following disclaimer.
9
#
10
#     2. Redistributions in binary form must reproduce the above copyright
11
#        notice, this list of conditions and the following disclaimer in the
12
#        documentation and/or other materials provided with the distribution.
13
#
14
#     3. Neither the name of Feinheit AG nor the names of its contributors
15
#        may be used to endorse or promote products derived from this software
16
#        without specific prior written permission.
17
#
18
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
19
# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
20
# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
21
# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
22
# ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
23
# (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
24
# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
25
# ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
26
# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
27
# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
28

  
29
from django.db import connections, models
30
from django.db.models.sql.compiler import SQLCompiler
31
from django.db.models.sql.query import Query
32
from django.db.models.sql.where import AND, ExtraWhere
33

  
34

  
35
class TreeQuery(Query):
36
    def get_compiler(self, using=None, connection=None, elide_empty=True):
37
        # Copied from django/db/models/sql/query.py
38
        if using is None and connection is None:
39
            raise ValueError('Need either using or connection')
40
        if connection is None:
41
            connection = connections[using]
42
        return TreeCompiler(self, connection, using)
43

  
44

  
45
class TreeExtraWhere(ExtraWhere):
46
    def relabeled_clone(self, change_map):
47
        new_sqls = []
48
        for sql in self.sqls:
49
            for old_table_name, new_table_name in change_map.items():
50
                sql = sql.replace(old_table_name, new_table_name)
51
            new_sqls.append(sql)
52
        self.sqls = new_sqls
53
        return self
54

  
55

  
56
class TreeCompiler(SQLCompiler):
57
    CTE = '''
58
    WITH RECURSIVE __tree (
59
        "tree_depth",
60
        "tree_path",
61
        "tree_ordering",
62
        "tree_pk"
63
    ) AS (
64
        SELECT
65
            0 AS tree_depth,
66
            array[T.id] AS tree_path,
67
            array["order"] AS tree_ordering,
68
            T.id
69
        FROM data_page T
70
        WHERE T.parent_id IS NULL
71

  
72
        UNION ALL
73

  
74
        SELECT
75
            __tree.tree_depth + 1 AS tree_depth,
76
            __tree.tree_path || T.id,
77
            __tree.tree_ordering || "order",
78
            T.id
79
        FROM data_page T
80
        JOIN __tree ON T.parent_id = __tree.tree_pk
81
    )
82
    '''
83

  
84
    def as_sql(self, *args, **kwargs):
85
        if '__tree' not in self.query.extra_tables:
86
            self.query.add_extra(
87
                select={
88
                    'tree_depth': '__tree.tree_depth',
89
                    'tree_path': '__tree.tree_path',
90
                    'tree_ordering': '__tree.tree_ordering',
91
                },
92
                select_params=None,
93
                where=None,
94
                params=None,
95
                tables=['__tree'],
96
                order_by=['tree_ordering'],
97
            )
98
            table_name = self.query.table_alias('data_page')[0]
99
            self.query.where.add(TreeExtraWhere(['__tree.tree_pk = %s.id' % table_name], None), AND)
100

  
101
        sql = super().as_sql(*args, **kwargs)
102
        return (''.join([self.CTE, sql[0]]), sql[1])
103

  
104

  
105
class TreeQuerySet(models.QuerySet):
106
    def with_tree_fields(self):
107
        self.query.__class__ = TreeQuery
108
        return self
109

  
110
    def ancestors(self, page, include_self=True):
111
        if not hasattr(page, 'tree_path'):
112
            pk = page.pk if not page.snapshot else page.snapshot.page_id
113
            page = self.with_tree_fields().get(pk=pk)
114

  
115
        ids = page.tree_path if include_self else page.tree_path[:-1]
116
        return self.with_tree_fields().filter(id__in=ids).order_by('tree_depth')
117

  
118
    def descendants(self, page, include_self=True):
119
        queryset = self.with_tree_fields().extra(where=['%s = ANY(tree_path)' % page.pk])
120
        if not include_self:
121
            return queryset.exclude(pk=page.pk)
122
        return queryset
123

  
124

  
125
TreeManager = models.Manager.from_queryset(TreeQuerySet)
combo/manager/forms.py
245 245
        super().save(*args, **kwargs)
246 246
        if self.cleaned_data.get('apply_to_subpages'):
247 247
            subpages = self.instance.get_descendants(include_myself=True)
248
            subpages.update(exclude_from_navigation=bool(not self.cleaned_data['include_in_navigation']))
248
            Page.objects.filter(pk__in=subpages.values('pk')).update(
249
                exclude_from_navigation=bool(not self.cleaned_data['include_in_navigation'])
250
            )
249 251
        else:
250 252
            self.instance.exclude_from_navigation = not self.cleaned_data['include_in_navigation']
251 253
            self.instance.save()
tests/test_manager.py
648 648
    app.get('/manage/pages/%s/' % page.pk)  # load once to populate caches
649 649
    with CaptureQueriesContext(connection) as ctx:
650 650
        app.get('/manage/pages/%s/' % page.pk)
651
    assert len(ctx.captured_queries) == 33
651
    assert len(ctx.captured_queries) == 34
652 652

  
653 653

  
654 654
def test_delete_page(app, admin_user):
......
926 926
    resp.form['site_file'] = Upload('site-export.json', site_export, 'application/json')
927 927
    with CaptureQueriesContext(connection) as ctx:
928 928
        resp = resp.form.submit()
929
        assert len(ctx.captured_queries) in [303, 304]
929
        assert len(ctx.captured_queries) in [308, 309]
930 930
    assert Page.objects.count() == 4
931 931
    assert PageSnapshot.objects.all().count() == 4
932 932

  
tests/test_pages.py
35 35
    page2 = Page()
36 36
    page2.slug = 'bar'
37 37
    page2.parent = page
38
    page2.save()
38 39
    assert page2.get_online_url() == '/foo/bar/'
39 40

  
40 41
    # directly give redirect url of linked page
tests/test_public.py
186 186
        assert resp.text.count('BAR2FOO') == 1
187 187
        queries_count_third = len(ctx.captured_queries)
188 188
        # +2 for validity info of parent page
189
        assert queries_count_third == queries_count_second + 1
189
        assert queries_count_third == queries_count_second + 2
190 190

  
191 191
    with CaptureQueriesContext(connection) as ctx:
192 192
        resp = app.get('/second/third/fourth/', status=200)
193
-