Djangoで、前のデータ・次のデータを取得する

Twitterでシェア FaceBookでシェア はてなブックマークでシェア

Python - Django
2018年11月22日21:15に更新(約21日前)
2018年11月13日0:42に作成(約31日前)

概要

ブログでは、「前の記事」「次の記事」といったリンクをよく見かけます。

ブログに限らず、前のデータ・次のデータを取得したいケースはよくあるものです。今回はそれを実装していきます。

サンプルモデル

以下のようなモデルを例に説明していきます。

class Post(models.Model):
    title = models.CharField('タイトル', max_length=255)
    created_at = models.DateTimeField('作成日', auto_now_add=True)

    def __str__(self):
        # インタラクティブシェルで見やすいようにしています。
        return f'pk:{self.pk} title:{self.title} created_at:{self.created_at}'

今回ですが、python manage.py shellでの対話シェル で試していきます。

データを4件足しました。おはよう、こんにちは、こんばんは、おやすみが記事タイトルです。

>>> from app.models import Post
>>> Post.objects.create(title='おはよう')
<Post: pk:5 title:おはよう created_at:2018-11-12 15:38:35.539682+00:00>
>>> Post.objects.create(title='こんにちは')
<Post: pk:6 title:こんにちは created_at:2018-11-12 15:38:40.033824+00:00>
>>> Post.objects.create(title='こんばんは')
<Post: pk:7 title:こんばんは created_at:2018-11-12 15:38:44.794430+00:00>
>>> Post.objects.create(title='おやすみ')
<Post: pk:8 title:おやすみ created_at:2018-11-12 15:38:48.371262+00:00>

最新の記事は「おやすみ」で、「おやすみ」の記事から見れば、前の記事は「こんばんは」です。

「こんばんは」の記事から見るならば、前の記事は「こんにちは」で、次の記事は「おやすみ」になります。

日付で判断する場合

モデルのDateField又はDateTimeFieldを基準に前のデータ・次のデータを取得したいならば、get_previous_by_フィールド名get_next_by_フィールド名が使えます。

DateFieldDateTimeFieldnull=Trueの指定があると使えないので注意してください。

「こんばんは」の記事から見て、次と前のデータが正しく取得できますね。

>>> post = Post.objects.get(pk=7)
>>> post.get_previous_by_created_at()
<Post: pk:6 title:こんにちは created_at:2018-11-12 15:38:40.033824+00:00>
>>> post.get_next_by_created_at()
<Post: pk:8 title:おやすみ created_at:2018-11-12 15:38:48.371262+00:00>

title='おはよう'のようにして、条件を加えることもできます。以下は、前のデータのうち、titleがおはようのものという指定になります。

>>> post.get_previous_by_created_at(title='おはよう')
<Post: pk:5 title:おはよう created_at:2018-11-12 15:38:35.539682+00:00>

次・前のデータや、その条件で見つからない場合は'DoesNotExist'例外が創出されます。

>>> post.get_next_by_created_at(title='おはよう')
Traceback (most recent call last):
  File "C:\python37\lib\site-packages\django\db\models\base.py", line 900, in _get_next_or_previous_by_FIELD
    return qs[0]
  File "C:\python37\lib\site-packages\django\db\models\query.py", line 303, in __getitem__
    return qs._result_cache[0]
IndexError: list index out of range

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "<console>", line 1, in <module>
  File "C:\python37\lib\site-packages\django\db\models\base.py", line 902, in _get_next_or_previous_by_FIELD
    raise self.DoesNotExist("%s matching query does not exist." % self.__class__._meta.object_name)
app.models.Post.DoesNotExist: Post matching query does not exist.

ビューから使う場合

ビューで使う場合は、前・次のデータがない場合に備えて例外をキャッチする必要があるでしょう。

        try:
            prev = post.get_previous_by_created_at()
        except Post.DoesNotExist:
            prev = None

テンプレートで使う場合

今回紹介した2つのメソッドは、DateFieldDateTimeFieldを持つモデルインスタンスならば無条件で使えます。なので、モデルインスタンスがテンプレートへ渡されている場合...具体例を出すと、DetailView等を使っている場合はテンプレートで{{ post }}のように書けるので、そのまま{{ post.get_previous_by_created_at }}と書けます。

例えばブログならば、詳細画面のテンプレートで以下のように書けそうに思います(これは問題があるコードです)。

<a href="{% url 'app:detail' post.get_previous_by_created_at.pk %}">前の記事</a>
<a href="{% url 'app:detail' post.get_next_by_created_at.pk %}">次の記事</a>

前や次のデータがない場合にDoesNotExist例外が上がるという話をしましたが、テンプレートでは基本的に例外は無視されます...なので、前・次のデータがない場合は{% app:detail 1 %}ではなく{% app:detail (空文字) %}となってしまい、NoReverseMatch例外となってしまいます。

やるべきことは、前・次のデータがあるときだけ、前・次記事のリンクを作成するという処理です。以下のコードは問題なく動作します。前のデータがなければ、前記事へのリンクは表示されません(これは及第点のコード、パフォーマンス面で若干問題があります)。

  {% if post.get_previous_by_created_at %}
    <a href="{% url 'app:detail' post.get_previous_by_created_at.pk %}">前の記事</a>
  {% endif %}

