MySQLのデータのダンプとリストア

タイトルはMySQLですが、実際には AWS RDSのAurora (MySQL)を想定しています。

Aurora Serverless v2 への移行も見据えて、MySQLのデータの移行方法を調べました。dumpをとる専用のコマンドがあり、それでバックアップを取得してそれを戻せば良いようです。

ドキュメントはこちらになります。
参考: MySQL :: MySQL 8.0 リファレンスマニュアル :: 4.5.4 mysqldump — データベースバックアッププログラム

ドキュメントの一番下の方に例として一番シンプルなコマンドが載っています。

# データベース全体のバックアップを作成
shell> mysqldump db_name > backup-file.sql
# ダンプファイルをサーバーにロード
shell> mysql db_name < backup-file.sql

ただし、これはそのローカルサーバーにDBMSがあってパスワード等もかかってない場合に使うものなので、現実的にはDBのエンドポイント等を指定する必要があります。

# バックアップ
$ mysqldump -h <エンドポイント> -u <ユーザー名> -p --databases <データベース名> > aurora_backup.sql
# リストア
$ mysql -h <エンドポイント> -u <ユーザー名> -p < aurora_backup.sql

ダンプするときにデータベース名はスペース区切りで複数まとめて指定することもできます。

一方で、ダンプファイルにDB名の情報は含まれているので、リストアする時はDB名の指定は不要です。

–all-databases という全てのデータベースを対象とするオプションもありますが、システム情報的なDBまで含まれてしまうのでこれは使わず、自分で必要なDBを指定した方が安全だと思います。バージョンが違うDB間で移行するような時は特に注意が必要です。

最後に、ダンプを取るときに特にオプション等でタイムゾーンに関する設定をしなければ、datatime型のデータはタイムゾーンの情報を持たずにダンプされてしまいます。

移行前後のDB間でタイムゾーンが違うと異なる時間で解釈されるリスク等もあるので、先に設定を揃えておきましょう。

Streamlit in SnowflakeにおけるSQLの結果取得について

今年から使いはじめた Streamlit in Snowflake についての記事です。

とはいえ、Sreamlit要素はほぼなく、Snowflake上で使うからこそ発生するSQLの結果の取得方法(=pandasのDataFrameとしての取得方法)をまとめておきます。

Streamlit in Snowflake の場合、Snowflkaeにログインして使いますので認証情報として現時点でログインしているセッションの情報が使えます。それを取得する専用の関数として、get_active_session があるので、これを呼び出すのが最初の準備です。

from snowflake.snowpark.context import get_active_session


# Snowflakeセッションの取得
session = get_active_session()

ここから下は 「query」という変数にSQL(SELECT)文が格納されている前提になります。

sessionの sql()というメソッドでSQLを発行し、結果を得ることができます。さらに、その結果は to_pandas() というメソッドを持っており、これを使うことでDataFrame型に変換できます。

結果を表示したい場合は streamlitの dataframeとかtableといったメソッドが使えますね。

# クエリの実行と結果の取得
result = session.sql(query).to_pandas()
st.dataframe(result)
st.table(result)

続いて、 SELECT文ではなく show columns や describe table のケースも紹介します。

こちらは sqlメソッドで発行できるのは同じなのですが、結果が to_padnas()メソッドを持っていません。

ここでは、collect() というメソッドを使います。

result = session.sql(query).collect()
st.dataframe(result)

簡潔ですが以上で Streamlit in SnowflakeでSQLを発行し結果をStreamlit内で使えるようになります。

Snowflakeで配列の値やカンマ区切り等の複合値を複数行に展開する方法

要するに第1正規化ができてないデータがあるときにそれを複数行に展開する方法です。

a,b,c みたいな文字列がデータにあるときにa, b, c をそれぞれ別行のデータに展開します。

これには lateral flatten という構文を使います。参考になるドキュメントはこちらです。
参考: FLATTEN | Snowflake Documentation

元々のデータが配列型の場合は split が不要になるのでその分を読み替えてください。
最初のwith句でダミーデータを生成して実験しています。

from 句で指定してるテーブルの後、joinで複数テーブルを結合している場合はそれらの一番最後にカンマを打ってから使う点に注意してください。また、 inputとか values とかも決まった単語ですのでそのまま使います。

with
dummy_data as (
    select
        1 as id,
        'a,b,c' as text
    union all select
        2 as id,
        None as text
    union all select
        3 as id,
        'd' as text
)

select
    id,
    value::varchar as text
from
    dummy_data, lateral flatten(input => split(text, ','))

これで、
id, text
1, a
1, b
1, c
3, d
という結果が得られます。

さて、id = 2のレコードについてはtextの値がNullだったので結果に出てきませんでした。
これがNullではなく空白文字列であれば、id=3のレコードと同様に行は作られたのですけどね。

