和暦対応&地名展開

この記事はPostgreSQL Advent Calendar 2013 - Qiitaの22日目の記事にゃ。

今回は新ネタはありません(新ネタの実装が間に合わなかった&来月のPostgreSQL Unconferenceに出すことにした)。
なので今回はブログの過去エントリからお気に入りのテーマを再構成した感じでまとめます。ご了承くださいませ。
テーマは2つ。和暦対応と地名展開です。

その1:和暦対応

今日は冬至日、二十四節気でいうところ「冬至」の始まりですね。

さて、PostgreSQLには色んなデータ型があって勿論日時型もありますが、残念ながら(?)二十四節気七十二候には対応していません(四半期を取り出すquarterとかはありますが)。
日本に住んでいるのだから、やっぱり二十四節気は使いたい。ということで対応してみましたw

実行例

早速、実行例を見てみましょう。例えばこんな感じ。

wareki=# SELECT CURRENT_DATE, ts2w24(CURRENT_DATE);
    date    | ts2w24 
------------+--------
 2013-12-22 | 冬至
(1 row)

せっかくなので月の表記もそれっぽくしてみます。

wareki=# SELECT CURRENT_DATE, ts2w12(CURRENT_DATE);
    date    | ts2w12 
------------+--------
 2013-12-22 | 師走

これだけじゃあまり変態っぽくないので、今度はこれを条件として使ってみます。

サンプルとして、lifelog というテーブルを検索してみることにします。テーブルには200件ほどのラー食のログが格納されています。

wareki=# \d lifelog
                            Table "public.lifelog"
   Column    |  Type   |                      Modifiers                       
-------------+---------+------------------------------------------------------
 id          | integer | not null default nextval('lifelog_id_seq'::regclass)
 data        | text    | 
 review_date | date    | 
Indexes:
    "lifelog_data_idx" gin (data gin_bigm_ops)

wareki=# TABLE lifelog LIMIT 10;
 id |                    data                     | review_date 
----+---------------------------------------------+-------------
  1 | 港南区で鶏白湯らーめんを食べた。            | 2012-09-01
  2 | 伊勢佐木町で油そばを食べた。                | 2012-09-02
  3 | 大さん橋で冷やしラーメンを食べた。          | 2012-09-02
  4 | 大和で豚骨醤油ラーメンを食べた。            | 2012-09-08
  5 | 穴守稲荷で豚骨醤油ラーメンを食べた。        | 2012-09-15
  6 | 羽咋市で海鮮塩ラーメンを食べた。            | 2012-09-17
  7 | 環状2号・上永谷で豚骨醤油ラーメンを食べた。 | 2012-09-19
  8 | 鶴屋町で醤油ラーメンを食べた。              | 2012-09-21
  9 | 西区で醤油ラーメンを食べた。                | 2012-09-22
 10 | 寿町で醤油ラーメンを食べた。                | 2012-09-23
・・・

例えば・・・今と真逆の季節、「夏至」(6/21-7/6)の頃に何を食べていたか検索してみましょう。
このときに夏至の期間を示す"<=>"という演算子を使います。

wareki=# SELECT * FROM lifelog WHERE review_date <=> '夏至';
 id  |              data              | review_date 
-----+--------------------------------+-------------
 171 | 品川で味噌つけ麺を食べた。     | 2013-06-22
 172 | 横浜で油そばを食べた。         | 2013-07-02
 173 | 新丸子で醤油ラーメンを食べた。 | 2013-07-06
(3 rows)

さらに「文月」(7月)のレビューに絞り込んでみます。

wareki=# SELECT * FROM lifelog WHERE review_date <=> '夏至' AND review_date <=> '文月';
 id  |              data              | review_date 
-----+--------------------------------+-------------
 172 | 横浜で油そばを食べた。         | 2013-07-02
 173 | 新丸子で醤油ラーメンを食べた。 | 2013-07-06

二十四節気をさらに3つの期間に分けた七十二候にも無駄に対応してみました。

wareki=# SELECT * FROM lifelog WHERE review_date <=> '禾乃登';
 id  |                 data                 | review_date 