上記のコードでは、{% if post.get_previous_by_created_at %}で先にデータがあるかを確認しています。データがあれば、改めてpost.get_previous_by_created_at.pkとします。しかし、これだと前のデータを取得するというSQLを2度呼ぶことになります。

ここまでくるのに長くなりましたが、Djangoのテンプレートタグwithを使えばスマートに解決できます。

  {% with prev=post.get_previous_by_created_at %}
    {% if prev %}
      <a href="{% url 'app:detail' prev.pk %}">前の記事</a>
    {% endif %}
  {% endwith %}


  {% with next=post.get_next_by_created_at %}
    {% if next %}
      <a href="{% url 'app:detail' next.pk %}">次の記事</a>
    {% endif %}
  {% endwith %}

withはテンプレート内で変数めいたものを定義できます。公式ドキュメントより抜粋

複雑な表現の変数の値をキャッシュし、簡単な名前で参照できるようにします。呼出しコストの高いメソッド (例えばデータベースを操作するようなメソッド) に何度もアクセスする際に便利です。

pkで判断する場合

今まで紹介したのは日付で判断する場合に使える手法です。pkや、その他のフィールドで判断するには自分でメソッドを定義する必要があります。

models.pyのPostモデルに、以下のメソッドを足します。

    def get_previous_by_pk(self):
        """前のデータを取得する。"""
        return type(self).objects.filter(pk__lt=self.pk).last()

    def get_next_by_pk(self):
        """次のデータを取得する。"""
        return type(self).objects.filter(pk__gt=self.pk).first()

type(self)は、インスタンスからクラスオブジェクトを取得するイディオムです。なので、今回のコードはPost.objects.filter(pk__lt=self.pk).last()というコードになります。type(self)でちょっとだけ汎用的なコードです。

まずget_previous_by_pk()から説明します。pk__lt=self.pkpkが、self.pk未満のものという指定になります。「おやすみ」のpkは8だったので、pkが8未満のPostをすべて取得することになります。以下は、Djangoの対話シェルで実際に試した結果です。ちゃんと8未満のデータが取れていますね。

>>> Post.objects.filter(pk__lt=8)
<QuerySet [<Post: pk:5 title:おはよう created_at:2018-11-12 15:38:35.539682+00:00>, <Post: pk:6 title:こんにちは created_at:2018-11-12 15:38:40.033824+00:00>, <Post: pk:7 title:こんばんは created_at:2018-11-12 15:38:44.794430+00:00>]>

このQuerysetの、一番最後がお目当てのデータです。こういった場合、Querysetのlast()メソッドで最後のデータを取得できます。

>>> Post.objects.filter(pk__lt=8).last()
<Post: pk:7 title:こんばんは created_at:2018-11-12 15:38:44.794430+00:00>

次のデータ取得処理も流れは同じです。pk__gt=self.pkで、pkがself.pkより大きいものという指定です。「おはよう」の記事はpkが5でしたが、この場合はpkが5より上のデータが取得できます。Djangoシェルでの試した結果です。

>>> Post.objects.filter(pk__gt=5)
<QuerySet [<Post: pk:6 title:こんにちは created_at:2018-11-12 15:38:40.033824+00:00>, <Post: pk:7 title:こんばんは created_at:2018-11-12 15:38:44.794430+00:00>, <Post: pk:8 title:おやすみ created_at:2018-11-12 15:38:48.371262+00:00>]>

5の次のデータは当然6ですね。お目当てはQuerySetの最初です。この場合はfirst()が使えます。

>>> Post.objects.filter(pk__gt=5).first()
<Post: pk:6 title:こんにちは created_at:2018-11-12 15:38:40.033824+00:00>

テンプレートでもちゃんと使うことができます。

  {% with prev=post.get_previous_by_pk %}
    {% if prev %}
      <a href="{% url 'app:detail' prev.pk %}">前の記事</a>
    {% endif %}
  {% endwith %}


  {% with next=post.get_next_by_pk %}
    {% if next %}
      <a href="{% url 'app:detail' next.pk %}">次の記事</a>
    {% endif %}
  {% endwith %}

自分で作ったget_previous_by_pk()get_next_by_pk()ですが、Djangoに用意されていたget_next_by_フィールド名()get_previous_by_フィールド名と違って見つからない場合は空のQuerySetを返します。この違いが気になる方は、Querysetが空なら例外を送出...のような実装にするとよいでしょう。

    def get_previous_by_pk(self):
        """前のデータを取得する。"""
        queryset = type(self).objects.filter(pk__lt=self.pk).last()
        if queryset:
            return queryset
        else:
            raise self.DoesNotExist("%s matching query does not exist." % self.__class__._meta.object_name)

    def get_next_by_pk(self):
        """次のデータを取得する。"""
        queryset = type(self).objects.filter(pk__gt=self.pk).first()
        if queryset:
            return queryset
        else:
            raise self.DoesNotExist("%s matching query does not exist." % self.__class__._meta.object_name)
Twitterでシェア FaceBookでシェア はてなブックマークでシェア

記事にコメントする