本ドキュメントは,「Readable,ReusableなSQLクエリの書く」ことを目的に, SQLコーディング規約についてまとめたものです. RDBMSとしてAmazon Redshift / Google Bigqueryを念頭としています. DO
は推奨,AVOID
は非推奨を表しています.
Table of Contents
命名規則
変数名の規則
DO
- Field nameは小文字を使用する
id
,name
,type
といった文字列はなんのidentifierやnameを表しているか曖昧なので,objectをprefixとして付与すること- 名前にスペースを含めるのが自然な場合はアンダースコアを使用する。(first nameはfirst_name)
- 30 byte以下の名前にする(日本語用いる場合,10文字目安)
AVOID
- CamelCase, camelBackは使わない(= snake_caseを用いる)
- 複数形を避ける = できるだけ集合体を表す用語を使用する. たとえばemployeesの代わりにstaff,またはindividualsの代わりにpeopleを使用する(スペルミスの恐れを回避するため)
- 略語を避ける. 略語を使う必要がある場合は,一般的に通じるものであることを確認する
- 予約語は変数名には用いない
- アルファベット, 数字, アンダースコア以外の文字は使用しない
EXAMPLE
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
--- Good
SELECT
id AS account_id,
name AS account_name,
type AS account_type,
FROM
account_table
--- Bad
SELECT
id,
name,
type,
FROM
account_table
SUFFIX RULE
suffix | 説明 |
---|---|
_id |
主キーである列など一意の識別子 |
_total |
値の集合の合計または総計 |
_num |
フィールドに数値が含まれていることを表す |
_name |
first_nameのように名前を強調する |
_seq |
連続した数値を含む |
_cnt |
カウント |
_size |
ファイルサイズや衣類などのサイズ |
_date |
何かの日付を含む列であることを表す |
_at |
TIMESTAMP型のデータタイプを表す |
_type |
カテゴリ変数を表す, 例: coupon_type |
PREFFIX RULE
prefix | 説明 |
---|---|
taxed_ |
税込の値を表す |
first_ , last_ |
期間の境界値を示す(last_ は範囲に含まれる) |
satrt_ , end_ |
期間の境界値を示す(end_ は範囲に含まれない) |
is_ , has_ |
BOOLEAN型のデータタイプを表す |
avg_ |
平均を表す |
なぜfield nameは30 byte以下なのか?
RDBMSの種類(Oracle Database 12.1)によって,カラム文字は30 byte以下という制約があるためです. とあるデータベースでは有効だったクエリが他に移植できなくなるというリスクを回避するためにこのルールがあります.
TABLE ALIAS
DO
- アンダースコアでテーブル名をsplitし,それぞれの頭文字を結合したエイリアスを用いる
- 同じテーブル同士で結合する場合は,(1)のルールと出現順番の組み合わせを用いる
EXAMPLE
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
--- Good
SELECT
bfco.account_id,
dd.fiscal_year,
dd.fiscal_quarter,
dd.fiscal_quarter_name,
cc.cost_category_level_1,
cc.cost_category_level_2
FROM
budget_forecast_cogs_opex AS bfco
LEFT JOIN date_details AS dd
ON bfco.accounting_period = dd.first_day_of_month
LEFT JOIN cost_category AS cc
ON bfco.unique_account_name = cc.unique_account_name
--- Bad
SELECT
a.*,
b.fiscal_year,
b.fiscal_quarter,
b.fiscal_quarter_name,
c.cost_category_level_1,
c.cost_category_level_2
FROM budget_forecast_cogs_opex a
LEFT JOIN date_details b
ON b.first_day_of_month = a.accounting_period
LEFT JOIN cost_category c
ON b.unique_account_name = c.unique_account_name
Query Syntax
DO
- 予約語は大文字を使用する(
SELECT
,WHERE
,FROM
,SUM
など) - 常にASキーワードを記載する
- 原則1行につき1文
- 1文70文字超えたら改行
- インデントはspace 4
- 比較演算子 (
=
,>
!=
)の前後にはスペースをいれる - カンマ(
,
)の後にスペース入れる - NOT EQUALの演算をする際は
<>
ではなくて!=
を用いる(!=
のほうがプログラミング言語との観点では一般的な表現方法) UNION
句はUNION ALL
かUNION DISTINCT
を明示的に記述するGROUP BY
,ORDER BY
は数字で表現する
EXAMPLE
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
-- Good: indent with space 4
WITH my_data AS (
SELECT
md.*
FROM
prod.my_data AS md
WHERE
md.filter = 'my_filter'
)
...
-- Bad: indent with space 2
WITH my_data AS (
SELECT *
FROM prod.my_data
WHERE filter = 'my_filter'
)
...
ブロックとインデント
DO
- インデントを活用し,同じブロックに属するkeywordsがすべて同じ位置で開始するようにコードを整列させる
JOIN
句は,「ブロック」の向こう側にインデントし,必要に応じて改行でグループ化するDISTINCT
はSELECT
と同じ行に配置する
EXAMPLE: インデントを用いてブロックを整列する
1
2
3
4
5
6
7
8
9
10
11
12
13
SELECT DISTINCT
ph.COLUMN1 AS COLUMN1,
dd.COLUMN2 AS COLUMN2
FROM
purchase_history AS ph,
INNER JOIN delivery_data AS dd
ON ph.COLUMN3 = dd.COLUMN3
AND ph.COLUMN4 = dd.COLUMN4
WHERE
ph.COLUMN5 = 1
AND dd.COLUMN6 = DATE('2020-12-01')
ORDER BY
1, 2
UNION句とブロック
DO
UNION
句を用いる場合は,UNION
の前後を改行し1行ずつ空ける
EXAMPLE
1
2
3
4
5
6
7
8
9
10
11
12
13
SELECT
table1.COLUMN1,
table1.COLUMN2,
FROM
table 1
UNION ALL
SELECT
table2.COLUMN1,
table2.COLUMN2,
FROM
table 2
CASE文とブロック
DO
WHEN
とそれに対応するTHEN
が一つのブロックとなるようにする
EXAMPLE
1
2
3
4
5
6
7
SELECT
CASE
WHEN XXX.HOGE = 1 THEN 1
ELSE 0
END AS is_hoge
FROM
XXX
WINDOW関数
DO
- 順序 (
ASC
,DESC
)は明示する
EXAMPLE
1
2
3
4
SUM(1) OVER (
PARTITION BY category_id, year
ORDER BY pledged DESC
) AS category_year
JOIN句ではLEFT
, OUTER
, INNER
を明示する
DO
JOIN
ではなくLEFT OUTER JOIN
とテーブル同士の関係性を明示する- JOINキーの順番はテーブルの出現順番に合わせる
- JOIN方向は
LEFT
に統一する(RIGHT JOIN
は用いない)- JOIN方向が左からと統一されていないと,集約Keyはどちらベースなのかの解釈に頭をつかってしまい,読みづらいコードとなってしまうため
EXAMPLE: JOINキーの順番
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
--- Good
SELECT
...
FROM
source
LEFT JOIN other_source
ON source.id = other_source.id
WHERE
...
--- Bad
SELECT
...
FROM
source
LEFT JOIN other_source
ON other_source.id = source.id
WHERE
...
サブクエリ
DO
- サブクエリのブロックは他と区別できるように配置する
- サブクエリ内は改行を厳密にやらなくてもいいとする
AVOID
- 避けることができるならサブクエリは用いない
EXAMPLE
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
SELECT
r.last_name,
(
SELECT MAX(YEAR(championship_date))
FROM champions AS c
WHERE c.last_name = r.last_name
AND c.confirmed = 'Y'
) AS last_championship_year
FROM
riders AS r
WHERE
r.last_name IN (
SELECT c.last_name
FROM champions AS c
WHERE YEAR(championship_date) > '2008'
AND c.confirmed = 'Y'
)
;
サブクエリ句ではなくCTEs(Common Table Expressions = WITH句)を用いる
- CTEsの方がサブクエリ句を用いるよりも可読性があがる
- CTEsの方がQuery Peformanceが改善するケースがある
EXAMPLE: CTEsの方がQuery Peformanceが改善するケース
以下の例では,CTE statmentを用いたクエリに対して,サブクエリ句を用いたクエリのデータスキャン量は大きくなってしまいます.
apc1
とapc2
がJOIN
句で呼ばれる際,CTE statementはavg_pet_count_over_time
を1回作成するだけで住みますが,WITH句の方ではそれぞれ独立に2回avg_pet_count_over_time
が作られてしまうことに起因しています.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
WITH
avg_pet_count_over_time AS (
SELECT
cat_id,
MAX(timestamp)::DATE AS max_pet_date,
MIN(timestamp)::DATE AS min_pet_date
FROM
cat_pet_fact
GROUP BY
1
)
SELECT
cd.cat_name,
apc1.max_pet_date,
apc2.min_pet_date
FROM
cat_dim AS cd
LEFT JOIN avg_pet_count_over_time as apc1
ON cd.cat_id = apc1.cat_id
LEFT JOIN avg_pet_count_over_time as apc2
ON cd.cat_id = apc2.cat_id
;
AVOID
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
SELECT
cat_name,
apc1.max_pet_date,
apc2.min_pet_date
FROM cat_dim
LEFT JOIN
(SELECT
cat_id,
MAX(timestamp)::DATE AS max_pet_date,
MIN(timestamp)::DATE AS min_pet_date
FROM cat_pet_fact
GROUP BY 1) AS apc1
ON cat_dim.cat_id = apc1.cat_id
LEFT JOIN
(SELECT
cat_id,
MAX(timestamp)::DATE AS max_pet_date,
MIN(timestamp)::DATE AS min_pet_date
FROM cat_pet_fact
GROUP BY 1) as apc2
ON cat_dim.cat_id = apc2.cat_id;
BigQuery Best Practices
必要なカラムのみクエリする
DO
- 分析に必要なカラムのみクエリする
BigQueryはカラム指向ストレージであるため,指定されたカラムは上から下までフルスキャンされます. そのため,指定されたカラムが多いほどスキャンのデータ量が増えます. スキャンデータ量が多いほど処理に時間がかかりパフォーマンスが下がり,また料金コストも上昇するというデメリットがあります.
カラム指向スト-レジとは?
- カラム指向ストレージとは,列単位でデータを格納する仕組みのこと(多くの他のRDBはレコード単位でデータを格納)
- 列単位ではデータタイプが同一なので,データの圧縮効率が高くなる(=ストレージコストが安くなる)というメリットがあります
LIMIT
ではなく_PARTITIONDATE
などのPARTITIONを用いる
LIMIT
句はデータのスキャン量には影響を与えません. なのでQuery Performanceの観点からもクエリコスト管理の観点からも推奨されません- 一部のデータだけ見たい場合は,
_PARTITIONDATE = "2017-01-01"
などのPARTITION機能を用いると,データスキャン量が減らせます
なお,一部のカラムを除いてクエリしたい場合はEXCEPT
関数を用います.
1
2
3
4
5
6
7
/*update_timestampカラム以外をクエリしたい場合*/
SELECT
* EXPECT(update_timestamp)
FROM
TABLE1
WHERE
_PARTITIONDATE BETWEEN TIMESTAMP("2016-01-01") AND TIMESTAMP("2016-01-31")
PARTITIONの指定可能対象
設定可能対象
- データ読み込み時の日付部分
- TIMESTAMP列
- DATE列
- DATETIME列
- INTEGER列
EXAMPLE
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
--- DATE列をPARTITION列として指定
CREATE TABLE
mydataset.newtable (transaction_id INT64, transaction_date DATE)
PARTITION BY
transaction_date
OPTIONS(
require_partition_filter=false
);
CREATE TABLE
mydataset.newtable (transaction_id INT64, transaction_date DATE)
PARTITION BY
transaction_date
AS SELECT transaction_id, transaction_date FROM mydataset.mytable;
--- DATE列をMonthlyにトランクして,PARTITION列として指定
CREATE TABLE
mydataset.newtable (transaction_id INT64, transaction_date DATE)
PARTITION BY
DATE_TRUNC(transaction_date, MONTH)
OPTIONS(
require_partition_filter=false
);
--- TIMESTAMP列をDailyにトランクして,PARTITION列として指定
CREATE TABLE
mydataset.newtable (transaction_id INT64, transaction_ts TIMESTAMP)
PARTITION BY
TIMESTAMP_TRUNC(transaction_ts, DAY)
OPTIONS(
require_partition_filter=false
);
--- データ取り込み日をPARTITION列として指定
CREATE TABLE
mydataset.newtable (transaction_id INT64)
PARTITION BY
_PARTITIONDATE
;
--- INTERGER列をPARTITION列として指定
CREATE TABLE mydataset.newtable (customer_id INT64, date1 DATE)
PARTITION BY
RANGE_BUCKET(customer_id, GENERATE_ARRAY(0, 100, 10))
OPTIONS(
require_partition_filter=false
)
Shared Table vs PARTITIONED TABLE
DO
- 日付ごとにテーブル(
PREFIX_YYYYMMDD
)を作るのではなく,パーティション分割テーブルを利用する
WHY
日付ごとにテーブルを作るアプローチは,以下のオーバーヘッドがあります:
- 個別のテーブルごとにメタデータを保持する
- クエリ実行時に個別のテーブルごとに権限を確認する必要がある
例外
日毎にrawデータ形式が異なる場合は,日付ごとに異なるスキーマを適用できるシャーディングテーブルを活用する(活用例:データソースでカラムが増減した場合でもそのまま取り込める)
JOIN
句で結合する前にテーブルサイズを小さくする
BigQueryではJOIN
句を用いてテーブルを結合する際,まずBigQueryは両方のテーブルデータをシャッフルして,結合条件の演算を実施します. このシャッフルはスロットをオーバーロードするリスクがあります. ですので,事前にテーブルサイズを小さくできるならば,小さくしてから結合することが推奨されます.
- BigQueryはストレージコストは大きくないので,データは非正規化したほうがよいとされています(=
JOIN
句を避けるようなテーブル設計)
JOIN
句のテーブルの順番
DO
- 可能であるならば,
JOIN
句で指定するテーブルはデータサイズが大きいテーブル(行数が大きいテーブル)から呼んだほうがよい
大きなテーブルをJOINの左側に,小さなテーブルをJOINの右側に配置すると,broadcast join
が作成されます. broadcast join
は,小さい方のテーブルのすべてのデータを,大きい方のテーブルを処理する各スロットに送ります. 具体的には,内部表側のデータがブロードキャストされる一方,外部表側のデータはそのままで移動されないことを指しています.
クラスタリング
DO
- 連続性の高いフィールドをクラスタキーとして設定
効果
- クラスタキーでスキャンしたブロックのみ費用が発生する(=費用削減)
- データはパーティション内でソートされて保持されているので,
WHERE
句でクラスタキーとなるフィールドでフィルターを指定した場合,不要なデータのスキャンを省略してくれる - RDBのINDEXのイメージが近い
設定可能対象
- DATE
- BOOL
- GEOGRAPHY
- INT64
- NUMERIC
- BIGNUMERIC
- STRING
- TIMESTAMP
- DATETIME
LIMITATIONS
- クラスタキーは4つまで設定可能
- STRINGをキーに設定した場合,最初の1,024 charactersのみソートに用いられる
EXAMPLE
1
2
3
4
5
6
7
8
9
10
11
12
CREATE TABLE
`mydataset.ClusteredSalesData`
PARTITION BY
DATE(timestamp)
CLUSTER BY
customer_id,
product_id,
order_id AS
SELECT
*
FROM
`mydataset.SalesData`
References
Online-contents
(注意:GitHub Accountが必要となります)