このように展開する値がNullのレコードも残したい場合は、 outerオプションを使います。= でではなく => で trueに指定するSQLでは見慣れない記法ですが、次のような形になります。

with
dummy_data as (
    select
        1 as id,
        'a,b,c' as text
    union all select
        2 as id,
        Null as text
    union all select
        3 as id,
        'd' as text
)

select
    id,
    value::varchar as text
from
    dummy_data, lateral flatten(input => split(text, ','), outer => true)

そもそも正規化がちゃんと行われているデータだけ使えばいいのであれば滅多に使わない記法なのですが、残念ながら自分はよく使う場面が頻繁にあることと、その割になかなか覚えられなくて毎回調べているので今回記事にしました。

SQLでNULL同士を等しいとみなして効率的に比較を行う方法

今回はSQLの小ネタです。

初心者がミスりがちな話なのですが、SQLでは通常 NULLとNULLは等しいとは見做されません。

例えば、`select null = null ` など実行すると、Trueではなくnull が返ってきます。

しかし、場合によってはnull同士は等しいものとして判定したいことがあり、その場合は何かしら一工夫する必要があります。両方nullという場合だけTrue返せばいいということもなく、当然値が入っているなら値が入ってるもの同士は通常の比較処理を行い、0や空文字も含めてnull以外の値とnullは違うものとみなし、その上でnull同士は等しいという判定をやるケースですね。

null以外の値が全部0以上の数値であることがわかっているなら coalesce でnullを-1に変換してから比較するとか、文字列方の列で、かつ値が入っている部分にnullって文字列がないことが確認できているなら null っていう文字列で埋めて比較するといった手段が取れます。

しかし、この列に絶対無いと言い切れる値が存在しない場合、補完して比較する方法は使えません。こういった場合に、スマートにnullを考慮した比較を行える方法をMySQLとSnowflakeの両環境について紹介します。

MySQLの場合

MySQLの場合、 <=> という演算子がサポートされています。これは、「NULLセーフイコール演算子」といいます。

これを使うと、 `selct null <=> null` がTrueになります。

ドキュメント: MySQL :: MySQL 8.0 Reference Manual :: 14.4.2 Comparison Functions and Operators

Snowflakeの場合

Snowflakeの場合、上記の<=>演算子はサポートされていませんが、`is disticnt from` という演算子が実装されています。

ドキュメント: IS [ NOT ] DISTINCT FROM | Snowflake Documentation

ちょっと長いので、<=>のほうが便利だよなぁとは思うのですが、標準SQLに準拠した書き方はこちらの方です。(最初、Snwoflake専用の方言かと勘違いしていました)

そこそこの頻度で使う機会がある構文だと思うので、頭の片隅にでも置いといてください。

MySQLでインデックスヒントを使う

※ MySQLって書いてますがその互換の Aurora で検証しています。

MySQLのレコード数が大きなテーブルからデータを検索するとき、インデックスは処理時間の短縮において重要な役割を果たしてくれます。

ただ、一個のテーブルにインデックスを何個も作成しているとSQLによっては最適なインデックスが使われないことがあります。このような場合、インデックスヒントを使うことでクエリを最適化できる可能性があります。

参考: MySQL :: MySQL 8.0 リファレンスマニュアル :: 8.9.4 インデックスヒント

自分の事例で言うと、昔仕事で扱っていたDBでインデックスが10個以上張られているテーブルがあり、きちんと指定してあげないと正しく使ってくれないものがあったことがあります。

また、最近は私用で個人的に集めているデータを溜めているテーブルにおいても、レコード数が増えるにれて正しいインデックスを使ってくれない事例が発生するようになりました。

自分の事例で言うと、どうもMySQLによってインデックスによる絞り込み効果が低いと判断されるとそれが使われないってことがあるようですね。半年分のデータを取得しようとすると正しく実行されないが、1ヶ月分のデータを取得しようとすると正しく使われる、といった事例をよく見ます。

テーブルに貼られているインデックスの確認方法

まずはどんなインデックスが使いうるのか知らないと話にならないのでその確認方法です。

SHOW CREATE TABLE {テーブル名};
でテーブル定義を丸ごと確認するか、インデックス情報取得専用の構文である、
SHOW INDEX FROM {テーブル名};
を使うことで確認できます。

僕個人が分析用に溜めている株価のテーブルだと次のようになります。code(証券コード)とdate(日付)を主キーとしていて、code, date 個別にもインデックスを貼っています。

