日程調整システムの構築

章の冒頭で示した2つめの集計例、イベントを行なう日程調整のための集計を再度見てみよう。

芋煮日程調整表(再掲)
氏名\日程候補10/310/410/1010/1110/1710/18
庄内三十彦---
山形醤二-
比内芹音---
中山祥一--
○の合計431322

まずここで問題である。

上記の日程調整表を格納するテーブル sched を設計せよ。

CREATE TABLE 文を思い浮かべてから先に進もう。

日程調整表のテーブル設計

得点集計のためのテーブル設計で述べたように、 表計算的な見た目に惑わされず、同種の性質を持つフィールドは1つのカラムに まとめることを考慮してテーブルを設計すると以下のようになる。

日程調整のためのテーブル sched
カラム意味
name氏名
date日付
okng○か否か

これをSQLに直す。点数集計と同様、特定の人の特定の日付に対する 回答は必ず1つであるから、この組み合わせに対してUNIQUE制約を付ける。

日程調整テーブル(暫定版)

CREATE TABLE sched (
  name, date, okng,
  UNIQUE(name, date)
);

日付(date)に関してはどうだろう。 イベントごとの候補となる日付は決まっているので、 点数集計時と同様日付候補を列挙したテーブルを作成し、 それを外部キー制約指定すればよい。 日付候補を格納するテーブルを datelist とし、外部キー制約を指定するように sched テーブルを改定する。

日程調整テーブル

CREATE TABLE datelist (date PRIMARY KEY);
CREATE TABLE sched (
  name, date, okng,
  UNIQUE(name, date),
  FOREIGN KEY(date) REFERENCES datelist(date)
);

日程調整システムのスクリプト化

シェルスクリプトから SQL 文を発行しデータベースに登録する手順を考えたい。 「得点集計システムの構築」で示した課題の点数集計の場合、 課題の点数は課題提出が発生するたび、1人1件ずつ得点が入力されていくのに対し、 この日程調整は、ある1人が回答をするときにその人の予定がまとめて入力される。 つまり1回のデータ入力に対し、複数のINSERTが発生するということである。

ここでは、「まとめ入力」の処理を行ないやすくするために、 データ読み込みに標準入力を利用する形でシェルスクリプトを構築する手法を示す。

テーブル設計と作成処理

先述の CREATE TABLE の内容を基に、実際の処理関数を作成する。 外部キー制約で参照する「候補日付一覧」は datelist.csv(下記参照) から取得するものとする。

datelist.csv

10/3
10/4
10/10
10/11
10/17
10/18

テーブル初期化時に、自動的に datelist.csv を datelist テーブルに読み込むようにする。

データ入力書式とインタフェースの策定

たとえば「庄内三十彦」さんが回答をするとき、 本人は「10/3」、「10/17」、「10/18」に○をつけたいので、 その3項目についてまとめて印を付けることになる。 ここでは、標準入力から続けて値を読み取る形式を考える。 たとえば今回作成するスクリプトを imoni.sh とし、 以下のように入力を送り込むものを想定する。 [C-d] は Control-d タイプによるEOF指示を意味する。

./imoni.sh -a 庄内三十彦        # データの追加
10/3=yes
10/4=no
10/10=no
10/11=no
10/17=yes
10/18=yes
[C-d]

もちろん実際には手入力で送り込むのではなく、Web インタフェースなどから上記のような入力を シェルスクリプトに送り込むことを想定している。

入力行の分割とDB登録処理

上記のように想定した入力行の並びからSQL文を組み立てて INSERT 処理(実際にはREPLACE文使用)していく流れを作成する。

シェルスクリプトで標準入力を読み取るには内部コマンド read を用いる。read の使用例を示す。

read a b c
The quick fox jumps over the lazy dog.
echo a=$a
a=The
echo b=$b
b=quick
echo c=$c
c=fox jumps over the lazy dog.

変数を複数指定した場合、読み込んだ文字列を空白区切りで単語分割し、 先頭の変数から順に代入する。 最後に指定した変数には残りの文字列をすべて代入する。 単語区切りは通常は空白文字だが、シェル変数 IFS に単語区切りにしたい文字を列挙することで区切りを変更できる。 今回の場合は「10/3=yes」のような入力文字列を「10/3」と「yes」 に分解したいので、IFSに "=" を代入して read を呼ぶ。つまり、

