0001-general-use-tree-queries-for-handling-page-hierarchy.patch
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 |
- |