Quantcast
Channel: akiyoko blog
Viewing all articles
Browse latest Browse all 160

Django でデータベースビューを扱う方法(初級者向け)

$
0
0

この投稿は 「Django Advent Calendar 2020 - Qiita」 5日目の記事です。


akiyoko です。
この記事では、Django でデータベースビューを扱う方法について説明します。「Django のコンポーネントとしてのビュー(View)」ではなく、「データベースのビュー」の話です。本記事では区別のために「データベースビュー」と表記することにします。



f:id:akiyoko:20201205082412p:plain:w350



検証環境
  • 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


classSalesResultPerMonth(models.Model):
    classMeta:
        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)コマンドを実行したときにマイグレーションファイルの内容がデータベースのテーブルに反映されず、テーブルが新たに作成されたり、テーブル構造が変更されたりすることがなくなります。


f:id:akiyoko:20201205100932p:plain:w550


 

具体例

簡単な例として、売上実績テーブルから月次の売上金額の合計を集計して、売上目標テーブルの売上目標金額と比較することを取り上げてみます。これをデータベースビューとして利用するイメージは次のようになります。


f:id:akiyoko:20201201094402p:plain

通常のモデルを作成する

まず、sales アプリケーションに「売上目標モデル」と「売上実績モデル」を次のように定義します。これらはマイグレーション対象となる通常のモデルです。

sales/models.py(モデル)

from django.db import models


classSalesTarget(models.Model):
    """売上目標モデル"""classMeta:
        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月}'classSalesResult(models.Model):
    """売上実績モデル"""classMeta:
        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


f:id:akiyoko:20201201094218p:plain

データベースビュー作成用の 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:34from django.db import migrations


classMigration(migrations.Migration):

    dependencies = [
        ('sales', '0001_initial'),
    ]

    operations = [
    ]


このひな型ファイルを次のように編集して、sales_result_per_month ビューを作成する DDL を書き加えます。

# Generated by Django 3.1.3 on 2020-12-04 12:34from django.db import migrations


classMigration(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 上で確認すると、このようになっています。


f:id:akiyoko:20201201114913p:plain


ビューに対応するモデルを作成する

sales_result_per_month ビューに対応するモデルを作成します。ここで Meta クラスに「managed = False」を指定します。繰り返しになりますが、これでマイグレーションの対象から外れます。


sales/models.py(モデル)

from django.db import models


classSalesTarget(models.Model):
    """売上目標モデル"""
    ...(略)...


classSalesResult(models.Model):
    """売上実績モデル"""
    ...(略)...


classSalesResultPerMonth(models.Model):  # 追加"""月別合計売上金額モデル"""classMeta:
        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


classSalesResultPerMonth(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)

    classMeta:
        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


classSalesResultAdmin(admin.ModelAdmin):
    """売上実績モデル用 ModelAdmin"""################################ モデル一覧画面のカスタマイズ###############################
    list_display = ('sales_date', 'format_amount', 'subject')
    ordering = ('sales_date', 'created_at')

    defformat_amount(self, obj):
        return format_yen(obj.amount)

    format_amount.short_description = '売上金額'
    format_amount.admin_order_field = 'amount'################################ モデル追加・変更画面のカスタマイズ###############################
    readonly_fields = ('id', 'created_at')


classSalesTargetAdmin(admin.ModelAdmin):
    """売上目標モデル用 ModelAdmin"""################################ モデル一覧画面のカスタマイズ###############################
    list_display = ('format_sales_month', 'format_amount')
    ordering = ('sales_month',)

    defformat_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'defformat_amount(self, obj):
        return format_yen(obj.amount)

    format_amount.short_description = '目標金額'
    format_amount.admin_order_field = 'amount'################################ モデル追加・変更画面のカスタマイズ###############################
    readonly_fields = ('id', 'created_at')


classSalesResultPerMonthAdmin(admin.ModelAdmin):
    """月別合計売上金額モデル用 ModelAdmin"""################################ モデル一覧画面のカスタマイズ###############################
    list_display = ('format_sales_month', 'format_target_amount', 'format_total_amount')
    ordering = ('sales_month',)

    defformat_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'defformat_target_amount(self, obj):
        return format_yen(obj.target_amount)

    format_target_amount.short_description = '目標売上金額'
    format_target_amount.admin_order_field = 'target_amount'defformat_total_amount(self, obj):
        return format_yen(obj.total_amount)

    format_total_amount.short_description = '合計売上金額'
    format_total_amount.admin_order_field = 'total_amount'################################ その他のカスタマイズ###############################defhas_add_permission(self, request):
        returnFalsedefhas_change_permission(self, request, obj=None):
        returnFalsedefhas_delete_permission(self, request, obj=None):
        returnFalse


admin.site.register(SalesTarget, SalesTargetAdmin)
admin.site.register(SalesResult, SalesResultAdmin)
admin.site.register(SalesResultPerMonth, SalesResultPerMonthAdmin)



管理サイトの月別合計売上金額モデルの一覧画面はこのように表示されます。


f:id:akiyoko:20201202105916p:plain:w500

何がどうなっているのか?が気になる方は、拙著『現場で使える Django 管理サイトのつくり方』にいろいろと書いてあるので、ぜひお手に取ってご確認くださいませ 🙇

akiyoko.hatenablog.jp



 

(おまけ)マイグレーションファイルの SQL を複数種類のデータベースに対応させる方法

例示したマイグレーションファイルの SQL は PostgreSQL だけにしか使えません。PostgreSQL を使うだけならこれでもよいのですが、ローカルでは SQLite を使って、本番環境では PostgreSQL を使って・・といったケースには対応できません。そのような場合は、RunPython を使って次のように書き分けることができます。

# Generated by Django 3.1.3 on 2020-12-04 12:34from django.db import migrations
from django.db.migrations import exceptions


defcode(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.')


defreverse_code(apps, schema_editor):
    schema_editor.execute('DROP VIEW IF EXISTS sales_result_per_month;')


classMigration(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・本文180ページ。


★ Amazon(電子版/ペーパー版)


★ BOOTH(ペーパー版)



現場で使える Django の教科書《実践編》

《基礎編》の続編にあたる「現場で使える Django の教科書」シリーズの第2弾。認証まわり、ファイルアップロード、ユニットテスト、デプロイ、セキュリティ、高速化など、さらに実践的な内容に踏み込んでいます。現場で Django を本格的に活用したい、あるいはすでに活用している方にピッタリの一冊。B5・本文180ページ。


★ Amazon(電子版)


★ BOOTH(ペーパー版)



現場で使える Django REST Framework の教科書

Django で REST API を構築する際の鉄板ライブラリである「Django REST Framework」(通称「DRF」)にフォーカスした、「現場で使える Django の教科書」シリーズの第3弾。B5・本文204ページ。


★ 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

*3:マイグレーション | Django ドキュメント | Django


Viewing all articles
Browse latest Browse all 160

Trending Articles



<script src="https://jsc.adskeeper.com/r/s/rssing.com.1596347.js" async> </script>