Как использовать инверсию GenericRelation

Должно быть, я действительно что-то недопонимаю с GenericRelation поле из структуры типов контента Django.

Чтобы создать минимальный автономный пример, я буду использовать пример приложения для опросов из учебника. Добавьте общее поле внешнего ключа в Choice модель, и сделать новый Thing модель:

class Choice(models.Model):
    ...
    content_type = models.ForeignKey(ContentType)
    object_id = models.PositiveIntegerField()
    thing = GenericForeignKey('content_type', 'object_id')

class Thing(models.Model):
    choices = GenericRelation(Choice, related_query_name='things')

С чистой базой данных, синхронизировать таблицы и создать несколько экземпляров:

>>> poll = Poll.objects.create(question='the question', pk=123)
>>> thing = Thing.objects.create(pk=456)
>>> choice = Choice.objects.create(choice_text='the choice', pk=789, poll=poll, thing=thing)
>>> choice.thing.pk
456
>>> thing.choices.get().pk
789

Пока все хорошо - отношение работает в обоих направлениях от экземпляра. Но из набора запросов обратное отношение очень странное:

>>> Choice.objects.values_list('things', flat=1)
[456]
>>> Thing.objects.values_list('choices', flat=1)
[456]

Почему обратное отношение снова дает мне идентификатор из thing? Я ожидал вместо этого первичного ключа выбора, эквивалентного следующему результату:

>>> Thing.objects.values_list('choices__pk', flat=1)
[789]

Эти ORM-запросы генерируют SQL следующим образом:

>>> print Thing.objects.values_list('choices__pk', flat=1).query
SELECT "polls_choice"."id" FROM "polls_thing" LEFT OUTER JOIN "polls_choice" ON ( "polls_thing"."id" = "polls_choice"."object_id" AND ("polls_choice"."content_type_id" = 10))
>>> print Thing.objects.values_list('choices', flat=1).query
SELECT "polls_choice"."object_id" FROM "polls_thing" LEFT OUTER JOIN "polls_choice" ON ( "polls_thing"."id" = "polls_choice"."object_id" AND ("polls_choice"."content_type_id" = 10))

Документы на Django, как правило, превосходны, но я не могу понять, почему второй запрос или найти какую-либо документацию об этом поведении - кажется, полностью возвращает данные из неправильной таблицы?

2 ответа

Решение

TL;DR Это была ошибка в Django 1.7, которая была исправлена ​​в Django 1.8.

Изменения пошли непосредственно к мастеру и не прошли период амортизации, что неудивительно, учитывая, что поддерживать обратную совместимость здесь было бы действительно сложно. Еще более удивительным является то, что в примечаниях к выпуску 1.8 об этой проблеме не упоминалось, поскольку исправление изменяет поведение работающего в данный момент кода.

Остальная часть этого ответа является описанием того, как я нашел коммит, используя git bisect run, Это здесь для моей собственной справки больше всего, поэтому я могу вернуться сюда, если мне когда-нибудь понадобится снова разделить большой проект.


Сначала мы настраиваем клон django и тестовый проект, чтобы воспроизвести проблему. Я использовал virtualenvwrapper здесь, но вы можете сделать изоляцию, как вы хотите.

cd /tmp
git clone https://github.com/django/django.git
cd django
git checkout tags/1.7
mkvirtualenv djbisect
export PYTHONPATH=/tmp/django  # get django clone into sys.path
python ./django/bin/django-admin.py startproject djbisect
export PYTHONPATH=$PYTHONPATH:/tmp/django/djbisect  # test project into sys.path
export DJANGO_SETTINGS_MODULE=djbisect.mysettings

создайте следующий файл:

# /tmp/django/djbisect/djbisect/models.py
from django.db import models
from django.contrib.contenttypes.models import ContentType
from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation

class GFKmodel(models.Model):
    content_type = models.ForeignKey(ContentType)
    object_id = models.PositiveIntegerField()
    gfk = GenericForeignKey()

class GRmodel(models.Model):
    related_gfk = GenericRelation(GFKmodel)

также этот:

# /tmp/django/djbisect/djbisect/mysettings.py
from djbisect.settings import *
INSTALLED_APPS += ('djbisect',)

Теперь у нас есть рабочий проект, создайте test_script.py использовать с git bisect run:

#!/usr/bin/env python
import subprocess, os, sys

db_fname = '/tmp/django/djbisect/db.sqlite3'
if os.path.exists(db_fname):
    os.unlink(db_fname)

cmd = 'python /tmp/django/djbisect/manage.py migrate --noinput'
subprocess.check_call(cmd.split())

import django
django.setup()

