Django аннотирует с помощью ExtractMonth, а ExtractYear не извлекает year

У меня есть такая модель:

class KeyAccessLog(models.Model):
    key = models.ForeignKey(
        Key, related_name="access_logs", on_delete=models.CASCADE
    )
    path = models.CharField(max_length=255)
    method = models.CharField(max_length=10)
    ip_address = models.GenericIPAddressField()
    created = models.DateTimeField(auto_add_now=True)

    class Meta:
        verbose_name = "Key Access Log"
        verbose_name_plural = "Key Access Logs"
        ordering = ("-pk",)
        indexes = [
            models.Index(
                models.Index(
                    "key",
                    ExtractMonth("created"),
                    ExtractYear("created"),
                    name="key_month_year_idx",
                ),
            ),
        ]

    def __str__(self):
        return f"{self.key} - {self.path} - {self.method}"

Моя идея заключается в том, чтобы использовать объявленный индекс при фильтрации по key, month, и year, но запрос, сгенерированный из ORM, не извлекается так, как это происходит за месяц.

dt_now = timezone.now()
qs = key.access_logs.filter(created__month=dt_now.month, created__year=dt_now.year)
print(qs.query)

дает мне:

SELECT
    "public_keyaccesslog"."id",
    "public_keyaccesslog"."key_id",
    "public_keyaccesslog"."path",
    "public_keyaccesslog"."method",
    "public_keyaccesslog"."ip_address",
    "public_keyaccesslog"."created"
FROM
    "public_keyaccesslog"
WHERE
    (
        "public_keyaccesslog"."key_id" = 1
        AND EXTRACT(
            MONTH
            FROM
                "public_keyaccesslog"."created" AT TIME ZONE 'UTC'
        ) = 5
        AND "public_keyaccesslog"."created" BETWEEN '2025-01-01 00:00:00+00:00' AND '2025-12-31 23:59:59.999999+00:00'
    )
ORDER BY
    "public_keyaccesslog"."id" DESC

Теперь я попытался явно аннотировать извлеченные месяц и год:

dt_now = timezone.now()
qs = key.access_logs.annotate(
    month=ExtractMonth("created"),
    year=ExtractYear("created")
).filter(month=dt_now.month, year=dt_now.year)
print(qs.query)

это дает мне почти тот же SQL:

SELECT
    "public_keyaccesslog"."id",
    "public_keyaccesslog"."key_id",
    "public_keyaccesslog"."path",
    "public_keyaccesslog"."method",
    "public_keyaccesslog"."ip_address",
    "public_keyaccesslog"."created",
    EXTRACT(
        MONTH
        FROM
            "public_keyaccesslog"."created" AT TIME ZONE 'UTC'
    ) AS "month",
    EXTRACT(
        YEAR
        FROM
            "public_keyaccesslog"."created" AT TIME ZONE 'UTC'
    ) AS "year"
FROM
    "public_keyaccesslog"
WHERE
    (
        "public_keyaccesslog"."key_id" = 1
        AND EXTRACT(
            MONTH
            FROM
                "public_keyaccesslog"."created" AT TIME ZONE 'UTC'
        ) = 5
        AND "public_keyaccesslog"."created" BETWEEN '2025-01-01 00:00:00+00:00' AND '2025-12-31 23:59:59.999999+00:00'
    )
ORDER BY
    "public_keyaccesslog"."id" DESC

Год никогда не фильтруется по извлеченному значению, как месяц, и тогда мой индекс не имеет никакого смысла.

Я бы ожидал, что это будет:

SELECT
    "public_keyaccesslog"."id",
    "public_keyaccesslog"."key_id",
    "public_keyaccesslog"."path",
    "public_keyaccesslog"."method",
    "public_keyaccesslog"."ip_address",
    "public_keyaccesslog"."created",
    EXTRACT(
        MONTH
        FROM
            "public_keyaccesslog"."created" AT TIME ZONE 'UTC'
    ) AS "month",
    EXTRACT(
        YEAR
        FROM
            "public_keyaccesslog"."created" AT TIME ZONE 'UTC'
    ) AS "year"
FROM
    "public_keyaccesslog"
WHERE
    (
        "public_keyaccesslog"."key_id" = 1
        AND EXTRACT(
            MONTH
            FROM
                "public_keyaccesslog"."created" AT TIME ZONE 'UTC'
        ) = 5
        AND EXTRACT(
            YEAR
            FROM
                "public_keyaccesslog"."created" AT TIME ZONE 'UTC'
        ) = 2025
    )
ORDER BY
    "public_keyaccesslog"."id" DESC

Есть какое-нибудь решение для этого?

"Виновник", если хотите, - это какая-то пользовательская логика, добавленная Django для этого. Действительно:

class YearExact(YearLookup, Exact):
    def get_direct_rhs_sql(self, connection, rhs):
        return "BETWEEN %s AND %s"
    
    def get_bound_params(self, start, finish):
        return (start, finish)

, который регистрируется как __exact поиск объекта ExtractYear (не путать с YearExact таким образом).

Вероятно, также не имеет особого смысла добавлять отдельный индекс для года: простое добавление индекса для created будет иметь (почти) ту же производительность, поскольку он выполняет логарифмический поиск, и использование индекса для ExtractYear это может привести к дополнительному ведению бухгалтерского учета, поскольку, вероятно, к 2025 году появится много записей.

Если вы действительно хотите избавиться от этого, вы можете просто зарегистрировать свой собственный ExtractYear, например:

from django.db.models.functions.datetime import ExtractYear
from django.db.models.lookups import Exact

class MyExtractYear(ExtractYear):
  pass

MyExtractYear.register_lookup(Exact)

и, таким образом, вместо этого используйте MyExtractYear. Но это, вероятно, не стоит усилий и может даже привести к снижению эффективности запросов.

Вернуться на верх