while IFS="=" read date value; do
  case $value in
    [Yy][Ee][Ss])	val='○' ;;
    [Nn][Oo])		val='×' ;;
    *)			val='-' ;;
  esac
  sqlite3 $db "REPLACE INTO sched VALUES('$who', '$date', '$val');"
done

のように処理すれば、= 文字の前後の値を分解取得できる。

-a オプションに氏名を指定して、 その人の予定を登録するところまでの処理をスクリプト化した暫定版を示す。

imoni0.sh(暫定版)

#!/bin/sh
db=${DB:-imoni.sq3}
query() {
  sqlite3 -cmd 'PRAGMA foreign_keys=ON' $db "$@"
}

create() {
  # sqlite_master から datelist テーブルを探す
  tbl=`query "SELECT name FROM sqlite_master WHERE name='datelist';"`
  if [ -z "$tbl" ]; then	# $tbl が空文字列ならテーブル未定義
    query<<-EOF
	CREATE TABLE datelist (date PRIMARY KEY);
	CREATE TABLE sched (
	  name, date, okng,
	  UNIQUE(name, date),	--nameとdateの同一組み合わせは重複不可
	  -- date は dateliste テーブルの date に存在しなければならない
	  FOREIGN KEY(date) REFERENCES datelist(date)
	);
	EOF
  fi
  if [ -s datelist.csv ]; then	# 外部キーの初期値をcsvファイルから取得
    query<<-EOF
	.mode csv
	.import datelist.csv datelist
	EOF
  fi
}

add() {				# 1人分の予定回答を登録する関数
  who="$1"			# 登録者の氏名
  while IFS="=" read date value; do	# 単語区切りを = にして2変数に読み込む
    case $value in
      [Yy][Ee][Ss])	val='○' ;;	# 右辺がyesであれば ○
      [Nn][Oo])		val='×' ;;	# 右辺が noであれば ×
      *)		val='-' ;;	# それ以外はとりあえず - にしておく
    esac
    query "REPLACE INTO sched VALUES('$who', '$date', '$val');"
done
}

while getopts a:i i; do
  case "$i" in
    a)	job=add
	who=$OPTARG ;;
    i)	job=create ;;
  esac
done
shift $((OPTIND-1))

case $job in
  add)				# 「-a 氏名」で起動した場合
    add $who ;;
  *)
    $job "$@" ;;
esac

記入結果一覧出力

集計した一覧表を出力し、日付ごとの「○」の数を出力する処理を作成する。 まず、暫定版の imoni0.sh を用いて実験用のレコードを登録してみる。

./imoni0.sh -i		# datelist.csvを用意してから!
./imoni0.sh -a 庄内三十彦
10/3=yes
10/4=no
10/10=no
10/11=no
10/17=yes
10/18=yes
[C-d]
./imoni0.sh -a 山形醤二
10/3=yes
10/4=yes
10/10=yes
10/11=yes
10/17=yes
10/18=no
[C-d]

これで登録された sched テーブルの中を確認してみる。

sqlite3 imoni.sq3
SELECT * FROM sched;
庄内三十彦|10/3|○
庄内三十彦|10/4|×
庄内三十彦|10/10|×
庄内三十彦|10/11|×
庄内三十彦|10/17|○
庄内三十彦|10/18|○
山形醤二|10/3|○
山形醤二|10/4|○
山形醤二|10/10|○
山形醤二|10/11|○
山形醤二|10/17|○
山形醤二|10/18|×

今回は、このスクリプトを最終的にWebアプリケーション化したいので、 上記の縦長出力を、以下のような横長出力に変えたい。

氏名10/310/410/1010/1110/1710/18
庄内三十彦×××

点数集計システムのときの特定学生の点数一覧表示のところで生成した横長の表の作成とは 別のやり方を示す。

今回の候補日付は「10/3」から「10/18」の6つである。 この程度の個数の属性を横長に展開するには以下のような 熟語的なSQL文があるのでこれを利用する。

予定表の縦持ちから横持ちへの変換SQL

SELECT name,
       max(CASE date WHEN '10/3'  THEN okng END) "10/3", --カラム別名を"10/3"に
       max(CASE date WHEN '10/4'  THEN okng END) "10/4", --以下同様
       max(CASE date WHEN '10/10' THEN okng END) "10/10",
       max(CASE date WHEN '10/11' THEN okng END) "10/11",
       max(CASE date WHEN '10/17' THEN okng END) "10/17",
       max(CASE date WHEN '10/18' THEN okng END) "10/18"
