django-cteと共通テーブル式(CTE)を用いた再帰クエリにより、階層構造を持つテーブルからデータを取得する

これは Django Advent Calendar 2020 - QiitaJSL(日本システム技研) Advent Calendar 2020 - Qiita の12/16分の記事です。

 
Django共通テーブル式(Common Table Expression、CTE)を用いた再帰クエリを使って、階層構造を持つテーブルからデータを取得したいことがありました。

ただ、現在のDjangoでは「共通テーブル式再帰クエリ」がサポートされていません。
#28919 (Add support for Common Table Expression (CTE) queries) – Django

SQLを書いても良いのですが、IDEのサポートがほしかったのでライブラリを探したところ、 django-cte がありました。
dimagi/django-cte: Common Table Expressions (CTE) for Django

そこで、django-cteと共通テーブル式を用いた再帰クエリを使った時のメモを残します。

 

目次

 

環境

 

そもそもやりたかったこと

リンゴの親子関係という階層構造を持つデータがあり、RDBに階層構造を保持したいとします*1*2

.
├── 東光
│   └── 千秋
│       ├── シナノゴールド
│       │   └── 奥州ロマン
│       └── 秋映
└── 国光
    └── フジ
        └── シナノスイート

 
また、この階層構造の途中のデータを取得すると、その祖先のデータもすべて取得したいとします。

例えば、「シナノゴールド」を指定すると、祖先の「千秋」「東光」も取得したいとします。

 

どうやって実現するか

階層構造をRDBに保持する方法としては、書籍

にていくつか言及があります。

 
ただ、今回やりたいことは比較的単純な階層構造であることに加え、SQLグラフ原論にて

RDB/SQLで階層構造を表現するメジャーな手段は、現在においても隣接リストモデルであるのは、動かしがたい事実

プログラマのためのSQLグラフ原論(初版第1刷) 付録 訳者による解説 (ミック) p311

と書かれていることから、隣接リストモデルにて表現します。

 
また、隣接リストからの取り出しについては、SQLアンチパターン

また、隣接リストに格納された階層構造をサポートするsQL拡張機能を備えているデータベース製品もあります。SQL-99標準では、WITHキーワードの後に共通テーブル式(Common Table Expression: CTE) を指定する形式の再帰クエリ構文を定義しています。

共通テーブル式を用いた再帰クエリは、Microsoft SQL Server 2005、Oracle Database 11g、IBM DB2MySQL 8.0、PostgreSQL 8.4、SQLite 3.8.3、Firebird 2.1 でサポートされています

SQLアンチパターン(初版第10刷) 2章ナイーブツリー(素朴な木) p19

とあります。

Djangoが公式サポートしているRDBは、いずれも上記に含まれています。
Databases | Django documentation | Django

そこで今回は、共通テーブル式を用いた再帰クエリにて実装することとします。

 

Djangoでの実装

モデル

RDBに保存するため、Djangoのモデルを定義します。

今回は

という構造とします。

なお、外部キー「親のサロゲートキー」では自己参照となりますが、Djangoでは ForeignKeyself を渡すことで可能になります。
https://docs.djangoproject.com/ja/3.1/ref/models/fields/#foreignkey

from django.db import models

class Apple(models.Model):
    name = models.CharField('名前', max_length=20)
    parent = models.ForeignKey('self',
                               on_delete=models.SET_NULL,
                               null=True,
                               blank=True)
    class Meta:
        db_table = 'apple'

 
このモデルの中身は以下を想定しています。

id name parent
1 東光 NULL
2 千秋 1
3 シナノゴールド 2
4 奥州ロマン 3
5 秋映 2
6 国光 NULL
7 フジ 6
8 シナノスイート 7

 
なお、親へさかのぼれないリンゴは、parentNULL を設定しています。

NULLを使ったのは、書籍「プログラマのためのSQLグラフ原論」のp24にも「最もよくある表現」と書かれていたためです。

もし他の値を設定したい場合は、同書の同ページにて言及されています。

 

共通テーブル式を用いた再帰クエリの書き方

モデルができたので、次は取得するクエリを作成します。

まずは、Djangoで生SQL版を実装する前に、共通テーブル式を用いた再帰クエリの書き方を見ていきます。

共通テーブル式はSQL99に含まれます。
新しい業界標準「SQL99」詳細解説

イメージ的にはこんな感じです。

WITH RECURSIVE <table> (<field>, ...)  /* 集めたデータを入れるテーブルとその項目 */
AS (
        /* 起点となるレコードを抽出する箇所 */
    UNION ALL
       /* 再帰してレコードを抽出する箇所 */
) 
SELECT * FROM <table>;  /* 集めたデータに対する処理 */

 

Djangoの生SQLで抽出

上記SQLイメージを元に、Djangoでの生SQL版を実装します。

 