select
SHOW INDEX FROM price
-- 結果
Table	Non_unique	Key_name	Seq_in_index	Column_name	Collation	Cardinality	Sub_part	Packed	Null	Index_type	Comment	Index_comment
0	price	0	PRIMARY	1	code	A	4561	None	None		BTREE		
1	price	0	PRIMARY	2	date	A	7336473	None	None		BTREE		
2	price	1	code	1	code	A	4383	None	None		BTREE		
3	price	1	date	1	date	A	3166	None	None		BTREE		

key_name列で、primary, code, date の3種類のインデックスがあることが確認できますね。

クエリが利用しようとしているインデックスの確認方法

おそらく処理時間とかを計測したりてこのクエリは正しくインデクス使ってないな、って気づくことになると思うのですが、実際にどのインデックスを使っているかは、そのクエリの先頭に EXPLAIN をつけて実行することで確認できます。

例えば、次のような感じです。possible_keys が利用可能なインデックスで、key が実際に使うインデックスです。

EXPLAIN SELECT
    *
FROM
    price
WHERE
    date >= '2023-01-01'

-- 結果 (これはkeyがNoneなのでインデックスを使ってない)
	id	select_type	table	partitions	type	possible_keys	key	key_len	ref	rows	filtered	Extra
0	1	SIMPLE	price	None	ALL	date	None	None	None	7336473	25.53	Using where

EXPLAIN SELECT
    *
FROM
    price
WHERE
    date >= '2023-12-01'

-- 結果 (これはdateをインデックスとして使っている)
	id	select_type	table	partitions	type	possible_keys	key	key_len	ref	rows	filtered	Extra
0	1	SIMPLE	price	None	range	date	date	3	None	176076	100.0	Using index condition

さて、上記の上の例でもdateをインデックスとして使って欲しい場合にインデックスヒントを使います。

インデックスヒントの使い方

インデックスヒントを使う場合、特定のインデックスを強制する USE INDEX, 特定のインデックスの利用を防ぐ IGNORE INDEX, USE INDEXよりも強力に指定したインデックスを強制する FORCE INDEX の3種類があります。

使い方は同じで、FROM句のテーブル名の後ろに、
テーブル名 USE INDEX (インデックス名)
のように書きます。カッコを忘れないように注意してください。

FORCE IDNEXでやってみます。

EXPLAIN SELECT
    *
FROM
    price FORCE INDEX(date)
WHERE
    date >= '2023-01-01'

-- 結果 (FORCEしたインデックスが使われている)

id	select_type	table	partitions	type	possible_keys	key	key_len	ref	rows	filtered	Extra
0	1	SIMPLE	price	None	range	date	date	3	None	176076	100.0	Using index condition

最後に

インデックスヒントを使うことでMySQLが利用するインデックスをある程度制御できるようにあります。ただ、実際にパフォーマスが改善するかどうかはまた別の問題です。というのもMySQLが選んだキーの方が効率的である可能性というのも十分あり得るからです。

このクエリはチューニングが必要だなと感じることがあった場合にインデックスヒントを使うことになると思いますが、処理時間が本当に改善してるかどうかはきちんと計測するようにしましょう。

PythonでSQLite3を使う

前回の記事に引き続き、SQLite3の話です。

今回はPythonのコードの中でSQLite3を使っていきます。Pythonでは標準ライブラリにSQLite3用のインターフェースがあり、特に何か追加でインストールしなくてもSQLite3を使えます。MySQL等と比較したときの大きなアドバンテージですね。
参考: sqlite3 — DB-API 2.0 interface for SQLite databases — Python 3.11.3 documentation

使い方はドキュメントにあるので早速使っていきましょう。

DB(SQLiter3のDBはただのファイル)は前回の記事で作ったのがあるのでそれに接続します。存在しなかったら勝手に生成されるのでここはあまり難しいところではありません。

前回の記事を少しおさらいしておくと、DBファイル名は、mydatabase.dbで、userってテーブルができています。これの全レコードをSELECTするコードは次の様になります。

import sqlite3


# データベースに接続
con = sqlite3.connect('mydatabase.db')
cursor = con.cursor()
# SQLの実行と結果の表示
cursor.execute("SELECT * FROM user")
row = cursor.fetchall()
print(row)
# 以下結果
# [(1, 'Alice', 20), (2, 'Bob', 30), (3, 'Charlie', 40)]

簡単ですね。

結果はタプルのリストで得られました。列名(SELECT文ではアスタリスク指定にしている)の一覧が欲しいと思われますが、それは、coursor.descriptionという属性に入ってます。ただ、入り方がちょっとクセがあって、DB API の仕様との整合性のために、不要なNone が入ってます。

# 列名 + 6個の None という値が入ってる
print(cursor.description)
"""
(
    ('id', None, None, None, None, None, None),
    ('name', None, None, None, None, None, None),
    ('age', None, None, None, None, None, None)
)
"""