FROM sched
GROUP BY name;

すこし長いSQL文だが、最終的にはシェルスクリプトで自動生成するので あまり長さは気にしなくてよい。さて、ここで SQL の CASE を使用する。CASE は以下の形式で記述する。

CASE  WHEN 比較値1 THEN 返却値1
        WHEN 比較値2 THEN 返却値2
		:
        WHEN 比較値n THEN 返却値n
	ELSE 返却値e
END

これはSQLの単純CASE文と呼び、 の値を順に各比較値と比べ、一致した場合にそれに対応する 返却値を返す。何も一致しなかった場合は ELSE 節の返却値を返す。ELSE 節は省略可能である。

さて、例で示したSELECT文を解剖しよう。 そのままよりは、この文に至るまでの流れを示すと分かりやすい。 まず以下の問い合わせを発行した場合を考える

SELECT name,
       CASE date WHEN '10/3'  THEN okng END "10/3", -- カラム別名を "10/3" に
       CASE date WHEN '10/4'  THEN okng END "10/4",
       CASE date WHEN '10/10' THEN okng END "10/10",
       CASE date WHEN '10/11' THEN okng END "10/11",
       CASE date WHEN '10/17' THEN okng END "10/17",
       CASE date WHEN '10/18' THEN okng END "10/18"
FROM sched;

これは以下のような結果を返す(擬似的に罫線つきの表で示す)。

カラムを横に増やしただけの出力
name10/310/410/1010/1110/1710/18
庄内三十彦
庄内三十彦×
庄内三十彦×
庄内三十彦×
庄内三十彦
庄内三十彦
山形醤二
山形醤二
山形醤二
山形醤二
山形醤二
山形醤二×

空欄は NULL である。さて、「SELECT name」の次の行の

CASE date WHEN '10/3'  THEN okng END "10/3"

は、CASE文で、date カラムの値と '10/3' を比較する。 '10/3' であった場合のみ okng カラムの値を返す。 1行目と7行目のレコードはどちらも date='10/3' なのでこれに該当するが、 それ以外の行は該当しないので何も返さない(NULL)。 ENDの次にある "10/3" はカラムに付けた別名定義で、 ヘッダつきで出力する場合の見出し文字列にも使われる。 以後同様ダブルクォートでくくった "10/3" は別名定義されたカラム名になる。 次の行に進む。

CASE date WHEN '10/4'  THEN okng END "10/4"

今度は date カラムが '10/4' の場合のみ okng の値が選択される。これを繰り返すと、上記の表のような結果になる。

続いて、12件あるこの出力をnameごとに分類する(GROUP BY name)。 name以外のカラムは、一つのnameに対して1つの値を取るように集約関数を 用いる。たとえばカラムを横に増やしただけの出力 の表の、「庄内三十彦」の「10/3」の6つの値に対して max() 関数を適用すると、 唯一の非NULL値である「○」が採用される。同様に、どのカラムも同一の name に対して6つの値があるが、非NULL値を持つのは1つだけなのでいずれも「○」 か「×」が選択される。

よって、予定表の縦持ちから横持ちへの変換 のリストに示したSQL文から得られる出力は以下の形式となる。

横持ち集計表
name10/310/410/1010/1110/1710/18
山形醤二×
庄内三十彦×××

結果の順番は保証されない点に注意する。

「○」の合計出力

予定決めでは○の一番多い日程を選びたい。単純化するため、 上に示した横持ち集計表の2人分で考える。 この表の各日程の「○」を数えた以下のようなものを付加したい。

○の数え上げ表
10/310/410/1010/1110/1710/18
○の数211121

○×の集計表とこの数え上げ表は種類が違うものなので、 1つの表には格納できない。しかし、 カラム数が同じ表は和集合の形でまとめることができる。今回の場合は UNION ALL で連続した表にできる。まず、数え上げ表を作成しよう。 これは、横持ち集計表の日程を表す各カラムの 値が "○" を数えればよい。よって、以下のような問い合せで求められる(暫定版)。

「○」を数える問い合わせ(暫定版)

SELECT '○の数',
       count(CASE date WHEN '10/3'  THEN okng END) "10/3",
       count(CASE date WHEN '10/4'  THEN okng END) "10/4",
       count(CASE date WHEN '10/10' THEN okng END) "10/10",
       count(CASE date WHEN '10/11' THEN okng END) "10/11",
       count(CASE date WHEN '10/17' THEN okng END) "10/17",
       count(CASE date WHEN '10/18' THEN okng END) "10/18"