集めたデータを入れるテーブルとその項目

共通テーブル名として、今回は tree としました。

また項目については、Appleモデルの idnameparent_id を用意します *3

それに加え、何階層さかのぼっているのかを確認するための項目 node を用意します*4

WITH RECURSIVE tree
    (node, id, name, parent_id)

 

起点となるレコードを抽出する箇所

起点となるレコードを特定するため、WHERE句を用意したSQLになります。

SELECT 0 AS node, base.id, base.name, base.parent_id
FROM apple AS base
WHERE base.id = %s

ここでは起点となるレコードなので、 node には 0 という固定値を設定します。

また、別の箇所で apple テーブルからの抽出を行うため、 AS で別名を付けておきます。

他に、WHERE句に %sプレースホルダーを用意します。
https://docs.djangoproject.com/ja/3.1/topics/db/sql/#passing-parameters-into-raw

 

再帰してレコードを抽出する箇所

起点と UNION ALL するSQLになります。

自身の id と共通テーブルの parent_id で INNER JOIN します。

SELECT tree.node + 1 AS node, 
       apple.id,
       apple.name,
       apple.parent_id
FROM apple
    INNER JOIN tree
        ON apple.id = tree.parent_id

 

集めたデータに対する処理

こちらの普通のSELECTです。

SELECT * 
FROM tree
ORDER BY node;

 

SQLの全体像

こんな感じになりました。

WITH RECURSIVE tree
    (node, id, name, parent_id)
AS (
        SELECT 0 AS node, base.id, base.name, base.parent_id
        FROM apple AS base
        WHERE base.id = %s
    UNION ALL
        SELECT tree.node + 1 AS node, 
               apple.id,
               apple.name,
               apple.parent_id
        FROM apple
            INNER JOIN tree
                ON apple.id = tree.parent_id
) SELECT * 
  FROM tree
  ORDER BY node;

 

動作確認

のちほど django-cte版も同じになるか確認するため、以下のようなassertするヘルパメソッドを用意します。

def assertCte(self, actual):
    # 件数
    self.assertEqual(len(actual), 3)

    # シナノゴールド自身があること
    own = actual[0]
    self.assertEqual(own.node, 0)
    self.assertEqual(own.name, 'シナノゴールド')

    # シナノゴールドの親(千秋)
    parent = actual[1]
    self.assertEqual(parent.node, 1)
    self.assertEqual(parent.name, '千秋')

    # 千秋の親(東光)
    grandparent = actual[2]
    self.assertEqual(grandparent.node, 2)
    self.assertEqual(grandparent.name, '東光')

 
その後、ヘルパメソッドを使ったテストコードを書いたところ、テストがパスしました。

class TestRecursive(TestCase):
    def test_1_raw_sql(self):
        shinano_gold = Apple.objects.get(name='シナノゴールド')

        apples = Apple.objects.raw(
            """
            WITH RECURSIVE tree
                (node, id, name, parent_id)
            AS (
                    SELECT 0 AS node, base.id, base.name, base.parent_id
                    FROM apple AS base
                    WHERE base.id = %s
                UNION ALL
                    SELECT tree.node + 1 AS node, 
                           apple.id,
                           apple.name,
                           apple.parent_id
                    FROM apple
                        INNER JOIN tree
                            ON apple.id = tree.parent_id
            ) SELECT * 
              FROM tree
              ORDER BY node;
            """
            , [shinano_gold.pk])

        self.assertCte(apples)

 

django-cteでの抽出

次に、django-cte での抽出を試します。

 

インストール

pipでインストールします。

pip install django-cte

 

モデルの objects を差し替え

次に、モデルの objectsCTEManager へ差し替えます。

from django_cte import CTEManager

class Apple(models.Model):
    ...
    objects = CTEManager()  # 追加

    class Meta:
        db_table = 'apple'

 

django-cteのクエリ全体像

READMEの Recursive Common Table Expressions と、生SQLの書き方を見比べると以下のようでした。

# WITH RECURSIVE ... AS() を関数化
def make_regions_cte(cte):
    return Region.objects.filter(
    ...
    ).union(
        ...
        all=True,
    )

# Withに割り当て
cte = With.recursive(make_regions_cte)

# 共通テーブルからの抽出
regions = (...)

 
そこで、コメントしたそれぞれの機能を実装していきます。

 

WITH RECURSIVE ... AS() を関数化

With.recursive() に渡す部分を関数化します。

recursive()関数のソースコードを読むと

:param make_cte_queryset: Function taking a single argument (a
not-yet-fully-constructed cte object) and returning a `QuerySet`
object. The returned `QuerySet` normally consists of an initial
statement unioned with a recursive statement.

https://github.com/dimagi/django-cte/blob/fede416338ec0c5a967e2f1f902435061ae630e1/django_cte/cte.py#L42

