PostgreSQL : 正規化されたテーブルをSQLだけで入れ子のJSONにする

月花です。

SQL芸人としての活動のメモです。

今回は、正規化された一対多のテーブルから、多の方を一行を連想配列JSONにしながら、複数の行をJSONにする、入れ子構造のJSONを抽出します。
結果的になんてことない単純なクエリになったのですが、割とパズル的に悩んでしまったので、残しておきます。

3人の生徒に、それぞれに2回の試験があり、その合計6つの結果を生徒ごとに分割して描画したい、というシナリオで書いていきます。
例では、PHPでHTMLを出力することにします。

通常であれば、普通にJOINして6レコードをループし、
生徒ごとにまとめてさらに3回のループを行うか、
ソートしてから、前のループと生徒IDが違えば閉じタグと開きタグを出力する、
みたいなことになりますが、条件式多くて読みづらいです。
なので、生徒ごとにまとめるところまでSQLにやらせてしまいましょう。レコードの集約はJSONにすれば、言語に依存しません。

目次です。

  • テーブル構造
    • 生徒テーブル students
    • 試験テーブル exams
    • 生徒-試験リレーションテーブル student_exams
  • この例での求めたいデータ
    • 普通にJOINしてみた場合
    • 理想の抽出結果
  • アプローチ
    • 横に広がっている試験データを連想配列に集約する
    • 生徒ごとに連想配列にした試験データを集約する
  • 取れたレコードをPHPで扱う

テーブル構造のサンプル

例では、生徒テーブルと試験テーブル、そしてそれらを繋ぐ生徒-試験リレーションテーブルの3つのテーブルが存在しているとする。
そこから、生徒テーブルを軸にして、生徒数分のレコードの中に、複数の試験をまとめて収めるJSONを書いていく。

生徒テーブル students

student_id name
1 田中
2 佐藤
3 木村

試験テーブル exams

exam_id name date math_point japanese_point english_point
1 中間試験 2017-09-10 10 20 30
2 中間試験 2017-09-10 40 50 60
3 中間試験 2017-09-10 70 80 90
4 期末試験 2017-09-30 15 25 35
5 期末試験 2017-09-30 45 55 65
6 期末試験 2017-09-30 75 85 95

なお、本来であれば試験名と日付も正規化するべきであるが、今回の話にはそれほど影響しないので、簡略化のために今回は正規化しない。

生徒-試験リレーションテーブル student_exams

student_id exam_id
1 1
2 2
3 3
1 4
2 5
3 6

この例での求めたいデータ

普通にJOINしてみた場合

SELECT
    s.*,
    e.*
FROM
    students s
    JOIN student_exams se ON s.student_id=se.student_id
    JOIN exams e ON se.exam_id = e.exam_id;

で、こうなる

student_id name exam_id name date math_point japanese_point english_point
1 田中 1 中間試験 2017-09-10 10 20 30
2 佐藤 2 中間試験 2017-09-10 40 50 60
3 木村 3 中間試験 2017-09-10 70 80 90
1 田中 4 期末試験 2017-09-30 15 25 35
2 佐藤 5 期末試験 2017-09-30 45 55 65
3 木村 6 期末試験 2017-09-30 75 85 95

しかし、これでは、たとえばPHPでこれを処理する場合、愚直にやれば6回ループになり、テーブルならtrタグが6つできたりする。
そういうテーブルを描画するならそれでいいが、生徒ごとのブロックとして閉じタグを挟みたい場合に厄介となる。
なので、3つにどうにか収めるように配列を操作するループを別途書いたり、ソートしてから生徒が変わったかを検知するif文を書いたりしないといけないので、端的にいってクソである。
なので、3レコードにしたい。

理想の抽出結果

student_id name exam_json
1 田中 {試験内容のJSON}
2 佐藤 {試験内容のJSON}
3 木村 {試験内容のJSON}

こうなればよい。
このJSONには、例でいえば、配列長が2で、その中には試験のデータをいれた1つの連想配列がはいっていればいい。
3回のループで生徒ごとに区切りつつ、内部のループで2つの試験のデータを出力すればいいのだ。
この条件に合う結果のJSONは、たとえばこうなる。

"[
    {"student_id":1,"student_name":"田中",
     "exam_data":[
        {"exam_id":1,"exam_name":"中間試験","date":"2017-09-10","math_point":10,"japanese_point":20,"english_point":30},
        {"exam_id":4,"exam_name":"中間試験","date":"2017-09-30","math_point":15,"japanese_point":25,"english_point":35}
      ]
    },
    {"student_id":2,"student_name":"佐藤",
     "exam_data":[
        {"exam_id":2,"exam_name":"中間試験","date":"2017-09-10","math_point":40,"japanese_point":50,"english_point":60},
        {"exam_id":5,"exam_name":"中間試験","date":"2017-09-30","math_point":45,"japanese_point":55,"english_point":65}
      ]
    }
]"

長くなったが、このJSONを作り、上のレコードを抽出することを目的とする。

アプローチ

まず、これを実現するためには、2回の集約が必要となる。

  1. 横に広がっている試験データを連想配列に集約する
  2. 生徒ごとに連想配列にした試験データを集約する

横に広がっている試験データを連想配列に集約する

まずは、試験データを1レコードずつ連想配列に変換していく。
次のステップで生徒IDを使うので、ここで生徒-試験リレーションテーブルをJOINして、生徒IDも含めてしまおう。

student_id exam_id json
1 1 {試験内容のJSON}
1 4 {試験内容のJSON}
2 2 {試験内容のJSON}

(省略)

