Prestoの行番号以外のRanking関数

前回の記事の続きです。
参考: Prestoで行番号や順位を振る関数まとめ

Presto(トレジャーデータ)で行番号を振る3つの関数を紹介したのですが、ドキュメントの同じページの同じセクションには、これら3つの関数以外にも複数の関数が紹介されています。
実は僕はこれらを使ったことがなくて、ドキュメントをみて初めて存在を知ったので、この機会に挙動を確認しておこうと思いました。
参考: Window Functions の Ranking Functionsのセクション

一つ目は CUME_DIST() です。
ドキュメントによると、値の累積分布を返す関数だとあります。各行について、その行より前の行か同じ値の行の数をグループ内の行数で割った値を返すそうです。前の行が小さい値を指すのか、大きい値を指すのかは、ORDER BY で指定した昇順(ASC)/降順(DESC)によって決まります。
例えば、値が 10, 20, 20, 40 ならば、 0.25, 0.75, 0.75, 1 を返してくるイメージですね。
一瞬、RANK()を行数で割った値のことか?と勘違いしたのですが、RANK()は1,2,2,4 なので、これを行数(4)で割ると0.25, 0.5, 0.5, 1 なので少し違います。

少し多めに8個の値で実行してみたのが次の例です。
比較用にRANK() を8で割った結果も一緒につけました。
結果をシンプルにするためにこの例では使いませんでしたが、もちろんOVERの中でPARTITION BY を使ってグループごとに算出することもできますよ。

WITH
    sample_table AS (
        SELECT 
            id,
            value
        FROM
            UNNEST(
                ARRAY[1, 2, 3, 4, 5, 6, 7, 8],
                ARRAY[20, 10, 20, 30, 15, 30, 25, 40]
            ) AS t(
                id,
                value
            )
  )
SELECT
    id,
    value,
    CUME_DIST() OVER (ORDER BY value) AS cume_dist_,
    CAST(RANK() OVER (ORDER BY value) AS DOUBLE)/8 AS rank_norm
FROM
    sample_table

-- 以下出力結果
id, value, cume_dist_, rank_norm
2,  10,    0.125,      0.125
5,  15,    0.25,       0.25
1,  20,    0.5,        0.375
3,  20,    0.5,        0.375
7,  25,    0.625,      0.625
4,  30,    0.875,      0.75
6,  30,    0.875,      0.75
8,  40,    1.0,        1.0

CUME_DIST と RANK(を行数で割った値)を比較すると、同じ値になる行もあれば、複数行で同じ値になっている行については結果が異なることも見て取れますね。

次の関数は、NTILE です。これは NTILE(n) OVER(〜) のように、バケット数n を指定して使います。これは、PARTITION BYで区切られた各グループの行たちを、さらにそれぞれn個のバケットに分割して、バケット番号を振るものです。8行のデータに対して、ntile(4)を実行したら、1,1,2,2,3,3,4,4 と番号を振る感じですね。元の行数がバケット数でちょうど割り切れない場合は、最初の方のバケットに優先的に割り当てられます。つまり、7行のデータを4バケットに割ったら、1〜3番目のバケットに2個、4番目のバケットに1個割り当てられて、1,1,2,2,3,3,4 となります。

実際にやってみます。(この例でもPARTITION BY使ってませんが、実際は使えます。)

WITH
    sample_table AS (
        SELECT 
            id,
            value
        FROM
            UNNEST(
                ARRAY[1, 2, 3, 4, 5, 6, 7],
                ARRAY[20, 10, 20, 30, 35, 30, 25]
            ) AS t(
                id,
                value
            )
  )
SELECT
    id,
    value,
    NTILE(4) OVER (ORDER BY value) AS ntile_
FROM
    sample_table

-- 以下出力結果
id, value, ntile_
2,  10,    1
3,  20,    1
1,  20,    2
7,  25,    2
4,  30,    3
6,  30,    3
5,  35,    4

想定通りの結果が出ましたね。今回の例では意図的に、バケットの境目になる2番目と3番目(id 1と3)のvalue を同じ20にしていたのですが、一方がバケット番号1でもう一方は2と振り分けられました。値が同じことよりも各バケットに規定の個数振り分けることが優先されている動きになりました。

この記事で最後に紹介するのは、 PERCENT_RANK() です。
その行のRANK() の結果を $r$、そのグループの行数を$n$とすると、$(r-1)/(n-1)$を返す、というドキュメント通りの説明が一番わかりやすいと思います。
そのままなので、やってみます。(先の2関数と同じように、PERCENT_RANKも本当はPARTITION BYが使えます。) 比較用にRANK() の結果もつけました。

