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

django-datatables-viewによるServer-side processingで、フォームの入力値に基づいてDataTableの絞り込みを行う

django-datatables-viewで、フォームの入力値に基づいてDataTableの絞り込みを行おうと考えた時に詰まったことがあったため、メモを残します。

 

目次

 

環境

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

また、今回の実装ではDataTableのインスタンスをどこかに保持する必要がある & わかりやすくするために、JavaScriptclass を使っています*1

 

DataTableのSearchについて

DataTableには標準でSearch機能があります。

Searchはリアルタイムで行われます。例えば、

f:id:thinkAmi:20201016001229p:plain

という元データに対し、 Search欄に入力すると

f:id:thinkAmi:20201016001242p:plain

と絞り込みが行われます。

 

やりたいこと

状況によってはSearch欄以外にフォームを設けて、そこで絞り込みを行いたいこともあるかもしれません。

例えば、フォームに入力してFilterボタンを押すと、元データを

f:id:thinkAmi:20201016001450p:plain

と絞り込みたいとします。

 

実装

フロントエンド

Server Side Processingの作りとほぼ変わりありません。

ただ、filterボタンを押した時に絞り込みを行うため、

addEventHandler() {
  $('#filter').on('click', () => {
    // columnsの引数には、columnDefsで定義した列indexをセット
    this.instance.columns(1).search($('#filterName').val());
    this.instance.columns(3).search($('#filterIsBornInNagano').prop('checked'));
    this.instance.draw();
  })
}

と、DataTable APIcolumns.search() を使って、フォームの入力欄に対応した列でDataTableのSearchが動くようにしています。
https://datatables.net/reference/api/columns().search()

 
全体像はこちら

$(document).ready(function() {
  const app = new SearchApp();
  app.addEventHandler();
  app.loadDataTable();
});


class SearchApp {
  constructor() {
    // DataTableインスタンスを保持しておきたいため、クラスとして定義
    this.instance = null;
  }

  addEventHandler() {
    $('#filter').on('click', () => {
      // columnsの引数には、columnDefsで定義した列indexをセット
      this.instance.columns(1).search($('#filterName').val());
      this.instance.columns(3).search($('#filterIsBornInNagano').prop('checked'));
      this.instance.draw();
    })
  }

  loadDataTable() {
    this.instance = $('#demo').DataTable({
      autoWidth: false,
      serverSide: true,
      processing: true,
      responsive: true,
      ajax: {
        url: '/search-app/data/',
        type: 'GET',
      },
      columnDefs: [
        {targets: 0, data: 'id'},
        {targets: 1, data: 'name'},
        {targets: 2, data: 'color__name'},
        {
          targets: 3,
          data: 'born_in_nagano',
          render: data => {
            const checked = data ? 'checked="checked"' : '';
            return `<input type="checkbox" ${checked} />`;
          }
        },
      ]
    });
  }
}

 

バックエンド

もし、フォームで検索するのが文字列だけ (上記例では 品種名 だけ)であれば、django-datatables-view は一般的な使い方で検索できるようになります。

しかし、今回のモデルは

class Apple(models.Model):
    name = models.CharField('品種', max_length=50)
    color = models.ForeignKey('search_app.Color', on_delete=models.CASCADE)
    born_in_nagano = models.BooleanField('長野県生まれ')

と、 born_in_nagano 列がBooleanFieldとして定義されています。

 

BooleanFieldの何が問題かというと、 django-datatables-viewBaseDatatableView では、BooleanFieldが検索対象とならないことです。

というのも、 filter_queryset メソッドのコードを読むと、カラムごとの検索において

# column specific filter
if col['search.value']:
    qs = qs.filter(**{
        '{0}__{1}'.format(column, filter_method): col['search.value']})

となっています。

また、その中の変数 filter_method の値が FILTER_ISTARTSWITH = 'istartswith' と定義されています。

そのため、常に前方一致での検索になってしまいます。

 
そこで今回は、 filter_queryset メソッドをオーバーライドし、検索対象の列だけ特別な処理を追加します。

つまり、上記で示した部分を