このためには、json_agg関数を用いる。
生徒を軸に、試験テーブルの1レコードごとに集約するために、GROUP BYには生徒ID、試験IDを指定して、試験テーブルのデータを集約する。
そのためのクエリは、単純に上記を落とし込んで、こうなる。

SELECT
    student_id,
    e.exam_id,
    json_agg(e) AS json
FROM student_exams se
    JOIN exams e ON se.exam_id=e.exam_id
GROUP BY student_id,e.exam_id
ORDER BY student_id ASC

すると結果は、このようになる。

student_id exam_id json
1 1 {[{"exam_id":1,"name":"中間試験","date":"2017-09-10T00:00:00","math_point":10,"japanese_point":20,"english_point":30}]
2 4 [{"exam_id":4,"name":"期末試験","date":"2017-09-30T00:00:00","math_point":15,"japanese_point":25,"english_point":35}]
3 2 [{"exam_id":2,"name":"中間試験","date":"2017-09-10T00:00:00","math_point":40,"japanese_point":50,"english_point":60}]

(省略)

ちなみに、json_agg関数の引数をカラムではなくテーブルにした場合、 table_name.* と同義になる。
すべてのカラムを出すしかないので、カラムを絞りたい場合は、サブクエリにするかWITH句で追い出しておく必要がある。

ここで、当初の目論見とはことなり、連想配列が配列長1の配列に入ってしまっていることに留意しておく。

生徒ごとに連想配列にした試験データを集約する

次に、生徒ごとに集約するので、今度はGROUP BYに生徒IDのみを指定し、再度json_aggにかけてやればよい。
postgresのjson_aggは内部でjson型を扱えるため、
JSONの中に「JSONの形をしたstring」が入ってしまい、最終的にjson_decodeした結果をjson_decodeする必要がある、
なんてことにならないようなツリー状にまとめてくれる。
そのため、そこのところは考慮せず、単純にさきほどのクエリをサブクエリとして、json_aggをかけてやるだけでよい。

SELECT
    student_id,
    json_agg(jsons.json) AS exam_json
FROM
    (
        --ここから
        SELECT
            student_id,
            e.exam_id,
            json_agg(e) AS json
        FROM student_exams se
            JOIN exams e ON se.exam_id=e.exam_id
        GROUP BY student_id,e.exam_id
        ORDER BY student_id ASC
        --ここまで先程のクエリ
    ) jsons
GROUP BY student_id;

これだけでよい。
そして結果は下記となる。

student_id exam_json
1 {[[{"exam_id":1,"name":"中間試験","date":"2017-09-10T00:00:00","math_point":10,"japanese_point":20,"english_point":30}], [{"exam_id":4,"exam_name":"期末試験","date":"2017-09-30T00:00:00","math_point":15,"japanese_point":25,"english_point":35}]]
2 [[{"exam_id":2,"name":"中間試験","date":"2017-09-10T00:00:00","math_point":40,"japanese_point":50,"english_point":60}], [{"exam_id":5,"exam_name":"期末試験","date":"2017-09-30T00:00:00","math_point":45,"japanese_point":55,"english_point":65}]]
3 [[{"exam_id":3,"name":"中間試験","date":"2017-09-10T00:00:00","math_point":70,"japanese_point":80,"english_point":90}], [{"exam_id":6,"exam_name":"期末試験","date":"2017-09-30T00:00:00","math_point":75,"japanese_point":85,"english_point":95}]]

生徒3人と試験6回のデータが3レコードとなって収まった。

SQLはここまでで、ここからはビジネスロジックのお話になる。
ここでは、PHPで行うとするが、JSONなので言語には依存しない。

取れたレコードをPHPで扱う

想定仕様は、生徒ごとに開きタグと閉じタグを出力し、生徒ごとのブロックを描画するものである。
取れたレコードを $records に入れたとすると、下記のようなPHPで扱う事ができる。

<?php

(省略)

// 生徒ごとのループ
foreach( $records as $record){
    // 開きタグ描画処理
    
    // jsonから配列に変換(オブジェクトに変換するなら第二引数は省略かfalse)
    $exam_array = json_decode($record['exam_json'], true);

    // 試験ごとのループ
    foreach( $exam_array as $exam_data_array ){
        // ここで、この中身は配列長が1のデータだったことを思い出そう
        // 仕様上そうなってしまうだけなので、先頭要素を取るだけで良い
        $exam_data = current( $exam_data_array );

        // $exam_data['name'] や、
        // $exam_data['date'] で、試験のデータが取れるので処理や描画
    }

    //閉じタグ描画処理
}

以上で、postgresに仕事をさせ、PHPに楽をさせることができた。

例では単純なものだったのでJOINが少なかったが、本来はもっと正規化してあるはずなのでサブクエリがごちゃつきがち。
WITH句やviewに、いかにわかりやすく追い出すかが出来るSQL芸人になれるかどうかのカギである。たぶん。

Air Mac Extremeを買った

月花です。
Air Mac Extremeを買いました。

www.apple.com


かねてより、ほしいとは思っていたのだ

  • ルータの調子が悪かった
  • macのバックアップしないとそろそろヤバイ
  • ファイルぽんぽんおける場所が欲しかった
  • PS4とつながればハッピー
    • ( 結局これはだめだった)

というわけで、購入時期が早くなったのだ。

ひととおりイカとFF14をしてみたが、ものすごく快適だ。
当然ルータを変えただけで上流はそのままなので、上り下りが速くなるわけではない。
深夜3時に130Mbpsしか出ないようなベストエフォート回線がルータのカタログスペックに追いつくことはないからそこはどうでもよい。

速度ではなく安定性が段違いによいらしく、イカをやれば22時前というピークタイムにも関わらずひっかかりなくキレッキレの動作をし、FF14をやれば逃げ遅れなどの失敗は自覚できたもののみだった。

これはよい買い物をしたぞと思ったしもっと早く買うべきだった。
まあでも最後まで読めばわかるけど、本当にMacユーザにしか値段相応の恩恵がなさそうなので、全体的には割とピーキーな感じ。

ルータの調子が悪かった

これはもう散々で、イカをすれば自分も味方も不審死し、FF14をやればギミックは失敗してタンクは気づけばHPがゼロ。
FF14しかやってなかったら単にヘタクソすぎワロタってだけなのだが、イカも不審とくれば、この2つはゲームシステムが違うのでまず間違いない。
イカもヘタすぎワロタって感じだけど、そこは無視しよう。

思えば長い付き合いだった。
就職して大阪に越してきたのが3年前の4月で、そのときに、ついでにルータを買い替えた。
とはいっても、安いルータから安いルータに変えただけで、へたをすれば前より安いルータになっていたかもしれなかった。
そこへいきなり2万のルータが黒船来航、すでにゴミ袋の中である。

macのバックアップしないとそろそろヤバイ

このMac Book Airとも長い。
購入時期がEarly2014に間に合わず、Mid2013の製品なので、そろそろ4年が経とうとしている。
2回ほどクリーンインストールはしているが、ハード的にそろそろだろう。

いよいよTime Machineか、というところだが、私にTime Machineを使いこなせると思わない。
ノートPCであるMac Book Airに外付けHDDを定期的に差し込み、バックアップをさせるという動作、絶対にしないからだ。
このAir Mac Extremeに接続した外付けHDDをTime Machine用のディスクとして設定できて、LANでつながっている限り、常にTime Machineがバックアップを取ってくれる。
これくらいしてくれないと私はバックアップをしない人間なんだ。もっと甘やかしてほしい。

PS4とつながればハッピーだった

Air Mac Extremeの裏にはUSBポートがあり、外付けHDDをNASのように扱える。
てきとうに軽く調べたら、どうもPS4が認識しそうなことが書いてあったんだけど、そうはうまくいかなかった。
そもそも、NASに対応しているのは、私が共有したかったキャプチャーギャラリーではなく、メディアプレイヤーだったのだ。

当初の筋書きとしては、
PS4スクリーンショットUSBメモリを使ってPCへ運ぶのが不便だから解決のためにNASであったのだが、やはりそれはだめだった。

まあどうせ置き場所近いし、そこまで頻繁にスクショ移さないし、外付けHDDの繋ぎ先を都度変えればいいだろうってことでそうした。
PS4のUSB端子は、キーボードとコントローラでふさがっているので、USBメモリを挿しっぱなしなどできず、それよりは楽だ。

Air Mac ExtremeexFAT読めない問題。

PS4が認識するのはFAT32exFATなので、じゃあとexFATパーティションを切ってスクショをPS4からコピーしたら、Air Mac Extremeが「ディスクの修復が必要です」と言い出した。
よく調べてみると、何の事はない、Air Mac ExtremeexFATに対応していないだけで、別に修復は必要なかった。こいつは手のかかる二万円だ。

しかたがないので、FAT32は表向きは32GBまでが対応ということなので、ためしに128GBの領域をFAT32で切り出してみたが、そうするとPS4が対応していない。
まあそうだよね。

しょうがないから、32GBの領域をFAT32で切り出して、どちらからも読み書きできることを確認した。
結果的にはUSBメモリよりも容量が小さくなった。ちょっと涙をこらえた。

CSS : 要素数・ブラウザ幅が可変長なサイトでflexをキレイに使う

月花です。
今回はCSSの備忘録です。

内容は、幅が可変長のサイトにおける、flexboxで要素は左寄せ、全体は中央寄せとする方法です。

テキストだと説明しづらいので、画像をつかっていきます。

やりたいこと

このようなレイアウトを組みたいとする。
f:id:gekka9:20170728122308p:plain
コードでは下記のようになる。

See the Pen zdvwqQ by gekka (@gekka9) on CodePen.

しかし、同じCSSで横幅が少しでも異なると

See the Pen zdvwBQ by gekka (@gekka9) on CodePen.


このように、一見入りそうだけど、実はマージン込みでは入らず、下に落ちてしまう。
この右側のスキマが気になるので、直していく。

よくある失敗例

flexには、幅全体を使って等間隔に割り付ける、 justify-content:space-around という指定がある。
しかし、これを単純につかってしまうと・・・。

See the Pen YxyZMX by gekka (@gekka9) on CodePen.

このように、最下段がおかしなことになる。

解決方針

一段あたりの要素の個数が決まっているなら、よくあるハウツーでは剰余の数だけ空の要素を入れればいい。
しかし今回は、ブラウザの幅によって個数を変えて、常に入りうる最大の数を一段に押し込みたい。

つまり、下記のようになるのが理想である。
f:id:gekka9:20170728122322p:plain

このためには、下記の X から Y を計算し、 Y/2 を margin-left に設定することでできるはずだ。
f:id:gekka9:20170728122327p:plain
これも、数か幅のどちらかが固定ならば、シンプルに定まるものだが、その場合は計算せずとも space-around を使えばよいだけだ。

今回は可変長のため、一行あたりに何個入っているかをHTMLとCSSは知らないので、全体の幅から要素の横幅に個数をかけたものを引くなどというような、個数を用いる計算はできない。

そこで、個数に依存しない計算方法として剰余を用い、
・ Y = 100%(px) mod X(px)
と求めることができる。

もし、CSS の calc に剰余が実装されているならば、これでいけるはずだった。
しかし現実は非情である。実装されてないばかりか、そもそもcalcはモダンブラウザしか使えないのでIEが死ぬ。

ここはおとなしくJavaScriptを持ち出すことにする。

JavaScript を使う戦略

上記の、 Y = 100%(px) mod X(px) という計算式において、 100% と X をピクセルで用いるには、単純にJSで幅を取ってやればいいだけなので、ページロード時とリサイズ時に計算をして、CSSを書き換えてやる。
ただし、 X にマージンを含めなければならないため、 .outerWidth(true) を用いることが必要なことに留意する。

See the Pen oejwep by gekka (@gekka9) on CodePen.

これで、どのようなブラウザの幅でも、どのような要素でも柔軟に対応できる要素グリッドが出来上がった。

Postgres・MDB2のキャスト演算子とプレースホルダ

月花です。

PDOによるデータベース操作の話をします。

今回はこんな話です

ことの発端

MDB2を使ったフレームワークで、Postgresから疑問符プレースホルダを使ったINSERTを行った際に、不思議なエラーが発生した。

INSERT INTO test (id,name,value) VALUES (1,NULL::TEXT,?);

こういうクエリをprepareして、

array('test')

を食わせてexecuteするような、一見問題のないINSERT文を実行した。

すると、

〜〜〜
MDB2 Error: unknown error
〜〜〜
[Native message: ERROR:  入力の最後で 構文エラー
LINE 1: INSERT INTO test (id,name,value) VALUES (1,NULL$1

とのこと。

不思議なエラー文だ。

糸口を探る

[Native message: ERROR:  入力の最後で 構文エラー
LINE 1: INSERT INTO test (id,name,value) VALUES (1,NULL$1

ということで、$1がどうにも気になる。
これはたぶん、プレースホルダだ。

原文では、NULLがベタ書きされている箇所は一個しか無いので、そうすると

NULL::TEXT

が気になってきた。

もしかして

INSERT INTO test (id,name,value) VALUES (1,CAST( NULL AS TEXT ),?);

コロンでのキャスト演算子ではなく、こうなら・・・・

いけた。

ということは、

  • コロン2つによるキャストが影響している
  • どうも不当にプレースホルダとして解釈されている
  • コロンを使わなければ治る

となれば原因は

結局は、

INSERT INTO test (id,name,value) VALUES (1,NULL::TEXT,?);

この、 NULL::TEXT の、 :TEXT が名前付きプレースホルダとして解釈され、 NULL$1 となり、さらに元々の疑問符プレースホルダとの競合もあり、構文エラーとなったようである。

ところが、これそんなはずはなく、正しい文法のはずだ。
ためしにいくつかのSQLクライアントから、疑問符プレースホルダを適当な値に変換したものを実行したが、どれも成功した。

ということは、フレームワーク・・・と思ったがこいつはMDB2との橋渡しをしているだけだ。

というわけでMDB2を探っていく。

環境の再現

適当な VirtualBoxMDB2をインストールして、下記のような環境にした。

# pear list
Installed packages, channel pear.php.net:
=========================================
Package           Version State
Archive_Tar       1.4.2   stable
Console_Getopt    1.3.1   stable
MDB2              2.4.1   stable
MDB2_Driver_pgsql 1.4.1   stable
PEAR              1.9.5   stable
Structures_Graph  1.0.4   stable
XML_RPC           1.5.4   stable
XML_Util          1.2.3   stable

これで、さくっと書いて実行する。

require_once 'MDB2.php';

$dsn = 'pgsql://***:***@***/***';
$options = array(
    'debug' => 2,
    'result_buffering' => false,
);

$mdb2 = MDB2::connect($dsn);
if (PEAR::isError($mdb2)) {
    die($mdb2->getMessage());
}else{
    $sql="INSERT INTO test (id,name,value) VALUES (1,NULL::TEXT,?);";
    $statement=$mdb2->prepare($sql);
    if (PEAR::isError($statement)){
        echo $statement->getDebugInfo();
    }else{
        $statement->execute(array('test'));
    }
}

$mdb2->disconnect();

とすると、やはり同じエラーが出た。
ということで、MDB2が悪いことが確定して一安心。

解決

そもそも、キャスト演算子がコロンでできている以上、検索のしにくさったら。
過去の例が全然見当たらない。

こういうときはとりあえずバージョンアップをしてみよう。

# pear install MDB2-2.5.0b5
# pear install MDB2_Driver_pgsql-1.5.0b4
#
# pear list
Installed packages, channel pear.php.net:
=========================================
Package           Version State
Archive_Tar       1.4.2   stable
Console_Getopt    1.3.1   stable
MDB2              2.5.0b5 beta
MDB2_Driver_pgsql 1.5.0b4 beta
PEAR              1.9.5   stable
Structures_Graph  1.0.4   stable
XML_RPC           1.5.4   stable
XML_Util          1.2.3   stable

とすると、あっさり成功したのでした。

なので、MDB2ChangeLogを読み漁る。
すると、MDB2_Driver_pgsql 1.5.0a1 において、

Changelog:

〜〜〜

- fixed bug #11652: failed prepared queries containing the "::type" style of casting

〜〜〜

なんと、2007-07-20 12:38 UTC に報告された不具合である。
10年前て・・・

ELB配下のEC2のうち、1つだけをIPアドレス制限する

月花です。

またAWSでハマってあれやこれややったので備忘録です。

やりたかったけどできなかったこと

ELB配下に複数のEC2インスタンスがあり、うち1つだけをIPアドレス制限する。
セキュリティグループでできると勘違いしていたのだが、実際はELBからEC2の通信を許可している以上、ELBにアクセスできればEC2にアクセスできるため、セキュリティグループの変更では不可能だった。

また、該当のELBにEIPを紐付け、Route53から飛ばすことで、違うURLとはなるが、1基だけIPアドレス制限をしようとしたが、AWSにおいて証明書はELBかCloudFrontにしか結びつかないため、HTTPS接続ができなかった。
図にするとこうなっているためだ。

クライアント <--HTTPS--> ELB(証明書あり) <--HTTP--> EC2(証明書なし)

このためAWSの機構では無理なので、Apacheの設定でどうにか設定した。

Apacheの設定を変更する

実際には.htaccessでやったが、httpd.confでやっても同じことと思われる。

X-Forwarded-For

筆者は全然知らなかったが、こういう名前のHTTPヘッダフィールドが存在する。
内容としては、クライアントのIPアドレスが入っている。
まさに、冒頭のような構成で、実際には負荷分散システムのIPアドレスでアクセスされてしまう際に使われるものだ。
一般的な負荷分散システムであれば、この値を引き渡してくれるらしい。

今回は、この値をホワイトリストとすることで解決した。

CIDR記法と正規表現

さきほどのX-Forwarded-Forの値をホワイトリストとして指定するにあたって、

xxx.xxx.xxx.xxx/26

といった、いわゆるCIDR記法は受け付けてくれない。
なので、このCIDR記法の範囲にあるIPアドレス全てを指定しなければならない。
ところがそれでは億劫なので、正規表現に変換してやる。

筆者は下記のサイトを使って変換した。
d.xenowire.net

このサイトで、許可したい正規表現を作って、それを指定する。

実際に記述する

先程の正規表現を使って、まずはホワイトリストの1件1件を定義する

SetEnvIf X-Forwarded-For "123\.45\.78\.(9[4-9]|[7-9][0-9]|1[0-1][0-9]|12[0-7])" allowed_ip1
SetEnvIf X-Forwarded-For "12\.3\.45\.(6[4-9]|[7-9][0-9]|1[0-1][0-9]|12[0-7])" allowed_ip2

2件あるならばこんな感じ。
「allowed_ip〜」の部分はエイリアスなので、好きな名前をつけよう。

そして、まず全てのアクセスを拒否、次に先程定義したものを許可する。

Order Deny,Allow
Deny from All
Allow from env=allowed_ip1
Allow from env=allowed_ip2

一連の流れとしては、

SetEnvIf X-Forwarded-For "123\.45\.78\.(9[4-9]|[7-9][0-9]|1[0-1][0-9]|12[0-7])" allowed_ip1
SetEnvIf X-Forwarded-For "12\.3\.45\.(6[4-9]|[7-9][0-9]|1[0-1][0-9]|12[0-7])" allowed_ip2
Order Deny,Allow
Deny from All
Allow from env=allowed_ip1
Allow from env=allowed_ip2

こうなる。

今回は.htaccessに書いたので、ディレクトリの指定はしていないが、httpd.confに書くときは、ディレクトリの指定が必要になる場合もある。

おわり。

jQuery : セレクタフィルタの様々な罠

月花です。

チェックボックスjQueryでわちゃわちゃしていたときにハマったのでメモします。
jQuery、卒業したいですね。

前提

こういうフォームがあります。

<!doctype html>
    <body>
        <form name="hoge">
            <label><input type="checkbox" name="fruits[]" value="4種盛り合わせ">4種盛り合わせ</label>
            <label><input type="checkbox" name="fruits[]" value="3種盛り合わせ">3種盛り合わせ</label>
            <label><input type="checkbox" name="fruits[]" value="レモン">レモン</label>
            <label><input type="checkbox" name="fruits[]" value="オレンジ">オレンジ</label>
            <label><input type="checkbox" name="fruits[]" value="ぶどう">ぶどう</label>
            <label><input type="checkbox" name="fruits[]" value="なし">なし</label>
        </form>
    </body>
</html>

果物を選択するチェックボックスが6つ並んでいます。

問題はこの「盛り合わせ」という2つの選択肢で、これに関しての仕様として、
1. チェックされたら、盛り合わせ以外の他の選択肢のチェックを外す
2. チェックされている間は、盛り合わせ以外の他の選択肢をチェックできない
3. ただし盛り合わせ同士は相互にチェックし合えるが、片方を押したら片方が非選択に戻る
ということになっています。

しかもなんとこのチェックボックス、上位フレームワークの影響でid属性を付けることはできません。
さらに、value値はなんと日本語です。勘弁してほしい。

セレクタだけで直接1要素を拾うことができず、value値が日本語で比較もあんまりしたくないので、フィルタを駆使して、上記を実装します。

:first は使えない

仕様の、3 が邪魔をして、:firstだけでなく、2番目を取る必要があります。
2番目をビシっと抜き出す、:second があれば良かったんですが、無いので、筆者は:nth-child()を使うことにしました。

わかってる人はアレっと思うでしょうが、後述ですし、そういう人にとって為になる情報はこの記事にはありません。

:nth-child() も使えない

とりあえずjQueryで1番目のチェックボックスが拾えれば、2番目も同様に拾えるし、なんとかなるやろってことで、やってみます。

$('input[name^=fruits]:nth-child(1)').change( function(){
    alert($(this).val());
});

しかしこれはうまく行きません。
1番目だけでなく、どこを押しても押した要素が拾われてしまいます。

これは、inputタグは全てそれぞれのlabelタグの中に入っているためです。
:nth-child() は親要素の長子を1とし、次子を2というように指定するのですが、それぞれのinputはそれぞれのlabelの長子なので、全てのinputが適合してしまい、全てのinputにchangeイベントがバインドされる結果となりました。

:nth-of-type()も、名前にchildが入っていないのですが、これは子要素のうち、このフィルタがくっついているセレクタで絞り込むため、長子しかいないこの場合は結局同じです。

:eq()を使う

筆者は、:eq()を知りませんでした。
正確には、知ってはいたんですけど、「eqがイコールの省略なのはわかるけど、何と何を比べてんねん」と思って詳しく知りませんでした。

:eq() は、そのセレクタの、親子関係を気にせず、ページの上から順番に振った通し番号を比較します。
要するに、DOM配列のインデックスです。なので、 :first と :eq(0) は等価になります。

ここで筆者は、しばらく nth-child() について考えていたため、インデックスの振り方でハマりました。
:nth-child() は、何番目の子供か、という通し番号です。0番目の子供というのは存在しないため、1から始まります。
:eq() はインデックスです。添字なので0から始まります。 めんどくさいですね。

これを使って、

$(function(){
    $('input[name^=fruits]:eq(0)').change( function(){
        if ($(this).is(':checked')) {
            //チェックされたとき
        } else {
            //チェックが外されたとき
        }
    });
    $('input[name^=fruits]:eq(1)').change( function(){
        if ($(this).is(':checked')) {
            //チェックされたとき
        } else {
            //チェックが外されたとき
        }
    });
});

これで、1番目の要素と2番目の要素だけにchangeイベントがバインドできました。

以外 をセレクタで表現する

1番目と2番目の要素だけにchangeイベントをバインドすることに成功しました。
その中で、問題の選択肢以外、というセレクタが必要になってきます。

これは、 :not() で解決します。

$('input[name^=fruits]:eq(0)').change( function(){
    if ($(this).is(':checked')) {
        //チェックされたとき
        $('input[name^=fruits]:not(:eq(0))').prop('checked', false);
        $('input[name^=fruits]:not(:eq(0))').attr('disabled', 'disabled');
    }
});

このように、さきほどの :eq() を :not() の引数の中に押し込めてやります。
これで、フィルタを反転させて、押した要素以外の全てのチェックを外し、選択不可にしました。
しかし、問題の選択肢2つの間では自由に選択することができなければいけません。

そこで、 :not() を複数のフィルタで指定します。

$('input[name^=fruits]:eq(0)').change( function(){
    if ($(this).is(':checked')) {
        //チェックされたとき
        $('input[name^=fruits]:not(:eq(0))').prop('checked', false);
        $('input[name^=fruits]:not(:eq(0),:eq(1))').attr('disabled', 'disabled');
    }
});

:not() の引数は、カンマ区切りでOR条件で複数のフィルタを指定できます。
この結果、1番目と2番目以外を選択不可にするということが実現できました。
ちなみに、半角スペースで区切ると、AND条件になります。

今回は、1番目でもなく かつ 2番目でもない、となればよいのでOR条件を使って ¬(1∨2) とし、実質 (¬1)∧(¬2) なので実現できています。
ド・モルガンの法則です。頻繁に使うんですが、普段は名前を意識しないので、名前を言われると懐かしさを感じますよね。

チェックが外されたときの挙動を追加して

$('input[name^=fruits]:eq(0)').change( function(){
    if ($(this).is(':checked')) {
        //チェックされたとき
        $('input[name^=fruits]:not(:eq(0))').prop('checked', false);
        $('input[name^=fruits]:not(:eq(0),:eq(1))').attr('disabled', 'disabled');
    }else{
        //チェックが外されたとき
        $('input[name^=fruits]').removeAttr('disabled');
    }
});

2番目の要素にも同様にバインド

$('input[name^=fruits]:eq(0)').change( function(){
    if ($(this).is(':checked')) {
        //チェックされたとき
        $('input[name^=fruits]:not(:eq(0))').prop('checked', false);
        $('input[name^=fruits]:not(:eq(0),:eq(1))').attr('disabled', 'disabled');
    }else{
        //チェックが外されたとき
        $('input[name^=fruits]').removeAttr('disabled');
    }
});
$('input[name^=fruits]:eq(1)').change( function(){
    if ($(this).is(':checked')) {
        //チェックされたとき
        $('input[name^=fruits]:not(:eq(1))').prop('checked', false);
        $('input[name^=fruits]:not(:eq(0),:eq(1))').attr('disabled', 'disabled');
    }else{
        //チェックが外されたとき
        $('input[name^=fruits]').removeAttr('disabled');
    }
});

共通化できそうなんですけど、共通化すると同じ要素に2度バインドしたりしてしまうのでうまく行きませんでした。
もう少しスマートにできる気はするんですけど。

最終的にはこう

<!doctype html>
    <head>
        <script
            src="https://code.jquery.com/jquery-3.1.1.slim.min.js"
            integrity="sha256-/SIrNqv8h6QGKDuNoLGA4iret+kyesCkHGzVUUV0shc="
            crossorigin="anonymous"></script>
        <script type="text/javascript">
            $(function(){
                $('input[name^=fruits]:eq(0)').change( function(){
                    if ($(this).is(':checked')) {
                        $('input[name^=fruits]:not(:eq(0))').prop('checked', false);
                        $('input[name^=fruits]:not(:eq(0),:eq(1))').attr('disabled', 'disabled');
                    } else {
                        $('input[name^=fruits]').removeAttr('disabled');
                    }
                });
                $('input[name^=fruits]:eq(1)').change( function(){
                    if ($(this).is(':checked')) {
                        $('input[name^=fruits]:not(:eq(1))').prop('checked', false);
                        $('input[name^=fruits]:not(:eq(0),:eq(1))').attr('disabled', 'disabled');
                    } else {
                        $('input[name^=fruits]').removeAttr('disabled');
                    }
                });
            });
        </script>
    </head>
    <body>
        <form name="hoge">
            <label><input type="checkbox" name="fruits[]" value="4種盛り合わせ">4種盛り合わせ</label>
            <label><input type="checkbox" name="fruits[]" value="3種盛り合わせ">3種盛り合わせ</label>
            <label><input type="checkbox" name="fruits[]" value="レモン">レモン</label>
            <label><input type="checkbox" name="fruits[]" value="オレンジ">オレンジ</label>
            <label><input type="checkbox" name="fruits[]" value="ぶどう">ぶどう</label>
            <label><input type="checkbox" name="fruits[]" value="なし">なし</label>
        </form>
    </body>
</html>

終わりです。

ついでに色々調べてたんですけど、セレクタって結構やるやつですね。
jQueryの是非はともかく、ちゃんと論理演算を考えながら実装すれば、行数とバインドの節約が捗りそうです。
とはいっても、仕様変更・追加で継ぎ足し継ぎ足しのjQuery、まとめて改善しようにもすでに稼働しているので余計なテストが・・・。厄介すぎる・・・。

参考文献

www.jquerystudy.info

bootstrap : twinviteでbootstrapを活用してエモい招待サイトを作る

月花です。

先日、関西で行われるクラブイベントのWebページと、twinviteを公開しました。
そのとき使った手法が極めて有効であったため、下記に残します。

この記事は、イベントのWebサイトを作ったりtwinviteで凝ったことがしたい方の手引書を目指して書きました。
・気合をいれたイベントであることを強調したい。
・次は周年記念回なので、いつもよりきれいに作りたい。
・他のイベントとの差別化を図りたい。
様々な動機はあると思いますが、Webページをこだわることは、どれにも有効な手段です。

簡素な記事のため、説明不足の箇所が多いですが、分からないワードは積極的にググってみましょう。よく使われるライブラリについて書いているので、日本語で記事を起こしている先駆者はたくさんいます。

これらは、ある程度のHTML+CSSの知識は必要ですが、仕様などの深い理解は必要ありません。

今回作ったもの

・Webサイト
IzanagiDistribution3.0
いわゆるランディングページです。誘導対象はtwinviteですが、商業的な色を出さないために動線は最小限にしてあります。

・twinvite
IzanagiDistribution Ver.3.0 - twinvite
イベント招待サイトです。
自由にHTMLを書いてデザインできる部分がある、twiplaとかtweetviteみたいなやつです。
今回はここを攻略していきます。

bootstrapについて

Webサイト、twinvite共、bootstrapというライブラリを使っています。
getbootstrap.com
このライブラリは、Webサイトのあらゆるパーツのデザインを定義したCSSの詰め合わせと、便利なJavaScript関数の集合体です。
つまり、このライブラリで定義しているクラス名やID名を指定するだけで、グリッドシステムやモーダルの起動が可能です。
twinviteなどのHTMLを自由記述できる部分を含むページでは、スタイルは基本的にHTMLタグのstyle属性を使って、

<div style="〜〜〜〜"></div>

とすべてのタグに記述する必要がありますが、bootstrapに定義されているデザインを使う分にはクラス名の指定があればよいので、style属性が必要なくなります。

ここで、なぜtwinviteでbootstrapが使えるか、についてですが、twinviteは元々bootstrapを使って作られていて、私達が記述するより前の時点でリンク済みであるためです。
また、font-awesomeもリンク済みであるため、こちらもクラス名の指定だけで、様々なアイコンフォントを活用することができます。
fontawesome.io

つまり、Webサイトを別途作成する場合、そちらもbootstrapを使って実装してやれば、理想的にはHTML部分のコピペだけで、ほぼ同じページが作れる、ということです。
今回私が作成した2つは、そのようになっているため、Webサイト完成後の極めて迅速なtwinvite対応が可能でした。

ちなみに、こちらは以前私が作成した、LoveDive! というイベントのtweetviteページのソースコードです。
エディタの横幅に収まらないほど、style属性にガリガリ記述していることがわかると思います。
f:id:gekka9:20170108180509p:plain
このコードは、下記のサイトの開催日時や金額が書かれたテーブル部分だけです。
http://gekka.sexy/love_dive.html

bootstrapが使えれば、もはやこのような苦労は必要ありません。

bootstrapの主な要素

レスポンシブ対応

近年、デバイスの増加により、様々な解像度の環境でWebサイトが見られることになります。
スマホ・PC・タブレットなど、その全てに対応するためにいくつもファイルを作り、デバイスを判断して読み込むHTMLやCSSを変えるといった解決策は非常に非効率です。
そこで、画面の幅だけを頼りに、CSSを出し分け、たとえばPCでは3カラムでスマホでは1カラムといったことが、1つのHTML、1つのCSSで実現可能になりました。そういった実装をレスポンシブデザインと呼びます。
冒頭で提示したサイトは両方ともレスポンシブです。PCでご覧になっているのであれば、ブラウザの幅をぐりぐり変えてみてください。手っ取り早くわかると思います。

bootstrapはレスポンシブに対応しており、デザインが切り替わる境界は全部で3つあります。
すなわち、4つの解像度レンジでデザインを出し分けることが可能です。当然、クラス名の指定だけによって。

グリッドシステム

グリッドは網目と言った意味がありますが、例えば正方形の画像を8つ並べるのであれば、PCでは横4つ縦2つ、スマホでは横2つ縦4つ、といった出し分けが適切でしょう。
このようなブラウザ解像度に合わせたグリッドレイアウトのカスタマイズが、bootstrapでは非常に強力にサポートされています。
CSS · Bootstrap

基本的には横幅を12分割し、そのうちどれくらいをHTML要素に割り当てるかによって指定します。
さらに、さきほどの、PCでは横4つ縦2つ、スマホでは横2つ縦4つといった実装は、グリッドの要素1つ1つに対して、下記のようなクラス名を当ててやります。

<div class="row"> <!--行の開始-->
  <div class="col-xs-6 col-sm-3 col-md-3"></div> <!--このdivが1つの要素-->
  <div class="col-xs-6 col-sm-3 col-md-3"></div>
  <div class="col-xs-6 col-sm-3 col-md-3"></div>
  <div class="col-xs-6 col-sm-3 col-md-3"></div>
  <div class="col-xs-6 col-sm-3 col-md-3"></div>
  <div class="col-xs-6 col-sm-3 col-md-3"></div>
  <div class="col-xs-6 col-sm-3 col-md-3"></div>
  <div class="col-xs-6 col-sm-3 col-md-3"></div>
</div> <!--行の終了-->

この例は、さきほどのページであれば、下記の部分に対応しています。
・PC表示
f:id:gekka9:20170108172230p:plain

スマホ表示
f:id:gekka9:20170108172237p:plain

それぞれのクラス名を説明します。

col-xs-6

このうち、xs の部分と、6の部分が重要です。
xsはブラウザの横幅が768pxより小さいときに適用するという指定で、
6は、先述の、横幅を12分割したうち、6個分の幅を使う、ということです。
同時に、

col-sm-3

を指定していますが、
mdはブラウザの横幅が768px以上のときに適用するという指定で、
3は、12分割中3個分の幅を使う、ということになります。
さらに、

col-md-3

によって、ブラウザの横幅が992px以上の場合も指定しています。

なので、
ブラウザの横幅が992pxより小さいときは、親要素の幅の半分の幅の要素が8個並び、
ブラウザの横幅が992px以上のときは、親要素の幅の1/4の幅の要素が8個並ぶ
という指定になります。

ここで、ひとつの div class="row" の中に合計12を超える指定をしていることになりますが、12を超えるごとに折返されるので、問題はありません。

その他の要素

見出しやアオリ文などのデザインを定義したTypography群、テーブルやボタン、フォームなど、ありとあらゆるものがすでに定義されており、単にHTMLを書いたものに適用するだけで非常に見栄えの良いサイトになります。

JavaScript

さきほどグリッドのところで提示した画像の1つ1つはボタンになっており、ボタンを押すとモーダルが起動するようになっています。
これは、bootstrapに元々組み込まれているJavaScriptによって、起動用のIDをボタンに設定し、対応するIDをモーダルの要素に設定してやれば、それだけで動作をします。
当然、このモーダルの中にもグリッドシステムなどの使用が可能です。
f:id:gekka9:20170108174642p:plain
画像はtwinviteでの動作例ですが、私はこのためのJavaScriptを1文字も書いていません。
この他にも様々な関数が用意されています。

twinviteへの適用

twinviteでは元々bootstrapが読み込まれているため、上記のような機能がすべて実装済みです。
twiplaやtweetviteでこったことをしようとして、HTMLタグにstyle属性をガリガリ書いている方であれば、これがどれほど強力なことかがわかるでしょう。
twinviteでは、linkタグやstyleタグが使えるため、そのようなことはもはや必要ありませんが、これらのタグはセキュリティ上の危険を孕むため、いつ使えなくなるかわかりません
その日が来る前に、是非bootstrapの学習をオススメ致します。

ただし、1つだけ大変なところがあります。
それは、自由記述部分の横幅が、ブラウザの横幅と一致しないことです。

先程のグリッド部分を、同じ幅のブラウザで表示してみました。
・Webサイト
f:id:gekka9:20170108174039p:plain

・twinvite
f:id:gekka9:20170108174049p:plain

Webサイト方はグリッドシステムがブラウザの横幅すべてを使っているため4つ入りますが、twinviteでは自由記述部分はかなり狭くなるため4つ入れると画像のサイズが極めて小さくなってしまいます。
そのため、グリッドシステムだけは、調整しています。
といっても、 col-sm-6 のようなクラス名を書き換えてやるだけです。

総括

さまざまなイベントが増え続ける今、同じハコや同じテーマでイベントをやることも多いでしょう。
その際、他と違うことを強調したい場合に、凝った招待サイトを作るといったことは非常に有効な手段だと考えています。もしくは、周年記念回のときだけ凝ってみるとか。

気合の入れた宣伝ページを作ることは、そのイベントの本気度や意気込みにそのまま直結していきます。
本気のイベントを気合を入れて打ち出し、最高のイベントへと導くことは、本当に楽しいです。

この記事の内容は私のような職業エンジニアでなくとも、十分に達成できる難易度のものだと思っています。
私自身もそういった告知ページを見ることは好きなので、こういった技術が浸透することを願っています。
是非、気合の入ったページを見せてください。