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

2018-11-13 / PythonDjango

概要

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

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

サンプルモデル

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

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モデルに、以下のメソッドを足します。もしpk以外のフィールドにしたい場合は、適宜書き換えてください。

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

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

type(self)は、インスタンスからクラスオブジェクトを取得するイディオムです。なので、今回のコードはPost.objects.filter(pk__lt=self.pk).order_by('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).order_by('pk')
<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).order_by('pk').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).order_by('pk')
<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).order_by('pk').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).order_by('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).order_by('pk').first()
        if queryset:
            return queryset
        else:
            raise self.DoesNotExist("%s matching query does not exist." % self.__class__._meta.object_name)

この記事の関連記事

関連記事はありません。

コメント欄

記事にコメントする

ずーすー

こちらやりたいこととドンピシャの記事で大変助かりました! ありがとうございました! いつも参考にさせて頂いております。

コメントに返信する

Narito Blog読者

いつも楽しんで、大変参考にして読んでいます。

私は最近プログラミングを独学し始めて、練習のためにブログを作っています。

もしよろしければ教えてほしいことがあります。

ブログの発行済み記事のページに「前の記事次の記事」のボタンをつけたいです。

そのためにまずNaritoさんの記事を読みました。 私の作成するものでは、Postモデルにあるpublished_dateが、blank=True、null=Trueになっていますので、 ひとつめの日付を単純に基準にするパターンが使えませんでした。

そこで、pkを使うものを参考にして作成しようとしました。

def get_previous_by_date(self):
    return type(self).objects.filter(published_date__lt=self.published_date).last()
def get_next_by_date(self):
    return type(self).objects.filter(published_date__gt=self.published_date).first()

を作り、テンプレートには、

{% with prev=post.get_previous_by_date %}
{% if prev %}
<a class="btn btn-primary" href="{% url 'app:detail(自分のアプリに合わせてあります) ' pk=prev.pk %}">前の記事</a>
{% endif %}
{% endwith %}

として(nextも同様に)、作成してみました。 すると、ボタンはできたのですが、 おすときちんと順番に遷移しません。 恐らく、draftの段階でpkがつき、publishされた順だと、pkが順になっていないことが原因かとは思うのですが、 なにか解決策はないでしょうか。

発行後の記事ではpkが順になっていないこともありNaritoさんの記事にあった、pkを使用するパターンでもうまくできないです。 アドバイスいただければ幸いです。

よろしくお願いします。

コメントに返信する

なりと

その書いているコードはpkでの並び替えは行っておらず、published_dateを基にしています。

多分、publiched_dateがNullのものがあるのが原因だと思うので、次のようにNullじゃないデータだけ取るようにしてみてください。

def get_previous_by_date(self):
    return type(self).objects.filter(published_date__isnull=False, published_date__lt=self.published_date).last()

また、次のようにprint関数などを使って、どういう順にデータが取れているかを確認する等もしてください。

def get_previous_by_date(self):
    queryset = type(self).objects.filter(published_date__isnull=False, published_date__lt=self.published_date)
        print(queryset)  # どういう順でデータが取れているかを確認
        return queryset.last()
Narito Blog読者

お返事ありがとうございます。 (うまくコードを記述できずに申し訳ないです。表示を直していただきありがとうございます。)

アドバイスどおりprint()で試してみました。

たとえば記事の投稿順を古いほうから、「い(pk=19)、お(pk=22)、う(pk=20)、え(pk=21)、あ(pk=18)」とすると、

queryset = type(self).objects.filter(published_date__isnull=False, published_date__lt=self.published_date)
print(queryset) 

とすると、<Post: い>, <Post: お>, <Post: う>, <Post: え>, <Post: あ>ときちんとでてきます。

queryset = type(self).objects.filter(published_date__isnull=False, published_date__lt=self.published_date)
print(queryset.last())

として、 「あ」のページで前の記事のボタンを押すと、遷移後「お」となり、コマンドラインで「い」が表示されています。 「お」のページで前の記事のボタンを押すと、遷移後「い」となります。

この動き方をするのであれば、「あ」→「え」と遷移し、コマンドラインに「う」が表示されればばっちりだと思うのですが、 うまくいきません。

何度も聞いてしまい非常に申し訳ないのですが、気づかれることはおありでしょうか。 自分でも、いろいろ試してみたいと思っていますので、なんとか自分で解決できればと思っているのですが……。

ご迷惑でしたらその旨お伝えください。

なりと

多分ソースコード見せてくれたほうが早いです。models.pyとテンプレートファイルの内容を教えてください。

なりと

次のように、order_byで一度公開日順に並び替えるようにしてください。

    def get_previous_by_date(self):
        """前のデータを取得する。"""
        return type(self).objects.filter(published_date__lt=self.published_date).order_by('published_date').last()

    def get_next_by_date(self):
        """次のデータを取得する。"""
        return type(self).objects.filter(published_date__gt=self.published_date).order_by('published_date').first()

なりと

発行後の記事ではpkが順になっていないこともありNaritoさんの記事にあった

これが原因でした。失礼しました。

Narito Blog読者

教えていただきありがとうございます。

一度並び替え直さないと、取得のときに順にならないのですね。 自分では気づけなかったと思います。

これからもいろいろと参考にさせていただき、精進していきたいと思っています。

改めて、ありがとうございました。