# 列名の一覧を得るコードの例
print([row[0] for row in cursor.description])
# ['id', 'name', 'age']

これだとちょっと使いにくいということもあるともうのですが、row_factory というのを使うと、列名: 値 の辞書で結果を得ることができます。ドキュメントに「How to create and use row factories」ってしょうがあるのでそれを参考にやってみます。

con = sqlite3.connect('mydatabase.db')
# row_factoryを指定
con.row_factory = sqlite3.Row
cursor = con.cursor()
cursor.execute("SELECT * FROM user")
row = cursor.fetchall()
print(row)
# [<sqlite3.Row object at 0x109b4de70>, <sqlite3.Row object at 0x109b4dae0>, <sqlite3.Row object at 0x109b4fc70>]

# これらはそれぞれ辞書形でデータが入っているので次の様にして値を取り出せる。
print(row[0]["name"])
# Alice
print(row[0]["age"])
# 20


# 上記の状態だと、DataFrameへの変換が簡単
import pandas as pd


print(pd.DataFrame(row))
"""
   0        1   2
0  1    Alice  20
1  2      Bob  30
2  3  Charlie  40
"""

これでSELECTはできましたね。

次はINSERT等のデータの編集の話です。INSERTだけ取り上げますが、UPDATEもDELETEも同様です。

基本的にexecuteでクエリを実行できるのですが、端末でSQLite3を使う時と違って、明示的にコミットしないと、セッションを切って繋ぎ直したらデータが消えてしまいます。commit()を忘れない様にしましょう。

con = sqlite3.connect('mydatabase.db')
cursor = con.cursor()
# 1行挿入
cursor.execute("INSERT INTO user (name, age) VALUES (?, ?)", ["Daniel", 35])
# コミット
con.commit()
# レコードが1レコード増えてる
cursor.execute("SELECT * FROM user")
row = cursor.fetchall()
print(row)
# [(1, 'Alice', 20), (2, 'Bob', 30), (3, 'Charlie', 40), (4, 'Daniel', 35)]

この他 CREATE TABLE等々も実行できますので以上の知識で通常の使用はできると思います。

ただ、Pythonで使う場合と端末で直接使う場合で異なる点もあります。それが、.(ドット)で始まるSQLite3の固有コマンドたちが使えないってことです。

.table が使えないのでテーブルの一覧を得るには代わりに次の様にします。

# テーブル名の一覧を取得するSQL文を実行
cursor.execute("SELECT name FROM sqlite_master")
print(cursor.fetchall())
# [('user',)]

そして、特定のテーブルのスキーマを確認するクエリはこちらです。userテーブルしか作ってないので、userテーブルで実験します。

# スキーマを取得するSQL
cursor.execute("SELECT sql FROM sqlite_master WHERE name = 'user'")
print(cursor.fetchone()[0])
"""
CREATE TABLE user (
    id INTEGER PRIMARY KEY,
    name TEXT,
    age INTEGER
)
"""

どちらも sqlite_master ってテーブルからデータを引っ張ってきてることからわかる通り、メタデータ的なテーブルが存在してその中に情報があるということです。

ちなみに、この sqlite_master に含まれる列名の一覧は次の様になってます。 sqlite_master の中に sqlite_master の情報はなさそうなので、*で全列SELECTして列名の一覧を取得しています。

cursor.execute("SELECT * FROM sqlite_master WHERE name = 'user'")
print([row[0] for row in cursor.description])
# ['type', 'name', 'tbl_name', 'rootpage', 'sql']

以上で、PythonでSQLite3を使える様になると思います。

SQLite3入門

SQLite3の概要

SQLite3というのは軽量なRDBMSです。C言語で書かれてます。特徴としては、データベースをファイルとして扱い、サーバーが不要って点が挙げられます。

リレーショナルデータベースをちょっと使いたいけど、わざわざMySQLを導入するほどではないとか既存のDB環境は汚したくないってときに重宝します。

ただ、当然ですがMySQLではないので、SQL以外のコマンド群、MySQLで言えばSHOWで始まる各種コマンドなどが無く、その代わりにSQLite3専用のコマンドが用意されていたりして、最初は少し戸惑ったので記事としてまとめておきます。今回はとりあえず端末で直接使う場面を想定して書きます。次回の記事で、Pythonライブラリから使う方法を書く予定です。

インストール方法

macの場合。最初からインストールされています。
バージョンを上げたい場合は、Homebrewでもインストールできるようです。

$ brew install sqlite3

Linux (EC2のAmazon Linux2 を想定) の場合、こちらも最初から入っていますが、yumで入れることもできます。

$ sudo yum update
$ sudo yum install sqlite

ディストリビューションによってはyumでは無く、apt-get等別のコマンドのことがあるので確認して入れたほうが良いでしょう。

