瀏覽代碼

Implement a much more efficient approach to range queries. (#3831)

master
Voxin Muyli 3 年之前
父節點
當前提交
34c4a1b344
No account linked to committer's email address

+ 34
- 0
src/oscar/apps/catalogue/expressions.py 查看文件

@@ -0,0 +1,34 @@
1
+from django.db.models.expressions import Subquery
2
+
3
+EXPAND_UPWARDS_CATEGORY_QUERY = """
4
+(SELECT "CATALOGUE_CATEGORY_JOIN"."id" FROM "catalogue_category" AS "CATALOGUE_CATEGORY_BASE"
5
+LEFT JOIN "catalogue_category" AS "CATALOGUE_CATEGORY_JOIN" ON (
6
+    "CATALOGUE_CATEGORY_BASE"."path" LIKE "CATALOGUE_CATEGORY_JOIN"."path" || '%%%%'
7
+    AND "CATALOGUE_CATEGORY_BASE"."depth" >= "CATALOGUE_CATEGORY_JOIN"."depth"
8
+)
9
+WHERE "CATALOGUE_CATEGORY_BASE"."id" IN (%(subquery)s))
10
+"""
11
+
12
+
13
+EXPAND_DOWNWARDS_CATEGORY_QUERY = """
14
+(SELECT "CATALOGUE_CATEGORY_JOIN"."id" FROM "catalogue_category" AS "CATALOGUE_CATEGORY_BASE"
15
+LEFT JOIN "catalogue_category" AS "CATALOGUE_CATEGORY_JOIN" ON (
16
+    "CATALOGUE_CATEGORY_JOIN"."path" LIKE "CATALOGUE_CATEGORY_BASE"."path" || '%%%%'
17
+    AND "CATALOGUE_CATEGORY_BASE"."depth" <= "CATALOGUE_CATEGORY_JOIN"."depth"
18
+)
19
+WHERE "CATALOGUE_CATEGORY_BASE"."id" IN (%(subquery)s))
20
+"""
21
+
22
+
23
+class ExpandUpwardsCategoryQueryset(Subquery):
24
+    template = EXPAND_UPWARDS_CATEGORY_QUERY
25
+
26
+    def as_sqlite(self, compiler, connection):
27
+        return super().as_sql(compiler, connection, self.template[1:-1])
28
+
29
+
30
+class ExpandDownwardsCategoryQueryset(Subquery):
31
+    template = EXPAND_DOWNWARDS_CATEGORY_QUERY
32
+
33
+    def as_sqlite(self, compiler, connection):
34
+        return super().as_sql(compiler, connection, self.template[1:-1])

+ 23
- 30
src/oscar/apps/offer/abstract_models.py 查看文件

@@ -19,6 +19,7 @@ from oscar.core.loading import (
19 19
 from oscar.models import fields
20 20
 from oscar.templatetags.currency_filters import currency
21 21
 
22
+ExpandDownwardsCategoryQueryset = get_class("catalogue.expressions", "ExpandDownwardsCategoryQueryset")
22 23
 ActiveOfferManager, RangeManager, BrowsableRangeManager \
23 24
     = get_classes('offer.managers', ['ActiveOfferManager', 'RangeManager', 'BrowsableRangeManager'])
24 25
 ZERO_DISCOUNT = get_class('offer.results', 'ZERO_DISCOUNT')
@@ -931,38 +932,30 @@ class AbstractRange(models.Model):
931 932
             )
932 933
 
933 934
         if self.included_categories.exists():
934
-            # build query to select all category subtrees.
935
-            category_filter = Q()
936
-            for path, depth in self.included_categories.values_list("path", "depth"):
937
-                category_filter |= Q(
938
-                    categories__depth__gte=depth, categories__path__startswith=path
939
-                )
940
-
941
-            # select all those product that are selected either by product class,
942
-            # category, or explicitly by included_products.
943
-            selected_products = Product.objects.annotate(
944
-                selected_categories=models.FilteredRelation(
945
-                    "categories", condition=category_filter
946
-                )
947
-            ).filter(
948
-                Q(product_class_id__in=self.classes.values("id"))
949
-                | Q(selected_categories__isnull=False)
950
-            ) | self.included_products.all()
935
+            expanded_range_categories = ExpandDownwardsCategoryQueryset(
936
+                self.included_categories.values("id")
937
+            )
938
+            selected_products = Product.objects.filter(
939
+                Q(categories__in=expanded_range_categories)
940
+                | Q(product_class__classes=self)
941
+                | Q(includes=self)
942
+                | Q(parent__categories__in=expanded_range_categories)
943
+                | Q(parent__product_class__classes=self)
944
+                | Q(parent__includes=self),
945
+                ~Q(excludes=self),
946
+                ~Q(parent__excludes=self)
947
+            )
951 948
         else:
952 949
             selected_products = Product.objects.filter(
953
-                product_class_id__in=self.classes.values("id")
954
-            ) | self.included_products.all()
955
-
956
-        # Include children of matching parents
957
-        selected_products = selected_products | Product.objects.filter(
958
-            parent__in=selected_products.filter(structure=Product.PARENT)
959
-        )
960
-
961
-        # now go and exclude all explicitly excluded products
962
-        excludes = self.excluded_products.values("id")
963
-        return selected_products.exclude(
964
-            Q(parent_id__in=excludes) | Q(id__in=excludes)
965
-        ).distinct()
950
+                Q(product_class__classes=self)
951
+                | Q(includes=self)
952
+                | Q(parent__product_class__classes=self)
953
+                | Q(parent__includes=self),
954
+                ~Q(excludes=self),
955
+                ~Q(parent__excludes=self)
956
+            )
957
+
958
+        return selected_products.distinct()
966 959
 