WITH
    sample_table AS (
        SELECT 
            id,
            value
        FROM
            UNNEST(
                ARRAY[1, 2, 3, 4, 5, 6, 7, 8, 9],
                ARRAY[20, 10, 20, 30, 35, 30, 25, 15, 40]
            ) AS t(
                id,
                value
            )
  )
SELECT
    id,
    value,
    RANK() OVER (ORDER BY value) AS rank_,
    PERCENT_RANK() OVER (ORDER BY value) AS percent_rank_
FROM
    sample_table

-- 以下出力結果
id, value, rank_, percent_rank_
2,  10,    1,     0.0
8,  15,    2,     0.125
3,  20,    3,     0.25
1,  20,    3,     0.25
7,  25,    5,     0.5
4,  30,    6,     0.625
6,  30,    6,     0.625
5,  35,    8,     0.875
9,  40,    9,     1.0

0〜1 までの値を振ってくれるので個人的には結構好みの結果です。
さて、(そんな利用例は滅多にないと思いますが、)元々抽出されたレコードが1件だたり、PARTITION BY で区切られたグループ内のデータの行数が1件だけだった場合、
$r=1, n=1$ なので、$(r-1)/(n-1)=0/0$となり、$0$除算のエラーにより結果はNULLになるんじゃないか、と思ってたのですが、どうやらこの場合は$0$を返してくるようです。
NULL考慮をしなくていいので楽ですね。(ちなみにこれは、トレジャーデータで試した場合の挙動の話です。もしかしたら他のPresto環境やDBMSの種類によっては違う動きになるかもしれません。)

Prestoで行番号や順位を振る関数まとめ

タイトルにはPrestoって書いてますが、例によってTreasureDataで検証しています。

集計業務を行なっていて、レコードに番号や順位を振りたい場面は意外にあるものです。レコードを何らかのグループごとに分けて、そのグループ内で特定の列ごとにソートしてそのグループ内で何番目のレコードかな、という情報を取得します。

そのようなときに使える関数がPrestoには複数実装されており、主に次の3つが使えます。
– ROW_NUMBER()
– RANK()
– DENSE_RANK()

ROW_NUMBER は単純で、1,2,3,4,… と通し番号を振ります。
RANKは順位なので、もしソートキーになる列の値が等しければ、同じ番号を振ってくれます。例えば、2位と3位が同じ値だったら、1,2,2,4,…という具合です。
DENSE_RANKはRANKと似ていますが、同じ順位のレコードが発生したとき、その次のレコードの番号を振るときに値が飛びません。先程の例で言えば、1,2,2,3,…と番号を振ります。

これらの関数は全てWINDOW関数と呼ばれるカテゴリに属しており、ドキュメントも次のページにまとまっています。
参考: Window Functions

これらのWINDOW関数は利用する時は、必ず OVER() という関数をセットで使います。

ROW_NUMBER() OVER (PARTITION BY [グループ分けする列] ORDER BY [ソートする列])

のようなイメージです。

PARTITION BY は必須ではなく、指定しなければ全レコードを通した番号を振ってくれ、指定すれば、指定した列の値が等しいレコードでグループを作って、そのグループ内での番号を振ってくれます。

ORDER BY も必須ではなく、未指定であればROW_NUMBER()の場合は、DBがSELECTするときに抽出した順で番号を振ってくれますが、普通は何かしら欲しい順序があると思うので指定するようにしましょう。
RANK() と DENSE_RANK() については、ORDER BYを指定しないと、どの列で順位をつけたら良いか不明なので、全部1を返してしまいます。これらを使う時は必ず指定しましょう。
ソートする列名の後ろに、 ASC(昇順)/DESC(降順) を指定することもできます。省略した場合は、ASC(昇順)です。

PARTITION BY, ORDER BY ともに、カンマ区切りで複数の列を指定することもできます。

さて、文章で説明ばっかり書いちゃったので実際にやってみましょう。
使うデータは実データではなく、最初のWITH句で生成したダミーデータです。以下のレコードを生成してます。
id, category, value
1, ‘A’, 20
2, ‘A’, 10
3, ‘A’, 20
4, ‘A’, 30
5, ‘B’, 15
6, ‘B’, 30

WITH sample_table AS(
    SELECT 
        id,
        category,
        value
    FROM
        UNNEST(
            ARRAY[1, 2, 3, 4, 5, 6],
            ARRAY['A', 'A', 'A', 'A', 'B', 'B'],
            ARRAY[20, 10, 20, 30, 15, 30]
        ) AS t(
            id,
            category,
            value
        )
)
SELECT 
    id,
    category,
    value,
    ROW_NUMBER() OVER(PARTITION BY category ORDER BY value) AS row_number_,
    RANK() OVER(PARTITION BY category ORDER BY value) AS rank_,
    DENSE_RANK() OVER(PARTITION BY category ORDER BY value) AS dense_rank_