Windowsの場合、公式サイトのダウンロードページでバイナリファイルが配布されているのでダンロードして使います。

SQLite3の起動と終了

MySQLとかだと接続情報(ユーザー名やパスワード、外部であればホスト名など)を含む長いコマンドを打ち込んで接続しますが、SQLite3はDBファイル名を指定して実行するだけでそのファイルに接続してDBとして使えるようになります。1ファイルが1データベースで、その中にテーブルを作っていくイメージです。MySQLは一つのRDBMSに複数のデータベース(スキーマ)を作れるのでこの点が違いますね。

mydatabase.db というファイルで起動する場合のコマンドは下記です。既存ファイルを指定しても良いし、無ければ新規に作成されます。
sqlite3 を起動するとプロンプトが sqlite> に変わります。

$ sqlite3 mydatabase.db
SQLite version 3.39.5 2022-10-14 20:58:05
Enter ".help" for usage hints.
sqlite>

終了するには、 .exit か .quit を入力します。 .exitの方はリターンコードを一緒に指定することもできます。先頭に.(ドット)が必要なので注意してください。

sqlite> -- 終了コマンド2種類。どちらかで閉じる。
sqlite> .quit
sqlite> .exit

個人的なぼやきですが、sqlite3の入門記事を書く人はSELECT文とかより先に終了方法を書くべきだと思っています。MySQLのノリでquitとかexitとか入れると syntax error が起きます。vimの終了方法並みに重要な知識です。

SQLite3におけるSQLの実行

SQLの実行は特に変わったことがありません。MySQLとほぼ同じように実行できます。

テーブルを作って、データを入れて、それを選択してみましょう。

-- テーブルの作成
sqlite> CREATE TABLE user (
    id INTEGER PRIMARY KEY,
    name TEXT,
    age INTEGER
);

-- データの挿入
sqlite> INSERT INTO user (name, age) VALUES ('Alice', 20);
sqlite> INSERT INTO user (name, age) VALUES ('Bob', 30);
sqlite> INSERT INTO user (name, age) VALUES ('Charlie', 40);

-- データの検索
sqlite> SELECT * FROM user;
-- 以下結果
1|Alice|20
2|Bob|30
3|Charlie|40

データ挿入時に id は指定しませんでしたが勝手に連番入れてくれてます。これはsqlite3の特徴の一つで、すべてのテーブルにはrowidっていう一意の行idが振られていて、 INTEGER PRIMARY KEY の列を作るとその列はrowid列の別名となり、自動的に振られたidが格納されるようになるのです。

DELETE や UPDATEなども他のRDBMSと同様の構文で行えるのですがこの記事では省略します。

SQLite3のコマンド

冒頭に少し書いた通り、SQLite3では、SQL以外にも独自のコマンドがあります。その代わり他のRDBMS固有のコマンドは使えません。SHOWなんとか、と打ちたくなって動かないことに気づいたら思い出しましょう。

そのコマンド群は . (ドット) 始まりになっています。代表的なものをいつくか挙げておきます。

  • .help: コマンドの一覧と使用方法を表示
  • .tables: データベース内のテーブル一覧を表示
  • .schema: テーブルのスキーマを表示 (指定したテーブルのCREATE TABLE文が見れる)
  • .backup: データベースをバックアップする
  • .import: CSVファイルなどの外部ファイルからデータを読み込み、テーブルに挿入する
  • .quitまたは.exit: SQLite3コマンドラインツールを終了する
  • .headers on|off: Selectした結果のヘッダーとして列名を表示するかどうか切り替える。初期設定はoff

一番最初に書いた .help で コマンドを一覧表示できるので一度一通りみておくと良いでしょう。(helpの起動方法が独自コマンドってこれもなかなか初心者に不親切な設計ですよ。)

まとめ

以上の内容を把握しておけば、最低限の用途でSQLite3を利用できるではと思います。

そして、最低限の用途で済まなくなったらSQLite3について詳しくなるよりも他のRDBMSへの移行を検討していくのが良いかなと考えています。簡易版RDBMSですからね。

トレジャーデータ(Presto)でアクセスログをセッションごとにまとめる方法

前回の記事で紹介したテクニックの応用として、最後の方にちょろっとユーザーのアクセスログデータをセッションごとにまとめたりもできるって話を書きました。
参考: DataFrameを特定列の値が連続してる行ごとにグルーピングする方法

ただ、僕は普段アクセスを分析するときは、Pythonでななくて、トレジャーデータからデータを取ってくる時点でセッションIDを振っているので、自分がいつもやっている方法を紹介しておこうという記事です。トレジャーデータのウィンドウ関数をまとめて紹介したことがあったのでこれも紹介したつもりになってましたがまだでしたね。