-----+--------------------------------------+-------------
   1 | 港南区で鶏白湯らーめんを食べた。     | 2012-09-01
   2 | 伊勢佐木町で油そばを食べた。         | 2012-09-02
   3 | 大さん橋で冷やしラーメンを食べた。   | 2012-09-02
 211 | 西区で坦々刀削麺を食べた。           | 2013-09-02
 212 | 神奈川区で直系家系ラーメンを食べた。 | 2013-09-04
(5 rows)

ちなみに「禾乃登」は「こくものすなわちみのる」と読むらしいです。期間は9/1-9/7らしいです。尤も、この機能の実装のために調査するまで、そんなことは全然知りませんでしたがw

逆に出力側を二十四節気として対応もできます。
どの季節に家系を食べたのか調べてみます。

wareki=# SELECT id, data, ts2w24(review_date) FROM lifelog WHERE data LIKE likequery('家系');
 id  |                 data                 | ts2w24 
-----+--------------------------------------+--------
  20 | 大口で家系ラーメンを食べた。         | 寒露
  22 | 大和で家系ラーメンを食べた。         | 寒露
  26 | 瀬谷区で家系ラーメンを食べた。       | 霜降
  31 | 大井町(東京)で家系ラーメンを食べた。 | 霜降
  37 | 瀬谷区で家系ラーメンを食べた。       | 霜降
  58 | 綱島で家系ラーメンを食べた。         | 小雪
  73 | 大井町(東京)で家系ラーメンを食べた。 | 冬至
  74 | 戸塚で家系ラーメンを食べた。         | 冬至
  77 | 職場近くで家系ラーメンを食べた。     | 小寒
  81 | 大井町(東京)で家系ラーメンを食べた。 | 小寒
  93 | 目黒で家系ラーメンを食べた。         | 雨水
  96 | 新丸子で家系ラーメンを食べた。       | 雨水
 112 | 職場近くで家系ラーメンを食べた。     | 春分
 147 | 中区で家系ラーメンを食べた。         | 立夏
 151 | 磯子区で家系ラーメンを食べた。       | 立夏
 159 | 伊勢佐木で家系ラーメンを食べた。     | 芒種
 161 | 杉田で家系ラーメンを食べた。         | 芒種
 184 | 川崎で家系ラーメンを食べた。         | 大暑
 190 | 鶴見で家系ラーメンを食べた。         | 立秋
 197 | 藤沢で家系ラーメンを食べた。         | 立秋
 202 | 杉田で朝から家系ラーメンを食べた。   | 処暑
 204 | 鴨居で家系ラーメンを食べた。         | 処暑
 210 | 野毛で家系ラーメンを食べた。         | 処暑
 212 | 神奈川区で直系家系ラーメンを食べた。 | 処暑
(24 rows)