FROM
    sample_table

結果は次のようになります。

idcategoryvaluerow_number_rank_dense_rank_
2A10111
1A20222
3A20322
4A30443
5B15111
6B30222

想定通りの結果が得られましたね。

トレジャーデータで1週間単位の集計

トレジャーデータのUDFである、TD_DATE_TRUNCの紹介記事です。この記事ではHiveではなく、Prestoを利用することを前提としています。

色々な集計業務を行う中で、週単位の集計を行う必要が出てくる場面は結構あります。トレジャーデータでは、そのような場面に備えて、タイムスタンプを週単位の値に変換してくれるUDFが容易されています。それが、冒頭に挙げたTD_DATE_TRUNCです。

ドキュメント: TD_DATE_TRUNC

使い方は簡単で、以下の構文で利用します。

TD_DATE_TRUNC(
    '集計したい時間の単位を示す文字列',
    元のタイムスタンプ,
    'タイムゾーン文字列'
)

集計したい時間の単位を示す文字列は次の単語が使えます。
minute/ hour/ day/ week/ month/ quarter/ year

ただし、後述しますが、 日単位(day)や月単位(month)などで集計したい場合は、TD_TIME_FORMATを使った方が手軽に目当ての結果を得やすいので、 実際に使う機会があるのは、週単位(week) かクオーター(quarter)に限られると思います。
(そのため、この記事のタイトルも1週間単位の集計としました)

さて、実際に使ってみましょう。例えば元の時刻が ‘2021-08-11 13:24:16’ だったとします。
(日本時間の場合)この時刻のタイムスタンプは、1628655856 です。そこで、これを週単位で切り詰めると次のようになります。

SELECT
    TD_DATE_TRUNC(
        'week',
        1628655856,
        'JST'
    )

上のクエリの結果は、 1628434800 です。このタイムスタンプがいつを表すかというと、’2021-08-09 00:00:00′ となり、元の時刻と同じ週の月曜日のちょうど0時となります。

この例で分かる通り、 TD_DATE_TRUNC はLONG型のタイムスタンプを受け取って、LONG型のタイムスタンプを返します。
実用上はタイムスタンプで結果を得てもいつのことなのかわかりにくいので、TD_TIME_FORMATで整形することになるでしょう。
time 列を週ごとにまとめて集計するであれば次のような書き方になると思います。

SELECT
    TD_TIME_FORMAT(
        TD_DATE_TRUNC(
            'week',
            time,
            'JST'
        ),
        'yyyy-MM-dd',
        'JST'
    )
FROM
    table_name

元々の時刻として、time列ではなく、’2021-08-11 13:24:16’のような文字列で時刻が入っている列を使う場合、タイムスタンプに変換する必要があるので、事前に、TD_TIME_PARSE する必要があります。 例えば、 created_at という列を週単位に集計するのであれば次のようなクエリになります。

SELECT
    TD_TIME_FORMAT(
        TD_DATE_TRUNC(
            'week',
            TD_TIME_PARSE(created_at, 'JST'),
            'JST'
        ),
        'yyyy-MM-dd',
        'JST'
    )
FROM
    table_name

以上のようにして、週単位での集計ができるようになりました。
四半期単位での集計がしたい時は、 week を quarter に置き換えることで同様に実行できます。

さて、最初の方で少し述べましたが、 TD_DATE_TRUNC は day や month でも使うことができます。しかし、日単位や月単位で集計したい場合は、 TD_TIME_FORMAT だけで目的を果たすことができるので、TD_DATE_TRUNCはむしろ使わない方がクエリがスッキリすると思います。

例えば、次の二つのクエリ内の2列は結果が同じです。明らかに2列目の方が記述が少なくて楽ですね。

SELECT
    -- TD_DATE_TRUNCを使った月単位の集計
    TD_TIME_FORMAT(
        TD_DATE_TRUNC(
            'month',
            time,
            'JST'
        ),
        'yyyy-MM-dd',
        'JST'
    ),
    -- TD_TIME_FORMAT を使った月単位の集計
    TD_TIME_FORMAT(time, 'yyyy-MM-01', 'JST')
FROM
    table_name

Prestoで各行ごとに複数列の値の中から最大値/最小値を取得する

※Prestoと書いていますが、正確にはTreasure Dataで動かすことを念頭に置いた記事です。
ただ、この記事で紹介するGREATEST / LEAST という関数はMySQLにも実装されているようなので、MySQLでも同じように動作すると思います。

そんなに頻繁にあることでは無いのですが、DBのとあるテーブルのデータについて、行ごとに、複数列の最大値を取得したいことがありました。

これ、行と列が逆なら簡単です。MAX関数使うだけです。