FROM sched
WHERE CASE date WHEN '10/3'  THEN okng END='○'
   OR CASE date WHEN '10/4'  THEN okng END='○'
   OR CASE date WHEN '10/10' THEN okng END='○'
   OR CASE date WHEN '10/11' THEN okng END='○'
   OR CASE date WHEN '10/17' THEN okng END='○'
   OR CASE date WHEN '10/18' THEN okng END='○';

○×集計表を得る問い合わせで用いた max 関数を count 関数に変え、"○" だけをcountするよう WHERE 句に条件を指定した。この問い合わせと ○×集計のための問い合わせを UNION ALL すれば求める結果が得られるが、 同じようなCASE文並びが2つあり冗長である。 何かよい書き方はないものだろうか。

WITH句

WITH句 を用いると、 問い合わせを行なうときの一瞬だけ利用できるビューのような仮想的な表を作成して 問い合わせを分かりやすくできる。

WITH 名前 AS (副問い合わせ) 主問い合わせ;

の形式で用い、主問い合わせの中で、 副問い合わせ の結果によって生成される 名前 のテーブルを参照することができる。

今回の場合は、 カラムを横に増やしただけの出力を得るための 問い合わせが集計の基本表となる。この問い合わせに sched2 と名付けて「○×集計」と「○の数え上げ」を一つの問い合わせにまとめると 以下のようになる。

○×表と数え上げ表の合体

WITH sched2 AS (	-- 一時表 sched2 を定義する
  SELECT name,
       CASE date WHEN '10/3'  THEN okng END "10/3",
       CASE date WHEN '10/4'  THEN okng END "10/4",
       CASE date WHEN '10/10' THEN okng END "10/10",
       CASE date WHEN '10/11' THEN okng END "10/11",
       CASE date WHEN '10/17' THEN okng END "10/17",
       CASE date WHEN '10/18' THEN okng END "10/18"
  FROM sched
) SELECT name,		-- 「○×表」を生成するSELECT文
         max("10/3")  "10/3",  max("10/4")  "10/4",  max("10/10") "10/10",
         max("10/11") "10/11", max("10/17") "10/17", max("10/18") "10/18"
	 -- カラム "10/3" の最大値をさらにカラム名を "10/3" としている
  FROM sched2
  GROUP BY name

  UNION ALL		-- 上と下の2つの表の和集合を取る

  SELECT '○の数',	-- 「数え上げ表」を生成するSELECT文
         count("10/3"),  count("10/4"),  count("10/10"),
	 count("10/11"), count("10/17"), count("10/17")
  FROM sched2
  WHERE '○' IN ("10/3", "10/4", "10/10", "10/11", "10/17", "10/18");

最後に用いた条件

'○' IN ("10/3", "10/4", "10/10", "10/11", "10/17", "10/18")

は、IN の後ろに列挙した値のどれかに '○' が一致すれば真を返すというもので、

"10/3"='○' OR "10/4"='○' OR "10/10"='○'
OR "10/11"='○' OR "10/17"='○' OR "10/18"='○'

と同じ結果を得るための書き換えである。 繰り返しになるが SQLite ではダブルクォートでくくられたものはまず識別子として認識されるので "10/3" などは、別名カラムの値に置き換えられる。 これにより以下のような結果が得られる。

合体○×表
name 10/310/410/1010/1110/1710/18
山形醤二 ×
庄内三十彦 ×××
○の数 211121

候補日の追加とSQL文の生成

さて○×表と数え上げ表の合体に示した問い合わせは WITH を用いて効率的ではあるがけっこう長いと感じたかもしれない。 しかしさらにそれに追い討ちをかける要望が来た。

「10/24と10/25も候補日に加えてよ♪」

sched テーブルの date カラムの外部キー制約を満たすのは簡単で、 datelist テーブルに新しい2つの候補日を足せばよい。問題は合体○×表である。 今後別の要望が来る可能性を考えると問い合わせ文をいちいち変更するのは避けたい。 ということで、問い合わせ文を自動生成することを考える。

今回必要な問い合わせ文の骨子は以下のとおりである。

○×数え上げ問い合わせの骨子

