この投稿は 「Django Advent Calendar 2020 - Qiita」 5日目の記事です。
akiyoko です。
この記事では、Django でデータベースビューを扱う方法について説明します。「Django のコンポーネントとしてのビュー(View)」ではなく、「データベースのビュー」の話です。本記事では区別のために「データベースビュー」と表記することにします。
目次
検証環境
- Windows 10 Home
- Django 3.1
- PostgreSQL 13.1
はじめに
あまり知られていないかもしれませんが、Django でもデータベースビューを扱うことができます。 具体的には、データベースビューに対応するモデルを用意することで、データベースビューのレコードをモデルオブジェクトとして取り扱うことができます(しかし当然ながら、データベースビューへのレコードの登録や更新、削除はできませんのでご注意を)。
ところで、データベースビューを使うと何が嬉しいのでしょうか?
例えば、テーブルの結果を 集計 したり、サブクエリ を使ったり、複数のテーブルを UNION したりするには、Django ORM の API を使って実現できないこともありませんが、コードが複雑になり、思わぬバグが混入する可能性が高くなってしまいます。それならいっそのこと SQL で書いてしまう方が早くて確実だというケースもあるでしょう。そんなときにデータベースビューを使えば、モデル側のコードをすっきりさせることができるのです。 *1
データベースビューに対応するモデル
通常のモデルとの違いは、Meta に「managed = False」を指定するだけです。これで、モデルをマイグレーションの対象外にすることができます。 *2
次の例を見てください。
sales/models.py(モデル)
from django.db import models class SalesResultPerMonth(models.Model): class Meta: managed = False db_table = 'sales_result_per_month' verbose_name = verbose_name_plural = '月別合計売上金額' sales_month = models.DateField('売上月') total_amount = models.PositiveIntegerField('合計売上金額') target_amount = models.PositiveIntegerField('目標売上金額')
モデルの Meta クラスに「managed = False」を指定しています。ちなみにデータベースビューに対応したモデルには「null=True」や「blank=True」などのフィールドオプションは付ける必要はありません。データベースビューには登録や更新ができないため、付けてもあまり意味がないからです。
モデルをマイグレーションの対象外にすると、マイグレーション(migrate)コマンドを実行したときにマイグレーションファイルの内容がデータベースのテーブルに反映されず、テーブルが新たに作成されたり、テーブル構造が変更されたりすることがなくなります。
具体例
簡単な例として、売上実績テーブルから月次の売上金額の合計を集計して、売上目標テーブルの売上目標金額と比較することを取り上げてみます。これをデータベースビューとして利用するイメージは次のようになります。
通常のモデルを作成する
まず、sales アプリケーションに「売上目標モデル」と「売上実績モデル」を次のように定義します。これらはマイグレーション対象となる通常のモデルです。
sales/models.py(モデル)
from django.db import models class SalesTarget(models.Model): """売上目標モデル""" class Meta: db_table = 'sales_target' verbose_name = verbose_name_plural = '売上目標' sales_month = models.DateField('売上月') amount = models.PositiveIntegerField('目標金額') created_at = models.DateTimeField('登録日時', auto_now_add=True) def __str__(self): return f'{self.sales_month:%Y年%m月}' class SalesResult(models.Model): """売上実績モデル""" class Meta: db_table = 'sales_result' verbose_name = verbose_name_plural = '売上実績' sales_date = models.DateField('売上日') amount = models.PositiveIntegerField('売上金額') subject = models.CharField('件名', max_length=255) created_at = models.DateTimeField('登録日時', auto_now_add=True) def __str__(self): return f'{self.sales_date:%Y年%m月%d日} - {self.subject}'
makemigrations コマンドでマイグレーションファイルを作成して、migrate コマンドでマイグレーションを実行すると、次のようなテーブルが作成されます。
(venv) > python manage.py makemigrations sales (venv) > python manage.py migrate sales
データベースビュー作成用の DDL をマイグレーションファイルに書く
データベースビューを作成するための DDL は、マイグレーションファイルに書くのがよいでしょう。次のように「--empty」オプションを付けて makemigrations コマンドを実行すると、空のマイグレーションファイルを作成することができます。
(venv) $ python manage.py makemigrations sales --empty
上記のコマンドを実行すると、次のようなマイグレーションファイルが「sales/migrations」ディレクトリの下に生成されます。
sales/migrations/0002_auto_20201204_2134.py(マイグレーションファイル)
# Generated by Django 3.1.3 on 2020-12-04 12:34 from django.db import migrations class Migration(migrations.Migration): dependencies = [ ('sales', '0001_initial'), ] operations = [ ]
このひな型ファイルを次のように編集して、sales_result_per_month ビューを作成する DDL を書き加えます。
# Generated by Django 3.1.3 on 2020-12-04 12:34 from django.db import migrations class Migration(migrations.Migration): dependencies = [ ('sales', '0001_initial'), ] sql = """ CREATE VIEW sales_result_per_month AS SELECT ROW_NUMBER() OVER() AS id, t.sales_month, t.amount AS target_amount, SUM(r.amount) AS total_amount FROM sales_target t LEFT OUTER JOIN sales_result r ON TO_CHAR(t.sales_month, 'YYYY-MM') = TO_CHAR(r.sales_date, 'YYYY-MM') GROUP BY t.sales_month, t.amount ORDER BY t.sales_month; """ reverse_sql = """ DROP VIEW IF EXISTS sales_result_per_month; """ operations = [ migrations.RunSQL(sql, reverse_sql), ]
RunSQL の第一引数には migrate コマンドが実行されたときに発行する SQL を、第二引数には特定のバージョンのマイグレーションの状態に戻す *3 ときに発行する SQL を指定します(第二引数は省略可)。ちなみにこの SQL は PostgreSQL 向けのものです。
マイグレーションファイルを手動で書くやり方については、次の記事を参考にしてみてください。
参考
マイグレーションを実行すると、データベースビューが作成されます。
(venv) $ python manage.py migrate sales
pgAdmin 4 上で確認すると、このようになっています。
ビューに対応するモデルを作成する
sales_result_per_month ビューに対応するモデルを作成します。ここで Meta クラスに「managed = False」を指定します。繰り返しになりますが、これでマイグレーションの対象から外れます。
sales/models.py(モデル)
from django.db import models class SalesTarget(models.Model): """売上目標モデル""" ...(略)... class SalesResult(models.Model): """売上実績モデル""" ...(略)... class SalesResultPerMonth(models.Model): # 追加 """月別合計売上金額モデル""" class Meta: managed = False db_table = 'sales_result_per_month' verbose_name = verbose_name_plural = '月別合計売上金額' sales_month = models.DateField('売上月') total_amount = models.PositiveIntegerField('合計売上金額') target_amount = models.PositiveIntegerField('目標売上金額')
モデルを作成する際には、次のように
(venv) > python manage.py inspectdb sales_result_per_month
inspectdb という既存のテーブルからモデルを自動生成する Django コマンドを実行した結果を参考にするとよいでしょう。
(出力結果例)
# This is an auto-generated Django model module. # You'll have to do the following manually to clean this up: # * Rearrange models' order # * Make sure each model has one field with primary_key=True # * Make sure each ForeignKey and OneToOneField has `on_delete` set to the desired behavior # * Remove `managed = False` lines if you wish to allow Django to create, modify, and delete the table # Feel free to rename the models, but don't rename db_table values or field names. from django.db import models class SalesResultPerMonth(models.Model): id = models.BigIntegerField(blank=True, null=True) sales_month = models.DateField(blank=True, null=True) target_amount = models.IntegerField(blank=True, null=True) total_amount = models.BigIntegerField(blank=True, null=True) class Meta: managed = False # Created from a view. Don't remove. db_table = 'sales_result_per_month'
管理サイトでデータベースビューの内容を確認する
最後に、このデータベースビューの内容を管理サイト上で確認できるようにしてみましょう。sales/admin.py を次のように編集します。
sales/admin.py(管理サイト用モジュール)
from django.contrib import admin from common.helpers.admin_helper import format_yen, format_yyyy_nen_mm_gatsu from .models import SalesResult, SalesResultPerMonth, SalesTarget class SalesResultAdmin(admin.ModelAdmin): """売上実績モデル用 ModelAdmin""" ############################### # モデル一覧画面のカスタマイズ ############################### list_display = ('sales_date', 'format_amount', 'subject') ordering = ('sales_date', 'created_at') def format_amount(self, obj): return format_yen(obj.amount) format_amount.short_description = '売上金額' format_amount.admin_order_field = 'amount' ############################### # モデル追加・変更画面のカスタマイズ ############################### readonly_fields = ('id', 'created_at') class SalesTargetAdmin(admin.ModelAdmin): """売上目標モデル用 ModelAdmin""" ############################### # モデル一覧画面のカスタマイズ ############################### list_display = ('format_sales_month', 'format_amount') ordering = ('sales_month',) def format_sales_month(self, obj): return format_yyyy_nen_mm_gatsu(obj.sales_month) format_sales_month.short_description = '売上月' format_sales_month.admin_order_field = 'sales_month' def format_amount(self, obj): return format_yen(obj.amount) format_amount.short_description = '目標金額' format_amount.admin_order_field = 'amount' ############################### # モデル追加・変更画面のカスタマイズ ############################### readonly_fields = ('id', 'created_at') class SalesResultPerMonthAdmin(admin.ModelAdmin): """月別合計売上金額モデル用 ModelAdmin""" ############################### # モデル一覧画面のカスタマイズ ############################### list_display = ('format_sales_month', 'format_target_amount', 'format_total_amount') ordering = ('sales_month',) def format_sales_month(self, obj): return format_yyyy_nen_mm_gatsu(obj.sales_month) format_sales_month.short_description = '売上月' format_sales_month.admin_order_field = 'sales_month' def format_target_amount(self, obj): return format_yen(obj.target_amount) format_target_amount.short_description = '目標売上金額' format_target_amount.admin_order_field = 'target_amount' def format_total_amount(self, obj): return format_yen(obj.total_amount) format_total_amount.short_description = '合計売上金額' format_total_amount.admin_order_field = 'total_amount' ############################### # その他のカスタマイズ ############################### def has_add_permission(self, request): return False def has_change_permission(self, request, obj=None): return False def has_delete_permission(self, request, obj=None): return False admin.site.register(SalesTarget, SalesTargetAdmin) admin.site.register(SalesResult, SalesResultAdmin) admin.site.register(SalesResultPerMonth, SalesResultPerMonthAdmin)
管理サイトの月別合計売上金額モデルの一覧画面はこのように表示されます。
何がどうなっているのか?が気になる方は、拙著『現場で使える Django 管理サイトのつくり方』にいろいろと書いてあるので、ぜひお手に取ってご確認くださいませ 🙇
(おまけ)マイグレーションファイルの SQL を複数種類のデータベースに対応させる方法
例示したマイグレーションファイルの SQL は PostgreSQL だけにしか使えません。PostgreSQL を使うだけならこれでもよいのですが、ローカルでは SQLite を使って、本番環境では PostgreSQL を使って・・といったケースには対応できません。そのような場合は、RunPython を使って次のように書き分けることができます。
# Generated by Django 3.1.3 on 2020-12-04 12:34 from django.db import migrations from django.db.migrations import exceptions def code(apps, schema_editor): if schema_editor.connection.vendor == 'sqlite': schema_editor.execute(""" CREATE VIEW sales_result_per_month AS SELECT ROW_NUMBER() OVER() AS id, t.sales_month, t.amount AS target_amount, SUM(r.amount) AS total_amount FROM sales_target t LEFT OUTER JOIN sales_result r ON STRFTIME('%Y-%m', t.sales_month) = STRFTIME('%Y-%m', r.sales_date) GROUP BY t.sales_month ORDER BY sales_month; """) elif schema_editor.connection.vendor == 'postgresql': schema_editor.execute(""" CREATE VIEW sales_result_per_month AS SELECT ROW_NUMBER() OVER() AS id, t.sales_month, t.amount AS target_amount, SUM(r.amount) AS total_amount FROM sales_target t LEFT OUTER JOIN sales_result r ON TO_CHAR(t.sales_month, 'YYYY-MM') = TO_CHAR(r.sales_date, 'YYYY-MM') GROUP BY t.sales_month, t.amount ORDER BY t.sales_month; """) elif schema_editor.connection.vendor == 'mysql': schema_editor.execute(""" CREATE VIEW sales_result_per_month AS SELECT ROW_NUMBER() OVER() AS id, t.sales_month, t.amount AS target_amount, SUM(r.amount) AS total_amount FROM sales_target t LEFT OUTER JOIN sales_result r ON DATE_FORMAT(t.sales_month, '%Y-%m') = DATE_FORMAT(r.sales_date, '%Y-%m') GROUP BY t.sales_month; ORDER BY t.sales_month; """, params=None) else: raise exceptions.BadMigrationError( 'Database vendor should be SQLite, PostgreSQL, or MySQL.') def reverse_code(apps, schema_editor): schema_editor.execute('DROP VIEW IF EXISTS sales_result_per_month;') class Migration(migrations.Migration): dependencies = [ ('sales', '0001_initial'), ] operations = [ migrations.RunPython(code, reverse_code, atomic=False) ]
まとめ
モデルの Meta クラスに「managed = False」を指定すると、マイグレーションの対象から外すことができるため、データベースビューに対応するモデルを作成することができます。
またこの方法を利用すれば、すでにあるテーブルに対応するモデルを作成することも可能です。いろいろ便利に使えそうですね!
宣伝
Django の技術同人誌をこれまでに4冊出しました。開発のお供にどうぞ。
現場で使える Django の教科書《基礎編》
「現場で使える Django の教科書」シリーズ第1弾となる Django の技術同人誌。Django を現場で使うための基礎知識やベストプラクティスについて、初心者・初級者向けに解説した本です。B5・本文192ページ。Django 3.2 LTS に対応。
★ Amazon(電子版/ペーパーバック)
★ BOOTH(紙の本)
現場で使える Django の教科書《実践編》
《基礎編》の続編にあたる「現場で使える Django の教科書」シリーズの第2弾。認証まわり、ファイルアップロード、ユニットテスト、デプロイ、セキュリティ、高速化など、さらに実践的な内容に踏み込んでいます。現場で Django を本格的に活用したい、あるいはすでに活用している方にピッタリの一冊。B5・本文180ページ。
★ Amazon(電子版/ペーパーバック)
★ BOOTH(紙の本)※在庫なし
現場で使える Django REST Framework の教科書
Django で REST API を構築する際の鉄板ライブラリである「Django REST Framework」(通称「DRF」)にフォーカスした、「現場で使える Django の教科書」シリーズの第3弾。B5・本文228ページ。Django 3.2 LTS に対応。
★ Amazon(電子版/ペーパーバック)
★ BOOTH(紙の本)※在庫なし
現場で使える Django 管理サイトのつくり方
Django の管理サイト(Django Admin)だけに特化した、ニッチでオンリーワンな一冊。管理サイトをカスタマイズする前に絶対に読んでほしい本です。B5・本文152ページ。
★ Amazon(電子版)
★ BOOTH(紙の本)
*1:モデルマネージャの raw() や django.db.connection.cursor オブジェクトの execute() を使えば、生の SQL を書くことも可能です。
*2:https://docs.djangoproject.com/ja/3.1/ref/models/options/#managed