967 960
     @property
968 961
     def is_editable(self):

+ 48
- 41
src/oscar/apps/offer/queryset.py 查看文件

@@ -1,25 +1,8 @@
1 1
 from django.db import models
2
-from django.db.models import Exists, OuterRef
3
-
4
-
5
-def product_class_as_queryset(product):
6
-    "Returns a queryset with the product_classes of a product (only one)"
7
-    ProductClass = product._meta.get_field("product_class").related_model
8
-    return ProductClass.objects.filter(
9
-        pk__in=product.__class__.objects.filter(pk=product.pk)
10
-        .annotate(
11
-            _product_class_id=models.Case(
12
-                models.When(
13
-                    structure=product.CHILD, then=models.F("parent__product_class")
14
-                ),
15
-                models.When(
16
-                    structure__in=[product.PARENT, product.STANDALONE],
17
-                    then=models.F("product_class"),
18
-                ),
19
-            )
20
-        )
21
-        .values("_product_class_id")
22
-    )
2
+
3
+from oscar.core.loading import get_class
4
+
5
+ExpandUpwardsCategoryQueryset = get_class("catalogue.expressions", "ExpandUpwardsCategoryQueryset")
23 6
 
24 7
 
25 8
 class RangeQuerySet(models.query.QuerySet):
@@ -27,31 +10,55 @@ class RangeQuerySet(models.query.QuerySet):
27 10
     This queryset add ``contains_product`` which allows selecting the
28 11
     ranges that contain the product in question.