SELECT
    MAX(col1),
    MAX(col2),
    MAX(col3)
FROM
    table_name

上のクエリで、列ごとに最大値が取得できます。
今回やりたいのはその逆で、行ごとの最大値が欲しいのです。
列が3つくらいであれば、CASE文で対応することもできなくは無いかなと思います。
こんなふうに。

SELECT
    id,
    CASE
        WHEN col1>=col2 AND col1>=col3 THEN col1
        WHEN col2>=col3 AND col2>=col1 THEN col2
        WHEN col3>=col1 AND col3>=col2 THEN col3
    END AS max_value
FROM
    table_name

ただ、列数が増えるとこの方法で対応するのはなかなか厄介です。(あまりやりたく無い)
それに、上のクエリでは対象の列にNULLが含まれていた場合に正常に動作しないので、NULLも考慮する必要がある場合はもっと複雑なクエリを書く必要があります。

もう少しスマートな方法としては、以前紹介した横縦変換の方法で値を縦持ちに変換して、
GROUP BY と MAX を使うこともできるかと思います。
参考: PrestoのUNNESTを利用した横縦変換

WITH
    unpivot_table AS (
        SELECT
            id,
              t.key,
              t.value
        FROM
            table_name
        CROSS JOIN UNNEST (
            array['col1', 'col2', 'col3'],
            array[col1, col2, col3]
        ) AS t (key, value)
    )
SELECT
    id,
    MAX(value) AS max_value
FROM
    unpivot_table
GROUP BY
    id

ただ、これはこれで仰々しくてちょっと嫌だなと思っていました。

それでドキュメントを調べてみると、どうやらGREATEST というメソッドが用意されていたようです。
参考: GREATEST and LEAST

これを使うと非常に話は単純で、次のクエリで行ごとに3列(co1, col2, col3)の最大値が取得できます。

SELECT
    id,
    GREATEST(
        col1,
        col2,
        col3
    ) AS max_value
FROM
    table_name

同様に、最小値を求めるLEAST も容易されています。(使用例略)

ちなみに、どの列が最大だったのかを取得できる GREATEST_BY みたいなのもあるといいなと思って探してみたのですが、流石にそれはなさそうでした。最大値と合わせてどの列が最大だったのかも欲しい場合は、上の UNNEST を使うクエリで縦持ちに変換して、MAX_BYするのが現実的かなと思います。(もしくはSQLで実行するのを諦めてPythonなどで書くか)

テーブルに自動採番(オートインクリメント)する列を作成する

今回もDBの話です。DBはMySQLやその互換のAuroraを想定しています。
テーブルに行を追加していくとき、自動的に通し番号でIDをふって欲しいことはよくあります。
MySQLにはそれ専用の、AUTO_INCREMENT属性があります。

参考: 3.6.9 AUTO_INCREMENT の使用

さて、CREATE TABLEするときに列名の横に、 AUTO_INCREMENT をつけるだけで使えるように見えますが、それだとエラーになります。


> CREATE TABLE
    sample_table1 (
        id INTEGER AUTO_INCREMENT,
        name VARCHAR(255)
    );
ERROR 1075 (42000): Incorrect table definition; there can be only one auto column and it must be defined as a key

it must be defined as a key とある通り、主キーかユニークキーを設定した列でないと、AUTO_INCREMENT属性は設定できません。
ついでに説明しておくと、 there can be only one auto columnとある通り、1テーブルに設定できるのは1列までです。


> CREATE TABLE
    sample_table1 (
        id INTEGER PRIMARY KEY AUTO_INCREMENT,
        name VARCHAR(255)
    );
Query OK, 0 rows affected (0.06 sec)

さて、これで連番が振られることを見ておきましょう。


> INSERT INTO 
    sample_table1 (name)
VALUES
   ('suzuki'), ('sato'), ('tanaka');
Query OK, 3 rows affected (0.03 sec)
Records: 3  Duplicates: 0  Warnings: 0

> SELECT * FROM sample_table1;
+----+--------+
| id | name   |
+----+--------+
|  1 | suzuki |
|  2 | sato   |
|  3 | tanaka |
+----+--------+
3 rows in set (0.01 sec)

連番が振られていますね。

僕はこれ、列の最大値の次の値が採番されていくと勘違いしていたのですが、実はAUTO_INCREMENTで振られる番号は別のところに保存されています。


> SHOW CREATE TABLE sample_table1\G
*************************** 1. row ***************************
       Table: sample_table1
Create Table: CREATE TABLE `sample_table1` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `name` varchar(255) COLLATE utf8mb4_bin DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin
1 row in set (0.01 sec)

AUTO_INCREMENT=4 と入っているように、テーブルが属性として次の番号を持っています。
そのため、今入っているレコードを消して、新たに行を追加すると、4番から採番されます。


