Open38

『実践Django Pythonによる本格Webアプリケーション開発』学習メモ

orphanizeeorphanizee

嵌り記録1

該当箇所

40p スニペットの登録・編集ページ

内容

書籍通りにforms.py/views.pyを実装後、以下のテストコードを実行すると
test_create_snippetがERROR、test_render_creation_formがFAILになる

テストコード

class CreateSnippetTest(TestCase):
    def setUp(self):
        self.user = UserModel.objects.create(
            username="test_user",
            email="test@example.com",
            password="top_secret_pass0001",
        )
        self.client.force_login(self.user)

    def test_should_resolve_snippet_new(self):
        found = resolve("/snippets/new/")
        self.assertEqual(snippets_new, found.func)

    def test_render_creation_form(self):
        response = self.client.get("/snippets/new")
        self.assertContains(response, "スニペットの登録", status_code=200)

    def test_create_snippet(self):
        data = {'title': 'title', 'code': 'コード', 'description': '解説'}
        self.client.post('/snippets/new', data)
        snippet = Snippet.objects.get(title='title')
        self.assertEqual('コード', snippet.code)
        self.assertEqual('解説', snippet.description)

実行結果

PS C:\dev\study\Python\djangosnippets> python manage.py test
Found 9 test(s).
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
EF.......
======================================================================
ERROR: test_create_snippet (snippets.tests.CreateSnippetTest)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "C:\dev\study\Python\djangosnippets\snippets\tests.py", line 93, in test_create_snippet
    snippet = Snippet.objects.get(title='title')
  File "C:\Users\tai\AppData\Local\Programs\Python\Python310\lib\site-packages\django\db\models\manager.py", line 85, in manager_method
    return getattr(self.get_queryset(), name)(*args, **kwargs)
  File "C:\Users\tai\AppData\Local\Programs\Python\Python310\lib\site-packages\django\db\models\query.py", line 650, in get
    raise self.model.DoesNotExist(
snippets.models.Snippet.DoesNotExist: Snippet matching query does not exist.

======================================================================
FAIL: test_render_creation_form (snippets.tests.CreateSnippetTest)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "C:\dev\study\Python\djangosnippets\snippets\tests.py", line 88, in test_render_creation_form
    self.assertContains(response, "スニペットの登録", status_code=200)
  File "C:\Users\tai\AppData\Local\Programs\Python\Python310\lib\site-packages\django\test\testcases.py", line 645, in assertContains
    text_repr, real_count, msg_prefix = self._assert_contains(
  File "C:\Users\tai\AppData\Local\Programs\Python\Python310\lib\site-packages\django\test\testcases.py", line 608, in _assert_contains
    self.assertEqual(
AssertionError: 301 != 200 : Couldn't retrieve content: Response code was 301 (expected 200)

----------------------------------------------------------------------
Ran 9 tests in 0.054s

FAILED (failures=1, errors=1)
Destroying test database for alias 'default'...
PS C:\dev\study\Python\djangosnippets>
orphanizeeorphanizee

試しに、スニペットの登録実行時のステータスコードを確認したところ、
302ではなく301が返ってきていた。なんだこれ?

テストコード

    def test_create_snippet(self):
        data = {'title': 'title', 'code': 'コード', 'description': '解説'}
        response = self.client.post('/snippets/new', data)
        self.assertEqual(response.status_code, 302)

結果

======================================================================
FAIL: test_create_snippet (snippets.tests.CreateSnippetTest)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "C:\dev\study\Python\djangosnippets\snippets\tests.py", line 93, in test_create_snippet
    self.assertEqual(response.status_code, 302)
AssertionError: 301 != 302
orphanizeeorphanizee

解決した!

原因

相対パスの末尾に「/」が付いてなかった
誤:「/snippets/new」
正:「/snippets/new/」

テストコード(修正後)

    def test_render_creation_form(self):
        # response = self.client.get("/snippets/new")
        response = self.client.get("/snippets/new/") # '/snippets/new/'に修正
        self.assertContains(response, "スニペットの登録", status_code=200)

    def test_create_snippet(self):
        data = {'title': 'title', 'code': 'コード', 'description': '解説'}
        # self.client.post('/snippets/new', data)
        self.client.post('/snippets/new/', data) # '/snippets/new/'に修正
        snippet = Snippet.objects.get(title='title')
        self.assertEqual('コード', snippet.code)
        self.assertEqual('解説', snippet.description)

実行結果

PS C:\dev\study\Python\djangosnippets> python manage.py test
Found 9 test(s).
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
.........
----------------------------------------------------------------------
Ran 9 tests in 0.066s

OK
Destroying test database for alias 'default'...
PS C:\dev\study\Python\djangosnippets>
orphanizeeorphanizee

嵌り記録2

該当箇所

57p Pygmentsを使った構文ハイライト

内容

Pygmentsのテンプレートフィルターが適用されない
適用されない

備考

部品化したテンプレートで指定したcssが適用されてない?

snippet_detail.html

{% block extraheader %}
<style>{% pygments_css %}</style>
{% endblock %}
orphanizeeorphanizee

共通テンプレートにpygment_cssの読み込み記述を加えたら反映された。
部品化したテンプレートで指定したcssが適用されてない、という仮説は正しそう

orphanizeeorphanizee

解決した!

原因

共通テンプレートにextraheaderのブロックを記述していなかった

解決策

base.html のheadタグにextraheader のブロックを追加

  <head>
    (・・・中略・・・)
    {% block extraheader %}
    {% endblock %}
  </head>
orphanizeeorphanizee

宿題:コメント機能の実装

やること

  • Modelの追加
  • Formクラスの追加
  • ビュー関数の追加
  • ルーティング設定の追加
  • テンプレートファイルの作成

テストコードは気力があったら書く

orphanizeeorphanizee

Modelは書籍通りに書いた後、Formクラスを書く

forms.py

class CommentForm(forms.ModelForm):
    class Meta:
        model = Comment
        fields = ('text',)
orphanizeeorphanizee

次にビュー関数を作成
Comment.objects.get(commented_to=snippet_id) で
コメント一覧を取ろうとしたら、0件の時にエラーになった
views.py

@login_required
def comment_new(request, snippet_id):
    # コメント対象のSnippetを取得
    snippet = get_object_or_404(Snippet, pk=snippet_id)
    # コメントを取得(Comment.objects.get()だと0件の場合エラーになるので注意)
    comments = Comment.objects.filter(commented_to=snippet_id)
    if request.method == 'POST':
        form = CommentForm(request.POST)
        if form.is_valid():
            new_comment = form.save(commit=False)
            new_comment.commented_to = snippet # ここ、スニペットのidじゃなくてobjectをセットするんだ…
            new_comment.commented_by = request.user
            new_comment.save()
            return redirect(comment_new, snippet_id=snippet_id)
    else:
        form = CommentForm()
    return render(request, "snippets/comment_new.html", {'snippet': snippet, 'form': form, 'comments': comments})
orphanizeeorphanizee

ルーティングの設定

urls.py

urlpatterns = [
    path("new/", views.snippets_new, name="snippets_new"),
    path("<int:snippet_id>/", views.snippet_detail, name="snippet_detail"),
    path("<int:snippet_id>/edit/", views.snippet_edit, name="snippet_edit"),
    path("<int:snippet_id>/comments/new", views.comment_new,name="comment_new"), #追加
]
orphanizeeorphanizee

テンプレートファイルの作成

comment_new.html

{% extends 'base.html' %}
{% load pygmentize %}
{% load django_bootstrap5 %}

{% block extraheader %}
<style>{% pygments_css %}</style>
{% endblock %}

{% block main %}
<h2>{{ snippet.title }} by {{ snippet.created_by.username }}</h2>

<div class="snippet-date">
  投稿日: {{ snippet.created_at|date:"DATETIME_FORMAT" }}
  {% if user.is_authenticated and snippet.created_by.id == user.id %}
  <a href="{% url 'snippet_edit' snippet.id %}">編集</a>
  {% endif %}
</div>
<div class="source-code">
  {{ snippet.code|pygmentize:"python3" }}
</div>

<p>{{ snippet.description }}</p>

<div class="container border">
    {% for comment in comments %}
    <tr>
      <p>{{ comment.text }}</p>
      <small>by {{ comment.commented_by.username }} さん {{ snippet.created_at }}</small>
    {% endfor %}

    <form method="POST">
        {% csrf_token %}
        {% bootstrap_form form %}
        {% bootstrap_button button_type="submit" content="コメント" %}
    </form>
</div>


{% endblock %}
orphanizeeorphanizee

一応完成したが、Modelのtextのデータ型がTEXTのせいか、
入力項目がtextareaになってデカい
ここはcssでサイズを制御するところかな…

orphanizeeorphanizee

どうやら、Modelの定義に「null=True」を書かないと、
create時にフィールドの値を指定しなくてもnullにはならないっぽい

Snippet.objects.filter(description__isnull=True) では検索ヒットせず、
Snippet.objects.filter(description='') でヒットした

以下確認コマンド

python manage.py shell
from snippets.models import Snippet
from snippets.models import Snippet

Snippet.objects.filter(description__isnull=True)
user = get_user_model().objects.get(username='admin')

Snippet.objects.create(title='スニペット1', created_by=user)
Snippet.objects.filter(description__isnull=True)
Snippet.objects.filter(description='')
orphanizeeorphanizee

Django のORMって連関エンティティ(Many-to-Many)用のマイグレーションを勝手に作ってくれるんだ
地味に便利だなこれ

orphanizeeorphanizee

78pにあるこのモデルって__str__()関数が間違えてね?
titleってフィールドをクラス内で定義していないと思うんだが

class Comment(models.Model):
    text = models.TextField("本文", blank = False)
    commented_at = models.DateTimeField("投稿日", auto_now_add = True)
    commented_to = models.ForeignKey(Snippet, verbose_name = "スニペット",
                                     on_delete=models.CASCADE)
    commented_by = models.ForeignKey(settings.AUTH_USER_MODEL,
                                     verbose_name="投稿者",
                                     on_delete=models.CASCADE)

    class Meta:
        db_table = "Comments"

    def __str__(self):
        return f'{self.pk} {self.title}' # title っていうフィールドが無い
orphanizeeorphanizee

78pに記載のSnippetモデルは第一章で作成したものよりもカラムが減ってるので、
そのまま81p以降のモデルAPI操作を行うとエラーになる。
かといって78pのSnippetモデルに変更すると、migrationやWebアプリがエラーで動かなくなる。

対策として、SnippetモデルとCommentモデルはそのまま(元々あったカラムは消さない)で
Tagモデルを追加した後に、migration を実行してからモデルAPIを操作する。

モデル

models.py

from django.db import models
from django.conf import settings

class Snippet(models.Model):
    title = models.CharField('タイトル', max_length=128)
    code = models.TextField('コード', blank=True)
    description = models.TextField('説明', blank=True)
    created_by = models.ForeignKey(settings.AUTH_USER_MODEL,
                                   verbose_name="投稿者",
                                   on_delete=models.CASCADE)
    created_at = models.DateTimeField("投稿日", auto_now_add=True)
    updated_at = models.DateTimeField("更新日", auto_now=True)
    class Meta:
        db_table = 'snippets'

    def __str__(self):
        return f'{self.pk} {self.title}'

class Comment(models.Model):
    text = models.TextField("本文", blank = False)
    commented_at = models.DateTimeField("投稿日", auto_now_add = True)
    commented_to = models.ForeignKey(Snippet, verbose_name = "スニペット",
                                     on_delete=models.CASCADE)
    commented_by = models.ForeignKey(settings.AUTH_USER_MODEL,
                                     verbose_name="投稿者",
                                     on_delete=models.CASCADE)

    class Meta:
        db_table = "Comments"

    def __str__(self):
        return f'{self.pk} {self.title}'

class Tag(models.Model):
    name = models.CharField("タグ名", max_length=32)
    snippets = models.ManyToManyField(Snippet, related_name='tags',
                                      related_query_name='tag')

    class Meta:
        db_table = "tags"

    def __str__(self):
        return f'{self.pk} {self.name}'

マイグレーションコマンド

python manage.py makemigrations
python manage.py migrate

モデルAPI操作コマンド

python manage.py shell

from django.contrib.auth.models import User
from snippets.models import Snippet

admin_user = User.objects.get(id=1)
s = Snippet(title="モデルAPIを触ってみる", code='print("hello")', description="モデルAPIは・・・", created_by=admin_user)

s.save()
s.title="タイトルを適当に変更"
s.save()
s
s.delete()
orphanizeeorphanizee

82pのCommentモデル操作コマンドを上記に合わせて修正
※上記のshell を一旦exit()で落としてから実行

from snippets.models import Comment, Snippet, Tag
from django.contrib.auth.models import User

admin_user = User.objects.get(id=1)
s = Snippet(title="モデルAPIを触ってみる", code='print("hello")', description="モデルAPIは・・・", created_by=admin_user)
s.save()

c = Comment(text="参考になりました!", commented_to=s, commented_by=admin_user)
c.save()
orphanizeeorphanizee

django-extensions が便利そうだったのでインストールした

手順

  1. インストール
pip install django-extensions
  1. settings.py に追加
INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'snippets.apps.SnippetsConfig',
    'django_bootstrap5',
    'pygments_renderer',
    'accounts.apps.AccountsConfig',
    'django_extensions', #追加
]
orphanizeeorphanizee

django-extension の機能の一つ「shell_plus」を実行
Snippet など自分が作ったモデルが最初からimport されている

PS C:\dev\study\Python\djangosnippets> python manage.py shell_plus
# Shell Plus Model Imports
from django.contrib.admin.models import LogEntry
from django.contrib.auth.models import Group, Permission, User
from django.contrib.contenttypes.models import ContentType
from django.contrib.sessions.models import Session
from snippets.models import Comment, Snippet, Tag
# Shell Plus Django Imports
from django.core.cache import cache
from django.conf import settings
from django.contrib.auth import get_user_model
from django.db import transaction
from django.db.models import Avg, Case, Count, F, Max, Min, Prefetch, Q, Sum, When
from django.utils import timezone
from django.urls import reverse
from django.db.models import Exists, OuterRef, Subquery
Python 3.10.5 (tags/v3.10.5:f377153, Jun  6 2022, 16:14:13) [MSC v.1929 64 bit (AMD64)] on win32
Type "help", "copyright", "credits" or "license" for more information.
(InteractiveConsole)
>>>
orphanizeeorphanizee

「ipython もインストールしておくと補完が効いて便利です」と書いてあったのでインストール

pip install ipython

起動して「from 」と打ってTabキー押すと補完される 確かに便利かも

PS C:\dev\study\Python\djangosnippets> ipython
Python 3.10.5 (tags/v3.10.5:f377153, Jun  6 2022, 16:14:13) [MSC v.1929 64 bit (AMD64)]
Type 'copyright', 'credits' or 'license' for more information
IPython 8.10.0 -- An enhanced Interactive Python. Type '?' for help.

In [1]: from
  abc                 argparse            asttokens           atexit              autopep8
  accounts            array               asynchat            attr                backcall
  aifc                asgiref             asyncio             attrs               base64              >
  antigravity         ast                 asyncore            audioop             bdb
orphanizeeorphanizee

N+1問題を再現

N+1問題とは

ORM 使用時にN件のデータ取得のためにN+1件のSQLが実行されること

再現

snippetモデルにはuserモデルのidだけが含まれており、
userモデルのuser_name は含まれていないため、
User テーブルに対するクエリも発行されている。

In [1]: snippets = Snippet.objects.all()[:5]

In [2]: for s in snippets:
   ...:     print("スニペット:", s.id, s.created_by.username)
   ...:
(0.000) SELECT "snippets"."id", "snippets"."title", "snippets"."code", "snippets"."description", "snippets"."created_by_id", "snippets"."created_at", "snippets"."updated_at" FROM "snippets" LIMIT 5; args=(); alias=default
(0.000) SELECT "auth_user"."id", "auth_user"."password", "auth_user"."last_login", "auth_user"."is_superuser", "auth_user"."username", "auth_user"."first_name", "auth_user"."last_name", "auth_user"."email", "auth_user"."is_staff", "auth_user"."is_active", "auth_user"."date_joined" FROM "auth_user" WHERE "auth_user"."id" = 1 LIMIT 21; args=(1,); alias=default
スニペット: 1 admin
(0.000) SELECT "auth_user"."id", "auth_user"."password", "auth_user"."last_login", "auth_user"."is_superuser", "auth_user"."username", "auth_user"."first_name", "auth_user"."last_name", "auth_user"."email", "auth_user"."is_staff", "auth_user"."is_active", "auth_user"."date_joined" FROM "auth_user" WHERE "auth_user"."id" = 1 LIMIT 21; args=(1,); alias=default
スニペット: 3 admin
(0.000) SELECT "auth_user"."id", "auth_user"."password", "auth_user"."last_login", "auth_user"."is_superuser", "auth_user"."username", "auth_user"."first_name", "auth_user"."last_name", "auth_user"."email", "auth_user"."is_staff", "auth_user"."is_active", "auth_user"."date_joined" FROM "auth_user" WHERE "auth_user"."id" = 1 LIMIT 21; args=(1,); alias=default
スニペット: 4 admin
(0.000) SELECT "auth_user"."id", "auth_user"."password", "auth_user"."last_login", "auth_user"."is_superuser", "auth_user"."username", "auth_user"."first_name", "auth_user"."last_name", "auth_user"."email", "auth_user"."is_staff", "auth_user"."is_active", "auth_user"."date_joined" FROM "auth_user" WHERE "auth_user"."id" = 1 LIMIT 21; args=(1,); alias=default
スニペット: 5 admin
(0.000) SELECT "auth_user"."id", "auth_user"."password", "auth_user"."last_login", "auth_user"."is_superuser", "auth_user"."username", "auth_user"."first_name", "auth_user"."last_name", "auth_user"."email", "auth_user"."is_staff", "auth_user"."is_active", "auth_user"."date_joined" FROM "auth_user" WHERE "auth_user"."id" = 1 LIMIT 21; args=(1,); alias=default
スニペット: 6 admin

対策

事前にモデルを結合して取得する(使用するデータがすべて入ってる状態にする)
Django の場合、select_related を使う

In [1]: snippets = Snippet.objects.select_related('created_by').all()[:5]

In [2]: for x in snippets:
   ...:     print(x.id, x.created_by.username)
   ...:
(0.000) SELECT "snippets"."id", "snippets"."title", "snippets"."code", "snippets"."description", "snippets"."created_by_id", "snippets"."created_at", "snippets"."updated_at", "auth_user"."id", "auth_user"."password", "auth_user"."last_login", "auth_user"."is_superuser", "auth_user"."username", "auth_user"."first_name", "auth_user"."last_name", "auth_user"."email", "auth_user"."is_staff", "auth_user"."is_active", "auth_user"."date_joined" FROM "snippets" INNER JOIN "auth_user" ON ("snippets"."created_by_id" = "auth_user"."id") LIMIT 5; args=(); alias=default
1 admin
3 admin
4 admin
5 admin
6 admin

In [3]:

SELECT文の発行が一回で済んでいる

orphanizeeorphanizee

「N件のデータ取得の前に1件のSELECT文が発行されるから1+N問題のほうが実態と合ってる」
→それはそう

orphanizeeorphanizee

Joinのアルゴリズム

  • Nested Loop Join
  • Hash Join
  • Sort Merge Join

MySQLはNested Loop Join を採用

orphanizeeorphanizee

嵌り記録3

該当箇所

86p上部

内容

_('Draft') 部分でエラーが出る

原因

不明 typo?

workaround

'Draft' に変更したらエラー消えた

orphanizeeorphanizee

2.2.4 モデルマネージャーやQuerySetのカスタマイズ

models.py を編集

# このクラスを追加
class DraftSnippetManager(models.Manager):
    def get_queryset(self):
        return super().get_queryset().filter(is_draft=True)

class Snippet(models.Model):
    title = models.CharField('タイトル', max_length=128)
    code = models.TextField('コード', blank=True)
    description = models.TextField('説明', blank=True)
    is_draft = models.BooleanField('Draft', default=True) # このフィールドを追加
    created_by = models.ForeignKey(settings.AUTH_USER_MODEL,
                                   verbose_name="投稿者",
                                   on_delete=models.CASCADE)
    created_at = models.DateTimeField("投稿日", auto_now_add=True)
    updated_at = models.DateTimeField("更新日", auto_now=True)

    objects = models.Manager()
    drafts = DraftSnippetManager()

    class Meta:
        db_table = 'snippets'

    def __str__(self):
        return f'{self.pk} {self.title}'

マイグレーションを実行

python manage.py makemigrations
python manage.py migrate

追加したモデルマネージャーを使ってみる

PS C:\dev\study\Python\djangosnippets> python manage.py shell_plus
# Shell Plus Model Imports
from django.contrib.admin.models import LogEntry
from django.contrib.auth.models import Group, Permission, User
from django.contrib.contenttypes.models import ContentType
from django.contrib.sessions.models import Session
from snippets.models import Comment, Snippet, Tag
# Shell Plus Django Imports
from django.core.cache import cache
from django.conf import settings
from django.contrib.auth import get_user_model
from django.db import transaction
from django.db.models import Avg, Case, Count, F, Max, Min, Prefetch, Q, Sum, When
from django.utils import timezone
from django.urls import reverse
from django.db.models import Exists, OuterRef, Subquery
Python 3.10.5 (tags/v3.10.5:f377153, Jun  6 2022, 16:14:13) [MSC v.1929 64 bit (AMD64)]
Type 'copyright', 'credits' or 'license' for more information
IPython 8.10.0 -- An enhanced Interactive Python. Type '?' for help.

In [1]: Snippet.drafts.all()
Out[1]: (0.000) SELECT "snippets"."id", "snippets"."title", "snippets"."code", "snippets"."description", "snippets"."is_draft", "snippets"."created_by_id", "snippets"."created_at", "snippets"."updated_at" FROM "snippets" WHERE "snippets"."is_draft" LIMIT 21; args=(); alias=default
<QuerySet [<Snippet: 1 タイトル(更新するよ)>, <Snippet: 3 何故か通らないテスト>, <Snippet: 4 テストコード>, <Snippet: 5 ffff>, <Snippet: 6 空の説明>, <Snippet: 7 空文字の説明>, <Snippet: 8 スニペット1>, <Snippet: 9 スニペット2>, <Snippet: 10 スニペット3>, <Snippet: 11 >, <Snippet: 13 タイトルを適当に変更>]>

In [2]:
orphanizeeorphanizee

モデルマネージャーとQuerySet両方をカスタムしたパターン

models.py

class SnippetQuerySet(models.QuerySet):
    def drafts(self):
        return self.filter(is_draft=True)

    def recent_updates(self):
        return self.order_by("-updated_at")
    recent_updates.queryset_only = True

class DraftSnippetManager(models.Manager):
    def get_queryset(self):
        return super().get_queryset().filter(is_draft=True)

class Snippet(models.Model):
    title = models.CharField('タイトル', max_length=128)
    code = models.TextField('コード', blank=True)
    description = models.TextField('説明', blank=True)
    is_draft = models.BooleanField('Draft', default=True)
    created_by = models.ForeignKey(settings.AUTH_USER_MODEL,
                                   verbose_name="投稿者",
                                   on_delete=models.CASCADE)
    created_at = models.DateTimeField("投稿日", auto_now_add=True)
    updated_at = models.DateTimeField("更新日", auto_now=True)

    #QuerySetを受け取ってManagerクラスを生成するため、最後に()が必要
    objects = DraftSnippetManager().from_queryset(SnippetQuerySet)()
    # drafts = DraftSnippetManager()

カスタムしたメソッドを呼び出せることを確認
また、recent_updates はqueryset_only = Trueなので
Snippet.objects.recent_updates().all() は失敗している

PS C:\dev\study\Python\djangosnippets> python manage.py shell_plus
# Shell Plus Model Imports
from django.contrib.admin.models import LogEntry
from django.contrib.auth.models import Group, Permission, User
from django.contrib.contenttypes.models import ContentType
from django.contrib.sessions.models import Session
from snippets.models import Comment, Snippet, Tag
# Shell Plus Django Imports
from django.core.cache import cache
from django.conf import settings
from django.contrib.auth import get_user_model
from django.db import transaction
from django.db.models import Avg, Case, Count, F, Max, Min, Prefetch, Q, Sum, When
from django.utils import timezone
from django.urls import reverse
from django.db.models import Exists, OuterRef, Subquery
Python 3.10.5 (tags/v3.10.5:f377153, Jun  6 2022, 16:14:13) [MSC v.1929 64 bit (AMD64)]
Type 'copyright', 'credits' or 'license' for more information
IPython 8.10.0 -- An enhanced Interactive Python. Type '?' for help.

In [1]: Snippet.objects.drafts().all()
Out[1]: (0.000) SELECT "snippets"."id", "snippets"."title", "snippets"."code", "snippets"."description", "snippets"."is_draft", "snippets"."created_by_id", "snippets"."created_at", "snippets"."updated_at" FROM "snippets" WHERE ("snippets"."is_draft" AND "snippets"."is_draft") LIMIT 21; args=(); alias=default
<SnippetQuerySet [<Snippet: 1 タイトル(更新するよ)>, <Snippet: 3 何故か通らないテスト>, <Snippet: 4 テストコード>, <Snippet: 5 ffff>, <Snippet: 6 空の説明>, <Snippet: 7 空文字の説明>, <Snippet: 8 スニペット1>, <Snippet: 9 スニペット2>, <Snippet: 10 スニペット3>, <Snippet: 11 >, <Snippet: 13 タイトルを適当に変更>]>

In [2]: Snippet.objects.all().recent_updates()
Out[2]: (0.000) SELECT "snippets"."id", "snippets"."title", "snippets"."code", "snippets"."description", "snippets"."is_draft", "snippets"."created_by_id", "snippets"."created_at", "snippets"."updated_at" FROM "snippets" WHERE "snippets"."is_draft" ORDER BY "snippets"."updated_at" DESC LIMIT 21; args=(); alias=default
<SnippetQuerySet [<Snippet: 13 タイトルを適当に変更>, <Snippet: 11 >, <Snippet: 10 スニペット3>, <Snippet: 9 スニペット2>, <Snippet: 8 スニペット1>, <Snippet: 7 空文字の説明>, <Snippet: 6 空の説明>, <Snippet: 5 ffff>, <Snippet: 4 テストコード>, <Snippet: 3 何故か通らないテスト>, <Snippet: 1 タイトル(更新するよ)>]>

In [3]: Snippet.objects.recent_updates().all()
---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
Cell In[3], line 1
----> 1 Snippet.objects.recent_updates().all()

AttributeError: 'DraftSnippetManagerFromSnippetQuerySet' object has no attribute 'recent_updates'

In [4]:
orphanizeeorphanizee

嵌り記録4

該当箇所

92p prefetch_related による(N+1問題の)回避策

内容

実習を順番に進めた状態でサンプルコードを動かすとエラーになる

In [1]: for s in Snippet.objects.all():
   ...:     print(f"Comments on snippet {s.id}: {s.comment_set.all()}")
   ...:
(0.000) SELECT "snippets"."id", "snippets"."title", "snippets"."code", "snippets"."description", "snippets"."is_draft", "snippets"."created_by_id", "snippets"."created_at", "snippets"."updated_at" FROM "snippets" WHERE "snippets"."is_draft"; args=(); alias=default
(0.000) SELECT "Comments"."id", "Comments"."text", "Comments"."commented_at", "Comments"."commented_to_id", "Comments"."commented_by_id" FROM "Comments" WHERE "Comments"."commented_to_id" = 1 LIMIT 21; args=(1,); alias=default
---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
Cell In[1], line 2
      1 for s in Snippet.objects.all():
----> 2     print(f"Comments on snippet {s.id}: {s.comment_set.all()}")

File ~\AppData\Local\Programs\Python\Python310\lib\site-packages\django\db\models\query.py:373, in QuerySet.__repr__(self)
    371 if len(data) > REPR_OUTPUT_SIZE:
    372     data[-1] = "...(remaining elements truncated)..."
--> 373 return "<%s %r>" % (self.__class__.__name__, data)

File ~\AppData\Local\Programs\Python\Python310\lib\site-packages\django\db\models\base.py:606, in Model.__repr__(self)
    605 def __repr__(self):
--> 606     return "<%s: %s>" % (self.__class__.__name__, self)

File C:\dev\study\Python\djangosnippets\snippets\models.py:50, in Comment.__str__(self)
     49 def __str__(self):
---> 50     return f'{self.pk} {self.title}'

AttributeError: 'Comment' object has no attribute 'title'

In [2]:

原因

やはり下のやつが影響してた(今の今までエラーにならなかったのが不思議)
https://zenn.dev/link/comments/b9f8ca9c22a20c

対策

models.py を修正(return f'{self.pk} {self.title}' の部分)

class Comment(models.Model):
    text = models.TextField("本文", blank = False)
    commented_at = models.DateTimeField("投稿日", auto_now_add = True)
    commented_to = models.ForeignKey(Snippet, verbose_name = "スニペット",
                                     on_delete=models.CASCADE)
    commented_by = models.ForeignKey(settings.AUTH_USER_MODEL,
                                     verbose_name="投稿者",
                                     on_delete=models.CASCADE)

    class Meta:
        db_table = "Comments"

    def __str__(self):
        return f'{self.pk} {self.text}'
        # return f'{self.pk} {self.title}'
orphanizeeorphanizee

Django ではOne-to-Many のケースではselect_related は使えない

In [3]: for s in Snippet.objects.select_related('comment_set').all():
   ...:     print(f"Comments on snippet {s.id}: {s.comment_set.all()}")
   ...:
---------------------------------------------------------------------------
FieldError                                Traceback (most recent call last)
Cell In[3], line 1
----> 1 for s in Snippet.objects.select_related('comment_set').all():
      2     print(f"Comments on snippet {s.id}: {s.comment_set.all()}")

(中略)

FieldError: Invalid field name(s) given in select_related: 'comment_set'. Choices are: created_by

In [4]:

なので、prefetch_related を使う
prefetch_related はPython 側で結合を行う(SQLでJOINはしない)

In [4]: for s in Snippet.objects.prefetch_related('comment_set').all():
   ...:     print(f"Comments on snippet {s.id}: {s.comment_set.all()}")
   ...:
(0.000) SELECT "snippets"."id", "snippets"."title", "snippets"."code", "snippets"."description", "snippets"."is_draft", "snippets"."created_by_id", "snippets"."created_at", "snippets"."updated_at" FROM "snippets" WHERE "snippets"."is_draft"; args=(); alias=default
(0.000) SELECT "Comments"."id", "Comments"."text", "Comments"."commented_at", "Comments"."commented_to_id", "Comments"."commented_by_id" FROM "Comments" WHERE "Comments"."commented_to_id" IN (1, 3, 4, 5, 6, 7, 8, 9, 10, 11, 13); args=(1, 3, 4, 5, 6, 7, 8, 9, 10, 11, 13); alias=default
Comments on snippet 1: <QuerySet [<Comment: 1 aaa>, <Comment: 2 つぇst>, <Comment: 3 aaa>]>
Comments on snippet 3: <QuerySet []>
Comments on snippet 4: <QuerySet []>
Comments on snippet 5: <QuerySet []>
Comments on snippet 6: <QuerySet []>
Comments on snippet 7: <QuerySet []>
Comments on snippet 8: <QuerySet []>
Comments on snippet 9: <QuerySet []>
Comments on snippet 10: <QuerySet []>
Comments on snippet 11: <QuerySet []>
Comments on snippet 13: <QuerySet [<Comment: 4 参考になりました!>]>

In [5]:
orphanizeeorphanizee

django-debug-toolbar をインストール

python -m pip install django-debug-toolbar

settings.py

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'snippets.apps.SnippetsConfig',
    'django_bootstrap5',
    'pygments_renderer',
    'accounts.apps.AccountsConfig',
    'django_extensions',
    'debug_toolbar', #追加
]

urls.pyに以下を追加

urlpatterns = [
    path('', top, name='top'),
    path('snippets/', include('snippets.urls')),
    path('admin/', admin.site.urls),
    path('accounts/', include("accounts.urls")),
    path('__debug__/', include('debug_toolbar.urls')), #追加
]

settings.py に以下を追加

MIDDLEWARE = [
    'django.middleware.security.SecurityMiddleware',
    'django.contrib.sessions.middleware.SessionMiddleware',
    'django.middleware.common.CommonMiddleware',
    'django.middleware.csrf.CsrfViewMiddleware',
    'django.contrib.auth.middleware.AuthenticationMiddleware',
    'django.contrib.messages.middleware.MessageMiddleware',
    'django.middleware.clickjacking.XFrameOptionsMiddleware',
    'debug_toolbar.middleware.DebugToolbarMiddleware', #追加
]

settings.py の末尾にに以下をすべて追加

INTERNAL_IPS = [
    "127.0.0.1",
]

デバッグ情報が出るようになった!

orphanizeeorphanizee

嵌り記録5

該当箇所

98p

内容

Django のdbshell が起動できない

PS C:\dev\study\Python\djangosnippets> python manage.py db_shell
Unknown command: 'db_shell'. Did you mean dbshell?
Type 'manage.py help' for usage.
PS C:\dev\study\Python\djangosnippets> python manage.py dbshell
CommandError: You appear not to have the 'sqlite3' program installed or on your path.
PS C:\dev\study\Python\djangosnippets>

原因

  • ×db_shell 〇dbshell
  • sqlite3 へのパスが通っていなかった

対策

sql3 をダウンロードし、パスを通す

  1. SQLite公式からsqlite-tools-win32 をダウンロード
    https://www.sqlite.org/download.html

  2. 圧縮ファイルを解凍

  3. 解凍して出てきたフォルダを適当な場所に置く
    ※画像では「C:\Program Files」に置いてるけど「C:\Program Files (x86)」のほうが適切

  4. 「Winキー + S」→「環境変数」で検索し、「環境変数を編集」を起動

  5. 「Path」を選択し、「編集」

  6. sqliteのフォルダへのパスを追加

  7. 再度dbshellを起動してみる
    ※powershell またはコマンドプロンプトを立ち上げなおす必要あり

PS C:\dev\study\Python\djangosnippets> python manage.py dbshell
SQLite version 3.40.1 2022-12-28 14:03:47
Enter ".help" for usage hints.
sqlite>
orphanizeeorphanizee

2.4 インデックスによる効率的なデータの取り出し

使用DBがsqlite3 からMySQLに変わるので、移行メモ

  1. MySQL をインストール

  2. MySQL へのパスを通す

    1. 「Winキー+S」→環境変数で検索し、「環境変数を編集」
    2. Pathを選択し、「編集」
    3. MySQL とMySQL Shell のbin のフォルダパスを追加
    4. Powershell を起動し、「mysql -u root -p」と打ってログインできれば成功
  3. データベース「snippet」を作る

CREATE DATABASE snippet;
  1. MySQLドライバをインストール
pip install mysqlclient
  1. sqlite内のデータをdump
python manage.py dumpdata --indent=1 > dump.json
  1. VS Code でdump.jsonを開き、文字コードをUTF-8で保存し直す

  2. settings.py にDB設定を追加

DATABASES = {
    'default': {
        # 'ENGINE': 'django.db.backends.sqlite3',
        # 'NAME': BASE_DIR / 'db.sqlite3',
        'ENGINE': 'django.db.backends.mysql',
        'NAME': 'snippet',
        'USER': 'root',
        'PASSWORD': 'xxxxxxxxxx',
        'HOST': 'localhost',
        'PORT': '3306',
    }
}
  1. マイグレーションを実行
python manage.py makemigrations
python manage.py migrate
  1. テーブルが作成されていることを確認
use snippet;
show tables;
mysql> use snippet;
Database changed
mysql> show tables;
+----------------------------+
| Tables_in_snippet          |
+----------------------------+
| auth_group                 |
| auth_group_permissions     |
| auth_permission            |
| auth_user                  |
| auth_user_groups           |
| auth_user_user_permissions |
| comments                   |
| django_admin_log           |
| django_content_type        |
| django_migrations          |
| django_session             |
| silk_profile               |
| silk_profile_queries       |
| silk_request               |
| silk_response              |
| silk_sqlquery              |
| snippets                   |
| tags                       |
| tags_snippets              |
+----------------------------+
19 rows in set (0.00 sec)

mysql>
  1. ダンプファイルからデータをインポート
python manage.py loaddata dump.json
  1. データが登録されていることを確認
SELECT * FROM snippets\G
orphanizeeorphanizee

メモ:MySQL を使用する場合、makemigrations を行ってもデータベースは作成されない。

orphanizeeorphanizee

2.4.1 InnoDBストレージエンジン

まず2万行以上のデータをExcelで作成し投入
(もっと効率良いテストデータの作り方あるよな・・?)

次にsnippets テーブルのインデックスを確認
主キーと外部キーで1件ずつ登録されている

SHOW INDEX FROM snippets\G
mysql> SHOW INDEX FROM snippets\G
*************************** 1. row ***************************
        Table: snippets
   Non_unique: 0
     Key_name: PRIMARY
 Seq_in_index: 1
  Column_name: id
    Collation: A
  Cardinality: 11
     Sub_part: NULL
       Packed: NULL
         Null:
   Index_type: BTREE
      Comment:
Index_comment:
      Visible: YES
   Expression: NULL
*************************** 2. row ***************************
        Table: snippets
   Non_unique: 1
     Key_name: snippets_snippet_created_by_id_a14886ce_fk_auth_user_id
 Seq_in_index: 1
  Column_name: created_by_id
    Collation: A
  Cardinality: 1
     Sub_part: NULL
       Packed: NULL
         Null:
   Index_type: BTREE
      Comment:
Index_comment:
      Visible: YES
   Expression: NULL
2 rows in set (0.00 sec)

この状態でセカンダリーインデックスが存在しない
created_at でソートをかけたSELECT文の実行速度を見る
(0.016)

In [1]: snippets = list(Snippet.objects.order_by("created_at").all()[:100])
(0.000)
                SELECT VERSION(),
                       @@sql_mode,
                       @@default_storage_engine,
                       @@sql_auto_is_null,
                       @@lower_case_table_names,
                       CONVERT_TZ('2001-01-01 01:00:00', 'UTC', 'UTC') IS NOT NULL
            ; args=None; alias=default
(0.000) SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED; args=None; alias=default
(0.016) SELECT `snippets`.`id`, `snippets`.`title`, `snippets`.`code`, `snippets`.`description`, `snippets`.`is_draft`, `snippets`.`created_by_id`, `snippets`.`created_at`, `snippets`.`updated_at` FROM `snippets` WHERE `snippets`.`is_draft` = 1 ORDER BY `snippets`.`created_at` ASC LIMIT 100; args=(True,); alias=default

今度はcreated_at にセカンダリーインデックスを張って実行速度を確認してみる
models.py

class Snippet(models.Model):
    title = models.CharField('タイトル', max_length=128)
    code = models.TextField('コード', blank=True)
    description = models.TextField('説明', blank=True)
    is_draft = models.BooleanField('Draft', default=True)
    created_by = models.ForeignKey(settings.AUTH_USER_MODEL,
                                   verbose_name="投稿者",
                                   on_delete=models.CASCADE)
    created_at = models.DateTimeField("投稿日", db_index=True, auto_now_add=True) #変更
    updated_at = models.DateTimeField("更新日", auto_now=True)

    objects = DraftSnippetManager().from_queryset(SnippetQuerySet)()

マイグレーションを実行

python manage.py makemigrations
python manage.py migrate

インデックスが張られていることを確認

SHOW INDEX FROM snippets\G
*************************** 3. row ***************************
        Table: snippets
   Non_unique: 1
     Key_name: snippets_created_at_17f94f62
 Seq_in_index: 1
  Column_name: created_at
    Collation: A
  Cardinality: 11
     Sub_part: NULL
       Packed: NULL
         Null:
   Index_type: BTREE
      Comment:
Index_comment:
      Visible: YES
   Expression: NULL
3 rows in set (0.00 sec)

mysql>

クエリを発行 超速!!
(0.000)

In [1]: snippets = list(Snippet.objects.order_by("created_at").all()[:100])
(0.000)
                SELECT VERSION(),
                       @@sql_mode,
                       @@default_storage_engine,
                       @@sql_auto_is_null,
                       @@lower_case_table_names,
                       CONVERT_TZ('2001-01-01 01:00:00', 'UTC', 'UTC') IS NOT NULL
            ; args=None; alias=default
(0.000) SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED; args=None; alias=default
(0.000) SELECT `snippets`.`id`, `snippets`.`title`, `snippets`.`code`, `snippets`.`description`, `snippets`.`is_draft`, `snippets`.`created_by_id`, `snippets`.`created_at`, `snippets`.`updated_at` FROM `snippets` WHERE `snippets`.`is_draft` = 1 ORDER BY `snippets`.`created_at` ASC LIMIT 100; args=(True,); alias=default

In [2]:
orphanizeeorphanizee

セカンダリーインデックスなし/ありで実行計画を確認

インデックスなし

ポイント

  • type がALL
  • rows が全行(100件取得なのに)
  • key がNULL
mysql> ALTER TABLE snippets DROP INDEX snippets_created_at_17f94f62;
Query OK, 0 rows affected (0.01 sec)
Records: 0  Duplicates: 0  Warnings: 0

mysql> EXPLAIN SELECT * FROM snippets ORDER BY created_at ASC LIMIT 100\G
*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: snippets
   partitions: NULL
         type: ALL
possible_keys: NULL
          key: NULL
      key_len: NULL
          ref: NULL
         rows: 20387
     filtered: 100.00
        Extra: Using filesort
1 row in set, 1 warning (0.00 sec)

インデックスあり

ポイント

  • type がindex
  • rows が100
  • key に使ったインデックス名がある
mysql> ALTER TABLE snippets ADD INDEX snippets_created_at_17f94f62(created_at);
Query OK, 0 rows affected (0.09 sec)
Records: 0  Duplicates: 0  Warnings: 0

mysql> EXPLAIN SELECT * FROM snippets ORDER BY created_at ASC LIMIT 100\G
*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: snippets
   partitions: NULL
         type: index
possible_keys: NULL
          key: snippets_created_at_17f94f62
      key_len: 8
          ref: NULL
         rows: 100
     filtered: 100.00
        Extra: NULL
1 row in set, 1 warning (0.00 sec)

mysql>
orphanizeeorphanizee

メモ:大量のデータ更新を行うバッチは、インデックスの更新の手間を省くために一度インデックスを削除して再度インデックスを作り直す場合がある