# column specific filter
if col['search.value']:
    # BooleanField対応のため、if col_no を追加し、指定した列だけ処理を変える
    if col_no == 3:
        if col['search.value'] == 'true':
            qs = qs.filter(**{f'{column}': True})
    else:
        qs = qs.filter(**{
            '{0}__{1}'.format(column, filter_method): col['search.value']})

とします。

 
なお、今回のサンプルでは結果を分かりやすく確認するため、パフォーマンスのことを考えず、filter_methodは FILTER_ICONTAINS にしておきます。

Viewの全体像はこんな感じです。

class AppleSearchDataTableView(BaseDatatableView):
    model = Apple

    columns = [
        'id',
        'name',
        'color__name',
        'born_in_nagano',
    ]

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

        if col == 'born_in_nagano':
            # Booleanな項目の場合な場合、返し方によりJSでの型が異なるので注意
            # 暗黙的に返す -> JSではstring型
            # 明示的に返す -> JSではboolean型
            return row.born_in_nagano

        return super().render_column(row, col)

    def get_filter_method(self):
        """ 今回は結果をわかりやすくするために、部分一致(大文字小文字区別なし)にする
            デフォルトは、FILTER_ISTARTSWITH
        """
        return self.FILTER_ICONTAINS

    def filter_queryset(self, qs):
        """ Booleanフィールドの値はうまくSearchできない
            そのため、オーバーライドして一部を書き換える
        """
        columns = self._columns
        if not self.pre_camel_case_notation:
            # get global search value
            search = self._querydict.get('search[value]', None)
            q = Q()
            filter_method = self.get_filter_method()
            for col_no, col in enumerate(self.columns_data):
                # col['data'] - https://datatables.net/reference/option/columns.data
                data_field = col['data']
                try:
                    data_field = int(data_field)
                except ValueError:
                    pass
                if isinstance(data_field, int):
                    column = columns[data_field]  # by index so we need columns definition in self._columns
                else:
                    column = data_field
                column = column.replace('.', '__')
                # apply global search to all searchable columns
                if search and col['searchable']:
                    q |= Q(**{'{0}__{1}'.format(column, filter_method): search})

                # column specific filter
                if col['search.value']:
                    # BooleanField対応のため、if col_no を追加し、指定した列だけ処理を変える
                    if col_no == 3:
                        if col['search.value'] == 'true':
                            qs = qs.filter(**{f'{column}': True})
                    else:
                        qs = qs.filter(**{
                            '{0}__{1}'.format(column, filter_method): col['search.value']})
            qs = qs.filter(q)
        return qs

 

以上より、フォームの入力値に基づいてDataTableの絞り込みが行えるようになりました。

 

参考:Search機能との連動について

JavaScript側で columns.search() を使っているため、DataTableのSearch機能と追加したフォームを併用できます。

例えば、まずSearchで絞り込み、

f:id:thinkAmi:20201016003720p:plain

 

続いて、フォームで更に絞り込む、ということができます。

f:id:thinkAmi:20201016003743p:plain

 

ソースコード

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

*1:インスタンスグローバル変数に保持すれば、classを使わなくて済みそうですが...

django-datatables-viewによるServer-side processingで、モデルの複数列を結合して表示する

django-datatables-viewで、モデルの複数列を結合して表示しようと考えた時に詰まったことがあったため、メモを残します。

 
目次

 

環境

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

 

やりたいこと

以下のような2つのモデルがあったとします。

class Color(models.Model):
    name = models.CharField('色', max_length=10)


class Apple(models.Model):
    name = models.CharField('品種', max_length=50)
    color = models.ForeignKey('concat_col_app.Color', on_delete=models.CASCADE)
    breeding = models.CharField('交配', max_length=100)

 
DataTableの表示では、 colorname の値を組み合わせ、以下のように タイトル 列として表示したいとします。

f:id:thinkAmi:20201011105435p:plain

 

エラーとなる方法

画面の表示に関するものなので、Django側は render_column を使い

class AppleConcatDataTableView(BaseDatatableView):
    model = Apple

    columns = [
        'id',
        'title',  # 組み合わせて表示したい列を追加
        'breeding',
    ]

    def render_column(self, row, col):
        # title列が入ってきたら、colorとnameを組み合わせて表示
        if col == 'title':
            return f'[{row.color.name}] {row.name}'

        return super().render_column(row, col)

