Quantcast
Channel: 私の小岩ホッチキス » kwatch私の小岩ホッチキス
Viewing all articles
Browse latest Browse all 23

[postgresql] MySQLユーザに捧げる、ポスグレの特徴的な機能12選

$
0
0

(これは PostgreSQL Advent Calendar 2014 参加エントリですが、Advent Calendar なんてただのブログ記事の延長でしかないので、過度な期待はしないでください。)

主に MySQL ユーザを対象として、PostgreSQL にはこんな機能がありますよー、という紹介記事です。

乗り換えを勧めるわけじゃないけど、MySQL と PostgreSQL の両方できる人が増えると世の中はもっと平和になると思うので、この記事を通じて「同じ RDBMS でもこんなに違いがあるんだー」と思っていただければ幸いです。

なお「ポスグレの特徴的な機能」と言ってますが、あくまで MySQL と比べて特徴的な機能、という意味です。Oracle や SQL Server や DB2 その他と比べるとごく普通の機能もあります。

Transactional DDL

PostgreSQL では、create table 文や alter table 文のような DDL (Data Definition Language) を、トランザクションの中で実行できます。 その場合、最後に commit しないと反映されないし、rollback するとなかったことにできます。

psql=> create table tbl1 ( value integer );
CREATE TABLE             ← テーブルが作成された
psql=> begin;            ← トランザクション開始
BEGIN
psql=> drop table tbl1;  ← テーブル削除
DROP TABLE
psql=> \d tbl1;          ← 削除されたことを確認
Did not find any relation named "tbl1".
psql=> rollback;         ← ロールバック
ROLLBACK
psql=> \d tbl1;          ← テーブルが消えてない!
     Table "public.tbl1"
 Column |  Type   | Modifiers
--------+---------+-----------
 value  | integer |

これはマイグレーションスクリプトを SQL で直書きするときにはとても便利です。 なにせ、DDQL の途中でエラーが発生しても、なかったことにできるのですから。

ただし、列挙型に値を追加するのは、トランザクションの中では実行できません。

マニュアルより

ALTER TYPE … ADD VALUE(列挙型に新しい値を追加する構文)はトランザクションブロック内で実行することはできません。

この制限って何でなんでしょうかねー。わりと困ります。このせいで、なんとなく列挙型を避けるようになってしまいました。わりと困ります。

非相関サブクエリ

サブクエリ (副問い合わせ) には、相関と非相関の 2 種類があります。

  • 相関サブクエリ … サブクエリの中から、外側の要素にアクセスしているもの
  • 非相関サブクエリ … サブクエリの中だけで完結し、外側の要素にアクセスしていないもの

そして PostgreSQL では、非相関サブクエリは通常 1 回しか実行されません。

たとえば次の例において、サブクエリは相関ではないため、仮に departments テーブルに N 行あったとしても、サブクエリは 1 回しか実行されません。

select id
    , name
    , description
from departments
where id in ( select dept_id from employees
              where gender = 'F' )

これに対し、MySQL では非相関サブクエリでも相関サブクエリのように実行されるため、上の例だとサブクエリが N 回実行されてしまいます。

これは効率が悪いので、MySQL ではサブクエリを使わず、次のように join を使う必要があります。

select d.id
    , d.name
    , d.description
from departments d
join employees e on d.id = e.dept_id
where e.gender = 'F'
group by d.id, d.name, d.description

ただこれを見ると分かるように、group by で重複を取り除く必要があるんですよね。 select で指定するカラムが多いと、これがとても面倒になります。 PostgreSQL ではそういう面倒がありません。

また PostgreSQL ではたいての場合、join するより非相関サブクエリを使ったほうが高速です (メモリに載る範囲なら)。 join は重たい操作なので、サブクエリを使うことで join を減らせるならそうしたほうがいいです。

MySQL と違い、PostgreSQL では非相関サブクエリを積極的に使っていきましょう。

with 句

PostgreSQL では、with 句を使ってサブクエリに名前を付けられます (注:ここでは with 句と呼んでいますが、一般的には「共通テーブル式」や「CTE (Common Table Expression)」と呼ばれることも多いです)。

先の select 文を、with 句を使って書くと次のようになります (with 句と select 文は別れているのではなく、2 つで 1 つの文になっていることに注意してください)。