なんか、年中食ってみるみたいですね(汗

実装

和暦月の表示、二十四節気、七十二候の実装は、PostgreSQLのDOMAIN機能を使っています。普通はCREATE TYPEで新しく型を作るのが定番だとは思いますが、今回はあえてTEXT型のDOMAINと範囲型をちょっと応用して、pl/pgSQLで簡易実装してみました。

まず、TEXT型をベースとしてCREATE DOMAINで制約チェックを加えたドメインとします。

CREATE DOMAIN wareki AS text
CONSTRAINT wareki_check NOT NULL
CHECK (
VALUE ~ '(立春|雨水|啓蟄|春分|清明|穀雨|立夏|小満|芒種|夏至|小暑|大暑|立秋|処暑|白露
|秋分|寒露|霜降|立冬|小雪|大雪|冬至|小寒|大寒|睦月|如月|衣更着|弥生|卯月|皐月|早月|>水無月|文月|葉月|長月|神無月|神有月|霜月|師走|東風解凍|黄鶯&#30541;薭|魚上氷|土脉潤起|霞始
靆|草木萠動|蟄虫啓戸|桃始笑|菜虫化蝶|雀始巣|桜始開|雷乃発声|玄鳥至|鴻雁北|虹始見|葭>始生|霜止出苗|牡丹華|蛙始鳴|蚯蚓出|竹笋生|蚕起食桑|紅花栄|麦秋至|螳螂生|腐草為蛍|梅>子黄|乃東枯|菖蒲華|半夏生|温風至|蓮始開|鷹乃学習|桐始結花|土潤溽暑|大雨時行|涼風至|>寒蝉鳴|蒙霧升降|綿柎開|天地始粛|禾乃登|草露白|鶺鴒鳴|玄鳥去|雷乃収声|蟄虫坏戸|水始涸
|鴻雁来|菊花開|蟋蟀在戸|霜始降|霎時施|楓蔦黄|山茶始開|地始凍|金盞香|虹蔵不見|朔風払>葉|橘始黄|閉塞成冬|熊蟄穴|&#40022;魚群|乃東生|麋角解|雪下出麦|芹乃栄|水泉動|雉始&#38602;|款冬華|水沢腹堅|鶏始乳)'
);

warekiというドメインでは、和暦月、二十四節気、七十二候の表記のみを許容するようにします。

あとはTIMESTAMP/DATE型からwarekiドメインに変換する関数を記述します。
まず、TIMESTAMP/DATE型から
EXTRACT(DOY FROM ts)
で、その年の年始からの日数(整数値)で取り出し、うるう年補正をします。
次に、取り出した整数値とINT4RANGE(int4の範囲型)とちまちまチェックします。
実装コードの一部を示すとこんな感じになります。

CREATE OR REPLACE FUNCTION ts2w24(ts timestamp without time zone) RETURNS wareki AS $$
DECLARE
  doy integer := EXTRACT(DOY FROM ts);
BEGIN
  IF isLeapYear(ts) AND doy > 60 THEN
    doy := doy - 1; -- 閏日対応
  END IF;
  IF int4range('[35, 50)') @> doy THEN
    return '立春';
  ELSIF int4range('[50, 65)') @> doy THEN
    return '雨水';

あとは、二十四節気と七十二候のそれぞれの範囲をint4range()で生成したものと、doy(年始からの日数)を延々比較していくというだけです。

そしてTIMESTAMP/DATE型が和暦表記に含まれるかどうか、という判定関数 include_wareki も作成しておきます。

CREATE OR REPLACE FUNCTION include_wareki(ts timestamp, w wareki) RETURNS  boolean AS $$
DECLARE
  tsr INT4RANGE;
  doy integer := EXTRACT(DOY FROM ts);
BEGIN
  tsr = wareki_to_doys(w);
  IF w = '冬至' THEN
    -- 冬至のみの例外処理
    return doy <@ '[1,6)'::int4range OR doy <@ '[356,366]'::int4range ;
  ELSE
    return tsr @> doy ;
  END IF;
END;
$$ LANGUAGE plpgsql;

上記関数内のwareki_to_doy()という関数は、和暦月/二十四節気/七十二候の文字列から、それぞれの範囲に街頭するINT4RANGE型を返却する関数です。これも単純に文字列比較をして一致したら、それに対応するINT4RANGEの値を返却するだけです。面倒ですがやってることは単純です。

CREATE OR REPLACE FUNCTION wareki_to_doys(w wareki) RETURNS  int4range AS $$
DECLARE
BEGIN
  -- warekiの値によってdoy(Day of Year)のint4rangeをセットする
  CASE w
    -- 二十四節気表記
    -- 本当は年毎に微妙に日付が微妙に違うので
    -- 年から算出するのが正しいけど今回は簡易的に固定値セット。
    WHEN '立春' THEN
      return int4range('[35, 50)');
    WHEN '雨水' THEN
      return int4range('[50, 65)'); 
・・・

最後に、この関数を使う、オペレータ <=> も生成します。これで、TIMESTAMP/DATE型が二十四節気/七十二候に含まれるかどうかという判定が書けるようになります(まあinclude_warekiを直接呼び出してもいいですが)。

CREATE OPERATOR <=> (
  LEFTARG = timestamp,
   RIGHTARG = wareki,
  PROCEDURE = include_wareki,
  COMMUTATOR = <=>
);

ということで、PostgreSQL二十四節気や七十二候を扱えるようにしてみました。
誰得ですけどw

地名展開

次は地名展開と全文検索を組み合わせる例の紹介です。
また、さっきの lifelog テーブルを見てみましょう。

wareki=# TABLE lifelog LIMIT 10;
 id |                    data                     | review_date 
----+---------------------------------------------+-------------
  1 | 港南区で鶏白湯らーめんを食べた。            | 2012-09-01
  2 | 伊勢佐木町で油そばを食べた。                | 2012-09-02
  3 | 大さん橋で冷やしラーメンを食べた。          | 2012-09-02
  4 | 大和で豚骨醤油ラーメンを食べた。            | 2012-09-08
  5 | 穴守稲荷で豚骨醤油ラーメンを食べた。        | 2012-09-15
  6 | 羽咋市で海鮮塩ラーメンを食べた。            | 2012-09-17
  7 | 環状2号・上永谷で豚骨醤油ラーメンを食べた。 | 2012-09-19
  8 | 鶴屋町で醤油ラーメンを食べた。              | 2012-09-21
  9 | 西区で醤油ラーメンを食べた。                | 2012-09-22
 10 | 寿町で醤油ラーメンを食べた。                | 2012-09-23
・・・

私は地元が横浜ということもあって、当然ながら横浜でラーメンを食べることが多いのですが、そういうときには横浜市内のもっと限定された地域「港南区」「(中区の)伊勢佐木町」で食べた、みたいな記述をすることが多いです。
さて、このlifelogテーブルのdataカラムにN-gram全文検索モジュールpg_bigmを使って全文検索インデックスを設定しておきます。
で、そのテーブルに対して「横浜」を条件値として与えて検索してみます。

wareki=# SELECT id, data FROM lifelog WHERE data LIKE likequery('横浜');
 id  |                data                
-----+------------------------------------
  68 | 横浜駅近くで醤油ラーメンを食べた。
  82 | 横浜駅で醤油ラーメンを食べた。
 172 | 横浜で油そばを食べた。
 193 | 横浜で火鍋風ラーメンを食べた。
 194 | 横浜で冷やしラーメンを食べた。
 201 | 横浜駅近くでタンメンを食べた。
 206 | 横浜駅近くで冷やし刀削麺を食べた。
(7 rows)

当たり前ですが「横浜」を含むレコードしかヒットしません。(´・ω・`)
本当は、横浜市内の「港南区」や「伊勢佐木町」で食べたレコードもヒットさせたいわけです。

そこで、likequery()の代わりに、地名を展開するexpand_area(text)を記述します。なお、この関数はTEXTの配列を返却するので、LIKE ANY (expand_area(text))のように記述します。
実行例を見てみます。

wareki=# SELECT id, data FROM lifelog WHERE data LIKE ANY (expand_area('横浜'));
 id  |                     data                      
-----+-----------------------------------------------
   1 | 港南区で鶏白湯らーめんを食べた。
   2 | 伊勢佐木町で油そばを食べた。
   8 | 鶴屋町で醤油ラーメンを食べた。
   9 | 西区で醤油ラーメンを食べた。
・・・
 206 | 横浜駅近くで冷やし刀削麺を食べた。
 207 | 西区で醤油ラーメンを食べた。
 210 | 野毛で家系ラーメンを食べた。
 211 | 西区で坦々刀削麺を食べた。
 212 | 神奈川区で直系家系ラーメンを食べた。
(65 rows)

と、こんな結果になります。id=206 のように「横浜」を含むレコードだけでなく、id=1 のように「港南区」やid=2 のように「伊勢佐木町」を含むレコードもヒットするようになりました。
そして「中区」で検索すると
wareki=# SELECT id, data FROM lifelog WHERE data LIKE ANY (expand_area('中区'));

 id  |                     data                      
-----+-----------------------------------------------
   2 | 伊勢佐木町で油そばを食べた。
  12 | 麺恋亭 中華街店でなめこそばを食べた。
  13 | 伊勢佐木町で油そばを食べた。
  52 | 石川町で味噌ラーメンを食べた。

中区内の地名を含むレコードが検索されます。
ふしぎ!

実装

ふしぎ!でもなんでもなく、これはexpand_area()が検索結果として「港南区」や「伊勢佐木町」を含むTEXT配列を返却しているからです。
例えば、expand_area('横浜')の場合は、

wareki=# SELECT expand_area('横浜');
                                                                                      
                                                          expand_area                 
                                                                                      
                                          
--------------------------------------------------------------------------------------
--------------------------------------------------------------------------------------
--------------------------------------------------------------------------------------
------------------------------------------
 {%みなとみらい%,%桜木町%,%蒔田%,%神奈川区%,%横浜%,%中区%,%弘明寺%,%鶴見区%,%生麦%,%南
太田%,%尻手%,%高島%,%中華街%,%吉野町%,%西区%,%緑区%,%浅間町%,%矢向%,%南区%,%関内%,%反
%,%鴨居%,%白楽%,%石川町%,%野毛%,%伊勢佐木町%,%中山%,%大黒町%,%長津田%,%山下町%,%六角橋
%,%東神奈川%,%鶴屋町%,%十日市場%,%横浜%}

のようなTEXT配列を生成します。

さて、このTEXT配列はどうやって生成しているのかというと、関数内で地名展開用の辞書テーブルを検索して生成しています。
地名展開用の辞書テーブル(area)は、以下のような構造のテーブルです。

wareki=# \d area
      Table "public.area"
 Column |   Type    | Modifiers 
--------+-----------+-----------
 id     | integer   | 
 name   | text      | 
 childs | integer[] | 

nameには地名を、childsにはその地名に含まれる地名群のid配列を設定します。
例えばこんな感じで。

wareki=# TABLE area;
  id  |     name     |                childs                
------+--------------+--------------------------------------
 1000 | 東京都       | {1100,1200,1300,1400}
・・・
 1400 | 町田市       | {1401,1402,1403,1404}
・・・
 2000 | 神奈川県     | {1400,2100,2200,2300,2400,2500,2600}
 2001 | 湘南         | {2500,2600,2700}
 2100 | 横浜市       | {2110,2120,2130,2140,2150,2160}
 2110 | 中区         | {2111,2112,2113,2114,2115,2116,2117}
 2111 | 山下町       | {}
 2112 | 中華街       | {}
 2113 | 伊勢佐木町   | {}
 2114 | 石川町       | {}
 2115 | 関内         | {}
 2116 | 桜木町       | {}
 2117 | 野毛         | {}
 2120 | 西区         | {2116,2121,2122,2123}
・・・

図で示すと、こういう擬似ツリー(或いは有方向の非循環グラフ)になります。

町田市のように、東京都にも神奈川県にも属するような市も表現可能ですw

こういうツリー状になった情報を検索するときに役立つのが、PostgreSQL 8.4から導入されたWITH句を使った再帰問い合わせです。
関数expand_area()は、この再帰問い合わせを応用して作成しています。
関数の定義を見てみましょう。

CREATE OR REPLACE FUNCTION expand_area(area_name TEXT) RETURNS TEXT[] AS $$
SELECT array_append(array_agg(likequery(regexp_replace(res.name, '(市|県|都)$', ''))), likequery(area_name)) FROM
(WITH RECURSIVE ar AS
  (SELECT * FROM area AS ar1 WHERE ar1.name LIKE likequery(area_name)
  UNION ALL
  SELECT ar2.* FROM ar, area as ar2 WHERE ar2.id = ANY (ar.childs))
SELECT distinct name FROM ar) as res;
$$ LANGUAGE sql;

引数として与えられた area_text は再帰問い合わせの基点となる
SELECT * FROM area AS ar1 WHERE ar1.name LIKE likequery(area_name)
のlikequery()に与えられます。
この基点クエリとUNIONをとる
SELECT ar2.* FROM ar, area as ar2 WHERE ar2.id = ANY (ar.childs))
の問い合わせが再帰的に実行されます。
ここでarのchilds、つまり自分の地名の配下の地名idと一致するレコードを検索しています。配下要素にヒットするレコードが存在しなくなるまで、これを再帰的に繰り返します。
最後に、この再帰クエリの結果となったレコードをarray_aggで配列に変換して返却します。
シンプルなSQLですが、これで任意の段数のツリー内のヒットしたノードと配下ノードの結果を返却することができます。
再帰問い合わせバンザイですね!

おわりに

以上、PostgreSQLの機能を使って誰得な検索をやってみる例を紹介しました。
今回の例はSQL関数やpl/pgSQL関数だけで簡単に実装しただけですが、C言語関数を組み合わせればもっと面白いことが出来そうです。
来月のUnconferenceまでには、もうちょっと面白そうな拡張を実装して紹介できればと思います。