WITH sched2 AS (
  SELECT name,
     ここに日付候補の数だけCASE文などが並ぶ
  FROM sched
) SELECT name,
     ここにmaxの列挙
  FROM sched2 GROUP BY name

  UNION ALL

  SELECT '○の値',
     ここにcountの列挙
  FROM sched2
  WHERE '○' IN (ここにカラム別名の列挙)

以上4ヶ所を、候補日のリスト、つまり datelist テーブルに登録された日付リストを基に生成すればよい。 4ヶ所の問い合わせ断片をそれぞれシェル変数 sq1、sq2、sq3、sq4 に代入するものとする。

gensql.sh

#!/bin/sh
db=${DB:-imoni.sq3}
nlt="
	"		# 改行文字+Tab
sqlite3 $db "SELECT date FROM datelist;" \
  | sort -n -t / \
  | { while read d; do
	# パイプの先の while 内で設定した変数を使いたいので
	# { } でグルーピング
        sq1=$sq1${sq1:+,$nlt}"CASE date WHEN '$d' THEN okng END \"$d\""
        sq2=$sq2${sq2:+,$nlt}"max(\"$d\") \"$d\""
        sq3=$sq3${sq3:+,$nlt}"count(\"$d\")"
        sq4=$sq4${sq4:+, }"\"$d\""
      done
      cat<<EOF
WITH sched2 AS (
  SELECT name,
     $sq1
  FROM sched
) SELECT name,
     $sq2
  FROM sched2 GROUP BY name

  UNION ALL

  SELECT '○の値',
     $sq3
  FROM sched2
  WHERE '○' IN ($sq4);
EOF
    }

簡単に説明する。最初のsqlite3起動の

sqlite3 $db "SELECT date FROM datelist;"

で候補日程を取り出している。その結果をパイプで

sort -n -t /

に渡しているが、これは datelist テーブルから date 一覧を取り出すときに、必ずしも日付順に出てくるとは限らないので、 sortコマンドの認識する区切り文字を / (スラッシュ)にして、「月/日」並びを数値的昇順に直している。

さらにそのパイプの先では

| { while文
    変数を利用した処理
  }

としている。パイプの先はサブシェルで実行されるため、もし

sql文 | sort文 | while ...

とすると、while文のブロック内で設定された変数はwhileを抜けると 失われる。そのため、while文全体とwhileブロック内で設定された 変数を使用する処理を { } でグループ化して while を抜けたあとでも 変数を利用できるようにしている。

これを実行すると○×表と数え上げ表の合体 で利用した問い合わせ文が得られる。コマンドラインから挙動を確かめるには パイプの先で sqlite3 コマンドに送ればよい。

./gensql.sh | sqlite3 imoni.sq3
山形醤二|○|○|○|○|○|×
庄内三十彦|○|×|×|×|○|○
○の値|2|1|1|1|2|1

なお、ここではシェルスクリプトで SQL 文を生成したが、場合によっては SQL で SQL 文を生成した方が明解なこともある。本節末尾の練習問題でこれを試みる。

一覧機能・削除機能の追加とコマンドライン版の完成

氏名を指定してのレコード削除機能を追加して、 コマンドライン版のスクリプトを完成させよう。 レコード削除は以下のように行なうものを想定する。

./imoni.sh -d 山形醤二

これを行なうシェル関数を作成する。第1引数に氏名をとって それに合致するものを削除するものは以下のようになる(データベースに問い合わせを 発行する query 関数は既出のものを利用する)。

delete() {
  who="$1"		# 第1引数 = 削除したい人
  query "DELETE FROM sched WHERE name='$who';"
}

一覧機能は、○×合体表をHTMLのtable形式で出力するよう、 gensql.sh を利用する関数を追加する。

これまでの機能をまとめて、コマンドライン利用のための スクリプトの形にしたものを示す。

imoni.sh

#!/bin/sh
db=${DB:-imoni.sq3}
query() {
  sqlite3 -cmd 'PRAGMA foreign_keys=ON' $db "$@"
}

create() {		# テーブル作成と外部キーテーブル初期化
  tbl=`query "SELECT name FROM sqlite_master WHERE name='datelist';"`
  echo tbl="[$tbl]"
  if [ -z "$tbl" ]; then	# $tbl が空文字列なら
    query<<-EOF
	CREATE TABLE datelist (date PRIMARY KEY);
	CREATE TABLE sched (
	  name, date, okng,
	  UNIQUE(name, date),
	  FOREIGN KEY(date) REFERENCES datelist(date)
	);
	EOF
  fi
  if [ -s datelist.csv ]; then
    query<<-EOF
	.mode csv
	.import datelist.csv datelist
	EOF
  fi
}