としてみました。

また、JavaScript側も、 title 列を受け取れるようにします。

$('#demo').DataTable({
  autoWidth: false,
  serverSide: true,
  processing: true,
  responsive: true,
  ajax: {
    url: '/concat-col-app/data/',
    type: 'GET',
  },
  columnDefs: [
    {targets: 0, data: 'id'},
    {targets: 1, data: 'title'},
    {targets: 2, data: 'breeding'},
  ]
});

 
しかし、初期表示はうまくいくものの、Searchやタイトル列をクリックするとエラーが表示されてしまいます。

f:id:thinkAmi:20201011110126p:plain

 
また、Djangoのログにも以下が記録されています。

...
    raise FieldError("Cannot resolve keyword '%s' into field. "
django.core.exceptions.FieldError: Cannot resolve keyword 'title' into field. Choices are: breeding, color, color_id, id, name

 

動作する方法

Djangoのログを見ると、Djangoのmodelに title が見当たらないのが原因のようです。

READMEにある Another example of views.py customisation のうち、 get_initial_queryset メソッドの説明を読むと

def get_initial_queryset(self):
    # return queryset used as base for futher sorting/filtering
    # these are simply objects displayed in datatable
    # You should not filter data returned here by any filter values entered by user. This is because
    # we need some base queryset to count total number of records.
    return MyModel.objects.filter(something=self.kwargs['something'])

https://bitbucket.org/pigletto/django-datatables-view/src/master/

と書かれています。

そのため、django-datatables-viewの get_initial_queryset メソッドをオーバーライドしてQuerySetに title 属性を追加することで、filterやsortにも対応できそうです。

 
そこで、Django側のViewを修正します。

今回はレコードごとに列を追加するため、Djangoの annotate句と

を使うこと、出力したい [色] 品種名 という形式の値を持つ title 属性を追加します。

from django.db.models import Value
from django.db.models.functions import Concat
from django_datatables_view.base_datatable_view import BaseDatatableView

from concat_col_app.models import Apple


class AppleConcatDataTableView(BaseDatatableView):
    model = Apple

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

    def get_initial_queryset(self):
        # title列を追加
        return super().get_initial_queryset().annotate(
            title=Concat(Value('['), 'color__name', Value('] '), 'name')
        )

 
なお、表示内容については、get_initial_querysetで作成した title 列の値そのもので良いため、今回は render_column のオーバーライドは不要です。

 
これにより、Searchやタイトル列クリックによるソートを行っても動作するようになりました。

以下の画像でも、タイトル列の記号にある通り、タイトル列でのソートができていることが分かります。

f:id:thinkAmi:20201011111402p:plain

 

ソースコード

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

django-datatables-viewによるServer-side processingで、色々なソートを試してみた

ライブラリ django-datatables-view を使ってjQuery DataTableのServer Side Processing を行った際、ソートを実装することがありました。

ただ、ソートを実装しようとしたところ詰まったことがあったため、メモを残します。

 

目次

 

環境

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

 

ソートを意識しない時の実装

このようなモデルがあったとします。

class Color(models.Model):
    name = models.CharField('色', max_length=10)


class Apple(models.Model):
    name = models.CharField('品種', max_length=50)
    color = models.ForeignKey('sort_app.Color', on_delete=models.CASCADE)
    breeding = models.CharField('交配', max_length=100)
    season = models.IntegerField('旬')
    born_in_nagano = models.BooleanField('長野県生まれ')

 

このAppleモデルをDataTableに表示します。

その時の要件として、

  • season (旬) が実行月と一致した場合は、チェックボックスにチェックを入れる
  • 最初は、id列でソート

があった時に、以下のような表示をしたいとします。

f:id:thinkAmi:20201010212004p:plain

 

この時の django-datatables-view を使ったViewの実装はこんな感じです。

class AppleAllFieldDataTableView(BaseDatatableView):
    model = Apple

    columns = [
        'id',
        'name',
        'color__name',
        'breeding',
        'season',
        'born_in_nagano',
    ]

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

        if col == 'season':
            if row.season == timezone.now().month:
                return True
            return False

        if col == 'born_in_nagano':
            # Booleanな項目の場合な場合、返し方によりJSでの型が異なるので注意
            # 暗黙的に返す -> JSではstring型
            # 明示的に返す -> JSではboolean型
            return row.born_in_nagano

        return super().render_column(row, col)

注意点として、Djangoのモデルで BooleanField は、そのままJavaScript側に渡すと string 型になります。

それでは扱いづらいので、 render_column() メソッドをオーバーライドし、明示的にPythonのTrue/Falseを返すようにします。これにより、JavaScriptには boolean 型の値が渡されます。

 
続いて、JavaScript側の実装です。

DataTableの中でチェックボックスを表示できるよう、render 属性を使って描画するHTMLを指定します。

$('#all-columns').DataTable({
  autoWidth: false,
  serverSide: true,
  processing: true,
  responsive: true,
  pageLength: 25,  // サンプルデータ量が多いため、デフォルト値を変えておく
  ajax: {
    'url': '/sort-app/all-columns/data/',
    'type': 'GET',
  },
  columnDefs: [
    {targets: 0, data: 'id'},
    {targets: 1, data: 'name'},
    {targets: 2, data: 'color__name'},
    {targets: 3, data: 'breeding'},
    {
      targets: 4,
      data: 'season',
      render: data => {
        const checked = data ? 'checked="checked"' : '';
        return `<input type="checkbox" ${checked} />`;
      }
    },
    {
      targets: 5,
      data: 'born_in_nagano',
      render: data => {
        const checked = data ? 'checked="checked"' : '';
        return `<input type="checkbox" ${checked} />`;
      }
    },
  ],
});

 
なお、JavaScript側の render()Django側の render_column() は、あくまで見た目を整えるだけであり、実際のデータとは異なります。

そのため、 列でソートしたとしても、DB上のデータは xx月であることから、チェックボックスがONの順番ではソートされません。

f:id:thinkAmi:20201010214057p:plain

 
これをベースに、いろいろ試していきます。  
 

複合ソートキーでの結果を初期表示する場合

JavaScript側で対応が必要になります。

jQuery DataTableでは、初期表示するソートキーの列は order 属性を使います。
DataTables example - Default ordering (sorting)

[ソートキー列のindex, ソート順文字列] という要素を用意し、それをArrayで指定します。ソート順文字列は ascdesc のどちらかを指定します。

$('#all-columns').DataTable({
  serverSide: true,
  processing: true,
  // ...
  columnDefs: [ /* 略 */ ]
  order: [
    [3, 'asc'],
    [5, 'desc'],
    // ソートキーが重複した場合にもソートが常に一定となるよう、idをソートキーに加えておく
    [0, 'asc'],
  ],
});

 
これにより、

  1. 交配
  2. 長野県生まれ
  3. ID

の複合キーで、指定した順(昇順/降順)にて、ソートされるようになりました。

f:id:thinkAmi:20201010212933p:plain

 

ソート不可列がある時のソートについて

JavaScript側、Django側の双方で対応が必要になります。

JavaScript側の修正

DataTableでソート不可列を指定するには、columnDefsの中で該当の列に orderable: false を指定します。

また、ソート不可列を order 属性に含めないよう、注意します。

$('#some-columns').DataTable({
  // ...
  columnDefs: [
    {targets: 0, data: 'id'},
    // ...
    {
      targets: 5,
      data: 'born_in_nagano',
      searchable: false,
      orderable: false,  // ソート不可
      render: data => {
        const checked = data ? 'checked="checked"' : '';
        return `<input type="checkbox" ${checked} />`;
      }
    },
  ],
  order: [
    // ソート不可な列を追加するとエラーになるので注意
    [3, 'asc'],
    [0, 'asc'],
  ]
});

 

Django側の修正

django-datatables-viewのREADMEにあるサンプルを読むと、

# https://bitbucket.org/pigletto/django-datatables-view/src/master/

# define column names that will be used in sorting
# order is important and should be same as order of columns
# displayed by datatables. For non sortable columns use empty
# value like ''
order_columns = ['number', 'user', 'state', '', '']

との記載があります。

そのため、 order_columns を追加で定義し、ソート不可項目のIndexの要素には、空文字を指定します。

class AppleSomeFieldDataTableView(BaseDatatableView):
    model = Apple

    columns = [
        'id',
        'name',
        'color__name',
        'breeding',
        'season',
        'born_in_nagano',
    ]

    order_columns = [
        'id',
        'name',
        'color__name',
        'breeding',
        'season',
        '',  # born_in_nagano部分はソート不可にする
    ]

    def render_column(self, row, col):
        ...

 

結果

f:id:thinkAmi:20201010214131p:plain

 

権限により、列の表示可否が変わる時のソートについて

例えば、「ログインしている場合のみ、 交配 列を表示する」という仕様が追加になったとします。

この場合、

  • ログイン状態により、DataTableの列表示可否を変更
  • ソート機能を変更

の2つの対応が必要になります。

 
そのため、順番に作業を行います。

 

ログイン状態により、DataTableの列表示可否を変更
テンプレートの変更

DataTableのヘッダの表示を動的に変更します。

<table id="perms-columns" class="table table-striped table-bordered dataTable" style="width:100%">
  <thead>
  <tr>
    <th>ID</th>
    <th>品種</th>
    <th></th>

    {# 交配は、ログインしていれば見える #}
    {% if request.user.is_authenticated %}
      <th>交配</th>
    {% endif %}

    <th></th>
    <th>長野県生まれ</th>
  </tr>
  </thead>
</table>

 

JavaScript側の変更

columnDefsを動的に制御するため、以下の関数を追加します。

function getColumnDefs() {
  const results = [];
  let colIndex = 0;
  results.push({targets: colIndex++, data: 'id'});
  results.push({targets: colIndex++, data: 'name'});
  results.push({targets: colIndex++, data: 'color__name'});

  if ($('#username').length) {
    results.push({targets: colIndex++, data: 'breeding'});
  }
  results.push({
    targets: colIndex++, data: 'season',
    render: data => {
      const checked = data ? 'checked="checked"' : '';
      return `<input type="checkbox" ${checked} />`;
    }
  });
  results.push({
    targets: colIndex++, data: 'born_in_nagano',
    render: data =>{
      const checked = data ? 'checked="checked"' : '';
      return `<input type="checkbox" ${checked} />`;
    }
  });

  return results;
}

 
そして、この関数の戻り値を、columnDefsに指定します。

なお、 columnDefs: () => { /* .. */ } のような定義では動作しませんでした。

columnDefs: getColumnDefs(),

 

Django側の修正

get_columns メソッドをオーバーライドし、ログインしている場合のみ 交配 列を追加します。

class ApplePermsFieldDataTableView(BaseDatatableView):
    model = Apple

    def get_columns(self):
        results = [
            'id',
            'name',
            'color__name',
        ]

        if self.request.user.is_authenticated:
            results.append('breeding')

        results.extend([
            'season',
            'born_in_nagano',
        ])

        return results

 

ソート機能を変更

列表示が動的になる場合、JavaScriptDjango側で表示する列数を合わせる必要があります。具体的には以下の4項目です。

 
ここまでで対応していないのはJavaScript側の order ですので、対応を行います。

まずは columnDefs同様、列を動的に変更する関数を用意します。

function getOrder() {
  const results = [];
  let bornIndex = 4;  // 非表示時の "born_in_nagano" 位置

  if ($('#username').length) {
    results.push([3, 'asc']);
    bornIndex++;  // 列が増えるため、"born_in_nagano" 位置を修正
  }
  results.push([bornIndex, 'desc']);

  // ソートキーが重複した場合にもソートが常に一定となるよう、idをソートキーに加えておく
  results.push([0, 'asc']);

  return results;
}

 

続いて、 order属性にこの関数の戻り値を割り当てます。

$('#perms-columns').DataTable({
  // ...
  columnDefs: getColumnDefs(),

  // 変更
  order: getOrder(),
});

 

結果

以上の対応により、動的に列数が変わったとしても、初期ソート状態やソート機能が利用できるようになります。

 

未ログイン時

交配 列が表示されず、 長野県生まれ 列の降順だけでのソートとなります。

f:id:thinkAmi:20201010215509p:plain

 

ログイン済時

交配 列の昇順・ 長野県生まれ 列の降順でソートされています。

f:id:thinkAmi:20201010215716p:plain

 
 

ソースコード

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