29 12
     """
30
-    def contains_product(self, product):
31
-        "Return ranges that contain ``product`` in a single query"
13
+
14
+    def _excluded_products_clause(self, product):
32 15
         if product.structure == product.CHILD:
33
-            return self._ranges_that_contain_product(
34
-                product.parent
35
-            ) | self._ranges_that_contain_product(product)
36
-        return self._ranges_that_contain_product(product)
37
-
38
-    def _ranges_that_contain_product(self, product):
39
-        Category = product.categories.model
40
-        included_in_subtree = product.categories.filter(
41
-            path__startswith=OuterRef("path")
42
-        )
43
-        category_tree = Category.objects.annotate(
44
-            is_included_in_subtree=Exists(included_in_subtree.values("id"))
45
-        ).filter(is_included_in_subtree=True)
16
+            # child products are excluded from a range if either they are
17
+            # excluded, or their parent.
18
+            return ~(
19
+                models.Q(excluded_products=product)
20
+                | models.Q(excluded_products__id=product.parent_id)
21
+            )
22
+        return ~models.Q(excluded_products=product)
46 23
 
24
+    def _included_products_clause(self, product):
25
+        if product.structure == product.CHILD:
26
+            # child products are included in a range if either they are
27
+            # included, or their parent is included
28
+            return models.Q(included_products=product) | models.Q(
29
+                included_products__id=product.parent_id
30
+            )
31
+        else:
32
+            return models.Q(included_products=product)
33
+
34
+    def _productclasses_clause(self, product):
35
+        if product.structure == product.CHILD:
36
+            # child products are included in a range if their parent is
37
+            # included in the range by means of their productclass.
38
+            return models.Q(classes__products__parent_id=product.parent_id)
39
+        return models.Q(classes__id=product.product_class_id)
40
+
41
+    def _get_category_ids(self, product):
42
+        if product.structure == product.CHILD:
43
+            # Since a child can not be in a catagory, it must be determined
44
+            # which category the parent is in
45
+            ProductCategory = product.productcategory_set.model
46
+            return ProductCategory.objects.filter(product_id=product.parent_id).values("category_id")
47
+
48
+        return product.categories.values("id")
49
+
50
+    def contains_product(self, product):
51
+        # the wide query is used to determine which ranges have includes_all_products
52
+        # turned on, we only need to look at explicit exclusions, the other
53
+        # mechanism for adding a product to a range don't need to be checked
47 54
         wide = self.filter(
48
-            ~models.Q(excluded_products=product), includes_all_products=True
55
+            self._excluded_products_clause(product), includes_all_products=True
49 56
         )
50 57
         narrow = self.filter(
51
-            ~models.Q(excluded_products=product),
52
-            models.Q(included_products=product)
53
-            | models.Q(included_categories__in=category_tree)
54
-            | models.Q(classes__in=product_class_as_queryset(product)),
58
+            self._excluded_products_clause(product),
59
+            self._included_products_clause(product)
60
+            | models.Q(included_categories__in=ExpandUpwardsCategoryQueryset(self._get_category_ids(product)))
61
+            | self._productclasses_clause(product),
55 62
             includes_all_products=False,
56 63
         )
57 64
         return wide | narrow

+ 20
- 3
tests/integration/offer/test_range.py 查看文件

@@ -295,13 +295,15 @@ class TestRangeQuerySet(TestCase):
295 295
     def test_contains_parent(self):
296 296
         ranges = models.Range.objects.contains_product(self.parent)
297 297
         self.assertEqual(
298
-            ranges.count(), 1, "Both ranges should contain the parent product"
298
+            ranges.count(), 1, "One range should contain the parent product"
299 299
         )
300 300
 
301 301
     def test_exclude_child(self):
302 302
         ranges = models.Range.objects.contains_product(self.child2)
303 303
         self.assertEqual(
304
-            ranges.count(), 1, "Only 1 range should contain the second child"
304
+            ranges.count(), 0,
305
+            "None of the ranges should contain the second child, because it"
306
+            " was excluded in the range that contains the parent."
305 307
         )
306 308
 
307 309
     def test_category(self):
@@ -329,10 +331,25 @@ class TestRangeQuerySet(TestCase):
329 331
             "The range containing the parent category of the parent product, should be selected",
330 332
         )
331 333
 
334
+        ranges = models.Range.objects.contains_product(self.child2)
335
+        self.assertEqual(
336
+            ranges.count(),
337
+            1,
338
+            "Since the parent category is part of the range, There should be 1 "
339
+            "range containing the child2 product, whose parent is in a subcategory",
340
+        )
341
+
332 342
         ranges = models.Range.objects.contains_product(self.child1)
333 343
         self.assertEqual(
334 344
             ranges.count(),
335 345
             3,
336 346
             "Since the parent category is part of the range, There should be 3 "
337
-            "ranges containing the child product, which is in a subcategory",
347
+            "ranges containing the child1 product, whose parent is in a subcategory",
348
+        )
349
+        cat_range.excluded_products.add(self.child2)
350
+        ranges = models.Range.objects.contains_product(self.child2)
351
+        self.assertEqual(
352
+            ranges.count(),
353
+            0,
354
+            "No ranges should contain child2 after explicitly removing it from the only range that contained it",
338 355
         )

Loading…
取消
儲存