add() {			# レコード登録機能
  who="$1"
  while IFS="=" read date value; do
    case $value in
      [Yy][Ee][Ss])	val='○' ;;
      [Nn][Oo])		val='×' ;;
      *)		val='-' ;;
    esac
    query "REPLACE INTO sched VALUES('$who', '$date', '$val');"
  done
}

list() {		# gensql.sh を利用してHTMLの表を出力
  echo '<table border="1">'
  ./gensql.sh | sqlite3 -header -html $db
  echo "</table>"
}

delete() {		# 削除機能
  who="$1"		# 第1引数 = 削除したい人
  query "DELETE FROM sched WHERE name='$who';"
}

while getopts a:id:l i; do
  case "$i" in
    a)	job=add
	who=$OPTARG ;;
    i)	job=create ;;
    l)	job=list ;;
    d)	job=delete who=$OPTARG ;;
  esac
done
shift $((OPTIND-1))

case $job in
  add|delete)		# オプション指定なしの場合
    $job $who
    ;;
  *)
    $job "$@" ;;
esac

排他制御と高速化の工夫

ここで作成しているシステムは、最終的にはWebインターフェースをつけて 多人数での同時アクセスが来てもよいものにしたい。 データベース管理に SQLite を用いているため、 たとえ同時アクセスが起きてもデータベースファイルは破壊されない。 ただ、1つの処理が終わるより前に次のプロセスがデータベースにアクセスしようと するとデータベースの排他エラーが出て、2つ目のプロセスは何もできずに終わる。 これを回避するため、sqlite3 コマンドにロック解除の待ち時間を指定すると その時間だけ待つようになる。待ち時間は、sqlite3 のドットコマンド .timeout にミリ秒を指定する。query 関数を以下のように変えるのが簡単である。

db=${DB:-imoni.sh}
query() {
  sqlite3 -cmd 'PRAGMA foreign_keys=ON' -cmd '.timeout 3000' $db "$@"
}

この例は最大3秒間の待ち時間を設定している。待ち時間を何ミリ秒に 設定すればよいかは実際の使用状況によるしかない。 これまで何度か sqlite3 コマンドを起動していると分かると思うが、 ほぼ一瞬でSQL処理が終わるため3秒はじゅうぶんに長いといえる。

複数のプロセスがほぼ同時にデータベースアクセスに来ることを考えると、 シェルスクリプトから sqlite3 を呼び出す回数はできる限り減らした方がよい。 前掲の imoni.sh では、1人分のデータ登録時に、標準入力を1行読むたびに query 関数で sqlite3 コマンドを呼び出している。これは、SQL 文をまとめて標準出力に書き出し、パイプの先の sqlite3 に処理させる形に変えることで、sqlite3 コマンド起動を1回に減らせる。

add() {
  who="$1"
  while IFS="=" read date value; do
    case $value in
      [Yy][Ee][Ss])	val='○' ;;
      [Nn][Oo])		val='×' ;;
      *)		val='-' ;;
    esac
    echo "REPLACE INTO sched VALUES('$who', '$date', '$val');"
  done | query
}

さらにいえば、上記で起動されたsqlite3コマンドは、REPLACE 文を1つ実行するたびに書き込みを行なうので、そのつど待ち時間が発生する。 複数のSQL文をまとめて実行したい場合はトランザクションを利用する。 SQLite3 では、

BEGIN;
 SQL文の並び
COMMIT;

とすることで、並び部分が1つの処理単位となる。これをふまえて さらに add 関数を書き換えると以下のようになる。

add() {
  who="$1"
  { echo "BEGIN;"
    while IFS="=" read date value; do
      case $value in
        [Yy][Ee][Ss])	val='○' ;;
        [Nn][Oo])	val='×' ;;
        *)		val='-' ;;
      esac
      echo "REPLACE INTO sched VALUES('$who', '$date', '$val');"
    done
    echo "COMMIT;"
  } | query
}

「REPLACEごとに毎回sqlite3を起動」、「sqlite3は1回だが毎回REPLACE」、 「sqlite3は1回でトランザクション利用」の3つでどの程度差が出るかについて、 Opteron 3280 2300MHz のマシンにて所要時間を大まかに計った結果を示しておく。 約4MBのデータベースファイルに約12万件のレコードが登録されているところに、 10件、100件、1000件、10000件のレコードを追加した場合の処理時間(秒)である。 トランザクション利用の速度