とあったため、QuerySetではなくQuerySetを返す関数を用意すれば良さそうです。

 
まずは起点となる部分の絞り込みを作ります。

shinano_gold = Apple.objects.get(name='シナノゴールド')

Apple.objects.filter(
    id=shinano_gold.pk
)

 
続いて、annotate() メソッドを使って、さかのぼり番号 node を追加します。
https://docs.djangoproject.com/ja/3.1/ref/models/querysets/#annotate

Apple.objects.filter(
    id=shinano_gold.pk
# 追加
).annotate(
    node=Value(0, output_field=IntegerField()),

 
最後に UNION ALL 後の部分を追加します。

Apple.objects.filter(
    id=shinano_gold.pk
).annotate(
    node=Value(0, output_field=IntegerField()),
# 追加
).union(
    cte.join(Apple, id=cte.col.parent_id)
       .annotate(node=cte.col.node + Value(1, output_field=IntegerField())),
    all=True,
)

あとはこのQuerySetを返せば関数が完成します。

 

With.recursiveに割り当て

関数を引数として渡すだけです。

cte = With.recursive(make_cte)

 

共通テーブルからの抽出

READMEの場合 With オブジェクトの join() メソッドを使っています。

しかし、今回の場合はWITH RECURSIVE ... AS() を関数化したところで UNION ALL しているため、これ以上のJOINは不要です。

そのため、Withオブジェクトの queryset() メソッドで、ここまで処理してきたQuerySetを取り出し、共通テーブルの処理へとつなげます。
https://github.com/dimagi/django-cte/blob/fede416338ec0c5a967e2f1f902435061ae630e1/django_cte/cte.py#L92

apples = (
    cte.queryset()
       .with_cte(cte)
       .annotate(node=cte.col.node)
    .order_by('node')
)

 

全体像

django-cte版の全体像はこんな感じです。

def make_cte(cte):
    shinano_gold = Apple.objects.get(name='シナノゴールド')

    return Apple.objects.filter(
        id=shinano_gold.pk
    ).annotate(
        node=Value(0, output_field=IntegerField()),
    ).union(
        cte.join(Apple, id=cte.col.parent_id)
           .annotate(node=cte.col.node + Value(1, output_field=IntegerField())),
        all=True,
    )

cte = With.recursive(make_cte)

apples = (
    cte.queryset()
       .with_cte(cte)
       .annotate(node=cte.col.node)
    .order_by('node')
)

 

動作確認

同じように、自作のヘルパメソッド self.assertCte(apples) を使ってテストコードで動作確認したところ、想定通りの動きとなりました。

また、発行されるSQLも想定通りでした。

WITH RECURSIVE cte AS(
    SELECT
        "apple"."id",
        "apple"."name",
        "apple"."parent_id",
        0 AS "node"
    FROM
        "apple"
    WHERE
        "apple"."id" = 3
    UNION ALL
    SELECT
        "apple"."id",
        "apple"."name",
        "apple"."parent_id",
        ("cte"."node" + 1) AS "node"
    FROM
        "apple"
        INNER JOIN
            "cte"
        ON  "apple"."id" = "cte"."parent_id"
)
SELECT
    "cte"."id",
    "cte"."name",
    "cte"."parent_id",
    "cte"."node" AS "node"
FROM
    "cte"
ORDER BY
    "node" ASC

 

その他

django-cteでルートのデータを取得する

今回の場合で言えば、「国光」のみを取得したいとなります。

とはいえ、書き方は上記の場合と変わらず、最初の Apple.objects.filter() の条件が異なるのみです。

以下のテストもパスします。

def test_3_django_cte_root(self):
    def make_cte(cte):
        kokko = Apple.objects.get(name='国光')

        return Apple.objects.filter(
            id=kokko.pk
        ).annotate(
            node=Value(0, output_field=IntegerField()),
        ).union(
            cte.join(Apple, id=cte.col.parent_id)
                .annotate(node=cte.col.node + Value(1, output_field=IntegerField())),
            all=True,
                )

    cte = With.recursive(make_cte)

    apples = (
        cte.queryset()
            .with_cte(cte)
            .annotate(node=cte.col.node)
            .order_by('node')
    )

    self.assertEqual(len(apples), 1)
    apple = apples.get()
    self.assertEqual(apple.node, 0)
    self.assertEqual(apple.name, '国光')

 
発行されるSQLも同じです。

WITH RECURSIVE cte AS(
    SELECT
        "apple"."id",
        "apple"."name",
        "apple"."parent_id",
        0 AS "node"
    FROM
        "apple"
    WHERE
        "apple"."id" = 6
    UNION ALL
    SELECT
        "apple"."id",
        "apple"."name",
        "apple"."parent_id",
        ("cte"."node" + 1) AS "node"
    FROM
        "apple"
        INNER JOIN
            "cte"
        ON  "apple"."id" = "cte"."parent_id"
)
SELECT
    "cte"."id",
    "cte"."name",
    "cte"."parent_id",
    "cte"."node" AS "node"
FROM
    "cte"
ORDER BY
    "node" ASC

 

django-cteの戻り値をdictとして取得したい

WITH RECURSIVE ... AS() を関数化した時の関数の中で、 values() メソッドを使います。

def make_cte(cte):
    shinano_gold = Apple.objects.get(name='シナノゴールド')

    return Apple.objects.filter(
        id=shinano_gold.pk
    # ここで values()
    ).values(
        'id',
        'parent',
        'name',
        node=Value(0, output_field=IntegerField()),
    ).union(
        cte.join(Apple, id=cte.col.parent_id)
            # こちらもvalues()
            .values(
                'id',
                'parent',
                'name',
                node=cte.col.node + Value(1, output_field=IntegerField())),
        all=True,
    )

 
dictなのでテストコードが少し変わります。

self.assertEqual(len(apples), 3)

# シナノゴールド自身があること
own = apples[0]
self.assertEqual(own['node'], 0)
self.assertEqual(own['name'], 'シナノゴールド')

# シナノゴールドの親(千秋)
own = apples[1]
self.assertEqual(own['node'], 1)
self.assertEqual(own['name'], '千秋')

# 千秋の親(東光)
own = apples[2]
self.assertEqual(own['node'], 2)
self.assertEqual(own['name'], '東光')

 

ソースコード

Githubに上げました。
https://github.com/thinkAmi-sandbox/django_cte-sample

*1:リンゴには「種子親」と「花粉親」がありますが、わかりやすくするため今回は種子親のみの階層構造とします。

*2:実際のところ、東光は「ゴールデンデリシャス x 印度」の交配で生まれていますが、わかりやすくするためそれ以上の親はさかのぼらないとします。参考: 東光 - 青森県の市販のりんごと話題のりんご

*3:SQLなので、モデルのフィールド名「parent」ではなく、実際のテーブル列名「parent_id」を指定します

*4:depthという名前の方が良いのかもしれませんが、RDBによっては使われる名前であることと、はてなブログシンタックスハイライトされてしまったため、「node」としました

Djangoで、SILENCED_SYSTEM_CHECKSを定義してSystem check frameworkのメッセージ出力を抑制する

これは JSL(日本システム技研) Advent Calendar 2020 - Qiita 12/15分の記事です。

 
DjangoにはSystem check frameworkがあり、Djangoプロジェクトの正しさをチェックしてくれます。
System check framework | Django ドキュメント | Django

そんな中、特定のチェックで大量に引っかかってしまうことがありました。

そこで、特定のチェックのメッセージ出力を抑える方法を探した時のメモを残します。

 
目次

 

環境

 

事例

例えば、Djangoを1系からバージョンアップする中で、urls.pyに

urlpatterns = [
    path('warn$', TemplateView.as_view(template_name='silence_app/index.html')),
]

と、 $ を残してしまったとします。

 
この場合、開発用のサーバを起動すると、

Performing system checks...

System check identified some issues:

WARNINGS:
?: (2_0.W001) Your URL pattern 'warn$' has a route that contains '(?P<', begins with a '^', or ends with a '$'. This was likely an oversight when migrating to django.urls.path().

System check identified 1 issue (0 silenced).

というメッセージが表示されます。

実際には他のメッセージも表示されているため、このメッセージだけを抑制したいとします。

 

対応

settings.pyに SILENCED_SYSTEM_CHECKS を定義します。
https://docs.djangoproject.com/en/3.1/ref/settings/#silenced-system-checks

今回は 2_0.W001 を抑制したいので、settings.pyに

SILENCED_SYSTEM_CHECKS = ['2_0.W001']

と定義します。

 
その後、開発サーバを起動すると

Performing system checks...

System check identified no issues (1 silenced).

へと表示が変わり、2_0.W001を抑制できました。

 
なお、この警告ですが、実際にアクセスしてみると

warn宛

$ curl localhost:8000/silence/warn -v
...
< HTTP/1.1 404 Not Found

warn$宛

$ curl localhost:8000/silence/warn$ -v
...
< HTTP/1.1 200 OK

となります。

 

ソースコード

Githubに上げました。 silence_app ディレクトリが今回のDjangoアプリです。
https://github.com/thinkAmi-sandbox/django_31-sample

pandoc & wkhtmltopdf のDockerイメージを作成し、複数マークダウンファイルを1つのpdfにする

この記事は、 JSL(日本システム技研) Advent Calendar 2020 - Qiita 12/8の記事です。

以前、markdownからpdfを作成する機会がありました。
GitLab CI + docker-reviewを使って、Markdownをtextlintしてからpdf化するCI環境を作ってみた - メモ的な思考的な

他の方法がないかを見たところ、 pandoc & wkhtmltopdfでも作成できそうでした。
pandoc + markdownでいい感じの執筆環境を作る - Qiita

そこで、pandoc & wkhtmltopdf のDockerイメージを作成し、複数マークダウンファイルを1つのpdfにしてみました。  
 

目次

 

環境

  • Docker
    • 以下のライブラリを入れる
      • pandoc 2.11.2
      • wkhtmltopdf 0.12.5
      • フォントは Google Noto

 

最終的なディレクトリ構成はこちら。

$ tree
.
├── Dockerfile
├── manuscript
│   ├── りんご.md
│   └── さつまいも.md
├── output
│   └── (merge.pdf)
└── settings
    ├── defaults.yaml
    └── style.css

 
マークダウンファイルは2つ用意しました。

なお、マークダウンファイル内で \ の後にトリプルバッククォートしている部分ですが、はてなブログに貼るために書いているため、本来 \ は不要です。

りんご.md

# りんごの種類

- シナノゴールド
- フジ

(以下の \ は不要)
\```python
print('りんごです!')
\```


<div class="hidden">
# コメント

シナノゴールドはイタリアをはじめとした海外に進出してる

</div>

<div class="page-break"></div>

 

さつまいも.md

# さつまいもの種類

- 紅はるか
- 安納芋

(以下の \ は不要)
\```python
print('さつまいもです!')
\```

<div class="hidden">
# コメント

紅優甘は、紅はるかの商標登録名

</div>


<div class="page-break"></div>

実装

Dockerfile

同じようなDockerfileがないかを探したところ、以下のリポジトリがありました。
https://github.com/slurdge/docker-pandoc-wkhtmltopdf

 
ただ、docker buildしてみると

E: Package 'libssl1.0-dev' has no installation candidate

というエラーでビルドできませんでした。

 

他のDockerfileがないかを探したところ、 wkhtmltopdf を使っているDockerfileがありました。
Docker コンテナ上で wkhtmltopdf を動かす - Qiita

そこで、これらを組み合わせて作ってみることにしました。

 

まずはpandocのリポジトリを見たところ、 pandoc-2.11.2-1-amd64.deb がありました。
https://github.com/jgm/pandoc/releases/

次にwkhtmltopdfのリポジトリを見たところ、 wkhtmltox_0.12.5-1.buster_amd64.deb 等のdebファイルがありました。
https://github.com/wkhtmltopdf/wkhtmltopdf/releases

そこで、今回はDebianベースで作ることにしました。

 
ただ、上記だけでは日本語を含んだマークダウンが文字化けしてしまいました。

そこで「Docker コンテナ上で wkhtmltopdf を動かす」の記事に合わせてフォントを入れることにしました。

DebianでNotoフォントを入れる方法を探したところ、 fonts-noto-cjkfonts-noto-cjk-extra を使えば良さそうでした。
Linuxだって、綺麗にフォントが表示できるんだからねッ!

そのため、pandocとwkhtmltopdfはGithubから、それ以外はdebファイルからインストールすることにしました。

 
ただ、debファイルからインストールする際にいくつか依存関係が発生することから、 gdebi もインストールしておきます。

 
他に、 --no-install-recommends でインストールすると

ERROR: The certificate of 'github.com' is not trusted.
ERROR: The certificate of 'github.com' doesn't have a known issuer.

が発生することから、 ca-certificates も追加しています。
Ubuntu on Docker で SSL/TLS 通信するとエラーになる問題の対処 - Qiita

 

最終的なDockerfileはこちら

FROM debian:buster-slim

RUN apt-get update && apt-get install -y --no-install-recommends \
  xorg \  
  libssl-dev \
  libxrender-dev \
  wget \
  gdebi \
  fonts-noto-cjk \
  fonts-noto-cjk-extra \
  ca-certificates \
  && rm -rf /var/lib/apt/lists/* \
  && apt-get autoremove \
  && apt-get clean

RUN wget https://github.com/jgm/pandoc/releases/download/2.11.2/pandoc-2.11.2-1-amd64.deb -O pandoc.deb \
    && dpkg -i ./pandoc.deb \
    && rm pandoc.deb

RUN wget https://github.com/wkhtmltopdf/wkhtmltopdf/releases/download/0.12.5/wkhtmltox_0.12.5-1.buster_amd64.deb -O wkhtmltox.deb \
    && dpkg -i ./wkhtmltox.deb \
    && rm wkhtmltox.deb

RUN mkdir /var/tmp/output

 

docker build

Dockerfileができたのでビルドします。

$ docker build ./ -t pandoc_wkhtmltopdf:1.0
...
Successfully built ae80e0763df1
Successfully tagged mydoc:1.0

 

ビルド後のサイズはこんな感じです。

$ docker image list pandoc*

REPOSITORY            TAG    IMAGE ID        CREATED          SIZE
pandoc_wkhtmltopdf    1.0    ae80e0763df1    2 minutes ago    998MB

 

docker run

Dockerイメージができたので、docker run します。

オプションとして以下を指定します。

オプション 内容
--mount type=bind,src=...,dst=... ホストとDockerでファイルを共有するため。生成したpdfのコピーを不要にする
-w /var/tmp/output 作業ディレクトリをホストと共有したディレクトリにすることで、cdとか不要に

 
docker run後、Dockerに入って作業ディレクトリにいればOKです。

$ docker run -it --mount type=bind,src="$(pwd)"/,dst=/var/tmp/output -w /var/tmp/output --name mypandoc pandoc_wkhtmltopdf:1.0
...
root@f190aac6d170:/var/tmp/output# 

 

pandocとwkhtmltopdfによる変換
Default filesファイルを作成

pandoc実行時にオプションを渡してpdfファイルへと変換します。

ただ、pandocにはオプションが多くあるため、実行時に指定漏れが発生しそうでした。

そこで、Default filesを使って、コマンド時のオプションは必要最低限とすることにしました。
https://pandoc.org/MANUAL.html#default-files

なお、今回複数マークダウンファイルを1つのpdfにまとめますが、 input-files ではワイルドカード指定ができなかったため、Default filesには記載しませんでした。
Wildcard for multiple input files in the defaults file variable "input-files" - Google グループ

from: markdown
to: html5

# 入力ファイルはコマンドラインから指定
# manuscript/*.md が指定できないため

# 出力ファイル (単一で指定)
output-file: output/merge.pdf

# コードブロックの背景色
highlight-style: tango

# 独自CSS
css:
- settings/style.css

 

独自CSSファイルの用意

今回は改ページと非表示のclassを用意しました。

.page-break {
    page-break-before: always;
}

.hidden {
    display: none;
}

 

pandocコマンドの実行

pandocコマンドで変換を行います。

# pandoc ./manuscript/*.md -d settings/defaults.yaml

[WARNING] This document format requires a nonempty <title> element.
  Defaulting to 'さつまいも' as the title.
  To specify a title, use 'title' in metadata or --metadata title="...".
Loading pages (1/6)
Counting pages (2/6)                                               
Resolving links (4/6)                                                       
Loading headers and footers (5/6)                                           
Printing pages (6/6)
Done 

 

pdfの確認

改ページされていること、不要な部分が表示されていないことが確認できました。

f:id:thinkAmi:20201207231228p:plain:w400

 

ソースコード

Githubに上げました。
https://github.com/thinkAmi-sandbox/pandoc_wkhtmltopdf_docker

 

その他参考

aptまわり

Django3系のORMでSQLのEXISTS句を使う

この記事は、 JSL(日本システム技研) Advent Calendar 2020 - Qiita 12/3の記事です。

以前、SQLDjangoのQuerySet APIでどう実装するのかを書きました。

 
上記ではサブクエリについてはふれなかったのですが、当時EXISTS句を使うのは大変だった記憶があります。

そんな中、EXISTS句を使う機会があったため、Django3系ではどうなっているのかメモに残します。

 
目次

 

環境

 

やりたいこと

ColorとAppleモデルがあり、Color : Apple = 1 : 多 の関係とします。

class Color(models.Model):
    name = models.CharField('名前', max_length=20)


class Apple(models.Model):
    name = models.CharField('名前', max_length=20)
    color = models.ForeignKey('Color', on_delete=models.PROTECT)

 
この時、Appleが存在するColorを全件抽出したいとします。

SQL的にはこんな感じ。

SELECT
    "sql_exists_color"."id",
    "sql_exists_color"."name"
FROM
    "sql_exists_color"
WHERE
    EXISTS(
        SELECT
            U0."id",
            U0."name",
            U0."color_id"
        FROM
            "sql_exists_apple" U0
        WHERE
            U0."color_id" = "sql_exists_color"."id"
    ) 

 

Django3系でのやり方

改めて調べたところ、Django3系では以前より直感的に書けるようになっていました。

 
上記のように、「Appleが存在するColorを全件抽出したい」場合は

Color.objects.filter(
      Exists(Apple.objects.filter(color=OuterRef('pk')))
  )

SQLと似たような形で書けるようになっていました。

Exists() の中にサブクエリを書き、Exists中のfilterにて、 OuterRef でサブクエリの外のフィールドを参照・比較します。

 

テストコードでの確認

テストコードで確認してみます。

AppleFactoryではColorが緑のものは生成しないようにし、self.assertListEqual(list(colors), [yellow, red]) にて期待通りの結果になるかを確認します。

from django.conf import settings
from django.db import connection
from django.test import TestCase
from django.db.models import Exists, OuterRef

from sql_exists.factories import ColorFactory, AppleFactory
from sql_exists.models import Color, Apple


class TestM2MExists(TestCase):
    def test_exists(self):
        ColorFactory(name='緑')
        yellow = ColorFactory(name='黄')
        red = ColorFactory(name='赤')
        AppleFactory(name='シナノゴールド', color=yellow)
        AppleFactory(name='シナノスイート', color=red)
        AppleFactory(name='フジ', color=red)
        AppleFactory(name='シナノドルチェ', color=red)

        colors = Color.objects.filter(
            Exists(Apple.objects.filter(color=OuterRef('pk')))
        )

        # テストコードでは常にDEBUG=Falseになり、connection.queriesが取得できないことから強制書き換え
        # なお、今回の場合SQLが発行されるのは、list()のタイミング
        # ちなみに、Django1.11からはmanage.pyのオブションに `--debug-mode` もある
        # https://docs.djangoproject.com/en/3.1/ref/django-admin/#cmdoption-test-debug-mode
        settings.DEBUG = True

        self.assertListEqual(list(colors), [yellow, red])

        for query in connection.queries:
            print(query)

        settings.DEBUG = False

 
テストを実行すると、以下の通りパスしました。

$ python manage.py test sql_exists
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
{'sql': 'SELECT "sql_exists_color"."id", "sql_exists_color"."name" FROM "sql_exists_color" WHERE EXISTS(SELECT U0."id", U0."name", U0."color_id" FROM "sql_exists_apple" U0 WHERE U0."color_id" = "sql_exists_color"."id")', 'time': '0.000'}
.
----------------------------------------------------------------------
Ran 1 test in 0.009s

OK
Destroying test database for alias 'default'...

 

ソースコード

Githubに上げました。
https://github.com/thinkAmi-sandbox/django_31-sample

PHPのDOMDocumentを使って、日本語を含むHTMLの一部分を文字列として生成する

うまいタイトルが思い浮かばなかったのですが、

コメント
<hr>
<div>ハロー
  <p>ワールド</p>
</div>

のようなものを、PHPのDOMDocumentを使って実現する時に悩んだので、メモに残します。

もし、他に良い方法があれば教えて頂けるとありがたいです。

 

目次

 

環境

 

悩んだ点と対応

タグのないところにテキストやタグを入れる

タグに囲まれている部分にテキストを入れる場合

$dom = new DOMDocument();
$dom->appendChild($dom->createElement('div', 'ハロー'));

とすれば

<div>ハロー</div>

というHTMLが作れます。

ただ、今回は

コメント
<hr>

のように、hrタグの前にテキストを入れる必要がありました。

 
この場合、 createTextNode を使えば良さそうでした。

 
そのため、

$dom = new DOMDocument();

$comment = $dom->createTextNode('コメント');
$dom->appendChild($comment);

$dom->appendChild($dom->createElement('hr'));

とすることで、

コメント<hr>

となりました。

 

日本語を含むHTMLを文字列にする

DOMDocumentをHTMLの文字列にするには、 saveHTML() を使えば良さそうでした。
PHP: DOMDocument::saveHTML - Manual

 
しかし、

$dom = new DOMDocument();

$comment = $dom->createTextNode('コメント');
$dom->appendChild($comment);

$dom->appendChild($dom->createElement('hr'));

$div = $dom->appendChild($dom->createElement('div', 'ハロー'));

$div->appendChild($dom->createElement('p', 'ワールド'));
$dom->saveHTML();

echo $html;

のように、日本語を含むDOMDocumentに対して使ったところ

&#12467;&#12513;&#12531;&#12488;
<hr>
<div>&#12495;&#12525;&#12540;
  <p>&#12527;&#12540;&#12523;&#12489;</p>
</div>

と、日本語は数値文字参照での表現となりました。

 
この場合、 mb_convert_encoding() を使えば良さそうでした。

 

そのため、 mb_convert_encoding() を使って文字コードHTML-ENTITIES から UTF-8 へと変更するよう、

// ここまでは同じ
$div->appendChild($dom->createElement('p', 'ワールド'));

// 変更
$html = mb_convert_encoding($dom->saveHTML(), 'utf-8', 'HTML-ENTITIES');

echo $html;

としたところ、

コメント
<hr>
<div>ハロー
  <p>ワールド</p>
</div>

と、日本語のHTMLの文字列となりました。

 

ソースコード

Githubに上げました。 dom_document/create_html.php が今回のファイルです。
https://github.com/thinkAmi-sandbox/php-sample

bootstrap-selectの1.13.8以前では、val()でoptionを選択した時のpreviousValueが取得できなかった

Bootstrapを使っている環境にて、select要素の見栄えを良くしたい場合、 bootstrap-select を使うことがあります。
snapappointments/bootstrap-select: The jQuery plugin that brings select elements into the 21st century with intuitive multiselection, searching, and much more.

 
bootstrap-selectにはいくつかイベントがあり、例えば changed.bs.select を使うと選択した値が変更された時にイベントが発火します。
https://developer.snapappointments.com/bootstrap-select/options/#events

ドキュメントにもある通り previousValue には変更前の値が入ってきます。

ただ、1.13.8以前の古いバージョンを使っている場合、previousValueが取得できなかったため、メモを残します。

 
目次

 

環境

  • Bootstrap 4.3.1
  • bootstrap-select 1.13.8/1.13.9
  • jQuery 3.5.1
  • popper 1.14.7

 

原因

以下にある通り、バグがあったようです。

 

対応

bootstrap-selectを1.13.9以降にアップデートします。

 

再現

以下のようなHTMLがあるとします。

<div style="padding: 10px">
  <div>
    <h1>1.13.8</h1>

    <select id="ringo"  class="selectpicker">
      <option value="1">フジ</option>
      <option value="2">紅玉</option>
      <option value="3">シナノゴールド</option>
    </select>
  </div>

  <div style="padding-top: 10px">
    <button id="auto" class="btn btn-primary">自動選択</button>
  </div>
</div>

 
表示はこんな感じ。

f:id:thinkAmi:20201123135256p:plain:w400

 
 
ここで、

$(document).ready(function() {
  $('#ringo').on('changed.bs.select', (event, clickedIndex, isSelected, previousValue) => {
    console.log(previousValue);
  });

  $('#auto').on('click', () => {
    $('#ringo').selectpicker('val', 3);
  })
});

と、自動選択ボタンを押すと、 シナノゴールド が表示されるようにします。

また、 changed.bs.select の発火を確認するため、イベントが発生したら previousValue の値をコンソールに出力します。

 

bootstrap-selectのバージョンが1.13.8の場合

undefined になります。

f:id:thinkAmi:20201123135309p:plain:w400

 

bootstrap-selectのバージョンが1.13.9の場合

正しい値 1 (フジ) が取得できます。

f:id:thinkAmi:20201123135226p:plain:w400  
 

ソースコード

Githubにあげました。
https://github.com/thinkAmi-sandbox/bootstrap_select-sample

django-datatables-viewによるServer-side processingで、DataTable向けのクエリパラメータを追加する

django-datatables-viewによるServer-side processingで、DataTable向けのクエリパラメータを追加しようと考えた時に詰まったことがあったため、メモを残します。

 

目次

 

環境

なお、サンプルの見栄えを良くするため、Bootstrap 4.5.2 も使っています。

 

やりたいこと

例えば以下のようなDataTableがあるとします。

f:id:thinkAmi:20201020214702p:plain

 
この状態で、もしHTMLのhidden input値があれば、その条件に従ってDataTableを絞り込んで表示したいとします。

例えば、

<input type="hidden" id="limitation" value="yellow">

と指定されている時は

f:id:thinkAmi:20201020215039p:plain

と絞り込まれた状態で表示します。

 

対応

フロントエンド

Server-side Processingで、バックエンドに渡すクエリパラメータを追加したい場合、 ajax オプションに data を追加します。
ajax - DataTables option

$('#demo').DataTable({
  autoWidth: false,
  serverSide: true,
  processing: true,
  responsive: true,
  ajax: {
    url: '/args-app/data/',
    type: 'GET',
    // 追加で渡すクエリパラメータを指定
    data: getLimitation(),
  },
  columnDefs: [
    {targets: 0, data: 'id'},
    {targets: 1, data: 'name'},
    {targets: 2, data: 'color__name'},
  ]
});


function getLimitation() {
  const limitation = $('#limitation');
  return limitation.length ? {limitation: limitation.val()} : {};
}

 

バックエンド

フロントからのクエリパラメータは、 _querydict の中に含まれています。

今回はクエリパラメータを元に絞り込みを行いたいため、 filter_queryset メソッドをオーバーライドします。

class AppleArgsDataTableView(BaseDatatableView):
    model = Apple

    columns = [
        'id',
        'title',
        'color__name',
    ]

    def render_column(self, row, column):
        if column == 'color__name':
            return row.color.name

        return super().render_column(row, column)

    def filter_queryset(self, qs):
        qs = super().filter_queryset(qs)

        # 追加されたクエリパラメータによる絞り込み
        limitation = self._querydict.get('limitation')
        if limitation == 'yellow':
            qs = qs.filter(color__name='黄')

        return qs

 

以上より、HTMLのhidden input値を元に、DataTableを絞り込んで表示できるようになりました。

 

ソースコード

GitHubに上げました。 args_app が今回のアプリです。
https://github.com/thinkAmi-sandbox/django-datatables-view-sample