> DELETE FROM sample_table1 WHERE id >= 2;
Query OK, 2 rows affected (0.02 sec)

> INSERT INTO 
    sample_table1 (name)
VALUES
   ('kobayashi'), ('ito'), ('takahashi');
Query OK, 3 rows affected (0.02 sec)
Records: 3  Duplicates: 0  Warnings: 0

> SELECT * FROM sample_table1;
+----+-----------+
| id | name      |
+----+-----------+
|  1 | suzuki    |
|  4 | kobayashi |
|  5 | ito       |
|  6 | takahashi |
+----+-----------+
4 rows in set (0.01 sec)

ユニーク性を担保する上では便利な仕組みですね。

テーブルの属性としてカウンタを持っているので、 ALTER TABLE 文を使ってリセットしたり、任意の値に設定したりできます。
構文はこうです。


ALTER TABLE [テーブル名] AUTO_INCREMENT = [値];

やってみます。


> ALTER TABLE sample_table1 AUTO_INCREMENT = 100;
Query OK, 0 rows affected (0.05 sec)
Records: 0  Duplicates: 0  Warnings: 0

> INSERT INTO 
    sample_table1 (name)
VALUES
   ('watanabe');
Query OK, 1 row affected (0.01 sec)

> SELECT * FROM sample_table1;
+-----+-----------+
| id  | name      |
+-----+-----------+
|   1 | suzuki    |
|   4 | kobayashi |
|   5 | ito       |
|   6 | takahashi |
| 100 | watanabe  |
+-----+-----------+
5 rows in set (0.00 sec)

予想通りに動きました。

MySQLでテーブルに主キー(PRIMARY KEY)制約を設定する

主キー(プライマリーキー)の説明自体はWikipediaを参照。
参考: 主キー – Wikipedia

これをSQLで設定する方法について紹介します。
(なお、DBは MySQL やその互換のAuroraを想定しています。)

新規に作成するテーブルに設定するときは次の構文で設定できます。
参考: 13.1.7 ALTER TABLE 構文


CREATE TABLE
    [テーブル名] (
        [主キーの列名] [データ型] NOT NULL PRIMARY KEY,
        [列名1] [データ型],
        ・・・
    );

NOT NULL は省略可能です。省略しても主キーには自動的にNOT NULL制約が付与されます。

やってみましょう。


>CREATE TABLE
    sample_table1 (
        id INTEGER PRIMARY KEY,
        name VARCHAR(255) NOT NULL,
        comment VARCHAR(255)
    );
Query OK, 0 rows affected (0.19 sec)

# 確認
> DESCRIBE sample_table1;
+---------+--------------+------+-----+---------+-------+
| Field   | Type         | Null | Key | Default | Extra |
+---------+--------------+------+-----+---------+-------+
| id      | int(11)      | NO   | PRI | NULL    |       |
| name    | varchar(255) | NO   |     | NULL    |       |
| comment | varchar(255) | YES  |     | NULL    |       |
+---------+--------------+------+-----+---------+-------+
3 rows in set (0.02 sec)

NOT NULL は省略しましたが、DESCRIBE の結果でわかる通り、id列にNOT NULL制約が入っていますね。

複数列の組み合わせで主キーを設定する場合は、次のように
PRIMARY KEY ([列名], [列名]) と言うのを書いても設定できます。

構文のイメージはこうです。もちろんですが、複数列の組み合わせでなく1列に対して設定する時もこの構文は使えます。


CREATE TABLE
    [テーブル名] (
        [主キーの列名1] [データ型] NOT NULL,
        [主キーの列名2] [データ型] NOT NULL,
        [列名1] [データ型],
        ・・・,
        PRIMARY KEY ([主キーの列名1], [主キーの列名2])
    );

例えば、 コード と 日付 に対してユニークなレコードを持つテーブルを作る場合は次のようになります。


>CREATE TABLE
    sample_table2 (
        code INTEGER,
        date DATE,
        value INTEGER,
        PRIMARY KEY (code, date)
    );
Query OK, 0 rows affected (0.07 sec)

> DESCRIBE sample_table2;
+-------+---------+------+-----+---------+-------+
| Field | Type    | Null | Key | Default | Extra |
+-------+---------+------+-----+---------+-------+
| code  | int(11) | NO   | PRI | NULL    |       |
| date  | date    | NO   | PRI | NULL    |       |
| value | int(11) | YES  |     | NULL    |       |
+-------+---------+------+-----+---------+-------+
3 rows in set (0.00 sec)

主キーを設定した列に対しては自動的にインデックスが作成されます。(インデックスの名前はPRIMARYになります)。
これも一応みておきましょう。