with target_depts as (  ← サブクエリに名前をつけて
        select dept_id from employees
        where gender = 'F'
     )
select id                ← それを select 文の中で使える
    , name
    , description
from departments
where id in ( select dept_id in target_depts )

これを見ると、一般的なプログラミングにおけるローカル変数や、あるいは Lisp における let* 文のように見えますね。

ただ個人的な意見を言うと、サブクエリを参照するのに ( select ... in ... ) と書かなければいけないのが面倒です。 できれば、where id in ( select dept_id in target_depts ) ではなく、where id in ( target_depts ) のように簡潔に書きたいです。 こういうところに、SQL の洗練されてなさを感じます。

なお with 句では、サブクエリを複数指定できます。 その場合、先に定義したサブクエリを後ろのサブクエリの中で参照できます (Lisp 的に言うと、let ではなく let* のようなイメージ)。

with tmp1 as ( select .... )
   , tmp2 as ( select ... from tmp1 ... )
   , tmp3 as ( select ... from tmp2 ... )
select ... from tmp3 ...

またサブクエリだけでなく、カラムにも別名を付けられます。 カラムの別名は、通常は select col1 as name1, col2 as name2, ... のように書くことが多いと思いますが、まとめて指定したほうがわかりやすいかもしれません。

select t(name1, name2) as (
        select col1, col2 from table123
    )
select t.name1, t.name2 from t;

returning 句

PostgreSQL では、insert 文や update 文の戻り値を returning 句で指定できます。

たとえば、insert 文でレコードを挿入したときに、id や created_at の値を返すことができます。 これは特に O/R マッパーにとってはうれしい機能です。

