FilterSchemaの紹介#

これはDjango Ninjaシリーズチュートリアルの第26回です。
「クエリ」はAPIによくある追加要件であり、本質的にはデータのフィルタリング(filtering)と絞り込みです。
記事、商品、ユーザーのいずれを絞り込む場合でも、異なる条件に基づいてデータをフィルタリングし、結果を取得することは、ほとんどのプロジェクトで必須の機能と言えます。
view関数において、クエリを実装する最も簡単な方法は、Django ORMのフィルタリングメソッドを使用することです。例えば、filterメソッドを使用して、特定の条件に基づいてQuerySetを絞り込むことができます。
この方法はシンプルで直接的であり、基本的なクエリ要件に適しています。しかし、限界もあります。フィールドや要件が増えるにつれて、クエリ条件がますます複雑になり、コードが冗長で保守しにくくなる可能性があります。
この問題を解決するため、Django NinjaはFilterSchemaを提供しており、より「構造化」された方法でクエリ条件を定義および管理できるようにしています。
この記事ではFilterSchemaを紹介し、ステップバイステップの実装と解説を通じて、Django NinjaでFilterSchemaを使用して、より柔軟でモジュール化されたAPIクエリ機能を実現する方法を理解してもらいます。
本文中のすべてのコード変更は、こちらのPRを参照してください。
GitHubサンプルプロジェクト#
従来のクエリ方法と問題点#
前回「記事リストの取得」APIについて言及しましたが、〈第11回:リクエスト(三)クエリパラメータ - クエリパラメータ〉で追加した「記事タイトルによるクエリ」機能を覚えていますか?
これが現在のコードの状況です:(クエリパラメータ名titleに注目してください)
...
def get_posts(
request: HttpRequest,
title: None | str = Query(None, min_length=2, max_length=10),
) -> QuerySet[Post]:
"""
記事リストの取得
"""
posts = Post.objects.all()
if title:
posts = posts.filter(
title__icontains=title).select_related('author')
return posts
あの時はDjango ORMのfilterメソッドを使用しました!
最後のselect_related('author')は「N+1」問題を回避するためであり、クエリロジックとは関係ないため、今は無視して構いません。
潜在的な問題#
このような書き方は直感的で、シンプルなクエリ要件には非常に効果的です。
しかし、プロジェクトの規模が拡大し、クエリ要件が複雑になると、次のようなジレンマが生じます:
- コードの重複:複数の場所で類似のフィルタリングを行う必要がある場合、同じフィルタリングロジックを繰り返していることに気づくかもしれません。
- 保守の困難:フィルタリング条件が増えるにつれて、view関数が保守しにくくなる可能性があります。新しいフィルタリング条件を追加するたびに、コードの複数箇所を変更する必要があるかもしれません。
- データの検証と変換:データの検証と変換を手動で処理する必要があり、これはエラーの可能性を高めるだけでなく、開発の複雑さも増大させます。
- 拡張性:範囲クエリや複数条件の組み合わせクエリなど、より複雑なフィルタリング条件をサポートする必要がある場合、ORMクエリを手動で組み立てる方法は非常に面倒で管理しにくくなる可能性があります。
したがって、複雑なクエリの場合、ORMのfilterメソッドを直接使用してクエリ条件を組み立てることはお勧めしません。
新しい要件:作者名も同時にクエリする#
新しい要件は、同じキーワードを使用して、記事のタイトルまたは作者の名前を同時にクエリし、どちらか一方が一致すれば結果に表示するというものです。(どちらか一方が満たされればよく、両方一致しても構いません)
この要件は、iThome鉄人レース公式サイトのこのクエリ機能に似ています:

ただし、私たちは2種類しかクエリできませんが、そこではタイトル、概要、参加者のニックネームの3種類を同時にクエリできます。
しかし本質的には同じです。
従来の方法 + Qオブジェクトによる実装#
従来の方法とDjangoのQオブジェクトを使用して実装すると、クエリは次のようになります:
from django.db.models import Q
...
posts = posts.filter(
Q(title__icontains=title) | Q(author__name__icontains=title)
).select_related('author')
この時、クエリパラメータをtitleと呼ぶのは適切ではありません。なぜなら2つのフィールドをクエリするからです。大丈夫です、後でqueryに変更します。
このコードには2つの重要なポイントがあります:
- クエリがやはり長くなりました!後で新しいクエリ条件が追加されたら、一体どうなることやら……
- この
Qとは何でしょうか?
QはDjango ORMのQオブジェクトであり、複雑なクエリロジックにおいて重要な役割を果たします。そのため、ここで簡単に紹介する必要があります。
Django Qオブジェクトの紹介#
複数条件のクエリ時にプログラムの構造が複雑になる問題を改善するために、DjangoはQオブジェクトを提供しています。
Qオブジェクトを使用すると、論理演算子(&、|など)を使用して条件を結合し、クエリ条件を柔軟に構成できます。これは複雑な条件フィルタリングを処理する際に非常に役立ちます。
例えば、タイトルに「Ninja」を含み、かつ作者名に「Alice」を含む記事をフィルタリングしたい場合は、次のように書きます:
上記の書き方は、実際にはよく見かける次の書き方と等価です:
したがって、通常「AND」の要件ではQオブジェクトを使用しません。
「OR」のクエリ条件こそが、Qの典型的な利用シーンです。
今、条件を「記事のタイトル」または「作者の名前」に「Alice」が含まれることに変更します。|を使用できます:
Qオブジェクトにより、特に複数の選択可能な条件に直面した際に、クエリがより柔軟で明確になります。
FilterSchemaを使用したクエリの改善#
従来のクエリ方法がコードの冗長化を引き起こしやすいことを理解し、Qオブジェクトの基礎を学んだところで、本日の主役であるFilterSchemaの紹介に入ります。
Django Ninjaが提供するFilterSchemaの主な機能は、クエリ文をより構造化、モジュール化し、view関数が冗長で読みにくくなるのを防ぐことです。
さらに、スキーマの検証メソッドと同様に、クエリロジックをview関数から分離することで、「関心の分離」原則もある程度実現しています。
しかし、一気に完成させるのではなく、段階的にコードを改善させてください。
少し回りくどいかもしれませんが、これによりFilterSchemaと複雑なクエリの実装についてより深く理解できるでしょう。
第1版の「改善」#
まずFilterSchemaを使用して、前述の「新しい要件:作者名も同時にクエリする」を実現します。
schemas.pyで新しいスキーマを作成しますが、今回はFilterSchemaです:
# post/schemas.py
from ninja import Field, FilterSchema, Schema
...
class PostFilterSchema(FilterSchema):
query: str | None = Field(None, min_length=2, max_length=10)
このFilterSchemaは、実際には「クエリパラメータ(query parameters)」のためのものです。そのため、そのフィールド(属性)名は、クライアントが使用すべきクエリパラメータ名となります。
「記事のタイトル」と「作者の名前」を同時にクエリするため、queryと名付けました。
次に、これをview関数で使用します:
@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(author__username__icontains=filters.query)
posts = posts.filter(q)
return posts
PS:ここでのプロジェクトのサンプルコードに誤りがあり、2つ目のQクエリが「content__icontains」と誤って記載されています。読者の皆様はご注意ください。次のブランチで修正しています。
この新しいview関数を見て、おそらく心の中でこう思ったでしょう:
冗談でしょ?全然簡単になってないじゃないか!
その通りです。これは単なるFilterSchemaの「未完成品」であるため、使わないよりも冗長に見えます。
それでも、中には理解する価値のある見どころがいくつかあります。
ポイント解説#
q = Q(title__icontains=filters.query) | \
Q(content__icontains=filters.query)
posts = posts.filter(q)
この部分から、Qオブジェクトは単独でさまざまな結合操作を行い、最後にDjangoのfilterメソッドのパラメータとして渡せることがわかります。
Django Ninjaのよくある構文#
この例のような、view関数のパラメータの「構文」は、Django Ninjaでは非常に一般的です:
そして、初心者はこれを見ると「誤解」しやすいです。
なぜなら?Pythonの関数定義がそうであるように、filtersの型はPostFilterSchema(これは問題ありません)で、そのデフォルト値がQuery()だと思ってしまうかもしれないからです。
しかし、そうではありません。
Query()はfiltersパラメータのデフォルト値ではありません。そうでなければ、その型はQueryであるべきではありませんか?
実際、= Query()という指定は人間のためではなく、Django Ninjaに次の内容を伝えるためのものです:
このパラメータの内容は、bodyやpathからではなく、HTTPリクエストのクエリパラメータ(query parameters)から取得すべきです。
このように考えると、とてもわかりやすいです。
Django Ninjaはクエリパラメータから文字列を取得しようとし、それらを分解し(複数のクエリ文字列がある場合)、1つずつPostFilterSchemaに渡して初期化と検証を行います:
- 検証失敗:422を返します。
- 検証成功:スキーマオブジェクトをview関数に関数のパラメータ(ローカル変数)として渡します。
クエリ結果#
これは「Alice」をキーワードとしたクエリ結果です:

30件の記事が見つかり、すべて作者名に「Alice」を含むユーザーからのものです。
まとめと次のステップ#
今回はここまでとします。私たちはすでに、QオブジェクトとFilterSchemaという2つの新しい概念に触れました。
また、Django Ninjaでよく見られるview関数のパラメータの「構文」も分析しました。これはフレームワークの使用方法と習慣を理解するために非常に重要です。
これらの概念を消化するには時間がかかりますが、このような段階的なアプローチは価値があると断言できます。
FilterSchemaの高度な使い方に直接踏み込むよりも、このような段階的な学習方法の方が理解を深めるのに役立ちます。
次回は、なぜQオブジェクトを理解することが重要なのか、そしてFilterSchemaを通じて構造化され「関心の分離」に適合した複数フィールドのクエリを作成する方法について説明します。
それでは、また次回お会いしましょう。