SQL スタイルガイド
SQL スタイルガイド
このガイドは SQL の標準を定めており、SQLFluff リンターとコードレビューによって適用されます。このスタイルガイドが対象とするコード変更は、dbt を使用して行われたものです。
Data Team 以外の方や、dbt 以外で SQL を開発している方は、リンティングツールの適用が難しい場合があることをご承知ください。ただし、このガイド内のガイダンスに従うことは歓迎します。
使い方
私たちは、人々が開発中にこのガイドに示されたスタイルを使用することを期待しています。CI パイプラインは手動でしか実行されないため、施行はレビュー時に行われることが引き続き想定されます。将来的には、すべての変更でパイプラインが実行され、最終的にはパイプラインの通過が必須となります。
以前のスタイルガイドは、リンターがサポートしていない混在インデントスタイルを使用していたため、すべてのモデルは最初のチェックで失敗する可能性があります。モデルを変更の過程で通常通り作業しながら新しいスタイルに更新していく意図があります(すべてのルールが自動施行できるわけではないため、明示的なカラムエイリアス化など)。これにより変更を管理しやすく保てます。
SQLFluff
SQLFLuff は dbt のようなテンプレートツールと連携する SQL リンターです。私たちはこれを使用して、記述する SQL の基本的な構造とスタイルを定義し、その構造とスタイルのレビューを著者の手に委ねます。SQLFluff は dbt 開発環境に含まれており、リンティングプロセス中に dbt テンプレートエンジンを使用します。次のコマンドで使用できます:
sqlfluff lint models/path/to/file/file-to-lint.sql
dbt の Python 仮想環境にいる場合は、dbt モデル名を指定した make コマンドを使用できます:
make lint-models MODEL="model_name"
dbt コマンドを使用してリントするファイルのリストを取得することもできます:
sqlfluff lint $(dbt list --model model_name --output path)
dbt を使用してテンプレート化されていない SQL を記述している場合は、SQLFluff をスタンドアロン Python パッケージとして直接インストールして使用できます。
pip install sqlfluff
sqlfluff lint path/to/file/file-to-lint.sql
SQLFluff には、可能な場合にルール違反に修正を適用する fix コマンドが含まれています。すべてのルール違反が自動修正可能ではないため、fix コマンドを使用した後に lint コマンドを実行してすべてのルール違反が解決されていることを確認することをお勧めします。
デフォルト設定からの変更
- ダイアレクトを Snowflake に設定
- テンプレーターを dbt に設定
- 以下のルールを除外:
- L016 行の長さ
- L031 FROM または JOIN 句のテーブルエイリアスなし
- L034 SELECT 文での単純ターゲットと計算の順序
- SELECT 文内でのカラムエイリアスの整列
- ハンギングインデントの使用を除外
- タブサイズを 2 スペースに設定
- キーワード、データ型、関数を常に大文字にする
- テーブルエイリアスを最低 4 文字に設定
Data Team が使用する設定ファイルは GitLab Data Team リポジトリにあります。
一般的なガイダンス
コード行数を少なくするために最適化しないでください。改行は安価ですが脳の時間は高価です。
DRY 原則に親しんでください。dbt では CTE、Jinja、マクロを活用し、Sisense ではスニペットを活用してください。同じ行を 2 回入力した場合、2 箇所でメンテナンスが必要です。
一貫性を保ってください。何かを行う最善の方法が不明な場合でも、コード全体で同じ方法で行えば、読みやすく、必要に応じて変更しやすくなります。
明示的にしてください。何かを明示的に定義することで、期待どおりに動作し、次の人(あなた自身かもしれません)が SQL を明示的にすると理解しやすくなります。
ベストプラクティス
タブを使用しないでください — スペースのみを使用します。エディターがタブをスペースに変換するよう設定してください。詳細はオンボーディングテンプレートを参照してください。
長いコード行(80〜100 文字)は新しい行に折り返してください。
Snowflake では不正確な結果を生成するため、結合で
USINGコマンドを使用しないでください。この件のフォーラムディスカッションを見るにはアカウントを作成してください。このトピックに関するフォーラムディスカッション。以下の関連ステートメントの違いを理解し、適切に使用してください:
UNION ALLとUNIONLIKEとILIKENOTと!と<>DATE_PART()とDATE_TRUNC()
カラムやテーブルにエイリアスを付ける場合は
AS演算子を使用してください。インライン加算
date_column + interval_columnよりDATEDIFFを優先してください。この関数はより明示的で、より広い範囲の日付部分で機能します。<>より!=を優先してください。!=は他のプログラミング言語でより一般的で、「等しくない」と読め、私たちが話す方法に近いためです。column ILIKE '%Match%'よりLOWER(column) LIKE '%match%'を優先してください。これにより、予期しない結果につながる大文字の混入の可能性を減らします。いずれかが十分な場合は
HAVINGよりWHEREを優先してください。
コメント
- モデルで単一行コメントを作成する場合は
--構文を使用する - モデルで複数行コメントを作成する場合は
/* */構文を使用する - コメントを作成する際は文字行制限を尊重する。コメントが長すぎる場合は新しい行またはモデルドキュメントに移動する
- dbt モデルドキュメントが利用可能な場合は活用する
- SQL で行われた計算には何が起こっているかの簡単な説明と、利用可能であればメトリクス(およびその計算方法)を定義するハンドブックへのリンクを含める
TODOコメントを残す代わりに、改善のための新しい Issue を作成する
命名規則
id、name、typeのような曖昧なフィールド名には、常に何を識別または命名しているかのプレフィックスを付けてください:-- 推奨 SELECT id AS account_id, name AS account_name, type AS account_type, ... -- vs -- 非推奨 SELECT id, name, type, ...すべてのフィールド名は スネークケース にしてください:
-- 推奨 SELECT dvcecreatedtstamp AS device_created_timestamp ... -- vs -- 非推奨 SELECT dvcecreatedtstamp AS DeviceCreatedTimestamp ...ブール型フィールド名は
has_、is_、またはdoes_で始めてください:-- 推奨 SELECT deleted AS is_deleted, sla AS has_sla ... -- vs -- 非推奨 SELECT deleted, sla, ...タイムスタンプは
_atで終わり、常に UTC にしてください。日付は
_dateで終わらせてください。日付を切り捨てる場合は、切り捨てに応じてカラムに名前を付けてください。
SELECT original_at, -- 2020-01-15 12:15:00.00 original_date, -- 2020-01-15 DATE_TRUNC('month',original_date) AS original_month -- 2020-01-01 ...カラム名として
dateやmonthなどのキーワードを避けてください。
参照規則
テーブルを結合して両方のテーブルのカラムを参照する場合は以下を検討してください:
テーブル名が短い(20 文字以下程度)場合は、エイリアスの代わりに完全なテーブル名を参照する(可能であれば CTE の名前を変更し、最後に説明的なエイリアスを検討する)
簡単にナビゲートできるよう SELECT 文の各カラムをテーブル名/エイリアスで修飾する
-- 推奨 SELECT budget_forecast.account_id, date_details.fiscal_year, date_details.fiscal_quarter, date_details.fiscal_quarter_name, cost_category.cost_category_level_1, cost_category.cost_category_level_2 FROM budget_forecast_cogs_opex AS budget_forecast LEFT JOIN date_details ON date_details.first_day_of_month = budget_forecast.accounting_period LEFT JOIN cost_category ON budget_forecast.unique_account_name = cost_category.unique_account_name -- vs -- 非推奨 SELECT a.account_id, 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
特殊文字を含むカラムや大文字小文字を区別するカラムなど、必要な場合にのみダブルクォートを使用してください。
-- 推奨 SELECT "First_Name_&_" AS first_name, ... -- vs -- 非推奨 SELECT FIRST_NAME AS first_name, ...JSON へのアクセスにはブラケット構文を優先してください。
-- 推奨 SELECT data_by_row['id']::bigint as id_value ... -- vs -- 非推奨 SELECT data_by_row:"id"::bigint as id_value ...明示的な結合ステートメントを優先してください。
-- 推奨 SELECT * FROM first_table INNER JOIN second_table ... -- vs -- 非推奨 SELECT * FROM first_table, second_table ...LATERAL FLATTENでも明示的な結合ステートメントを優先してください。ただし、現在のコードベースはこの慣行を一貫して守っていないことに注意してください。-- 推奨 SELECT data.value, source.uploaded_at FROM source INNER JOIN LATERAL FLATTEN(input => source.jsontext['data']) AS data ... -- vs -- 非推奨 SELECT data.value, source.uploaded_at FROM source, LATERAL FLATTEN(input => source.jsontext['data']) AS data ...
共通テーブル式(CTE)
CTE は SQL をより読みやすくし、パフォーマンスが高いため、サブクエリよりも CTE を優先してください:
-- 推奨 WITH important_list AS ( SELECT DISTINCT specific_column FROM other_table WHERE specific_column != 'foo' ) SELECT primary_table.column_1, primary_table.column_2 FROM primary_table INNER JOIN important_list ON primary_table.column_3 = important_list.specific_column -- vs -- 非推奨 SELECT primary_table.column_1, primary_table.column_2 FROM primary_table WHERE primary_table.column_3 IN ( SELECT DISTINCT specific_column FROM other_table WHERE specific_column != 'foo')他のテーブルを参照するには CTE を使用してください。
CTE はクエリの先頭に置いてください。
パフォーマンスが許す限り、CTE は単一の論理的な作業単位を実行するべきです。
CTE 名はわかりやすさを保ちながらできるだけ簡潔にしてください。
replace_sfdc_account_id_with_master_record_idのような長い名前を避け、CTE にコメントを付けた短い名前を優先してください。これにより結合でのテーブルエイリアス化を避けられます。
複雑または注目すべきロジックを持つ CTE はファイル内にコメントし、dbt ドキュメントに記載してください。
複数のモデルで重複している CTE は独自のモデルに抽出してください。
集計
非集計カラムを最初に、集計カラムをその後に続ける形で SELECT 文のカラムを並べてください:
-- 推奨 SELECT customer_id, customer_name, order_date, product_category, COUNT(*) AS order_count, SUM(order_amount) AS total_amount, MAX(order_date) AS latest_order_date FROM orders -- 以下のいずれかを使用 GROUP BY customer_id, customer_name, order_date, product_category GROUP BY 1, 2, 3, 4 {{ dbt_utils.group_by(n=4) }} -- vs -- 非推奨 SELECT COUNT(*) AS order_count, customer_id, SUM(order_amount) AS total_amount, customer_name, MAX(order_date) AS latest_order_date, order_date, product_category FROM orders GROUP BY customer_id, customer_name, order_date, product_categorydbt モデルで
GROUP BY句を記述する際は、集計を明示的かつ意図的にするアプローチを優先してください。これは group_by dbt マクロ および明示的または位置参照のカラム参照を使用することで実現できます。集計ロジックの重要な変更を隠す可能性があるため GROUP BY ALL は避けてください。-- 推奨 SELECT customer_id, region, SUM(revenue) AS total_revenue FROM {{ ref('orders') }} -- 以下のいずれかを使用 {{ dbt_utils.group_by(n=2) }} GROUP BY customer_id, region GROUP BY 1, 2 -- vs -- 非推奨 SELECT customer_id, region, SUM(revenue) AS total_revenue FROM {{ ref('orders') }} GROUP BY ALL
データ型
デフォルトのデータ型を使用し、エイリアスは使用しないでください。詳細は Snowflake のデータ型サマリを参照してください。デフォルトは:
DECIMAL、NUMERIC、INTEGER、BIGINTなどの代わりにNUMBERDOUBLE、REALなどの代わりにFLOATSTRING、TEXTなどの代わりにVARCHARDATETIMEの代わりにTIMESTAMPこの例外はタイムスタンプです。
TIMEよりTIMESTAMPを優先してください。TIMESTAMPのデフォルトはTIMESTAMP_NTZでタイムゾーンを含みません。
論理データ型には
BOOLEANを優先:1/0の代わりにTRUE/FALSE。
関数
NVLよりIFNULLを優先してください。単一行の
CASE文よりIFFを優先してください:-- 推奨 SELECT IFF(column_1 = 'foo', column_2,column_3) AS logic_switch, ... -- vs -- 非推奨 SELECT CASE WHEN column_1 = 'foo' THEN column_2 ELSE column_3 END AS logic_switch, ...ブール文の選択には
IFFを優先してください:-- 推奨 SELECT IFF(amount < 10,TRUE,FALSE) AS is_less_than_ten, ... -- vs -- 非推奨 SELECT (amount < 10) AS is_less_than_ten, ...可能な場合は繰り返しの
CASE文を簡略化することを優先してください:-- 推奨 SELECT CASE field_id WHEN 1 THEN 'date' WHEN 2 THEN 'integer' WHEN 3 THEN 'currency' WHEN 4 THEN 'boolean' WHEN 5 THEN 'variant' WHEN 6 THEN 'text' END AS field_type, ... -- vs -- 非推奨 SELECT CASE WHEN field_id = 1 THEN 'date' WHEN field_id = 2 THEN 'integer' WHEN field_id = 3 THEN 'currency' WHEN field_id = 4 THEN 'boolean' WHEN field_id = 5 THEN 'variant' WHEN field_id = 6 THEN 'text' END AS field_type, ...date_partより明示的な日付関数を優先し、extractよりdate_partを優先してください:DAYOFWEEK(created_at) > DATE_PART(dayofweek, 'created_at') > EXTRACT(dow FROM created_at)DATEDIFF関数を使用する際は日付部分の間隔に注意してください。この関数は整数の間隔の結果のみを返します。
サンプルコード
このサンプルコードは SQLFluff リンターで処理され、スタイルガイドが適用されています。
WITH my_data AS (
SELECT my_data.*
FROM prod.my_data_with_a_long_table_name AS my_data
INNER JOIN prod.other_thing
WHERE my_data.filter = 'my_filter'
),
some_cte AS (
SELECT DISTINCT
id AS other_id,
other_field_1,
other_field_2,
date_field_at,
data_by_row,
field_4,
field_5,
LAG(
other_field_2
) OVER (PARTITION BY other_id, other_field_1 ORDER BY 5) AS previous_other_field_2
FROM prod.my_other_data
),
/*
This is a very long comment: It is good practice to leave comments in code to
explain complex logic in CTEs or business logic which may not be intuitive to
someone who does not have intimate knowledge of the data source. This can help
new users familiarize themselves with the code quickly.
*/
final AS (
SELECT
-- This is a singel line comment
my_data.field_1 AS detailed_field_1,
my_data.field_2 AS detailed_field_2,
my_data.detailed_field_3,
DATE_TRUNC('month', some_cte.date_field_at) AS date_field_month,
some_cte.data_by_row['id']::NUMBER AS id_field,
IFF(my_data.detailed_field_3 > my_data.field_2, TRUE, FALSE) AS is_boolean,
CASE
WHEN my_data.cancellation_date IS NULL
AND my_data.expiration_date IS NOT NULL
THEN my_data.expiration_date
WHEN my_data.cancellation_date IS NULL
THEN my_data.start_date + 7 -- There is a reason for this number
ELSE my_data.cancellation_date
END AS adjusted_cancellation_date,
COUNT(*) AS number_of_records,
SUM(some_cte.field_4) AS field_4_sum,
MAX(some_cte.field_5) AS field_5_max
FROM my_data
LEFT JOIN some_cte
ON my_data.id = some_cte.id
WHERE my_data.field_1 = 'abc'
AND (my_data.field_2 = 'def' OR my_data.field_2 = 'ghi')
GROUP BY 1, 2, 3, 4, 5, 6
HAVING COUNT(*) > 1
ORDER BY 8 DESC
)
SELECT *
FROM final