SQL問い合わせ方法10件100件1000件10000件
毎回sqlite3起動0.141.4414.39145.98
毎回REPLACE実行0.080.727.7278.04
トランザクション利用0.010.020.030.16

多数の書き込みをともなうデータベース操作はトランザクションを明示的に 指定することで、圧倒的に速くなるのが見て取れる。

imoni.sh をベースに、query関数とadd関数に上記の修正をほどこしたものを imoni2.sh として以後のWebインタフェース化に用いる。

imoni2.sh

#!/bin/sh
db=${DB:-imoni.sq3}
query() {
  sqlite3 -cmd 'PRAGMA foreign_keys=ON' -cmd '.timeout 3000' $db "$@"
}

create() {		# テーブル作成と外部キーテーブル初期化
  tbl=`query "SELECT name FROM sqlite_master WHERE name='datelist';"`
  if [ -z "$tbl" ]; then	# $tbl が空文字列なら
    query<<-EOF
	CREATE TABLE datelist (date PRIMARY KEY);
	CREATE TABLE sched (
	  name, date, okng,
	  UNIQUE(name, date),
	  FOREIGN KEY(date) REFERENCES datelist(date)
	);
	EOF
  fi
  ndate=`query "SELECT count(date) FROM datelist;"`
  if [ "$ndate" -eq 0 -a -s datelist.csv ]; then
    query<<-EOF
	.mode csv
	.import datelist.csv datelist
	EOF
  fi
}

add() {			# レコード登録機能
  who="$1"
  { echo "BEGIN;"
    while IFS="=" read date value; do
      case $value in
	[Yy][Ee][Ss])	val='○' ;;
	[Nn][Oo])		val='×' ;;
	*)		val='-' ;;
      esac
      echo "REPLACE INTO sched VALUES('$who', '$date', '$val');"
    done
    echo "COMMIT;"
  } | query
}

list() {		# gensql.sh を利用してHTMLの表を出力
  echo ''
  ./gensql.sh | sqlite3 -header -html $db
  echo "
" } delete() { # 削除機能 who="$1" # 第1引数 = 削除したい人 query "DELETE FROM sched WHERE name='$who';" } while getopts a:id:l i; do case "$i" in a) job=add who=$OPTARG ;; i) job=create ;; l) job=list ;; d) job=delete who=$OPTARG ;; esac done shift $((OPTIND-1)) case $job in add|delete) # オプション指定なしの場合 $job $who ;; *) $job "$@" ;; esac

練習問題: 日程候補の追加

調整が難航して日程候補を増やさねばならなくなったケースに応えよう。

日程候補を追加・削除するシェルスクリプト edit-date.sh を作成せよ。 次のようなコマンドラインインタフェースで動くものとする。

とくに新しいことはなく、getopts とシェル関数を利用した処理を淡々と書き連ねるのみである。

練習問題: 解答例

追加指定された日付が日付としてありえるものかの判断を 簡略化したものをまず示す。

edit-date.sh

#!/bin/sh
PATH=/usr/local/sqlite3/bin:$PATH
db=${DB:-imoni.sq3}
query() {
  sqlite3 -cmd "PRAGMA foreign_keys=ON" $db "$@"
}

adddate() {
  case $1 in
    */*/*)	exit 1 ;;
    */*)	query "REPLACE INTO datelist VALUES('$1');" ;;
    *)		exit 1 ;;
  esac
}
listdate() {
  query "SELECT * FROM datelist;"
}
deldate() {
  query "DELETE FROM datelist WHERE date='$1';"
}

while getopts a:d:l f; do
  case $f in
    a)	adddate $OPTARG ;;
    d)	deldate $OPTARG ;;
    l)	listdate ;;
    \?)	exit 1 ;;
  esac
done
shift $((OPTIND-1))

-a オプションに指定した日付チェックを行なっているのが adddate 関数内の以下の部分である。

  case $1 in
    */*/*)	exit 1 ;;
    */*)	query "REPLACE INTO datelist VALUES('$1');" ;;
    *)		exit 1 ;;
  esac

この簡略判定は、「スラッシュが1つあればよし」と判断している。 これを若干改善しよう。シェルスクリプトなので外部コマンドの力を活用する。