使う関数は、 TD_SESSIONIZE_WINDOW というUDFです。名前がTD_で始まっていることから分かる通り、トレジャーデータ専用の関数です。
ドキュメント: Supported Presto and TD Functions – Product Documentation – Treasure Data Product Documentation

例がわかりやすいので、そのまま引用します。アクセスログにタイムスタンプ(time列)とIPアドレス(ip_address列)、アクセスされたパス(path列)があるデータに対して、IPアドレスごとに分けて、60分(=3600秒)間隔が空いてたら別セッションとしてセッションidをふるって操作をやりたい場合次のクエリになります。

SELECT
    TD_SESSIONIZE_WINDOW(time, 3600)
        OVER (PARTITION BY ip_address ORDER BY time)
    AS session_id,
    time,
    ip_address,
    path
FROM
    web_logs

TD_SESSIONIZE_WINDOW 関数に直接渡す引数は、セッションを区切るtimeスタンプの列(トレジャーデータなのでほぼ確実にtime列を使うことになると思います)と、セッションを区切る時間です。そして、ウィンドウ関数なので、OVERを使って、区切りやソート順を指定できます。区切りはIPアドレスだけでなくユーザーIDやデバイス情報はど複数指定することもできます。ソート順はほぼ自動的にtimeを使うことになるでしょうね。

結果として振られるsession_idはUUIDになるので、実行するたびに結果がわかります。ちょっとVALUEを使ってダミーデータ作ってお見せします。
結果がタイムスタンプになると説明しにくかったので、time_formatとして読めるようにした時刻列持つかしました。

-- 実行したクエリ
SELECT
    TD_SESSIONIZE_WINDOW(time, 3600)
        OVER (PARTITION BY ip_address ORDER BY time)
    AS session_id,
    time,
    TD_TIME_FORMAT(time, 'yyyy-MM-dd HH:mm:ss', 'JST') AS time_format,
    ip_address,
    path
FROM
-- 以下ダミーデータ
    (
        VALUES
            (TD_TIME_PARSE('2022-12-12 12:00:00', 'JST'), '127.0.0.x', './hoge1.html'),
            (TD_TIME_PARSE('2022-12-12 12:30:00', 'JST'), '127.0.0.x', './hoge2.html'),
            (TD_TIME_PARSE('2022-12-12 13:30:00', 'JST'), '127.0.0.x', './hoge3.html'),
            (TD_TIME_PARSE('2022-12-12 12:10:00', 'JST'), '127.0.0.y', './hoge1.html'),
            (TD_TIME_PARSE('2022-12-12 12:20:00', 'JST'), '127.0.0.y', './hoge2.html'),
            (TD_TIME_PARSE('2022-12-12 13:19:59', 'JST'), '127.0.0.y', './hoge3.html')
  ) AS t(time, ip_address, path)


-- 以下が出力
f6d83ca3-6f3b-4af8-be10-197a38074cd7	1670814600	2022-12-12 12:10:00	127.0.0.y	./hoge1.html
f6d83ca3-6f3b-4af8-be10-197a38074cd7	1670815200	2022-12-12 12:20:00	127.0.0.y	./hoge2.html
f6d83ca3-6f3b-4af8-be10-197a38074cd7	1670818799	2022-12-12 13:19:59	127.0.0.y	./hoge3.html
7c9f176f-950c-4b5e-a997-eaa0d8ed77ec	1670814000	2022-12-12 12:00:00	127.0.0.x	./hoge1.html
7c9f176f-950c-4b5e-a997-eaa0d8ed77ec	1670815800	2022-12-12 12:30:00	127.0.0.x	./hoge2.html
fa9cb3f0-0c3f-4dbd-9976-b10ea12d653e	1670819400	2022-12-12 13:30:00	127.0.0.x	./hoge3.html

まず、127.0.0.xからのアクセスと127.0.0.yからのアクセスには別のid振られていまね。yの方は間隔が最大でも3599秒しか離れていないので3アクセスが1セッションとして同じIDになっています。
一方で、xの方は、2回目と3回目のアクセスが3600秒離れているのでこれは別セッションとして扱われて、idが2種類になっています。
細かいですがこれは結構重要で、引数で指定した3600ってのは、3600未満までの感覚までしか同一セッションとは見なさないということになります。

さて、ここから応用です。

これ、Webページの個々のアクセスのような動作をセッションかする関数なのですが、少し工夫したら前回の記事で書いたようなタイムスタンプに限らないただの連番とか、あと、日付が連続してるかどうかによるグルーピングとかもできます。

ある特定のユーザーが何日連続で訪問してくれたかって集計とか、特定のコンテンツが何日連続で掲載されていたかといった集計にも使えますね。

