『実践Django Pythonによる本格Webアプリケーション開発』学習メモ
嵌り記録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>
試しに、スニペットの登録実行時のステータスコードを確認したところ、
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
解決した!
原因
相対パスの末尾に「/」が付いてなかった
誤:「/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>
嵌り記録2
該当箇所
57p Pygmentsを使った構文ハイライト
内容
Pygmentsのテンプレートフィルターが適用されない
備考
部品化したテンプレートで指定したcssが適用されてない?
snippet_detail.html
{% block extraheader %}
<style>{% pygments_css %}</style>
{% endblock %}
共通テンプレートにpygment_cssの読み込み記述を加えたら反映された。
部品化したテンプレートで指定したcssが適用されてない、という仮説は正しそう
解決した!
原因
共通テンプレートにextraheaderのブロックを記述していなかった
解決策
base.html のheadタグにextraheader のブロックを追加
<head>
(・・・中略・・・)
{% block extraheader %}
{% endblock %}
</head>
宿題:コメント機能の実装
やること
- Modelの追加
- Formクラスの追加
- ビュー関数の追加
- ルーティング設定の追加
- テンプレートファイルの作成
テストコードは気力があったら書く
Modelは書籍通りに書いた後、Formクラスを書く
forms.py
class CommentForm(forms.ModelForm):
class Meta:
model = Comment
fields = ('text',)
次にビュー関数を作成
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})
ルーティングの設定
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"), #追加
]
テンプレートファイルの作成
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 %}
一応完成したが、Modelのtextのデータ型がTEXTのせいか、
入力項目がtextareaになってデカい
ここはcssでサイズを制御するところかな…
DjangoのORMって複合主キーが定義出来ないのか…
どうやら、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='')
Django のORMって連関エンティティ(Many-to-Many)用のマイグレーションを勝手に作ってくれるんだ
地味に便利だなこれ
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 っていうフィールドが無い
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()
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()
django-extensions が便利そうだったのでインストールした
手順
- インストール
pip install django-extensions
- 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', #追加
]
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)
>>>
「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
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文の発行が一回で済んでいる
「N件のデータ取得の前に1件のSELECT文が発行されるから1+N問題のほうが実態と合ってる」
→それはそう
テーブル結合の方法についての分かりやすい図
Joinのアルゴリズム
- Nested Loop Join
- Hash Join
- Sort Merge Join
MySQLはNested Loop Join を採用
嵌り記録3
該当箇所
86p上部
内容
_('Draft') 部分でエラーが出る
原因
不明 typo?
workaround
'Draft' に変更したらエラー消えた
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]:
モデルマネージャーと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]:
嵌り記録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]:
原因
やはり下のやつが影響してた(今の今までエラーにならなかったのが不思議)
対策
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}'
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]:
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",
]
デバッグ情報が出るようになった!
以下の記事を参考にdjango-silk もインストールした
嵌り記録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 をダウンロードし、パスを通す
-
SQLite公式からsqlite-tools-win32 をダウンロード
https://www.sqlite.org/download.html
-
圧縮ファイルを解凍
-
解凍して出てきたフォルダを適当な場所に置く
※画像では「C:\Program Files」に置いてるけど「C:\Program Files (x86)」のほうが適切
-
「Winキー + S」→「環境変数」で検索し、「環境変数を編集」を起動
-
「Path」を選択し、「編集」
-
sqliteのフォルダへのパスを追加
-
再度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>
2.4 インデックスによる効率的なデータの取り出し
使用DBがsqlite3 からMySQLに変わるので、移行メモ
-
MySQL をインストール
-
MySQL へのパスを通す
- 「Winキー+S」→環境変数で検索し、「環境変数を編集」
- Pathを選択し、「編集」
- MySQL とMySQL Shell のbin のフォルダパスを追加
- Powershell を起動し、「mysql -u root -p」と打ってログインできれば成功
-
データベース「snippet」を作る
CREATE DATABASE snippet;
- MySQLドライバをインストール
pip install mysqlclient
- sqlite内のデータをdump
python manage.py dumpdata --indent=1 > dump.json
-
VS Code でdump.jsonを開き、文字コードをUTF-8で保存し直す
-
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',
}
}
- マイグレーションを実行
python manage.py makemigrations
python manage.py migrate
- テーブルが作成されていることを確認
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>
- ダンプファイルからデータをインポート
python manage.py loaddata dump.json
- データが登録されていることを確認
SELECT * FROM snippets\G
メモ:MySQL を使用する場合、makemigrations を行ってもデータベースは作成されない。
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]:
セカンダリーインデックスなし/ありで実行計画を確認
インデックスなし
ポイント
- 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>
メモ:大量のデータ更新を行うバッチは、インデックスの更新の手間を省くために一度インデックスを削除して再度インデックスを作り直す場合がある