GNU date または NetBSD date コマンドでは、-d オプションに日付を指定して、日付出力する日時を決められる。 たとえば次のようにすると日付文字列を正規化できる(GNU date コマンドを gdate として呼ぶ)。

gdate -d 09/0030 +%m/%d
09/30
gdate -d 09/300 +%m/%d
gdate: invalid date '09/300'
gdate -d 09/xyz +%m/%d
gdate: invalid date '09/xyz'

これを利用して adddate 関数内部を書き変えるとしたら次のようなものが考えられる。

  newdate=`gdate +%m/%d -d "$1"` || {
  	echo Invalid date >&2; exit 1; }
  query "REPLACE INTO datelist VALUES('$newdate');" 

date コマンドの %m からの出力の場合、 1月から9月までは0が前置されたものとなる。0 を付けたくない場合はシェル変数操作の # オペレータで消せばよい。

  query "REPLACE INTO datelist VALUES('${newdate#0}');" 

練習問題: SQLによる繁雑な問い合わせ文の生成

縦持ちテーブルを横持ちテーブルに直す問い合わせは熟語的ではあるが、 横に伸ばすカラム数が可変だと面倒なのは確かである。 「gensql.sh」を用いた方法では、 横に展開するカラムに関わる部分をシェルスクリプトで行なったが、 これをSQL自身で行なうことにより、SQLに閉じた明解な処理とすることができる。

gensql.sh と同じSQL文を生成するSQL文を作成し、gensql.sql というファイルに格納せよ。

練習問題: 解答例

まず、datelist から値を取り出すと以下のようになる。

SELECT date FROM datelist;
10/3
10/4
10/10
10/11
10/17
10/18

この繰り返しを用いて、 「○×数え上げ問い合わせの骨子」で必要な 4箇所の列挙部分を作り、その他の固定的な部分は SELECT 文で生成する。 したがって、以下の問い合わせ文で ○×数え上げ表を生成する問い合わせ文が得られる。

gensql.sql

SELECT 'WITH sched2 AS (
 SELECT name,';
-- (1)↓日付候補の数だけのCASE文列挙
SELECT group_concat(
         printf('  CASE date WHEN ''%s'' THEN okng END "%s"', date, date),
	 X'2c0a')		-- 「カンマ(0x2c)+改行(0x0a)」で連結する
FROM   datelist;

SELECT ' FROM sched
) SELECT name,';
-- (2)↓maxの列挙
SELECT group_concat(printf('   max("%s") "%s"', date, date), X'2c0a')
FROM   datelist;

SELECT ' FROM sched2
 GROUP BY name

 UNION ALL

 SELECT ''○の数'',';
-- (3)↓count関数の列挙
SELECT group_concat(printf('    count("%s")', date), X'2c0a')
FROM   datelist;
SELECT ' FROM sched2 WHERE ''○'' IN (';
-- (4)↓INに対する値の列挙
SELECT group_concat(printf('"%s"', date)) FROM datelist;

SELECT ');';

これは datelist テーブルの入っている imoni.sq3 データベースを開いた sqlite3 コマンドに送り込む。

sqlite3 imoni.sq3 < gensql.sql
WITH sched2 AS (
 SELECT name,
  CASE date WHEN '10/3' THEN okng END "10/3",
  CASE date WHEN '10/4' THEN okng END "10/4",
  CASE date WHEN '10/10' THEN okng END "10/10",
  CASE date WHEN '10/11' THEN okng END "10/11",
  CASE date WHEN '10/17' THEN okng END "10/17",
  CASE date WHEN '10/18' THEN okng END "10/18"
 FROM sched
) SELECT name,
   max("10/3") "10/3",
   max("10/4") "10/4",
   max("10/10") "10/10",
   max("10/11") "10/11",
   max("10/17") "10/17",
   max("10/18") "10/18"
 FROM sched2
 GROUP BY name

 UNION ALL

 SELECT '○の数',
    count("10/3"),
    count("10/4"),
    count("10/10"),
    count("10/11"),
    count("10/17"),
    count("10/18")
 FROM sched2 WHERE '○' IN (
"10/3","10/4","10/10","10/11","10/17","10/18"
);

この出力結果をさらに sqlite3 に送り込めばよい。

sqlite3 imoni.sq3 < gensql.sql | sqlite3 imoni.sq3
山形醤二|○|○|○|○|○|×
庄内三十彦|○|×|×|×|○|○
○の数|2|1|1|1|2|1
yuuji@koeki-u.ac.jp