例えば、WITH句か何かでユーザーさんがアクセスしてくれた日付のデータを作って、それをTD_TIME_PARSEでタイムスタンプに戻し、60*60*24+1=86401 を区切りにするとできます。

例えばこんな感じです。

-- 実行したクエリ
SELECT
    TD_SESSIONIZE_WINDOW(TD_TIME_PARSE(date), 86401)
        OVER (PARTITION BY user_id ORDER BY date)
    AS session_id,
    date,
    user_id
FROM
    (
        VALUES
            ('2022-12-04', 1),
            ('2022-12-05', 1),
            ('2022-12-06', 1),
            ('2022-12-08', 1),
            ('2022-12-09', 1),
            ('2022-12-05', 2),
            ('2022-12-06', 2)
  ) AS t(date, user_id)

-- 以下出力
321b325b-36eb-43c1-afcd-155cfe7fff8d	2022-12-05	2
321b325b-36eb-43c1-afcd-155cfe7fff8d	2022-12-06	2
2738c31e-79ca-4830-b20f-c48a1b14ef72	2022-12-04	1
2738c31e-79ca-4830-b20f-c48a1b14ef72	2022-12-05	1
2738c31e-79ca-4830-b20f-c48a1b14ef72	2022-12-06	1
5447acfb-0718-43a4-9d0a-4d714b79a7d1	2022-12-08	1
5447acfb-0718-43a4-9d0a-4d714b79a7d1	2022-12-09	1

ユーザーidが1の方を見ると、4,5,6日と8,9日で別のidが振られていますね。
86401 が重要で、ここを86400にすると全部バラバラのidになるので注意してください。

この、TD_SESSIONIZE_WINDOWを通常のWebアクセスのセッション化意外に使う使い方をトレジャーデータさんがどの程度想定してるのかが不明なので、なかなか推奨しにくいところではあるのですが、知っておくと便利な場面は結構あるので頭の片隅にでも置いといてください。

Treasure Data でハッシュ関数計算(MD5/SHA1)

諸事情ありまして、先日 Treasure Data (Presto) で 文字列のSHA1を計算する必要が発生しました。(滅多に無いことなのですが。)
その時に方法を検証したので紹介します。ついでにMD5のやり方も紹介します。PythonやMySQLでやる場合は、MD5もSHA1もたいして変わらないのですが、Treasure Data はこの二つは事情がかなり違います。その辺も書いていきたいと思います。

まず、他のDBではどうなっているかということで、MySQLの場合を見ていきます。(僕はAWS RDSのMySQL互換のインスタンスで試してます。)
MySQLには md5 と sha1 という関数が実装されており、これらが「文字列」を受け取り、結果を「16進法表記の文字列」で返してくれます。コメントでつけてるのは結果です。
ドキュメントはこちら: 12.13 暗号化関数と圧縮関数

SELECT
    MD5('abcde'), -- ab56b4d92b40713acc5af89985d4b786
    SHA1('abcde') -- 03de6c570bfe24bfc328ccd7ca46b76eadaf4334

文字列をそのまま受け取ってくれるし、結果も文字列なので簡単ですね。

Pythonで行う方法は昔記事にしました。
参考: pythonでMD5
文字列をバイト型に変換して、専用の関数を呼び出し、結果がバイト型なのでそれを文字列に変換するのでしたね。

さて、いよいよ本題のTreasure Dataです。まず、MD5の方なのですが、実はTD_MD5というUDFが実装されており、MySQLのMD5と同じように動作します。
参考: Supported Presto and TD Functions – Product Documentation

SELECT
    TD_MD5('abcde') -- ab56b4d92b40713acc5af89985d4b786

簡単に使えて結果もMySQLと同じですね。

さて、TD_MD5の存在を知っていたので僕は SHA1についても TD_SHA1が存在すると勘違いしていました。しかし、ドキュメントの先ほどのUDFのページを見ると TD_SHA1はありません。無理矢理書いて実行しても、「Function td_sha1 not registered」と言われます。

では、Treasure Data で SHA1は計算できないのかとも思ったのですが、実はPresto自体に、SHA1という関数が定義さてれいます。(もちろんMD5もあります。)
参考: Binary Functions and Operators — Presto 0.263.1 Documentation

しかし、これつかえばMySQLと同じように動かせる!と思って試したところエラーになりました。

SELECT
    SHA1('abcde')
-- 以下エラーメッセージ
-- Unexpected parameters (varchar(5)) for function sha1. Expected: sha1(varbinary) 

ドキュメントを見ていただけるとわかる通り、PrestoのMD5やSHA1はバイナリを受け取ってバイナリを返します。要するにPythonのハッシュ関数と似たような動きをするのです。

