أهلا! بادئ ذي بدء ، أود أن أشكرك على هذا المشروع ، الذي أثبت أنه مفيد للغاية بالاشتراك مع إطار عمل Django REST في تطبيقنا.
أعاني حاليًا من مشكلة التسلسل والتصفية والطلب حسب حقول النماذج ذات الصلة. في هذا العدد ، أود التركيز فقط على الطلب.
دعني أوضح نواياي بالنموذج التالي:
# models.py
from django.contrib.auth.models import User
from django.db import models
class Order(models.Model):
user = models.ForeignKey(User, on_delete=models.PROTECT, null=False, blank=False)
created = models.DateTimeField(null=False, blank=False, auto_now_add=True)
submitted = models.DateTimeField(null=True, blank=True)
def __str__(self):
return f'Order #{self.id}'
class Product(models.Model):
name = models.CharField(null=False, blank=False, max_length=256)
price = models.DecimalField(null=False, blank=False, decimal_places=2, max_digits=12)
def __str__(self):
return self.name
class OrderLine(models.Model):
product = models.ForeignKey(Product, on_delete=models.PROTECT, null=False, blank=False, related_name='order_lines')
quantity = models.IntegerField(null=False, blank=False)
product_price = models.DecimalField(null=False, blank=False, decimal_places=2, max_digits=12)
total_price = models.DecimalField(null=False, blank=False, decimal_places=2, max_digits=12)
order = models.ForeignKey(Order, on_delete=models.CASCADE, null=False, blank=False, related_name='order_lines')
def __str__(self):
return f'{self.order}: {self.product.name} x{self.quantity}'
بعد ذلك ، لنبدأ مع DRF ViewSet دون أي تصفية وترتيب مهيأ:
# views.py
from rest_framework import viewsets
from django_filters.rest_framework import DjangoFilterBackend, FilterSet, OrderingFilter
from .models import Order
class OrderFilterSet(FilterSet):
pass
class OrderViewSet(viewsets.ModelViewSet):
queryset = Order.objects.all()
serializer_class = OrderSerializer # the definition of OrderSerializer is irrelevant and not shown here
filter_backends = (DjangoFilterBackend, OrderingFilter)
filterset_class = OrderFilterSet
ordering_fields = ()
class Meta:
model = Order
هدفي في نطاق هذه المشكلة هو السماح للعميل بطلب قائمة الطلبات المطلوبة من خلال الحقول التالية:
ordering=[-]created
- الطلب حسب تاريخ إنشاء الطلب ( [-]
يشير إلى -
اختياري لطلب الوصف.)ordering=[-]user
- اطلب بالاسم الكامل للمستخدم الذي قدم الطلبordering=[-]total_quantity
- اطلب حسب الكمية الإجمالية للمنتجات في الطلبالأسلوب الأساسي مع ordering_fields
الذي تم تكوينه على ViewSet
يسمح فقط بالطلب بناءً على حقول النموذج ، الصفحة 1. لحسن الحظ ، يمكننا استخدام طريقة أكثر تقدمًا لتصنيف filters.OrderingFilter
كما هو موضح جيدًا في https://django-filter.readthedocs.io/en/master/ref/filters.html#orderingfilter :
# views.py
from rest_framework import viewsets
from django_filters.rest_framework import DjangoFilterBackend, FilterSet, OrderingFilter
from .models import Order
class OrderOrderingFilter(OrderingFilter):
def __init__(self):
super().__init__(fields={
# example: model field
# Order by order creation date
'created': 'created',
# example: expression on related model
# Order by user full name
'user': '', # ???
# example: aggregate expression
'total_quantity': '' # ???
})
class OrderFilterSet(FilterSet):
ordering = OrderOrderingFilter()
class OrderViewSet(viewsets.ModelViewSet):
queryset = Order.objects.all()
serializer_class = OrderSerializer # the definition of OrderSerializer is irrelevant and not shown here
filter_backends = (DjangoFilterBackend,)
filterset_class = OrderFilterSet
class Meta:
model = Order
ومع ذلك ، حتى عند استخدام هذه الطريقة المتقدمة ، يبدو أن مرشح django يتطلب اسم حقل. الطريقة الوحيدة للتحايل على هذه المشكلة هي عن طريق annotate
'في مجموعة QuerySet التي تمت تصفيتها:
# ... the rest is omitted to brevity ...
class OrderOrderingFilter(OrderingFilter):
def __init__(self):
super().__init__(fields={
# ... the rest is omitted to brevity ...
'user_order': 'user',
})
def filter(self, qs, value):
if value:
qs = self._annotate(qs)
return super().filter(qs, value)
def _annotate(self, qs, value):
if 'user' in value or '-user' in value:
qs = qs.annotate(user_order=functions.Concat(
models.F('user__first_name'),
models.Value(' '),
models.F('user__last_name'),
))
return qs
يمكن للبعض أن يقول إن استخدام .annotate
هنا فكرة سيئة لأنه لا يمكن للمرء أن يخلط .annotate
مع .filter
كما هو موضح في https://docs.djangoproject.com/en/2.1 / topic / db / aggregation / # order -of-annotate-and-filter-clauses. ومع ذلك ، فأنت بخير طالما أنك لا تستخدم التجميع في مكالماتك .annotate
، لذلك نحن جيدون حتى الآن. (_ سأعود بالفعل إلى التجميع لاحقًا_)
هناك مشكلة أخرى تتعلق بأحدث نموذج التعليمات البرمجية وهي أن الكود متكرر ومن الأفضل استخراجه في فئة أساسية يمكن بعد ذلك توريثها بسهولة. في الوقت الحالي ، قمت بكتابة صنف OrderingFilter
الخاص بي ، والذي يعد بديلاً كاملاً للصف الذي يوفره django-filter. كود المصدر الكامل أدناه. قد تلاحظ أنه يستعير كثيرًا من مرشح django:
import typing
from django.db import models
from django.db.models import expressions
from django.forms.utils import pretty_name
from django.utils.translation import ugettext_lazy as _
from django_filters import filters
from django_filters.constants import EMPTY_VALUES
__all__ = ('OrderingFilter',)
class OrderingFilter(filters.BaseCSVFilter, filters.ChoiceFilter):
"""
An alternative to django_filters.filter.OrderingFilter that allows to specify any Django ORM "expression" for ordering.
Usage examples:
class MyOrderingFilter(ddf.OrderingFilter):
def __init__(self):
super().__init__(fields={
# a model field
'id': 'id'
# an expression
'published_by':
functions.Concat(
expressions.F(f'published_user__first_name'),
expressions.Value(' '),
expressions.F(f'published_user__last_name')
),
# a complete field descriptor with custom field label
'reported_by': {
'label': 'Reporter',
'desc_label': 'Reporter (descending)', # optional, would be derived from 'label' anyway
'expr': functions.Concat(
expressions.F(f'reported_user__first_name'),
expressions.Value(' '),
expressions.F(f'reported_user__last_name')
),
}
})
For more information about expressions, please see the official Django documentation at
https://docs.djangoproject.com/en/2.1/ref/models/expressions/
"""
_fields: typing.Mapping[str, 'FieldDescriptor']
def __init__(self, fields: typing.Mapping[str, typing.Any]):
self._fields = normalize_fields(fields)
super().__init__(choices=build_choices(self._fields))
# <strong i="15">@override</strong>
def filter(self, qs: models.QuerySet, value: typing.Union[typing.List[str], None]):
return qs if value in EMPTY_VALUES else qs.order_by(*(expr for expr in map(self.get_ordering_expr, value)))
def get_ordering_expr(self, param) -> expressions.Expression:
descending = param.startswith('-')
param = param[1:] if descending else param
field_descriptor = self._fields.get(param)
return None if field_descriptor is None else \
field_descriptor.expr if not descending else field_descriptor.expr.desc()
def normalize_fields(fields: typing.Mapping[str, typing.Any]) -> typing.Mapping[str, 'FieldDescriptor']:
return dict((
param_name,
FieldDescriptor(param_name, {'expr': normalize_expr(field)} if isinstance(field, (str, expressions.Expression)) else field)
) for param_name, field in fields.items())
def normalize_expr(expr: typing.Union[str, expressions.Expression]):
return models.F(expr) if isinstance(expr, str) else expr
descending_fmt = _('%s (descending)')
class FieldDescriptor:
expr: models.Expression
def __init__(self, param_name: str, data: typing.Mapping[str, typing.Any]):
self.expr = normalize_expr(data['expr'])
self.label = data.get('label', _(pretty_name(param_name)))
self.desc_label = data.get('desc_label', descending_fmt.format(self.label))
def build_choices(fields: typing.Mapping[str, 'FieldDescriptor']):
choices = []
for param_name, field_descriptor in fields.items():
choices.append((param_name, field_descriptor.label))
choices.append((f'-{param_name}', field_descriptor.desc_label))
return choices
باستخدام هذه الفئة ، يصبح OrdersOrderingFilter
لطيفًا وموجزًا:
# ... the rest is omitted to brevity ...
class OrderOrderingFilter(ddf.OrderingFilter):
def __init__(self):
super().__init__(fields={
# ... the rest is omitted to brevity ...
'user': functions.Concat(
models.F('user__first_name'),
models.Value(' '),
models.F('user__last_name'),
),
})
هذا يطرح السؤال الذي جئت به إلى هنا:
_ ما رأيك في توفير القدرة على تحديد الطلب باستخدام تعبير Django ORM في django_filters.rest_framework.OrderingFilter
؟ _
ولكن قبل أن تجيب ، دعني أذكرك بأننا قد حللنا الصفحتين 1 و 2 فقط. لحل الصفحة 3 ، سنحتاج إلى تعبير تجميعي:
aggregates.Sum(models.F('order_lines__quantity'))
لا يمكننا استخدام هذا التعبير في .annotate
، لأن نتائج مزجه مع تصفية QuserySet ستكون غير متوقعة. لا يمكننا استخدام هذا التعبير مباشرة في استدعاء Query.order(...)
أيضًا ، لأن
django.core.exceptions.FieldError: Using an aggregate in order_by() without also including it in annotate() is not allowed: Sum(F(order_lines__quantity))
لذا فإن الصفحة 3 بعيدة المنال ، وتتطلب حلاً يخترق كل شيء بدءًا من ViewSet. يتعلق الأمر بإطار Django REST أكثر من عوامل تصفية django. هل نحتاج فعلاً إلى دعم التعبير في django_filters.rest_framework.OrderingFilter
بدون دعم تعبير التجميع؟ هذا قد يربك مستخدمي django-filter فقط دون تحقيق الكثير من الفوائد.
أنا أتطلع لسماع رأيك. أعلم أن هناك الكثير من المعلومات التي يجب هضمها. آمل أن يساعد مشروعي الاختباري: https://github.com/earshinov/django_sample/tree/master/django_sample/ordering_by_expression
مرحباearshinov. شكرا على التقرير. مثير جدا. اسمحوا لي أن أفكر في ذلك. أرسل لي في مارس إذا لم أقم بالرد بحلول ذلك الوقت. 🙂
فكرة أخرى: من الممكن المضي قدمًا والسماح لمرشح بتحديد ليس تعبيرًا واحدًا أو أسماء حقول متعددة.
قبل (الخيار 1):
class OrderOrderingFilter(ddf.OrderingFilter):
def __init__(self):
super().__init__(fields={
'user': functions.Concat(
models.F('user__first_name'),
models.Value(' '),
models.F('user__last_name'),
),
})
بعد (الخيار 2):
class OrderOrderingFilter(ddf.OrderingFilter):
def __init__(self):
super().__init__(fields={
'user': ('user__first_name', 'user__last_name'),
})
في كلتا الحالتين ، يطلب ordering=user
البيانات حسب الاسم الأول للمستخدم أولاً ، ثم الاسم الثاني للمستخدم ، لكن الخيار 2 أسهل في الكتابة ، وربما يكون أكثر فاعلية عندما يتعلق الأمر باستعلامات قاعدة البيانات.
مرحبا carltongibson ،
طلبت مني الاتصال بك في المباراة. قد يكون بالفعل :)
بالعودة إلى اقتراحي بشأن الترتيب حسب التعابير (واحد أو متعدد) ، أعتقد حقًا أنه جزء مفقود من اللغز.
أرى أربع عمليات جدول أساسية:
أ. التسلسل
ب. ~ ترقيم الصفحات ~ (غير ذي صلة بهذه المناقشة)
ج. الفلتره
د. فرز
باستخدام DRF و django-filter يمكن تنفيذ كل ذلك مع دعم النماذج ذات الصلة ، باستثناء الفرز :
أ. للتسلسل ، لدينا مسلسلات متداخلة . أيضًا ، إذا احتجنا إلى إرجاع حقل مجمع (فكر في count
) ، فيمكننا .annotate()
مجموعة الاستعلام:
from django.db.models import aggregates, functions, F, Value
class ModelViewSet(...):
def get_queryset(self):
qs = Model.objects.all()
if self.action == 'list':
qs = qs.annotate(
author_full_name=functions.Trim(functions.Concat(
F('author__first_name'),
Value(' '),
F('author__last_name'),
)),
submodel_count=aggregates.Count('submodel'))
)
return qs
ج. للتصفية باستخدام تعبيرات Django ، يمكن للمرء تنفيذ مرشح مخصص (انظر المثال أدناه). التصفية حسب الحقل المجمع ( submodel_count
) ممكنة باستخدام عوامل التصفية القياسية ( filters.NumberFilter
).
from django_filters import filters
from django_filters.rest_framework import FilterSet
class ModelFilterSet(FilterSet):
author = UserFilter(field_name='author', label='author', lookup_expr='icontains')
class UserFilter(filters.Filter):
"""A django_filters filter that implements filtering by user's full name.
def filter(self, qs: QuerySet, value: str) -> QuerySet:
# first_name <lookup_expr> <value> OR last_name <lookup_expr> <value>
return qs if not value else qs.filter(
Q(**{f'{self.field_name}__first_name__{self.lookup_expr}': value}) |
Q(**{f'{self.field_name}__last_name__{self.lookup_expr}': value})
)
د. لا يوجد حل للفرز :-(
إليك التنفيذ الكامل لمخصصنا OrderingFilter
، والذي كنا نستخدمه حتى الآن:
class OrderingFilter(filters.BaseCSVFilter, filters.ChoiceFilter):
"""An alternative to :class:`django_filters.filters.OrderingFilter` that allows to specify any Django ORM expression for ordering.
Usage example:
.. code-block:: python
from django.db import models
from django.db.models import aggregates, expressions, fields
import ddl
class OrderOrderingFilter(ddl.OrderingFilter):
def __init__(self):
super().__init__(fields={
# a model field
'created': 'created'
# an expression
'submitted': expressions.ExpressionWrapper(
models.Q(submitted_date__isnull=False),
output_field=fields.BooleanField()
),
# multiple fields or expressions
'user': ('user__first_name', 'user__last_name'),
# a complete field descriptor with custom field label
'products': {
'label': 'Total number of items in the order',
# if not specified, `desc_label` would be derived from 'label' anyway
'desc_label': 'Total number of items in the order (descending)',
'expr': aggregates.Sum('order_lines__quantity'),
# it is also possible to filter by multiple fields or expressions here
#'exprs': (...)
},
})
For more information about expressions, see the official Django documentation at
https://docs.djangoproject.com/en/dev/ref/models/expressions/
"""
_fields: typing.Mapping[str, 'FieldDescriptor']
def __init__(self, fields: typing.Mapping[str, typing.Any]):
self._fields = normalize_fields(fields)
super().__init__(choices=build_choices(self._fields))
# <strong i="7">@override</strong>
def filter(self, qs: models.QuerySet, value: typing.Union[typing.List[str], None]):
return qs if value in EMPTY_VALUES else qs.order_by(*(itertools.chain(*(self.__get_ordering_exprs(param) for param in value))))
def __get_ordering_exprs(self, param) -> typing.Union[None, typing.List[expressions.Expression]]:
descending = param.startswith('-')
param = param[1:] if descending else param
field_descriptor = self._fields.get(param)
return () if field_descriptor is None else \
field_descriptor.exprs if not descending else \
(expr.desc() for expr in field_descriptor.exprs)
def normalize_fields(fields: typing.Mapping[str, typing.Any]) -> typing.Mapping[str, 'FieldDescriptor']:
return dict((
param_name,
FieldDescriptor(param_name, field if isinstance(field, collections.Mapping) else {'exprs': normalize_exprs(field)})
) for param_name, field in fields.items())
def normalize_exprs(exprs: typing.Union[
typing.Union[str, expressions.Expression],
typing.List[typing.Union[str, expressions.Expression]]
]) -> typing.List[expressions.Expression]:
# `exprs` is either a single expression or a Sequence of expressions
exprs = exprs if isinstance(exprs, collections.Sequence) and not isinstance(exprs, str) else (exprs,)
return [normalize_expr(expr) for expr in exprs]
def normalize_expr(expr: typing.Union[str, expressions.Expression]) -> expressions.Expression:
return models.F(expr) if isinstance(expr, str) else expr
descending_fmt = _('%s (descending)')
class FieldDescriptor:
exprs: typing.List[models.Expression]
def __init__(self, param_name: str, data: typing.Mapping[str, typing.Any]):
exprs = data.get('exprs') or data.get('expr')
if not exprs:
raise ValueError("Expected 'exprs' or 'expr'")
self.exprs = normalize_exprs(exprs)
self.label = data.get('label', _(pretty_name(param_name)))
self.desc_label = data.get('desc_label', descending_fmt.format(self.label))
def build_choices(fields: typing.Mapping[str, 'FieldDescriptor']):
choices = []
for param_name, field_descriptor in fields.items():
choices.append((param_name, field_descriptor.label))
choices.append((f'-{param_name}', field_descriptor.desc_label))
return choices
_Update_: يتضمن تنفيذ normalize_fields
وطرق مساعدة أخرى.
جيد. 😀
شكرا على ping.
Tbh لم تتح لي لحظة للتفكير في هذا الأمر.
في كل أفكارك الرائعة هنا ، هل لديك اقتراح لتغيير طفيف يمكننا إجراؤه؟ (ربما يكون من الأسهل المضي قدمًا في العلاقات العامة).
نعم ، ليس من الواضح على الفور كيفية إجراء مثل هذه التغييرات المتوافقة مع الإصدارات السابقة ، وسأضطر إلى التفكير فيها. لا تتوقع إقامة علاقات عامة قريبًا (ستكون في إجازة في الأماكن التي يندر فيها الإنترنت).
هذا جيد. 🙂
ليس هناك اندفاع هنا. من الأفضل أن نفكر مليًا ، على كل حال.
carltongibson ، حسنًا ، فكرنا قليلاً ، لكن لم نجد طريقة مقبولة للجمع بين التطبيق القديم والجديد لـ OrderingFilter
.
إذا كنا سنضع كل شيء في فئة واحدة ، فإن المشكلة الأكبر هي أنه في التطبيق القديم ، تخزن الحقول ديكت model_field
- parameter_name
أزواج ، بينما في التطبيق الجديد يكون العكس هو parameter_name
- model_field
(يجب أن يكون كذلك ، لأنه بدلاً من model_field
، يمكن تمرير تعبير ، والذي يمكن تخزينه فقط في قيمة ، ولكن ليس في مفتاح في قاموس) .
من الناحية الفنية ، من الممكن التغلب على هذه المشكلة من خلال تفسير القاموس "بالطريقة القديمة" إذا كان يحتوي على سلاسل فقط ، و "بالطريقة الجديدة" بخلاف ذلك. في هذه الحالة ، سيتعين على المستخدم "قلب" إدخالات القاموس عند الحاجة إلى الترتيب حسب التعبير. وكن حريصًا على "قلب" مدخلات القاموس مرة أخرى ، في حالة إزالة تعبير الطلب ... هذا يبدو وكأنه تجربة مستخدم رهيبة بالنسبة لي. كما أنه سيجعل التنفيذ معقدًا.
هل تعتقد أنه من الممكن تقديم OrderingFilter الجديد مع الاسم القديم باسم مختلف ، مثل ExpressionOrderingFilter
؟
هذا شيء عظيم ، بالضبط في اللحظة التي كنت في حاجة إليها! شكراearshinov!
كيف أفعل شيئًا مثل:
MyModel.objects.all().order_by(F('price').desc(nulls_last=True))
مع مرشح الطلب الخاص بك؟
o = ExpressionOrderingFilter(
fields={
'price': F('price').desc(nulls_last=True)
}
)
يبدو أنه يعمل بشكل رائع!
الآن الشيء الوحيد المتبقي هو معرفة كيفية دمج المرشحات المتعددة ، ak. "السعر" و "الأسهم" في واحد.
ach - بدأت الاستجابة لهذا ولكن جهاز الكمبيوتر الخاص بي توقف.
أعتقد أن التغييرات المقترحة هنا معقولة. كان الزوج model_field
- parameter_name
منطقيًا في ذلك الوقت نظرًا لأننا عادةً ما نستمد المعلمات / النماذج المكشوفة / إلخ من النموذج ، ولكن لا يوجد سبب لضرورة ذلك. سيكون تبديل التعيين منطقيًا ، ويسمح لنا بالاستفادة من التعبيرات الأكثر تعقيدًا.
أيضًا ، لا أعتقد أن عملية الإيقاف ستكون صعبة للغاية ، وسأكون سعيدًا بالمساعدة في ذلك. بشكل أساسي ، سيتحول fields
و field_labels
إلى params
و param_labels
. من السهل التحويل من واحد إلى الآخر ، مع الاحتفاظ بالتوافق مع الإصدارات السابقة ورفع إشعار الإيقاف للمستخدمين الذين يستخدمون الوسائط القديمة.
شيء واحد يجب مراعاته هو التحويل التلقائي للتعبيرات المعقدة للحالة التنازلية ( param
مقابل -param
). على سبيل المثال ، إذا كان الارتفاع هو .asc(nulls_last=True)
. ، فهل يجب أن يكون معكوس هذا .desc(nulls_first=True)
، أم يجب أن تظل القيم الخالية في النهاية بغض النظر عن اتجاه الفرز؟
حسنًا ، رائعrpkilby.
سعيد لرؤيتنا نتحرك إلى الأمام هنا. شاغلي الرئيسي هو أننا نوثق بشكل صحيح ما نقوم به. يجد المستخدمون بالفعل مستندات df قليلاً _terse_ فهل نقول 🙂 - يسعدنا إضافة واجهة برمجة التطبيقات ولكننا نحتاج إلى التأكد من أنها واضحة.
مثل هذا الشيء لا يجب أن يكون جهد شخص واحد.
rpkilby ، carltongibson ، إذا كنت بحاجة إلى مساعدتي في دمج تغييراتي في المشروع ، أعتقد أنه يمكنني تخصيص بعض الوقت. لكني بحاجة لاتجاهات. أين وكيف أبدأ؟
مرحبًاearshinov. سأبدأ بصياغة المستندات. ما القصة التي نحكيها؟ من هناك يتغير الرمز. العلاقات العامة ، حتى لو كانت مجرد مسودة تعطينا شيئًا نتحدث عنه.
أيضًا ، اختبر الحالات إذا كانت لديك. قد يحتاجون إلى تعديل لمطابقة التغييرات النهائية ، ولكن التفكير في الحالات المختلفة سيكون مفيدًا للغاية (على سبيل المثال ، تحويل .asc
إلى .desc
، التعامل مع nulls_fist
/ nulls_last
وسيطات ، وما إلى ذلك).
التعليق الأكثر فائدة
ach - بدأت الاستجابة لهذا ولكن جهاز الكمبيوتر الخاص بي توقف.
أعتقد أن التغييرات المقترحة هنا معقولة. كان الزوج
model_field
-parameter_name
منطقيًا في ذلك الوقت نظرًا لأننا عادةً ما نستمد المعلمات / النماذج المكشوفة / إلخ من النموذج ، ولكن لا يوجد سبب لضرورة ذلك. سيكون تبديل التعيين منطقيًا ، ويسمح لنا بالاستفادة من التعبيرات الأكثر تعقيدًا.أيضًا ، لا أعتقد أن عملية الإيقاف ستكون صعبة للغاية ، وسأكون سعيدًا بالمساعدة في ذلك. بشكل أساسي ، سيتحول
fields
وfield_labels
إلىparams
وparam_labels
. من السهل التحويل من واحد إلى الآخر ، مع الاحتفاظ بالتوافق مع الإصدارات السابقة ورفع إشعار الإيقاف للمستخدمين الذين يستخدمون الوسائط القديمة.شيء واحد يجب مراعاته هو التحويل التلقائي للتعبيرات المعقدة للحالة التنازلية (
param
مقابل-param
). على سبيل المثال ، إذا كان الارتفاع هو.asc(nulls_last=True)
. ، فهل يجب أن يكون معكوس هذا.desc(nulls_first=True)
، أم يجب أن تظل القيم الخالية في النهاية بغض النظر عن اتجاه الفرز؟