> SHOW INDEX FROM sample_table2\G
*************************** 1. row ***************************
        Table: sample_table2
   Non_unique: 0
     Key_name: PRIMARY
 Seq_in_index: 1
  Column_name: code
    Collation: A
  Cardinality: 0
     Sub_part: NULL
       Packed: NULL
         Null:
   Index_type: BTREE
      Comment:
Index_comment:
*************************** 2. row ***************************
        Table: sample_table2
   Non_unique: 0
     Key_name: PRIMARY
 Seq_in_index: 2
  Column_name: date
    Collation: A
  Cardinality: 0
     Sub_part: NULL
       Packed: NULL
         Null:
   Index_type: BTREE
      Comment:
Index_comment:
2 rows in set (0.00 sec)

あまり使わないのですが、設定されている主キーを外すときは、 ALTER TABLE 文を使います。

構文は次の通りです。
参考: 13.1.7 ALTER TABLE 構文


ALTER TABLE [テーブル名] DROP PRIMARY KEY;

やってみます。


> ALTER TABLE sample_table2 DROP PRIMARY KEY;
Query OK, 0 rows affected (0.17 sec)
Records: 0  Duplicates: 0  Warnings: 0

# 結果確認
> DESCRIBE sample_table2;
+-------+---------+------+-----+---------+-------+
| Field | Type    | Null | Key | Default | Extra |
+-------+---------+------+-----+---------+-------+
| code  | int(11) | NO   |     | NULL    |       |
| date  | date    | NO   |     | NULL    |       |
| value | int(11) | YES  |     | NULL    |       |
+-------+---------+------+-----+---------+-------+
3 rows in set (0.00 sec)

# インデックスも消える
> SHOW INDEX FROM sample_table2\G
Empty set (0.00 sec)

インデックスは一緒に消えてしまいますが、 NOT NULL制約は残りましたね。

主キーが設定されてないテーブルに後から設定するのも ALTER TABLE 文を使います。


ALTER TABLE [テーブル名] ADD PRIMARY KEY ([列名]);

やってみます。(さっき消したキーを復活させます。)


> ALTER TABLE sample_table2 ADD PRIMARY KEY (code, date);
Query OK, 0 rows affected (0.13 sec)
Records: 0  Duplicates: 0  Warnings: 0

# 結果確認
> DESCRIBE sample_table2;
+-------+---------+------+-----+---------+-------+
| Field | Type    | Null | Key | Default | Extra |
+-------+---------+------+-----+---------+-------+
| code  | int(11) | NO   | PRI | NULL    |       |
| date  | date    | NO   | PRI | NULL    |       |
| value | int(11) | YES  |     | NULL    |       |
+-------+---------+------+-----+---------+-------+
3 rows in set (0.00 sec)

MySQLのテーブルにレコード作成時刻や更新時刻を自動で記録する列を作る

たまに新しいテーブルを作るたびに調べてるのでメモです。
(タイトルにMySQLと書いてますが、実際はそれと互換のAuroraでやってます。)

DBのテーブルに新しい行を追加したり、更新したりするときにその時刻を記録する列を用意しておくと言うのは結構一般的なことだと思います。
phpMyAdmin からGUIでポチポチ設定してもいいのですが、SQLでもできた方が楽です。

さて、その方法ですが、MySQLのドキュメントにそのまま書いてあります。
11.3.5 TIMESTAMP および DATETIME の自動初期化および更新機能

DEFAULT CURRENT_TIMESTAMP で、行作成時にタイムスタンプが記録され、
ON UPDATE CURRENT_TIMESTAMP で、行更新時にタイムスタンプも更新されます。

列名として、 created_at, updated_at を使う場合は次のようにすれば良いでしょう。
例えば、タイムスタンプの他にidとnameを格納するテーブルを作るとしたら次のようになります。
(ついでに NOT NULL制約も入れました。)


CREATE TABLE
    sample_table (
        id INTEGER,
        name VARCHAR(255),
        created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
        updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
    );

さて、試してみましょう。まず作成時刻から。


INSERT INTO
    sample_table(id, name)
VALUES
    (1, 'テスト1'),
    (2, 'テスト2'),
    (3, 'テスト3')
;

SELECT * FROM sample_table;
+------+------------+---------------------+---------------------+
| id   | name       | created_at          | updated_at          |
+------+------------+---------------------+---------------------+
|    1 | テスト1    | 2021-04-04 23:36:29 | 2021-04-04 23:36:29 |
|    2 | テスト2    | 2021-04-04 23:36:29 | 2021-04-04 23:36:29 |
|    3 | テスト3    | 2021-04-04 23:36:29 | 2021-04-04 23:36:29 |
+------+------------+---------------------+---------------------+
3 rows in set (0.00 sec)

時刻が勝手に入りましたね。

