コンテンツにスキップ

複数フィールドクエリ#

2024 iThome鉄人レース

これはDjango Ninjaシリーズチュートリアルの第27回です。

前回は、Django ORMのQオブジェクトとDjango NinjaのFilterSchemaについて学びましたが、後者はまだ半分しか学んでいないように感じます。

多く議論されたのは、view関数におけるFilterSchemaのパラメータ定義方法でした。これは確かに非常に重要ですが、FilterSchemaの一部に過ぎません。

今回は残りの内容を補完します:

  1. FilterSchemaの完成:「より本格的な」書き方を使用して、FilterSchemaの真の力を解放します。
  2. より高度なフィールドクエリ機能の実装:複数フィールドのクエリ——日付範囲のフィルタリング。
  3. 第20回で学んだ「複数フィールド間の検証」の追加実装:クエリパラメータの日付範囲が有効かどうかを検証します。

今回も情報満載の記事になりそうです。前置きはこのくらいにして、さっそく始めましょう!

本文中のすべてのコード変更は、こちらのPRを参照してください。

GitHubサンプルプロジェクト#

👉 Django-Ninja-Tutorial


1. クエリロジックをFilterSchemaに移行する#

前回のコード実装を覚えていますか?

FilterSchemaを追加で定義したにもかかわらず、view関数内のコードは減るどころか、逆に増えてしまいました!(2つのフィールドを同時にクエリする必要があるため、クエリロジックも増えましたが)

@router.get(...)
@paginate(CustomPagination)
def get_posts(
    request: HttpRequest,
    filters: PostFilterSchema = Query(),  # FilterSchemaを使用
) -> QuerySet[Post]:
    """
    記事リストの取得
    """
    posts = Post.objects.all()
    if filters.query:
        q = Q(title__icontains=filters.query) | \
            Q(content__icontains=filters.query)
        posts = posts.filter(q)
    return posts

これは全く意味がわかりませんね🐸

それは、FilterSchemaの使い方が間違っているからです!

FilterSchemaの「正しい」使い方#

クエリロジックはできるだけFilterSchema内にカプセル化すべきです。そうすることで、view関数がより簡潔になり、「関心の分離」の効果を達成できます。

より合理的な書き方——クエリロジックをFilterSchemaに移行する方法を見てみましょう:

class PostFilterSchema(FilterSchema):
    query: str | None = Field(
        None,
        q=['title__icontains', 'author__username__icontains'],
        min_length=2,
        max_length=10,
    )

主な変更点はqueryフィールドのField部分です。今回、q=パラメータの内容が追加されました:

q=["title__icontains", "author__name__icontains"]

見覚えがありますよね?その通り、これらは実際にはQオブジェクトの条件文です。Django Ninjaは背後で自動的にQオブジェクトを呼び出してこれらのクエリを実行します。

Mypyからの警告#

q=パラメータを使用すると、Mypyが再び警告を出します:

Unexpected keyword argument "q" for "Field"

この警告は間違っていません。なぜなら、PydanticのFieldには確かにこのパラメータは存在しないからです。これはDjango Ninjaが独自に実装したものです。

これを無視するか、必要なコメントを追加することができます。

view関数の簡略化#

このようにすれば、view関数は以下のように書くだけで済みます:

...
def get_posts(
    request: HttpRequest,
    filters: PostFilterSchema = Query(),
) -> QuerySet[Post]:
    """
    記事リストの取得
    """
    posts = Post.objects.select_related('author')
    posts = filters.filter(posts)
    return posts

かなり簡単になりましたよね?

クエリロジックがview関数から「分離」されたため、view関数の責務が単一になり、保守しやすくなりました。


2. 複数フィールドクエリ:日付フィルタリング機能の追加#

新しい要件:記事のタイトルや作者名でクエリできることに加えて、「投稿日」でのフィルタリングも追加します!

新しいURLクエリパラメータを2つ導入します:

  • start_date
  • end_date

これら2つは、Postモデルのcreated_atフィールド(つまり投稿日)をクエリし、フィルタリングするために使用され、特定の時間範囲内の記事データを絞り込みます。

さらに、両者は「両方指定するか、どちらも指定しない(all or nothing)」という要件もあります。両方を省略することはできますが、片方だけは指定できません。日付範囲を検索するには、開始日終了日がそろっている必要があるからです。

これは同時に、典型的な「複数フィールド」クエリでもあります。

コードの追加#

これが上記のロジックを追加した後のFilterSchemaです:

class PostFilterSchema(FilterSchema):
    query: str | None = Field(
        None, q=["title__icontains", "author__username__icontains"])
    start_date: str | None = Field(None, q="created_at__gte")
    end_date: str | None = Field(None, q="created_at__lte")

ここで、start_dateend_dateはどちらもモデルフィールドcreated_atに対するクエリ条件です。

したがって、created_at__gtecreated_at__lteを使用してフィルタリングロジックを記述し(これらはそれぞれ対応するQオブジェクトに対応します)、条件に一致するデータを絞り込みます。

では、view関数はどうでしょうか?ご想像の通り——全く変更する必要はありません

これがFilterSchemaを使用するメリットです。

APIドキュメントのレンダリング問題#

興味深いことに、これらのクエリパラメータにドキュメントの例を追加しようとして、次のように書くと:

start_date: str | None = Field(
    None, q='created_at__gte', examples=['2021-01-01']

APIドキュメントを見ると次のようになります:

😱 Could not render Parameters, see the console.

しかし、example='2021-01-01'と書くと成功します。

これはおそらくDjango NinjaとPydanticの統合におけるバグでしょう。ひとまずこれはスルーしましょう!

クライアントクエリの例#

特定の期間内の記事をクエリしたい場合は、以下のURLクエリパラメータを使用できます:

?start_date=2023-01-01&end_date=2023-01-31

これにより、2023年1月のすべての記事を簡単にクエリできます。

ちなみに、日付の時間が指定されていない場合、デフォルトでは0時0分0秒になります。そのため、同じ日を入力すると何も見つかりません。

これは改善または再調整が必要な詳細であり、一般的なアプローチはプログラム内部でend_dateに1日追加することですが、私は直接両者を同じにできないようにすることを選びました(笑)。実際にどうするかは要件次第です。

単純な期間クエリに加えて、ある作者の特定の期間内の記事をクエリすることもできます。例えば作者Aliceの場合:

?start_date=2023-01-01&end_date=2023-01-31&query=alice

結果には、2023年1月のAliceのすべての記事が表示されます。


FilterSchemaのデフォルトクエリ条件関係#

この部分は特に紹介しておく必要があります。ドキュメントによると、デフォルトでは:

  • Field-level expressions are joined together using OR operator.
  • The fields themselves are joined together using AND operator.

つまり、単一のフィールド内の複数のQステートメントは、互いにORの関係にあります。例えば上記のqueryの場合:

q=['title__icontains', 'author__username__icontains']

記事のタイトル「または」作者名をクエリできます。

一方、異なるフィールドの条件(両方存在する場合)はANDの関係になります——同時に満たす必要があります。したがって、作者名と日付範囲の条件は同時に満たされなければなりません。

これらのデフォルトロジックは自由に変更できます。詳細は上記のドキュメントを参照してください。


3. 日付範囲の検証#

この例では、フィールドクエリに加えて、ユーザーが入力した開始日が終了日よりも前であることを確認する必要があります。

さらに、2つのフィールドのクエリ値は、両方指定するか両方省略する必要があります(両方省略した場合は検証不要)。

この要件は見覚えがありますよね?第20回で言及した「複数フィールド間の検証」ではないでしょうか?

その通りです。Pydanticのmodel_validatorを通じてこれを実現します。これにより、検証プロセス中に入力データに対するカスタムロジックのチェックを行うことができます。

Pydanticのmodel_validatorを使用した日付範囲の検証の実装#

コードが少し多いので、重要な部分を直接見てみましょう:

class PostFilterSchema(FilterSchema):
    ...
    start_date: str | None = Field(None, q='created_at__gte')
    end_date: str | None = Field(None, q='created_at__lte')

    @model_validator(mode='after')
    def check_date_range(self) -> Self:
        # 開始日と終了日がどちらもNoneの場合は、チェックを行わない
        if self.start_date is None and self.end_date is None:
            return self

        if not all([self.start_date, self.end_date]):
            raise ValueError('開始日と終了日は同時に提供するか、同時に提供しないかのどちらかでなければなりません')

        try:
            start_date_dt = datetime.strptime(self.start_date, '%Y-%m-%d')
            end_date_dt = datetime.strptime(self.end_date, '%Y-%m-%d')
        except ValueError:
            raise ValueError('日付の形式が無効です。YYYY-MM-DDである必要があります')

        if start_date_dt > end_date_dt:
            raise ValueError('開始日は終了日よりも前でなければなりません')

        return self
クエリ条件に対して、Pydanticのmodel_validatorを使用して複数フィールド間の検証を行い、ユーザーが入力した日付が有効かつ合理的であることを保証します。

実際、複数フィールド間の検証は多くの詳細を考慮する必要があることが多く、そうしないと見落としが発生し、間接的に新しいバグを生み出す可能性があります。

この例は典型的なケースです。

日付の形式が正しいか、日付範囲が合理的か、2つの日付フィールドが同時に存在するか同時に空であるかなど、さまざまな入力の可能性を全体的に考慮する必要があります。

緻密な検証ロジックはAPIの信頼性を向上させ、無効または不合理な入力によるシステムの予期せぬ動作を防ぎます。粗いロジックはその逆となります。

エラーレスポンスのテスト#

PS:ここでのエラーのスローについて、サンプルコードではまだValueErrorを使用しており、DjangoのValidationErrorには変更していません。(最新版では修正済み)

しかし、以下のレスポンスは不必要な重複を減らすためにDjangoのValidationErrorシミュレートしています。

  1. 開始日のみを入力した場合:(フロントエンドで通常制限されるため、発生確率は低いです)
{
    "detail": "開始日と終了日は同時に提供するか、同時に提供しないかのどちらかでなければなりません"
}
  1. 無効な日付を入力した場合(例:2023-02-30):
{
    "detail": "日付の形式が無効です。YYYY-MM-DDである必要があります"
}
  1. 無効な日付範囲を入力した場合(例:start_date=2023-01-31&end_date=2023-01-01):
{
    "detail": "開始日は終了日よりも前でなければなりません"
}

まとめと次のステップ#

この2つの記事では、FilterSchemaの効果的な使い方を紹介し、複数フィールドのクエリと日付のフィルタリングを完成させ、model_validatorを使用してデータ検証を強化し、クエリロジックの正確性を確保する方法を実演しました。

また、これらの応用シナリオのサンプルコードも確認し、読者が各ステップの目的と効果をよりよく理解できるようにしました。

次の章では、Django Ninjaにおける認証(Authentication)メカニズムを探り、pytestを使用した単体テストの方法を紹介します。これらはバックエンド開発において不可欠な要素です。