from django.contrib.contenttypes.models import ContentType
from djbisect.models import GFKmodel, GRmodel

ct = ContentType.objects.get_for_model(GRmodel)
y = GRmodel.objects.create(pk=456)
x = GFKmodel.objects.create(pk=789, content_type=ct, object_id=y.pk)

query1 = GRmodel.objects.values_list('related_gfk', flat=1)
query2 = GRmodel.objects.values_list('related_gfk__pk', flat=1)

print(query1)
print(query2)

print(query1.query)
print(query2.query)

if query1[0] == 789 == query2[0]:
    print('FIXED')
    sys.exit(1)
else:
    print('UNFIXED')
    sys.exit(0)

Скрипт должен быть исполняемым, поэтому добавьте флаг chmod +x test_script.py, Он должен находиться в каталоге, в который клонируется Django, т.е. /tmp/django/test_script.py для меня. Это потому что import django следует сначала выбрать локально извлеченный проект django, а не какую-либо версию из пакетов сайта.

Пользовательский интерфейс git bisect был разработан, чтобы выяснить, где появились ошибки, поэтому обычные префиксы "плохо" и "хорошо" возвращаются назад, когда вы пытаетесь узнать, когда исправлена ​​определенная ошибка. Это может показаться несколько перевернутым, но тестовый скрипт должен завершиться с успехом (код возврата 0), если ошибка присутствует, и он должен завершиться неудачей (с ненулевым кодом возврата), если ошибка исправлена. Это сбило меня с толку несколько раз!

git bisect start --term-new=fixed --term-old=unfixed
git bisect fixed tags/1.8
git bisect unfixed tags/1.7
git bisect run ./test_script.py

Таким образом, этот процесс выполняет автоматический поиск, который в конечном итоге находит коммит, в котором была исправлена ​​ошибка. Это займет некоторое время, потому что между Django 1.7 и Django 1.8 было много коммитов. Он разделил пополам 1362 ревизий, примерно 10 шагов, и в итоге вывел:

1c5cbf5e5d5b350f4df4aca6431d46c767d3785a is the first fixed commit
commit 1c5cbf5e5d5b350f4df4aca6431d46c767d3785a
Author: Anssi Kääriäinen <akaariai@gmail.com>
Date:   Wed Dec 17 09:47:58 2014 +0200

    Fixed #24002 -- GenericRelation filtering targets related model's pk

    Previously Publisher.objects.filter(book=val) would target
    book.object_id if book is a GenericRelation. This is inconsistent to
    filtering over reverse foreign key relations, where the target is the
    related model's primary key.

Это именно тот коммит, в котором запрос изменился с неверного SQL (который получает данные из неверной таблицы)

SELECT "djbisect_gfkmodel"."object_id" FROM "djbisect_grmodel" LEFT OUTER JOIN "djbisect_gfkmodel" ON ( "djbisect_grmodel"."id" = "djbisect_gfkmodel"."object_id" AND ("djbisect_gfkmodel"."content_type_id" = 8) )

в правильную версию:

SELECT "djbisect_gfkmodel"."id" FROM "djbisect_grmodel" LEFT OUTER JOIN "djbisect_gfkmodel" ON ( "djbisect_grmodel"."id" = "djbisect_gfkmodel"."object_id" AND ("djbisect_gfkmodel"."content_type_id" = 8) )

Конечно, из хеша коммитов мы можем легко найти запрос на получение и билет на github. Надеемся, что это может помочь кому-то еще и в один прекрасный день - разделение на Django может быть сложно настроить из-за миграций!

Комментарий - слишком поздно для ответа - большинство удалено

Не важным результатом обратного несовместимого исправления проблемы #24002 является то, что GenericRelatedObjectManager (например, things) перестал работать для набора запросов на долгое время, и его можно было использовать только для фильтров и т. д.

>>> choice.things.all()
TypeError: unhashable type: 'GenericRelatedObjectManager'
# originally before 1c5cbf5e5:  [<Thing: Thing object>]

Это было исправлено пол года спустя #24940 в версии 1.8.3 и в основной ветке. Проблема была не важна, потому что общее название thing работает легче без запроса (choice.thing), и не ясно, что это использование задокументировано или недокументировано.

документы: Обратные родовые отношения:

настройка related_query_name создает отношение из связанного объекта обратно к этому. Это позволяет запрашивать и фильтровать связанный объект.

Было бы хорошо, если бы можно было использовать конкретное имя отношения вместо общего. С примером из документов: taged_item.bookmarks более читабельно, чем taged_item.content_object, но это не стоило бы потрудиться для его реализации.

Другие вопросы по тегам