psql=> create table tmp (
psql(>   id          serial     primary key
psql(> , val         text       not null
psql(> , created_at  timestamp  not null default current_timestamp
psql(> );
CREATE TABLE
psql=> insert into tmp (val) values ('homhom')
psql->     returning id, created_at;
 id |         created_at
----+----------------------------
  1 | 2014-12-01 06:01:02.674958
(1 row)

INSERT 0 1

MySQL だと、id 値は last_insert_id を使って取ってこれますが、created_at の値は select 文を発行しないと取ってこれないはず。

なお returning 句を with 句と組み合わせた使ったトリックが Let’s Postgres に掲載 されていたので、興味のある人は読んでみてください。

シーケンス

PostgreSQL には「シーケンス (Sequence)」という、連番を生成する機能があります。 そして生成したシーケンスに対し、以下の関数が使用できます。

  • currval() … 現在の値を取得する
  • nextval() … 次の値を取得する (現在値が 1 増える)
  • setval() … 値を設定し直す

例:

-- シーケンスを作成
psql=> create sequence myseq1 start 1;
CREATE SEQUENCE

-- いきなりは現在値を取得できない
psql=> select currval('myseq1');
ERROR:  currval of sequence "myseq1" is not yet defined in this session

-- 連番を生成する
psql=> select nextval('myseq1');
 nextval
---------
       1
(1 row)

psql=> select nextval('myseq1');
 nextval
---------
       2
(1 row)

psql=> select nextval('myseq1');
 nextval
---------
       3
(1 row)

-- 現在値を取得する
psql=> select currval('myseq1');
 currval
---------
       3
(1 row)

-- シーケンスを削除
psql=> drop sequence myseq1;
DROP SEQUENCE

ところで、MySQL の auto_increment に相当するのは、PostgreSQL では serial 型です (カラムオプションではなくデータ型であることに注意)。

-- MySQL の場合は auto_increment というカラムオプション
create table t1 (
  id    integer   primary key auto_increment,
  val   text      not null
);

-- PostgreSQL の場合は serial というデータ型
create table t1 (
  id    serial    primary key,
  val   text      not null
);

実は PostgreSQL では、serial 型を使ってテーブルを作成すると自動的にシーケンスが作られ、その nextval() がデフォルト値として設定されます。

次の例を見てください。tmp テーブルの id カラムが serial 型だと、

  • tmp_id_seq というシーケンスが自動的に作られ、
  • id カラムのデフォルト値に nextval('tmp_id_seq') が設定される

ことがわかります。

psql=> create table tmp (
psql(>   id     serial       primary key
psql(> , name   text         not null
psql(> );
CREATE TABLE
psql=> \d tmp
                         Table "public.tmp"
 Column |  Type   |                    Modifiers
--------+---------+---------------------------------------------------
 id     | integer | not null default nextval('tmp_id_seq'::regclass)
 name   | text    | not null
Indexes:
    "tmp_pkey" PRIMARY KEY, btree (id)

この例で分かるように、デフォルト値が nextval('tmp_id_seq') であるため、insert 時に連番が生成されて id カラムに設定されるわけです。

psql=> insert into tmp (name) values ('Haruhi'), ('Mikuru'), ('Yuki');
INSERT 0 3
psql=> select * from tmp;
 id |  name
----+--------
  1 | Haruhi
  2 | Mikuru
  3 | Yuki
(3 rows)

psql=> select currval('tmp_id_seq');
 currval
---------
       3           ← シーケンスの現在値
(1 row)

このような仕組みのため、MySQL のような「id に nullを指定すると連番が設定される」という機能はありません。

-- MySQL ではこれができるけど、PostgreSQL ではできない
psql=> insert into tmp (id, name) values (null, 'Tsuruya');
ERROR:  null value in column "id" violates not-null constraint
DETAIL:  Failing row contains (null, Tsuruya).

また MySQL とは違って、id カラムに 0 を指定したレコードを作成できます。

psql=> insert into tmp (id, name) values (0, 'Sasaki');
INSERT 0 1
psql=> select * from tmp;
 id |  name
----+--------
  1 | Haruhi
  2 | Mikuru
  3 | Yuki
  0 | Sasaki
(4 rows)

このように、PostgreSQL の serial 型は MySQL の auto_increment と挙動は似ていますが、仕組みは全然違います。

ところで、MySQL では auto_increment を使って「複数テーブルで利用する連番」を作ることがあります。

ソーシャルゲームのためのMySQL入門』より:

もう一つの大きな問題としては、このテーブルを2つ以上のデータベースに分割(Sharding)したくなった時です。この場合、複数データベースにまたがってユニークなidを確保するにはauto_incrementだけではちょっと面倒です。

そこでモバゲータウンではidだけを払い出すテーブルを1つ作って1レコードだけinsertして、updateにより採番し、実際のテーブルには採番したidを指定してinsertするという手段を用いています。

CREATE TABLE `seq_log` (
  `id` bigint(20) unsigned NOT NULL
) ENGINE=MyISAM
INSERT INTO seq_log VALUES (0);

my $sth = $dbh->prepare_cached("update seq_log set id=LAST_INSERT_ID(id+1)");
$sth->execute();
return($dbh->{'mysql_insertid'});

この方法は MySQL のマニュアルにも書かれている由緒あるやり方ですが、分かりづらいですよね。 特に、「LAST_INSERT_ID() に引数を渡すと副作用を生じる」という仕様を知らないと、まず理解はできないでしょう (PostgreSQL だと update seq_log set id = id+1 returning id に相当)。

しかし PostgreSQL なら、シーケンスを作って共有するだけで、複数 DB で使える連番が簡単に実現できます。 つまり、あるテーブル専用のシーケンスではなく、独立したシーケンスを作ればいいだけなので、とても手軽です。

-infinity と ’0000-00-00′

MySQL では、遠い過去の日付として '0000-00-00' を使うことがありました。 しかし、西暦 0 年はまだしも、0 月 0 日というのは存在するはずのない値であり、他の DB では使えません。

PostgreSQL では '0000-00-00' は使えませんが、かわりに '-infinity' が使えます。

psql=> create table datetest ( created_on date not null );
CREATE TABLE
psql=> insert into datetest(created_on) values ('-infinity');
INSERT 0 1
psql=> select * from datetest;
 created_on
------------
 -infinity
(1 row)

当然ですが、'-infinity''0001-01-01' より古いと見なされます (ここで ::date は date 型へのキャストです)。

psql=> select '-infinity'::date < '0001-01-01'::date;
 ?column?
----------
 t
(1 row)

なお遠い未来を表すなら、'infinity' が使えます。

infinity-infinityないことを調べるには、isfinite() 関数を使います (ここで ::timestamp は timestamp 型へのキャストです)。

psql=> select isfinite('infinity'::timestamp);
 isfinite
----------
 f              ← false
(1 row)

psql=> select isfinite('2014-12-01 00:00:00'::timestamp);
 isfinite
----------
 t              ← true
(1 row)

generate_series() 関数

generate_series() とは、連続した整数を生成する関数です (正確には集合を返す関数だそうです)。 ちょうど、Ruby の 1..3 や Python の range(1, 4) と同じようなものだと思ってください。

psql=> select t.i from generate_series(1, 3) as t(i);
 i
---
 1
 2
 3
(3 rows)

(ここでは as t(i) を使って、テーブル名とカラム名の別名をつけています。)

これはたとえば、ダミーのテストデータを生成するときに役にたちます。 (generate_series() を使ったテストデータ作成については、Kawarasho氏が書いていますので興味があればどうぞ。)

psql=> create table users (
psql(>   id      serial     primary key
psql(> , name    text       not null
psql(> );
CREATE TABLE
psql=> insert into users (name)
psql-> select 'Name-' || t.i
psql-> from generate_series(1, 3) as t(i);
INSERT 0 3
psql=> select * from users;
 id |  name
----+--------
  1 | Name-1
  2 | Name-2
  3 | Name-3
(3 rows)

また generate_series() は、連続した日時 (timestamp 型) を生成できます。 また timestamp 型を date 型にキャストすれば、連続した日付を生成できます (繰り返しますが ::date::timestamp はそれぞれ date 型や timestamp 型へのキャストです)。

--
-- 日付の場合
--
psql=> select t.date::date
psql-> from generate_series('2014-12-01'::date,
psql(>                      '2014-12-03'::date,
psql(>                      '1 day'::interval)
psql->      as t(date);
    date
------------
 2014-12-01
 2014-12-02
 2014-12-03
(3 rows)

--
-- 日時の場合
--
psql=> select t.datetime
psql-> from generate_series('2014-12-01 00:00:00'::timestamp,
psql(>                      '2014-12-01 03:00:00'::timestamp,
psql(>                      '1 hour'::interval)
psql->      as t(datetime);
      datetime
---------------------
 2014-12-01 00:00:00
 2014-12-01 01:00:00
 2014-12-01 02:00:00
 2014-12-01 03:00:00
(4 rows)

これを使うと、日付単位で集計したデータに、日付の抜けをなくせます。

--
-- たとえばこの SQL だと、注文のない日は集計データの
-- 抜けが発生する
-- (注:「... + '1 month'::interval」は1ヶ月後を表す)
--
select date(o.created_at) as "Date"
     , count(*)           as "Count"
from sales_orders as o
where o.created_at >= '2014-12-01'::date
  and o.created_at <  '2014-12-01'::date + '1 month'::interval
group by date(o.created_at)
order by date(o.created_at)

--
-- 日付データを生成して join すれば、注文のない日でも
-- 集計データの抜けがなくなる
-- (注:「... + '1 month'::interval - '1 day'::interval」は
--      同月最終日を表す)
--
with calendar(date) as (
        select t.date::date
        from generate_series(
                     '2014-12-01'::date,
                     '2014-12-01'::date + '1 month'::interval
                                        - '1 day'::interval,
                     '1 day'::interval)
             as t(date)
     )
select date(o.created_at) as "Date"
     , count(*)           as "Count"
from sales_orders as o
right join calendar on calendar.date = date(o.created_at)
where o.created_at >= '2014-12-01'::date
  and o.created_at <  '2014-12-01'::date + '1 month'::interval
group by date(o.created_at)
order by date(o.created_at)

Window関数

Window関数とは何でしょうか? PostgreSQlのチュートリアルより引用してみましょう:

ウィンドウ関数は現在の行に何らかとも関係するテーブル行の一纏まり全般の計算を行います。 これは集約関数により行われる計算の形式と似たようなものです。 とは言っても、通常の集約関数とは異なり、ウィンドウ関数の使用は単一出力行に行をグループ化しません。 行はそれぞれ個別の身元を維持します。 裏側では、ウィンドウ関数は問い合わせ結果による現在行だけでなく、それ以上の行にアクセスすることができます。

はい、何を言ってるのかさっぱりですね。 ポスグレのマニュアルは、分かってる人が分かってる人向けに書いた文章なので、分かってない人が読んでも分かるようにはならないことがあります。

とりあえず「連番をつける」例と、「順位づけをする」例を紹介しますので、Window 関数だとこんなことができるのかー、ぐらいに見といてください。

--
-- こんなテーブルがあったとして
--
psql=> select * from members
psql-> ;
 id  |  name  | group_id
-----+--------+----------
 101 | Haruhi |       10
 102 | Mikuru |       10
 103 | Yuki   |       10
 104 | Sasaki |       11
 105 | Kuyou  |       11
 106 | Kyoko  |       11
(6 rows)

--
-- 連番 (通し番号) をつけたい場合
--
psql=> select row_number() over () as num
psql->      , name
psql-> from members
psql-> ;
 num |  name
-----+--------
   1 | Haruhi
   2 | Mikuru
   3 | Yuki
   4 | Sasaki
   5 | Kuyou
   6 | Kyoko
(6 rows)

--
-- グループごとに連番をつけたい場合
--
psql=> select group_id
psql->      , row_number() over
psql->          (partition by group_id order by name)
psql->          as num
psql->      , name
psql-> from members
psql-> order by group_id, name
psql-> ;
 group_id | num |  name
----------+-----+--------
       10 |   1 | Haruhi
       10 |   2 | Mikuru
       10 |   3 | Yuki
       11 |   1 | Kuyou
       11 |   2 | Kyoko
       11 |   3 | Sasaki
(6 rows)

--
-- グループごとに順位づけをしたい場合
-- (この例では名前の長い順で順位づけ)
--
psql=> select group_id
psql->      , rank() over
psql->          (partition by group_id
psql(>           order by length(name) desc)
psql->          as ranking
psql->      , name
psql-> from members
psql-> order by group_id, ranking
psql-> ;
 group_id | ranking |  name
----------+---------+--------
       10 |       1 | Haruhi
       10 |       1 | Mikuru
       10 |       3 | Yuki
       11 |       1 | Sasaki
       11 |       2 | Kuyou
       11 |       2 | Kyoko
(6 rows)

MySQL には Window 関数がないので、こういったことをするには非等値結合を使ったトリッキーな SQL を書く必要があります。 それよりは Window 関数のほうがまだいいのかなと思います。

Window 関数について参考になりそうな記事:

再帰 SQL

再帰 SQL とは、1 回のクエリで、1 つのテーブルに何度も select 文を実行するような機能です。 テーブルが木構造のデータを表していて、それに対して親子関係をたどるときに使います。 具体例:

  • 上司、その上司、そのまた上司、… をたどりたい
  • 営業本部の配下に属するすべての組織を列挙したい
  • 部品構成表を使って、生産に必要な部品と数を計算したい

たとえば製品Aと製品Bの部品構成が次の通りだとします。

+ 製品A
  + 部品X  (2コ)
    - ねじ (10コ)
    - ばね (5コ)
  + 部品Y  (3コ)
    - ねじ (18コ)
+ 製品B
  + 部品X  (5コ)
    - ねじ (10コ)
    - ばね (5コ)

この場合、製品Aに必要なねじやばねの個数は次の通りです。

* 製品Aのねじの個数
       = (部品Xのねじの個数 * 2) + (部品Yのねじの個数 * 3)
       = 10 * 2 + 18 * 3
       = 74 コ
* 製品Aのばねの個数
       = (部品Xのばねの個数 * 2) + (部品Yのばねの個数 * 3)
       = 5 * 2 + 0 * 3
       = 10 コ

このように、部品表はツリー構造になっています。 これをテーブルにすると、こんな感じです。

psql=> select * from bill_of_materials
psql-> ;
 parent | child | count
--------+-------+-------
 製品A  | 部品X |     2
 製品A  | 部品Y |     3
 製品B  | 部品X |     5
 部品X  | ねじ  |    10
 部品X  | ばね  |     5
 部品Y  | ねじ  |    18
(6 rows)

これを使って、製品Aに必要な最終部品 (この場合はねじとばね) とその数を計算してみましょう。 そのためには木構造をたどる必要があるので、再帰 SQL を使います。

with recursive t(child, count) as (  -- これが再帰 SQL
        select bom.child    -- 帰納法における初期化式に相当
             , bom.count
        from bill_of_materials bom
        where bom.parent = '製品A'
      union all
        select bom.child    -- 帰納法における漸化式に相当
             , bom.count * t.count
        from bill_of_materials bom
        join t on t.child = bom.parent
    ),
    products(name) as (       -- これは再帰 SQL ではない
        select distinct parent from bill_of_materials
    )
select t.child      as "Item"
     , sum(t.count) as "Count"
from t
where t.child not in ( select name from products )
group by t.child
order by t.child
;

実行結果は次の通り。前述の計算式と合ってますね。

 Item  | Count
-------+-------
 ねじ  |    74
 ばね  |    10
(4 rows)

再帰 SQL を理解するうえで参考になりそうな記事:

部分インデックス

PostgreSQL でインデックスを作るとき、全部の行ではなく、条件に一致した行にだけインデックスをつけることができます。

たとえば、deleted_at カラムが削除フラグを兼ねている場合を考えましょう。こんな感じです。

  • deleted_at が設定されていたら削除済みとみなす
  • deleted_at が null なら削除されてないとみなす

この場合、deleted_at が設定されている場合にだけインデックスをつけると、「削除されたデータ」だけを取り出すのがとても高速になります。 また全部の行にインデックスをつけるよりも、インデックスのサイズがずっと小さくなります (PostgreSQL では null 値にもインデックスが作成されるため)。

create table users (
  id          serial    primary key
, name        text      not null unique
, created_at  timestamp  not null default current_timestamp
, deleted_at  timestamp
);
create index users_deleted_at_idx on users(deleted_at)
             where deleted_at is not null;

こうすると、select * from users where deleted_at is null が爆速になるでしょう。

式インデックス

インデックス作成時に、カラム名ではなく任意の式を指定できます。 これを「式インデックス」といいます (昔は「関数インデックス」と呼ばれてたようです)。

たとえば次のような SQL を考えてみましょう (ここで date(created_at) は、timestamp 型から日付部分を取り出すという意味です)。

select * from sales_orders
where date(created_at) == '2014-12-01';

この場合、sales_orders.created_at にインデックスが作成されてあっても、使われません。 なぜなら、date(created_at) という関数呼び出しが使われているせいです。

この場合なら、次のようにすればインデックスが使われます。

select * from sales_orders
where created_at >= '2014-12-01'
  and created_at <  '2014-12-02';

何らかの事情で SQL をこのように変更できない場合は、created_at ではなく date(created_at) を使った式インデックスを作成すれば、前述の SQL でも高速になります。

create index sales_orders_date_idx
          on sales_orders(date(created_at));

また別の例として、カラムに長い文字列が格納されているときに、その一部だけを使ってインデックスを作ることもできます。

たとえば、Git のコミット情報を格納するテーブルがあったとして:

create table git_commits (
  hash       text      not null
, account    text      not null
, message    text      not null
, created_at timestamp not null default current_timestamp
);

hash カラムにインデックスをつけると、Git の hash 値は 40 文字あるので、メモリ消費量が多くなります。 それを抑えたいなら、先頭の 8 文字だけを使ったインデックスを作るといいでしょう。

create index git_commits_hash_short_idx
          on git_commits(substr(hash, 1, 8));

この場合、hash 値を使って検索するには次のようにします。

select * from git_commits
where hash = 'c489823fdbab158b148ee7ffc6844e3eb5ef8ae6'
  and substr(hash, 1, 8) = 'c489823f';  -- ← これがないと
                                        -- インデックスが効かない

この方法の注意点としては、一意制約をかける必要がある場合には使えない (この場合なら 40 文字全体を使ったユニークキーを作る必要があるため) ことです。 値の重複がないようにするには、アプリ側で作り込みが必要になります。

array_agg() と json_agg()

1 : N の関係にあるデータを考えます。 たとえば、グループとそれに所属するメンバーは 1 : N の関係ですね。

/* groups テーブル */
 id  |  name
 ----+---------
 10  |  SOS
 11  |  Tengai

/* members テーブル */
 id   |  name   |  group_id
 -----+---------+----------
 101  |  Haruhi |  10
 102  |  Mikuru |  10
 103  |  Yuki   |  10
 104  |  Sasaki |  11
 105  |  Kuyou  |  11
 106  |  Kyoko  |  11

このような 1 : N の関係にあるデータを join すると、たとえば次のようになります。

select groups.id    as "Group ID"
     , groups.name  as "Group name"
     , members.name as "Member"
from groups
left join members on members.group_id = groups.id
order by groups.id, members.id
;
/***** 実行結果 *****/
 Group ID | Group name | Member
----------+------------+--------
       10 | SOS        | Haruhi
       10 | SOS        | Mikuru
       10 | SOS        | Yuki
       11 | Tengai     | Sasaki
       11 | Tengai     | Kuyou
       11 | Tengai     | Kyoko
(6 rows)

これは正しい動作ではありますが、1 : N のデータを 1 : N のまま取り出すことができず、タプルのリストになってしまっています。 「そんなの当たり前じゃん!」と思われるでしょうが、1 : N のデータを 1 : N のまま取り出すことはできないのでしょうか?

PostgreSQL なら、集約関数のひとつである array_agg() を使えば、1 : N のまま取り出せます。

select groups.id    as "Group ID"
     , groups.name  as "Group name"
     , array_agg(members.name order by members.id)
                    as "Members"
from groups
left join members on members.group_id = groups.id
group by groups.id, groups.name
order by groups.id
;
/***** 実行結果 *****/
 Group ID | Group name |       Members
----------+------------+----------------------
       10 | SOS        | {Haruhi,Mikuru,Yuki}
       11 | Tengai     | {Sasaki,Kuyou,Kyoko}
(2 rows)

また PostgreSQL 9.3 から導入された json_agg() を使うと、より複雑なデータ構造で取り出せます。

-- idとnameをもつ型を定義 (複合型)
create type row_id_name as (id integer, name text);

-- それを使ってキャストするのがコツ
-- (キャストなしだと、JSONのキーが "f1", "f2", ... になる)
select groups.id    as "Group ID"
     , groups.name  as "Group name"
     , json_agg(row(members.id, members.name)::row_id_name
                        order by members.id)
                    as "Members"
from groups
left join members on members.group_id = groups.id
group by groups.id, groups.name
order by groups.id
;
/***** 実行結果 *****/
 Group ID | Group name |            Members
----------+------------+-------------------------------
       10 | SOS        | [{"id":101,"name":"Haruhi"}, +
          |            |  {"id":102,"name":"Mikuru"}, +
          |            |  {"id":103,"name":"Yuki"}]
       11 | Tengai     | [{"id":104,"name":"Sasaki"}, +
          |            |  {"id":105,"name":"Kuyou"},  +
          |            |  {"id":106,"name":"Kyoko"}]
(2 rows)

これでできるんですけど、この方法は create type を使って複合型を定義する必要があるので、ちょっと困ります。

別の方法としては、相関サブクエリを使う方法があります。 この方法だと join も group by もいりませんが、「select 文の中の select 文の中の select 文」という三重の入れ子になるので、わかりにくさとしてはどっちもどっちです。

select groups.id    as "Group ID"
     , groups.name  as "Group name"
     , ( select json_agg(t.*)
         from ( select id, name from members
                where members.group_id = groups.id
                order by name
              ) as t
       )            as "Members"
from groups
order by groups.id
;

なおこの SQL だと、members.group_id にインデックスが作られてないとクソ遅くなる可能性があります。 注意してください。

個人的な意見を言うと、相関サブクエリ方式も悪くはないのですが、join と group by を使った方法でかつ create type なしでできるようになってほしいです。 つまり、json_agg() はもっと頑張れ。

あと JSON 型を使わなくても同様のことができる方法があれば教えてください。

その他

配列型

1カラムに複数の値を設定できる。Relational 的にはどうなの?と言われそうだが、実際便利なので仕方ない。

参考:

JSON 型, JSONB 型

JSON を表すデータ型。Relational 的にはどうなの?と言われそうだが、超絶に便利なので仕方ない。

参考:

Listen/Notify

Observer パターンとか、PubSub とか。

参考:

FDW (Foreign Data Wrapper)

別システムの DB や CSV ファイルが、あたかもテーブルかのようにアクセスできる。

参考:

PostGIS

位置情報を扱うときの定番。近距離を扱うだけなら必要ないかもしれないけど、たとえば東京-北京間の距離を計算するときは地球が楕円体であることを考慮する必要がある。そういうのをやってくれるライブラリ。

参考:

おわりに

疲れた・・・

PostgreSQL に興味がわいたら、こちらもどうぞ。

内部構造から学ぶPostgreSQL 設計・運用計画の鉄則

PostgreSQL全機能バイブル

間違いなどありましたら遠慮なくコメントをどうぞ。


Viewing all articles
Browse latest Browse all 23

Trending Articles