次に更新です。


UPDATE
    sample_table
SET
    name = 'test 2' 
WHERE
    id = 2
;

SELECT * FROM sample_table;
+------+------------+---------------------+---------------------+
| id   | name       | created_at          | updated_at          |
+------+------------+---------------------+---------------------+
|    1 | テスト1    | 2021-04-04 23:36:29 | 2021-04-04 23:36:29 |
|    2 | test 2     | 2021-04-04 23:36:29 | 2021-04-04 23:38:26 |
|    3 | テスト3    | 2021-04-04 23:36:29 | 2021-04-04 23:36:29 |
+------+------------+---------------------+---------------------+
3 rows in set (0.01 sec)

id = 2 のレコードの updated_at も更新されました。

SQLでデータベースやテーブルの文字コードや照合順序の設定を変える

前回の記事でRDSのクラスタ単位の文字コードや照合順序をutf8mb4に対応させる方法を書きましたが、
後から設定を変えても既存のデータベースやテーブルの設定はそのまま変わりません。

変更する場合はそれぞれ ALTER 文を実行して変更していく必要があります。

まず、テーブルの設定を変更する場合は、次の構文を使います。

ALTER TABLE tbl_name
CONVERT TO CHARACTER SET charset_name [COLLATE collation_name];
(参考: 13.1.7 ALTER TABLE 構文)


-- 元の設定を確認
MySQL [sample_db]> SHOW CREATE TABLE sample_table\G
*************************** 1. row ***************************
       Table: sample_table
Create Table: CREATE TABLE `sample_table` (
  `id` int(11) DEFAULT NULL,
  `name` varchar(255) DEFAULT NULL,
  `text` varchar(255) DEFAULT NULL
) ENGINE=InnoDB DEFAULT CHARSET=latin1
1 row in set (0.00 sec)

-- 設定を変更
MySQL [sample_db]> ALTER TABLE sample_table CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_bin;
Query OK, 0 rows affected (0.19 sec)
Records: 0  Duplicates: 0  Warnings: 0

-- 変更結果を確認
MySQL [sample_db]> SHOW CREATE TABLE sample_table\G
*************************** 1. row ***************************
       Table: sample_table