md5(binary) → varbinary
Computes the md5 hash of binary.

sha1(binary) → varbinary
Computes the sha1 hash of binary.

ということで、文字列は受け取ってくれないで事前にキャストしましょう。

SELECT
    SHA1(CAST('abcde' AS VARBINARY)) -- A95sVwv+JL/DKMzXyka3bq2vQzQ=

CASTしてから渡すと無事に動きました。でも結果が違いますね(実は表記方法が違うだけで中身は違わないのですが。) 結果がバイナリで戻り、(たぶん)BASE64表記で帰ってきたようです。

TO_HEXっていうバイナリを16進法表記の文字列に変換する関数もあるので、それも追加します。

SELECT
    TO_HEX(SHA1(CAST('abcde' AS VARBINARY))) -- 03DE6C570BFE24BFC328CCD7CA46B76EADAF4334

MySQLの結果に近づいてきました。ただ、A-Fのアルファベットが大文字になっていますね。LOWERで小文字にしましょう。

SELECT
    LOWER(TO_HEX(SHA1(CAST('abcde' AS VARBINARY)))) -- 03de6c570bfe24bfc328ccd7ca46b76eadaf4334

これでMySQLのSHA1と同じ結果が得られました。
ちなみに、MD5の方もこれと同じ流儀で計算できます。

SELECT
    LOWER(TO_HEX(MD5(CAST('abcde' AS VARBINARY)))) -- ab56b4d92b40713acc5af89985d4b786

どう考えても TD_MD5 一発で済ませる方が簡単ですね。
将来的にTD_SHA1も実装されることを期待したいです。

VALUESを使ったダミーデータの生成

WINDOW関数の話はひと段落しましたが、今回もPrestoの話です。例によって僕はトレジャーデータで検証しています。(今回の記事で紹介する内容はMySQLでは動かないという話も見かけました。)

ここ最近の記事で掲載したサンプルのSQLでは、テーブルに保存されたデータではなくSQLで生成したデータを使っていました。事前にこういうデータを用意してますとか書くのが面倒だったので。その際、ARRAY関数で作った配列を並べて、UNNESTでテーブル型に変換していたのですが、実はVALUES関数をつかってダミーデータを作ることもできるそうです。

この機会に、VALUES関数について調べたので、わかったことを記事にまとめておきます。PrestoのVALUES関数のドキュメントはこちらです。
参考: VALUES — Presto 0.261 Documentation

Description を読むと、VALUESは問い合わせ(query)が使用できる場所ならどこでも使用できる、トップレベルでさえ使える、といきなり衝撃的なことが書かれています。僕は、INSERT文などの特定の構文の中でしか使えないと思っていました。

試しにトップレベルで使ってみましょう。

VALUES 1, 2, 3

-- 出力結果
_col0
1
2
3

確かに、無名列(_col0)に3行のレコードが生成されました。

複数列のデータを作ることもできます。

VALUES
  (1, 'a'),
  (2, 'b'),
  (3, 'c')

-- 出力結果
_col0, _col1
1,     'a'
2,     'b'
3,     'c'

ドキュメントには、ASを使えばテーブルと列に名前も付けれる、と書かれているので、色々試したのですが、トップレベルでVALUES を使った場合にASで列名をつける方法は結局見つけられませんでした。いろんな位置にカッコをつけたりして検証したのですが。

無名テーブルではなく、テーブル名と列名をつけたい場合は、次のようにSELECT文のFROM句の中で使うことで実現できます。

SELECT
  id,
  name
FROM
  (
    VALUES
      (1, 'a'),
      (2, 'b'),
      (3, 'c')
  ) AS t(id, name)

-- 出力結果
id, name
1,  'a'
2,  'b'
3,  'c'

この結果は、UNNESTを使って書いた下のクエリの結果と同じになります。

SELECT
  id,
  name
FROM
  UNNEST(
    ARRAY[1, 2, 3],
    ARRAY['a', 'b', 'c']
  ) AS t(id, name)

UNNEST と VALUES のどちらがいいか、という話ですが 読みやすさというか、直感的でぱっと見で結果の予想がつきやすいのは VALUESの方なんじゃないかなと思います。ただ、10行くらいのダミーデータを生成しようとすると、UNNESTの方が行数が少なくなりやすく、このブログのサンプルコードとしてはそちらの方が使いやすかったりします。非本質的な部分なのであまりスペースを取りたくないので。

処理速度等の性能面の比較は行っていません。性能が気になるほどの莫大なデータをこのような方法でSQLに直接書くべきではないと思いますので、もしそのような大規模データをサンプルとして用意したい場合は、VALUESを使うとか言ってないで、素直にどの下のテーブルに格納してそこからSELECTした方が良いと思います。