Create Table: CREATE TABLE `sample_table` (
  `id` int(11) DEFAULT NULL,
  `name` varchar(255) COLLATE utf8mb4_bin DEFAULT NULL,
  `text` varchar(255) COLLATE utf8mb4_bin DEFAULT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin
1 row in set (0.01 sec)

ちなみに、 “CONVERT TO” をつけ忘れると、テーブル全体のデフォルト設定だけ変更されて、列の設定が元のままになります。
(列ごとに、個別の文字コード設定を行いたい場合はこちらの方法を使うことになりそうです。)


-- 元の設定を確認
MySQL [sample_db]> SHOW CREATE TABLE sample_table2\G
*************************** 1. row ***************************
       Table: sample_table2
Create Table: CREATE TABLE `sample_table2` (
  `id` int(11) DEFAULT NULL,
  `name` varchar(255) DEFAULT NULL,
  `text` varchar(255) DEFAULT NULL
) ENGINE=InnoDB DEFAULT CHARSET=latin1
1 row in set (0.00 sec)

-- 設定を変更 (CONVERT TO 無し)
MySQL [sample_db]> ALTER TABLE sample_table2 CHARACTER SET utf8mb4 COLLATE utf8mb4_bin;
Query OK, 0 rows affected (0.06 sec)
Records: 0  Duplicates: 0  Warnings: 0

-- 変更結果を確認
MySQL [sample_db]> SHOW CREATE TABLE sample_table2\G
*************************** 1. row ***************************
       Table: sample_table2
Create Table: CREATE TABLE `sample_table2` (
  `id` int(11) DEFAULT NULL,
  `name` varchar(255) CHARACTER SET latin1 DEFAULT NULL,
  `text` varchar(255) CHARACTER SET latin1 DEFAULT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin
1 row in set (0.00 sec)

各列のCHARACTERが latin1 のままですね。

続いて、データベースのデフォルトの文字コードと照合順序の設定の変更方法です。
新規作成したテーブルはデータベースのデフォルト設定を引き継ぐので、多くのテーブルを作る場合は、最初にデータベース単位で設定しておくべきでしょう。

DATABSE単位の設定は文字コード周りくらいしかないので、 ALTER DATABASE のドキュメントもシンプルです。

参考: 13.1.1 ALTER DATABASE 構文

ALTER DATABASE sample_db CHARACTER SET utf8mb4 COLLATE utf8mb4_bin


-- 元の設定を確認
MySQL [sample_db]> SHOW CREATE DATABASE sample_db;
+-----------+----------------------------------------------------------------------+
| Database  | Create Database                                                      |
+-----------+----------------------------------------------------------------------+
| sample_db | CREATE DATABASE `sample_db` /*!40100 DEFAULT CHARACTER SET latin1 */ |
+-----------+----------------------------------------------------------------------+
1 row in set (0.00 sec)

-- 設定を変更 
MySQL [sample_db]> ALTER DATABASE sample_db CHARACTER SET utf8mb4 COLLATE utf8mb4_bin;
Query OK, 1 row affected (0.03 sec)

-- 変更結果を確認
MySQL [sample_db]> SHOW CREATE DATABASE sample_db;
+-----------+-------------------------------------------------------------------------------------------+
| Database  | Create Database                                                                           |
+-----------+-------------------------------------------------------------------------------------------+
| sample_db | CREATE DATABASE `sample_db` /*!40100 DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin */ |
+-----------+-------------------------------------------------------------------------------------------+
1 row in set (0.01 sec)

PrestoのUNNESTを利用した横縦変換

以前Prestoのクエリで縦横変換(pivot)を行う方法を初回しましたが、今回はその逆で横縦変換(unpivot)を紹介します。

参考: PrestoのMap型を使った縦横変換

参考記事と逆の変換やるわけですね。
そのため元のテーブルがこちら。

横持ちのテーブル
uid c1 c2 c3
101 11 12 13
102 21 22 23

結果として出力したいテーブルがこちらになります。

縦持ちのテーブル (vtable)
uid key value
101 c1 11
101 c2 12
101 c3 13
102 c1 21
102 c2 22
102 c3 23

一番シンプルな書き方は、UNIONを使う方法だと思います。
key の値ごとにvalue を抽出してそれぞの結果を縦に積み上げます。


SELECT
    uid,
    'c1' AS key,
    c1 AS value
FROM
    htable
UNION ALL SELECT
    uid,
    'c2' AS key,
    c2 AS value
FROM
    htable
UNION ALL SELECT
    uid,
    'c3' AS key,
    c3 AS value
FROM
    htable

ただ、この書き方には課題もあって、元の列数が多いとクエリが非常に冗長になります。
そこで、 UNNESTを使った方法を紹介しておきます。

ドキュメントは SELECT の説明のページの途中に UNNESTの章があります。

これを使うと次の様に書けます。


SELECT
    uid,
    t.key,
    t.value
FROM
    htable
CROSS JOIN UNNEST (
  array['c1', 'c2', 'c3'],
  array[c1, c2, c3]
) AS t (key, value)

とてもシンプルに書けました。

PrestoのMap型を使った縦横変換

前回の記事で紹介したPrestoのMap型ですが、これを使うとデータの縦横変換(ピボット)がスマートに行えます。

参考: ピボットテーブル&チャート

上記リンク先のトレジャーデータの記事中の画像のテーブルを例に説明します。

縦持ちのテーブル (vtable)
uid key value
101 c1 11
101 c2 12
101 c3 13
102 c1 21
102 c2 22
102 c3 23

このようなテーブルを次のように変換したいとします。

横持ちのテーブル
uid c1 c2 c3
101 11 12 13
102 21 22 23

この変換は、MAP_AGGを使って、key列とvalue列の対応のMapを一旦作成し、
それぞれのMapから各Keyの値を取り出すことで実現できます。
具体的にクエリにしたのが次です。


SELECT
    uid,
    kv['c1'] AS c1,
    kv['c2'] AS c2,
    kv['c3'] AS c3
FROM (
    SELECT
        uid,
        MAP_AGG(key, value) AS kv
    FROM
        vtable
    GROUP BY
        uid
) AS t

個人的には服問い合わせはあまり好きではなく、PrestoではWITHを使って書きたいので次のようにすることが多いです。


WITH
    t AS (
        SELECT
            uid,
            MAP_AGG(key, value) AS kv
        FROM
            vtable
        GROUP BY
            uid
    )
SELECT
    uid,
    kv['c1'] AS c1,
    kv['c2'] AS c2,
    kv['c3'] AS c3
FROM
    t

Prestoではない、(MAP型のない)通常のSQLでは次のように書かないといけないのですが、
上記のMap型を使ったものの方が随分すっきりかけているように見えます。
(MAP_AGGを知らない人には読めないのが難点ですが)


SELECT
    uid,
    MAX(
        CASE WHEN key = 'c1' THEN
            value
        ELSE
            NULL
        END
    ) AS c1,
    MAX(
        CASE WHEN key = 'c2' THEN
            value
        ELSE
            NULL
        END
    ) AS c2,
    MAX(
        CASE WHEN key = 'c3' THEN
            value
        ELSE
            NULL
        END
    ) AS c3
FROM
    vtable
GROUP BY
    uid