長いSQLの1人実装を生成AIで解消しようとした
この記事は株式会社ガラパゴス(有志)アドベントカレンダー2024の1日目の記事です。
今年もアドベントカレンダーの時期がやって来ました!1年が終わるのは早いですね。
私は個人的なことを神頼みするタイプではないので初詣で「うちのチームにかわいい後輩が欲しい」みたいなお願いはしないのですが、個人的に今年は良い年でした。
残り短いですが、皆さまにとって良い2024年になりますように。
それでは本題に入ります。
背景
最近様々なデータを紐づけて、多種多様な条件を付けて分析できるようにデータを整形する作業が増えています。
似たような条件も多いので都度過去の実装を参照しながら必要な部分をコピペすることで時間短縮を図っていますが、大体新しい条件を取り扱う際に仕様を読み間違えたりSQLの実装でミスしたりしてチームに迷惑をかけています。
というわけであまり時間や人手をかけずに正しいかどうか確認する方法は無いものか、と考えていた際にこれまで個人的にあまり活用できていなかった生成AIの存在を思い出しました。という訳で、今回は生成AIを用いることでSQLの実装まわりをどのように改善できそうか検証してみます。
試したこと
- 元となるテーブルのスキーマと欲しいテータのスキーマを与え、それぞれの集計条件等も日本語で仕様として渡します。それらのインプットを元にSQLを生成AIに書いてもらいました。
- 作成したSQLに誤りがあるかどうかを確認し、問題があれば指摘して再度SQLを書き直してもらいました。これを何回か繰り返しました。
- またこちらでもサンプルでSQLを作成し、その内容が正しいかどうかを生成AIにレビューしてもらいました。
最初に渡したインプット
以下の条件で、テーブル1とテーブル2を紐づけるSQLを生成してください:
○テーブル1
- イベント名
- 利用規約確認か購入のいずれか
- タイムスタンプ(マイクロ秒)
- ユーザーID
○テーブル2
- メンバーID
- 確認日時(秒単位)
- 購入日時(秒単位)
- 購入アイテム
- 利用ポイント数
○欲しいデータ
- 独自形式のユーザーID
- メンバーID
- メンバーステータス
- 利用規約確認、購入経験あり、ポイント利用あり
- 初回確認日時
- 初回購入日時
- 初回ポイント利用日時
○テーブル1とテーブル2を紐づける条件
- 利用規約確認イベントのタイムスタンプと確認日時が最も近い
- 場合によってはテーブル1にイベントが登録されない可能性がある
- 購入イベントのタイムスタンプと購入日時が最も近い
- ただし購入アイテムに「かめ」を含むデータのみテーブル1のイベントとして登録される
- 利用規約確認の方が紐づく割合が高いため、購入イベントの紐づけは補助的に行う
- 1人のユーザーに対して2人以上のメンバーは紐づけない
- 1人のメンバーに対して2人以上のユーザーは紐づけない
○ステータス関連の条件
- 基本的に利用規約確認→購入→ポイント利用の順で遷移する
- 初回確認日時=テーブル1とテーブル2が紐づいた初回の確認日時とする
- 初回購入日時=初回確認日時以降で最も早い購入日時とする
- 初回ポイント利用日時=初回ポイント利用日時以降で最も早く、利用ポイント数が1以上となった購入日時とする
- メンバーステータス=初回確認日時があれば利用規約確認、また初回購入日時もあれば購入経験あり、さらに初回ポイント利用日時もあればポイント利用ありとする
実行結果
今回は2種類の生成AIにSQLを作成、レビューしてもらいました。それぞれ細かい点では特色が見られたものの、最初のアウトプットで既に概ね正しいものを生成してくれました。
Claude 3.5 Sonnet での結果
初回の出力
WITH
-- 利用規約確認イベントの抽出
terms_events AS (
SELECT
user_id,
timestamp / 1000000 as event_time_sec
FROM table1
WHERE event_name = '利用規約確認'
),
-- 購入イベントの抽出(かめ商品のみ)
purchase_events AS (
SELECT
user_id,
timestamp / 1000000 as event_time_sec
FROM table1
WHERE event_name = '購入'
),
-- テーブル2の各メンバーに対する最適なユーザーIDの紐づけ
user_member_mapping AS (
-- 利用規約確認での紐づけ
SELECT
t2.member_id,
te.user_id,
t2.confirm_time,
ABS(te.event_time_sec - t2.confirm_time) as time_diff,
ROW_NUMBER() OVER (PARTITION BY te.user_id ORDER BY ABS(te.event_time_sec - t2.confirm_time)) as user_rank,
ROW_NUMBER() OVER (PARTITION BY t2.member_id ORDER BY ABS(te.event_time_sec - t2.confirm_time)) as member_rank
FROM table2 t2
LEFT JOIN terms_events te
ON ABS(te.event_time_sec - t2.confirm_time) < 86400 -- 1日以内の差異を許容
UNION ALL
-- 購入での紐づけ(補助的)
SELECT
t2.member_id,
pe.user_id,
t2.confirm_time,
ABS(pe.event_time_sec - t2.purchase_time) as time_diff,
ROW_NUMBER() OVER (PARTITION BY pe.user_id ORDER BY ABS(pe.event_time_sec - t2.purchase_time)) as user_rank,
ROW_NUMBER() OVER (PARTITION BY t2.member_id ORDER BY ABS(pe.event_time_sec - t2.purchase_time)) as member_rank
FROM table2 t2
LEFT JOIN purchase_events pe
ON ABS(pe.event_time_sec - t2.purchase_time) < 86400 -- 1日以内の差異を許容
WHERE t2.purchase_item LIKE '%かめ%'
),
-- 1対1の紐づけを確定
final_mapping AS (
SELECT DISTINCT
member_id,
user_id
FROM user_member_mapping
WHERE user_rank = 1 AND member_rank = 1
),
-- メンバーの状態を分析
member_status AS (
SELECT
fm.user_id,
t2.member_id,
MIN(t2.confirm_time) as first_confirm_time,
MIN(CASE WHEN t2.confirm_time >= MIN(t2.confirm_time) THEN t2.purchase_time END) as first_purchase_time,
MIN(CASE
WHEN t2.confirm_time >= MIN(t2.confirm_time)
AND t2.points_used > 0
THEN t2.purchase_time
END) as first_points_time,
CASE
WHEN MIN(t2.confirm_time) IS NOT NULL AND
MIN(CASE WHEN t2.confirm_time >= MIN(t2.confirm_time) THEN t2.purchase_time END) IS NOT NULL AND
MIN(CASE WHEN t2.confirm_time >= MIN(t2.confirm_time) AND t2.points_used > 0 THEN t2.purchase_time END) IS NOT NULL
THEN 'ポイント利用あり'
WHEN MIN(t2.confirm_time) IS NOT NULL AND
MIN(CASE WHEN t2.confirm_time >= MIN(t2.confirm_time) THEN t2.purchase_time END) IS NOT NULL
THEN '購入経験あり'
WHEN MIN(t2.confirm_time) IS NOT NULL
THEN '利用規約確認'
END as member_status
FROM final_mapping fm
JOIN table2 t2 ON fm.member_id = t2.member_id
GROUP BY fm.user_id, t2.member_id
)
-- 最終的な結果を出力
SELECT
user_id,
member_id,
member_status,
first_confirm_time,
first_purchase_time,
first_points_time
FROM member_status
ORDER BY user_id;
このSQLの主なポイントを説明します:
1.イベントデータの正規化
- テーブル1のタイムスタンプをマイクロ秒から秒に変換
- 利用規約確認と購入イベントを分離して処理
2.ユーザーとメンバーの紐づけロジック
- 時間差の最小値を基準に紐づけ
- ROW_NUMBERを使用して1対1の関係を確保
- 利用規約確認を優先的に使用し、購入情報は補助的に使用
3.ステータス判定ロジック
- 利用規約確認→購入→ポイント利用の順序を考慮
- 各イベントの初回日時を正確に計算
- 条件に基づいて3段階のステータスを付与
4.時系列の考慮
- 初回確認日時以降の購入のみを考慮
- ポイント利用は購入と同時に発生するため購入日時を基準
このSQLで改善や調整が必要な点はありますか?例えば:
- 時間差の許容範囲(現在は1日)の調整
- ステータスの名称変更
- 追加で必要な条件
2回目の入力
このSQLの紐づけ部分を見ると、各イベントでは1人のユーザーに1人のメンバーを紐づけられています。しかしデータ全体で見た時にこの紐づけが異なるマッピングが発生する可能性があるので、ユーザーとメンバーが1対1の関係にならないと思われます
2回目の出力
ご指摘ありがとうございます。確かにその通りですね。利用規約確認イベントと購入イベントで別々に1対1のマッピングを行っているため、全体としては1対多や多対1の関係が発生してしまう可能性があります。この問題を修正しましょう。
WITH
-- 利用規約確認イベントの抽出
terms_events AS (
SELECT
user_id,
timestamp / 1000000 as event_time_sec
FROM table1
WHERE event_name = '利用規約確認'
),
-- 購入イベントの抽出(かめ商品のみ)
purchase_events AS (
SELECT
user_id,
timestamp / 1000000 as event_time_sec
FROM table1
WHERE event_name = '購入'
),
-- 全ての紐づけ候補を統合して優先順位付け
all_mappings AS (
-- 利用規約確認での紐づけ候補
SELECT
t2.member_id,
te.user_id,
ABS(te.event_time_sec - t2.confirm_time) as time_diff,
1 as mapping_type, -- 利用規約確認の方が優先度が高い
t2.confirm_time
FROM table2 t2
LEFT JOIN terms_events te
ON ABS(te.event_time_sec - t2.confirm_time) < 86400 -- 1日以内の差異を許容
WHERE te.user_id IS NOT NULL
UNION ALL
-- 購入での紐づけ候補
SELECT
t2.member_id,
pe.user_id,
ABS(pe.event_time_sec - t2.purchase_time) as time_diff,
2 as mapping_type, -- 購入の方が優先度が低い
t2.confirm_time
FROM table2 t2
LEFT JOIN purchase_events pe
ON ABS(pe.event_time_sec - t2.purchase_time) < 86400
WHERE t2.purchase_item LIKE '%かめ%'
AND pe.user_id IS NOT NULL
),
-- スコアリングとランク付け
scored_mappings AS (
SELECT
member_id,
user_id,
time_diff,
mapping_type,
confirm_time,
-- マッピングタイプと時間差を考慮したスコアを計算
-- 利用規約確認の方が優先度が高いため、mapping_typeに大きな重みを付ける
(mapping_type * 100000 + time_diff) as mapping_score,
-- 各ユーザーに対する最適なメンバーを選定
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY (mapping_type * 100000 + time_diff)) as user_rank,
-- 各メンバーに対する最適なユーザーを選定
ROW_NUMBER() OVER (PARTITION BY member_id ORDER BY (mapping_type * 100000 + time_diff)) as member_rank
FROM all_mappings
),
-- 相互に最適なマッピングのみを選択
final_mapping AS (
SELECT
member_id,
user_id
FROM scored_mappings
WHERE user_rank = 1 AND member_rank = 1
-- 同一ユーザーまたはメンバーが複数回出現した場合は除外
GROUP BY member_id, user_id
HAVING COUNT(*) = 1
),
-- メンバーの状態を分析
member_status AS (
SELECT
fm.user_id,
t2.member_id,
MIN(t2.confirm_time) as first_confirm_time,
MIN(CASE WHEN t2.confirm_time >= MIN(t2.confirm_time) THEN t2.purchase_time END) as first_purchase_time,
MIN(CASE
WHEN t2.confirm_time >= MIN(t2.confirm_time)
AND t2.points_used > 0
THEN t2.purchase_time
END) as first_points_time,
CASE
WHEN MIN(t2.confirm_time) IS NOT NULL AND
MIN(CASE WHEN t2.confirm_time >= MIN(t2.confirm_time) THEN t2.purchase_time END) IS NOT NULL AND
MIN(CASE WHEN t2.confirm_time >= MIN(t2.confirm_time) AND t2.points_used > 0 THEN t2.purchase_time END) IS NOT NULL
THEN 'ポイント利用あり'
WHEN MIN(t2.confirm_time) IS NOT NULL AND
MIN(CASE WHEN t2.confirm_time >= MIN(t2.confirm_time) THEN t2.purchase_time END) IS NOT NULL
THEN '購入経験あり'
WHEN MIN(t2.confirm_time) IS NOT NULL
THEN '利用規約確認'
END as member_status
FROM final_mapping fm
JOIN table2 t2 ON fm.member_id = t2.member_id
GROUP BY fm.user_id, t2.member_id
)
-- 最終的な結果を出力
SELECT
user_id,
member_id,
member_status,
first_confirm_time,
first_purchase_time,
first_points_time
FROM member_status
ORDER BY user_id;
主な変更点は以下の通りです:
1.マッピングロジックの統合
- 利用規約確認と購入イベントのマッピング候補を一つのCTEに統合
- mapping_typeで優先順位を付与(1=利用規約確認、2=購入)
2.スコアリングシステムの導入
- mapping_typeと時間差を組み合わせたスコアを計算
- 利用規約確認の方が優先されるよう、mapping_typeに大きな重みを付与
3.厳密な1対1マッピングの確保
- user_rankとmember_rankの両方が1のものだけを選択
- GROUP BYとHAVING句で重複マッピングを除外
4.優先順位の明確化
- mapping_typeに大きな重み(100000)を掛けることで、利用規約確認の優先度を確実に高く設定
これにより、データ全体で見たときに:
- 1人のユーザーが複数のメンバーと紐づくことを防止
- 1人のメンバーが複数のユーザーと紐づくことを防止
- 利用規約確認イベントでの紐づけを優先的に採用
改善点や調整が必要な点はありますか?例えば:
- スコアリングの重み付けの調整
- マッピングの優先順位の変更
- 時間差の許容範囲の調整
3回目の入力
同一ユーザーやメンバーが複数回発生した場合は除外するのではなく、時間が最も早いマッピングを採用するようにしてもらいたいです
3回目の出力
はい、承知しました。最も早い時間のマッピングを採用するように修正します。
WITH
-- 利用規約確認イベントの抽出
terms_events AS (
SELECT
user_id,
timestamp / 1000000 as event_time_sec
FROM table1
WHERE event_name = '利用規約確認'
),
-- 購入イベントの抽出(かめ商品のみ)
purchase_events AS (
SELECT
user_id,
timestamp / 1000000 as event_time_sec
FROM table1
WHERE event_name = '購入'
),
-- 全ての紐づけ候補を統合して優先順位付け
all_mappings AS (
-- 利用規約確認での紐づけ候補
SELECT
t2.member_id,
te.user_id,
ABS(te.event_time_sec - t2.confirm_time) as time_diff,
1 as mapping_type, -- 利用規約確認の方が優先度が高い
t2.confirm_time,
te.event_time_sec
FROM table2 t2
LEFT JOIN terms_events te
ON ABS(te.event_time_sec - t2.confirm_time) < 86400 -- 1日以内の差異を許容
WHERE te.user_id IS NOT NULL
UNION ALL
-- 購入での紐づけ候補
SELECT
t2.member_id,
pe.user_id,
ABS(pe.event_time_sec - t2.purchase_time) as time_diff,
2 as mapping_type, -- 購入の方が優先度が低い
t2.confirm_time,
pe.event_time_sec
FROM table2 t2
LEFT JOIN purchase_events pe
ON ABS(pe.event_time_sec - t2.purchase_time) < 86400
WHERE t2.purchase_item LIKE '%かめ%'
AND pe.user_id IS NOT NULL
),
-- スコアリングとランク付け
scored_mappings AS (
SELECT
member_id,
user_id,
time_diff,
mapping_type,
confirm_time,
event_time_sec,
-- マッピングタイプと時間差を考慮したスコアを計算
(mapping_type * 100000 + time_diff) as mapping_score,
-- 各ユーザーに対する最適なメンバーを選定(同一ユーザーの場合は最も早い時間を優先)
ROW_NUMBER() OVER (
PARTITION BY user_id
ORDER BY (mapping_type * 100000 + time_diff), event_time_sec
) as user_rank,
-- 各メンバーに対する最適なユーザーを選定(同一メンバーの場合は最も早い時間を優先)
ROW_NUMBER() OVER (
PARTITION BY member_id
ORDER BY (mapping_type * 100000 + time_diff), event_time_sec
) as member_rank
FROM all_mappings
),
-- 相互に最適なマッピングのみを選択
final_mapping AS (
SELECT
member_id,
user_id,
confirm_time
FROM scored_mappings
WHERE user_rank = 1 AND member_rank = 1
),
-- メンバーの状態を分析
member_status AS (
SELECT
fm.user_id,
t2.member_id,
MIN(t2.confirm_time) as first_confirm_time,
MIN(CASE WHEN t2.confirm_time >= MIN(t2.confirm_time) THEN t2.purchase_time END) as first_purchase_time,
MIN(CASE
WHEN t2.confirm_time >= MIN(t2.confirm_time)
AND t2.points_used > 0
THEN t2.purchase_time
END) as first_points_time,
CASE
WHEN MIN(t2.confirm_time) IS NOT NULL AND
MIN(CASE WHEN t2.confirm_time >= MIN(t2.confirm_time) THEN t2.purchase_time END) IS NOT NULL AND
MIN(CASE WHEN t2.confirm_time >= MIN(t2.confirm_time) AND t2.points_used > 0 THEN t2.purchase_time END) IS NOT NULL
THEN 'ポイント利用あり'
WHEN MIN(t2.confirm_time) IS NOT NULL AND
MIN(CASE WHEN t2.confirm_time >= MIN(t2.confirm_time) THEN t2.purchase_time END) IS NOT NULL
THEN '購入経験あり'
WHEN MIN(t2.confirm_time) IS NOT NULL
THEN '利用規約確認'
END as member_status
FROM final_mapping fm
JOIN table2 t2 ON fm.member_id = t2.member_id
GROUP BY fm.user_id, t2.member_id
)
-- 最終的な結果を出力
SELECT
user_id,
member_id,
member_status,
first_confirm_time,
first_purchase_time,
first_points_time
FROM member_status
ORDER BY user_id;
主な変更点は以下の通りです:
1.イベント時間の追加
- all_mappingsにevent_time_secを追加
- これにより、最も早い時間のマッピングを特定可能に
2.ROW_NUMBER()の順序条件の変更
- スコアが同じ場合は、event_time_secで並び替えるように変更
- ORDER BY (mapping_type * 100000 + time_diff), event_time_sec
3.HAVING句の削除
- 重複を除外する代わりに、最も早い時間のマッピングを採用
この変更により:
- 同一ユーザーが複数のマッピング候補を持つ場合、最も早い時間のものが採用される
- 同一メンバーが複数のマッピング候補を持つ場合、最も早い時間のものが採用される
- 依然として利用規約確認イベントが優先される(mapping_typeによる重み付けは維持)
調整が必要な点はありますか?例えば:
- 時間の優先順位の重み付け調整
- 別の時間軸の追加
- スコアリングロジックの変更
4回目の入力
ROW_NUMBER()でrankを計算するのはBigQueryではあまり計算効率が良くないと聞いたことがあります。ROW_NUBER()の代わりにARRAY_AGGでOFFSET(0)を取る方法で実装するとどうなりますか?
4回目の出力
はい、その通りです。BigQueryではARRAY_AGGとOFFSET(0)を使用する方が効率的な場合が多いです。その方法で実装し直してみましょう。
WITH
-- 利用規約確認イベントの抽出
terms_events AS (
SELECT
user_id,
timestamp / 1000000 as event_time_sec
FROM table1
WHERE event_name = '利用規約確認'
),
-- 購入イベントの抽出(かめ商品のみ)
purchase_events AS (
SELECT
user_id,
timestamp / 1000000 as event_time_sec
FROM table1
WHERE event_name = '購入'
),
-- 全ての紐づけ候補を統合
all_mappings AS (
-- 利用規約確認での紐づけ候補
SELECT
t2.member_id,
te.user_id,
ABS(te.event_time_sec - t2.confirm_time) as time_diff,
1 as mapping_type, -- 利用規約確認の方が優先度が高い
t2.confirm_time,
te.event_time_sec
FROM table2 t2
LEFT JOIN terms_events te
ON ABS(te.event_time_sec - t2.confirm_time) < 86400 -- 1日以内の差異を許容
WHERE te.user_id IS NOT NULL
UNION ALL
-- 購入での紐づけ候補
SELECT
t2.member_id,
pe.user_id,
ABS(pe.event_time_sec - t2.purchase_time) as time_diff,
2 as mapping_type, -- 購入の方が優先度が低い
t2.confirm_time,
pe.event_time_sec
FROM table2 t2
LEFT JOIN purchase_events pe
ON ABS(pe.event_time_sec - t2.purchase_time) < 86400
WHERE t2.purchase_item LIKE '%かめ%'
AND pe.user_id IS NOT NULL
),
-- ユーザーごとの最適なマッピング候補を選択
user_best_matches AS (
SELECT
user_id,
ARRAY_AGG(
STRUCT(member_id, time_diff, mapping_type, confirm_time, event_time_sec)
ORDER BY (mapping_type * 100000 + time_diff), event_time_sec
)[OFFSET(0)] as best_match
FROM all_mappings
GROUP BY user_id
),
-- メンバーごとの最適なマッピング候補を選択
member_best_matches AS (
SELECT
member_id,
ARRAY_AGG(
STRUCT(user_id, time_diff, mapping_type, confirm_time, event_time_sec)
ORDER BY (mapping_type * 100000 + time_diff), event_time_sec
)[OFFSET(0)] as best_match
FROM all_mappings
GROUP BY member_id
),
-- 相互に最適なマッピングのみを選択
final_mapping AS (
SELECT
ubm.user_id,
ubm.best_match.member_id as member_id,
ubm.best_match.confirm_time as confirm_time
FROM user_best_matches ubm
INNER JOIN member_best_matches mbm
ON ubm.user_id = mbm.best_match.user_id
AND ubm.best_match.member_id = mbm.member_id
),
-- メンバーの状態を分析
member_status AS (
SELECT
fm.user_id,
t2.member_id,
MIN(t2.confirm_time) as first_confirm_time,
MIN(CASE WHEN t2.confirm_time >= MIN(t2.confirm_time) THEN t2.purchase_time END) as first_purchase_time,
MIN(CASE
WHEN t2.confirm_time >= MIN(t2.confirm_time)
AND t2.points_used > 0
THEN t2.purchase_time
END) as first_points_time,
CASE
WHEN MIN(t2.confirm_time) IS NOT NULL AND
MIN(CASE WHEN t2.confirm_time >= MIN(t2.confirm_time) THEN t2.purchase_time END) IS NOT NULL AND
MIN(CASE WHEN t2.confirm_time >= MIN(t2.confirm_time) AND t2.points_used > 0 THEN t2.purchase_time END) IS NOT NULL
THEN 'ポイント利用あり'
WHEN MIN(t2.confirm_time) IS NOT NULL AND
MIN(CASE WHEN t2.confirm_time >= MIN(t2.confirm_time) THEN t2.purchase_time END) IS NOT NULL
THEN '購入経験あり'
WHEN MIN(t2.confirm_time) IS NOT NULL
THEN '利用規約確認'
END as member_status
FROM final_mapping fm
JOIN table2 t2 ON fm.member_id = t2.member_id
GROUP BY fm.user_id, t2.member_id
)
-- 最終的な結果を出力
SELECT
user_id,
member_id,
member_status,
first_confirm_time,
first_purchase_time,
first_points_time
FROM member_status
ORDER BY user_id;
主な変更点は以下の通りです:
1.ROW_NUMBER()の置き換え
- user_best_matchesとmember_best_matchesでARRAY_AGGとOFFSET(0)を使用
- STRUCTを使って必要な情報をまとめて保持
2.マッピングロジックの変更
- 各ユーザー/メンバーの最適なマッピングを別々のCTEで計算
- INNER JOINで相互に最適なマッピングを特定
この実装の利点:
- BigQueryでより効率的な実行が期待できる
- メモリ使用量が削減される可能性がある
- コードの意図がより明確になった
調整が必要な点はありますか?例えば:
- ARRAY_AGGの中のSTRUCTに含める項目の調整
- ソート条件の変更
- 結合条件の追加/変更
レビュー時の入力
私もこのテーマでSQLを実装しました。どのような問題があるか教えて下さい
WITH term_mapping AS (
SELECT
user_id,
timestamp,
member_id,
ABS(confirm_time - timestamp / 1000000) AS time_diff,
confirm_time,
FROM table1
INNER JOIN table2
ON ABS(confirm_time - timestamp / 1000000) < 60
WHERE event_name = '利用規約確認'
),
-- 時間差が最小の1件のみを取得
term_mapping_one AS (
SELECT t.*
FROM (
SELECT ARRAY_AGG(tm ORDER BY time_diff LIMIT 1)[OFFSET(0)] t
FROM (
SELECT ARRAY_AGG(tma ORDER BY time_diff) tm
FROM term_mapping tma
GROUP BY user_id, timestamp
)
GROUP BY member_id
)
),
-- ユーザーごとに最初の1件のみに絞る
term_mapping_one_user AS (
SELECT t.*
FROM (
SELECT ARRAY_AGG(tm ORDER BY timestamp LIMIT 1)[OFFSET(0)] t
FROM term_mapping_one tm
GROUP BY user_id
)
),
purchase_mapping AS (
SELECT
user_id,
timestamp,
member_id,
ABS(purchase_time - timestamp / 1000000) AS time_diff,
confirm_time
FROM table1
INNER JOIN table2
ON ABS(confirm_time - timestamp / 1000000) < 60
WHERE event_name = '購入'
),
-- 時間差が最小の1件のみを取得
purchase_mapping_one AS (
SELECT p.*
FROM (
SELECT ARRAY_AGG(pm ORDER BY time_diff LIMIT 1)[OFFSET(0)] p
FROM (
SELECT ARRAY_AGG(pma ORDER BY time_diff) pm
FROM purchase_mapping pma
GROUP BY user_id, timestamp
)
GROUP BY member_id
)
),
-- ユーザーごとに最初の1件のみに絞る
purchase_mapping_one_user AS (
SELECT p.*
FROM (
SELECT ARRAY_AGG(tm ORDER BY timestamp LIMIT 1)[OFFSET(0)] p
FROM purchase_mapping_one pm
GROUP BY user_id
)
),
-- 2つの条件を結合
merge_mappings AS (
SELECT
user_id,
COALESCE(t.timestamp, p.timestamp) AS timestamp,
COALESCE(t.member_id, p.member_id) AS member_id,
COALESCE(t.confirm_time, p.confirm_time) AS confirm_time
FROM term_mapping_one_user t
FULL OUTER JOIN purchase_mapping_one_user p
USING(user_id)
),
-- メンバーごとに最初の1件のみに絞る
merge_mappings_one AS (
SELECT m.*
FROM (
SELECT ARRAY_AGG(mm ORDER BY confirm_time LIMIT 1)[OFFSET(0)] m
FROM merge_mappings mm
GROUP BY member_id
)
),
-- テーブル2と紐づけ
table2_member AS (
SELECT
m.member_id,
m.user_id,
m.timestamp,
m.confirm_time,
t2.purchase_time,
t2.points_used
FROM merge_mappings_one m
LEFT JOIN table2 t2
USING(member_id)
WHERE m.confirm_time <= t2.purchase_time OR t2.purchase_time IS NULL
),
purchase_agg_table AS (
SELECT
member_id,
user_id,
timestamp,
confirm_time,
MIN(purchase_time) AS first_purchase_time
FROM table2_member
GROUP BY member_id, user_id, timestamp, confirm_time
)
points_agg_table AS (
SELECT
purchase_agg_table.*,
first_points_time
FROM purchase_agg_table
LEFT JOIN (
SELECT
member_id,
user_id,
timestamp,
confirm_time,
MIN(purchase_time) AS first_points_time
FROM table2_member
WHERE max_points_used > 0
GROUP BY member_id, user_id, timestamp, confirm_time
)
)
SELECT
member_id,
user_id,
timestamp,
confirm_time,
first_purchase_time,
first_points_time,
CASE WHEN first_points_time IS NOT NULL THEN "ポイント利用あり"
WHEN first_purchase_time IS NOT NULL THEN "購入経験あり"
ELSE "利用規約確認"
END AS member_status
FROM points_agg_table
レビュー時の出力
1.データの整合性に関する問題:
INNER JOIN table2
ON ABS(confirm_time - timestamp / 1000000) < 60
- 時間差の許容範囲が60秒と短すぎる可能性があります
- 許容範囲を広げることで、より多くの有効なマッピングを見つけられる可能性があります
2.購入イベントのフィルタリング:
WHERE event_name = '購入'
- 「かめ」を含む購入アイテムの条件が抜けています
- WHERE event_name = '購入' AND t2.purchase_item LIKE '%かめ%' とする必要があります
3.ポイント利用の判定:
WHERE max_points_used > 0
- max_points_used というカラムが定義されていません
- おそらく points_used の誤りと思われます
4.イベントの順序性:
WHERE m.confirm_time <= t2.purchase_time OR t2.purchase_time IS NULL
- これは良い実装ですが、ポイント利用に関しても同様の順序性チェックが必要かもしれません
5.冗長なGROUP BY:
GROUP BY member_id, user_id, timestamp, confirm_time
- 多くのカラムでグループ化していますが、member_idだけで十分な可能性があります
改善案を含めた修正版を提案させていただきます:
WITH term_mapping AS (
SELECT
user_id,
timestamp,
member_id,
ABS(confirm_time - timestamp / 1000000) AS time_diff,
confirm_time,
FROM table1
INNER JOIN table2
ON ABS(confirm_time - timestamp / 1000000) < 86400 -- 1日の許容範囲
WHERE event_name = '利用規約確認'
),
-- 時間差が最小の1件のみを取得
term_mapping_one AS (
SELECT t.*
FROM (
SELECT ARRAY_AGG(tm ORDER BY time_diff LIMIT 1)[OFFSET(0)] t
FROM (
SELECT ARRAY_AGG(tma ORDER BY time_diff) tm
FROM term_mapping tma
GROUP BY user_id, timestamp
)
GROUP BY member_id
)
),
-- ユーザーごとに最初の1件のみに絞る
term_mapping_one_user AS (
SELECT t.*
FROM (
SELECT ARRAY_AGG(tm ORDER BY timestamp LIMIT 1)[OFFSET(0)] t
FROM term_mapping_one tm
GROUP BY user_id
)
),
purchase_mapping AS (
SELECT
user_id,
timestamp,
member_id,
ABS(purchase_time - timestamp / 1000000) AS time_diff,
confirm_time
FROM table1
INNER JOIN table2
ON ABS(purchase_time - timestamp / 1000000) < 86400 -- 1日の許容範囲
WHERE event_name = '購入'
AND purchase_item LIKE '%かめ%' -- かめ商品の条件を追加
),
-- 以下のCTEは同様の構造で続く...
merge_mappings AS (
SELECT
COALESCE(t.user_id, p.user_id) as user_id, -- user_idのCOALESCEを追加
COALESCE(t.timestamp, p.timestamp) AS timestamp,
COALESCE(t.member_id, p.member_id) AS member_id,
COALESCE(t.confirm_time, p.confirm_time) AS confirm_time,
-- マッピングの種類を記録
CASE
WHEN t.user_id IS NOT NULL THEN 1 -- 利用規約確認
ELSE 2 -- 購入
END as mapping_type
FROM term_mapping_one_user t
FULL OUTER JOIN purchase_mapping_one_user p
USING(user_id)
),
-- メンバーごとに最初の1件のみに絞る(マッピング種類を考慮)
merge_mappings_one AS (
SELECT m.*
FROM (
SELECT ARRAY_AGG(mm ORDER BY mapping_type, confirm_time LIMIT 1)[OFFSET(0)] m
FROM merge_mappings mm
GROUP BY member_id
)
),
table2_member AS (
SELECT
m.member_id,
m.user_id,
m.timestamp,
t2.confirm_time,
t2.purchase_time,
t2.points_used
FROM merge_mappings_one m
LEFT JOIN table2 t2
ON m.member_id = t2.member_id
WHERE t2.confirm_time <= t2.purchase_time -- 順序性のチェック
OR t2.purchase_time IS NULL
),
member_status_summary AS (
SELECT
member_id,
user_id,
MIN(confirm_time) as first_confirm_time,
MIN(CASE WHEN confirm_time <= purchase_time THEN purchase_time END) as first_purchase_time,
MIN(CASE
WHEN confirm_time <= purchase_time
AND points_used > 0
THEN purchase_time
END) as first_points_time
FROM table2_member
GROUP BY member_id, user_id
)
SELECT
member_id,
user_id,
first_confirm_time,
first_purchase_time,
first_points_time,
CASE
WHEN first_points_time IS NOT NULL THEN 'ポイント利用あり'
WHEN first_purchase_time IS NOT NULL THEN '購入経験あり'
ELSE '利用規約確認'
END AS member_status
FROM member_status_summary
ORDER BY user_id, member_id
主な改善点:
- 時間の許容範囲を1日(86400秒)に拡大
- 購入イベントに「かめ」商品の条件を追加
- マッピングタイプを追加して利用規約確認を優先
- 集計ロジックを簡略化
- イベントの順序性チェックを強化
- 不要なカラムをGROUP BYから除外
これらの改善により:
- より多くの有効なマッピングを見つけられる
- データの整合性が向上
- パフォーマンスが改善
- コードの可読性が向上
いかがでしょうか?
ChatGPT 4o での結果
初回の出力
以下は、指定された条件に基づき、テーブル1(イベントデータ)とテーブル2(メンバーデータ)を紐づけ、必要なデータを抽出するSQLクエリです。
WITH Closest_Confirmation AS (
-- 利用規約確認イベントと確認日時を最も近いものに紐づける
SELECT
t1.user_id AS user_id,
t2.member_id AS member_id,
t2.confirmation_time AS confirmation_time,
ABS(UNIX_TIMESTAMP(t1.timestamp) - t2.confirmation_time) AS time_diff
FROM
table1 t1
INNER JOIN
table2 t2
ON
t1.event_name = '利用規約確認'
ORDER BY
user_id, member_id, time_diff
),
Ranked_Confirmation AS (
-- 各ユーザーごとに最も近い確認日時を選ぶ
SELECT
user_id,
member_id,
confirmation_time,
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY time_diff ASC) AS rank
FROM
Closest_Confirmation
),
Selected_Confirmation AS (
-- 上位1件のみを抽出
SELECT
user_id,
member_id,
confirmation_time
FROM
Ranked_Confirmation
WHERE
rank = 1
),
Closest_Purchase AS (
-- 購入イベントと購入日時を紐づける
SELECT
t1.user_id AS user_id,
t2.member_id AS member_id,
t2.purchase_time AS purchase_time,
ABS(UNIX_TIMESTAMP(t1.timestamp) - t2.purchase_time) AS time_diff
FROM
table1 t1
INNER JOIN
table2 t2
ON
t1.event_name = '購入'
AND t1.event_detail LIKE '%かめ%' -- 購入アイテムに「かめ」を含む条件
ORDER BY
user_id, member_id, time_diff
),
Ranked_Purchase AS (
-- 各ユーザーごとに最も近い購入日時を選ぶ
SELECT
user_id,
member_id,
purchase_time,
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY time_diff ASC) AS rank
FROM
Closest_Purchase
),
Selected_Purchase AS (
-- 上位1件のみを抽出
SELECT
user_id,
member_id,
purchase_time
FROM
Ranked_Purchase
WHERE
rank = 1
),
Member_Status AS (
-- 各メンバーの初回確認日時、初回購入日時、初回ポイント利用日時を計算
SELECT
t2.member_id,
MIN(sc.confirmation_time) AS first_confirmation_time,
MIN(CASE WHEN sp.purchase_time > sc.confirmation_time THEN sp.purchase_time END) AS first_purchase_time,
MIN(CASE
WHEN sp.purchase_time > sc.confirmation_time AND t2.points_used > 0
THEN sp.purchase_time
END) AS first_points_use_time,
CASE
WHEN MIN(sc.confirmation_time) IS NOT NULL THEN '利用規約確認'
|| CASE
WHEN MIN(CASE WHEN sp.purchase_time > sc.confirmation_time THEN sp.purchase_time END) IS NOT NULL
THEN '、購入経験あり'
END
|| CASE
WHEN MIN(CASE
WHEN sp.purchase_time > sc.confirmation_time AND t2.points_used > 0
THEN sp.purchase_time
END) IS NOT NULL
THEN '、ポイント利用あり'
END
ELSE 'なし'
END AS member_status
FROM
table2 t2
LEFT JOIN
Selected_Confirmation sc
ON
t2.member_id = sc.member_id
LEFT JOIN
Selected_Purchase sp
ON
t2.member_id = sp.member_id
GROUP BY
t2.member_id
)
-- 必要なデータを取得
SELECT
sc.user_id AS user_unique_id,
t2.member_id,
ms.member_status,
ms.first_confirmation_time,
ms.first_purchase_time,
ms.first_points_use_time
FROM
Member_Status ms
LEFT JOIN
Selected_Confirmation sc
ON
ms.member_id = sc.member_id
LEFT JOIN
table2 t2
ON
ms.member_id = t2.member_id;
クエリの説明
1.Closest_Confirmation: 利用規約確認イベントと確認日時が最も近いペアを計算。
2.Ranked_Confirmation: ROW_NUMBERを使ってユーザーごとに最も近い確認日時を順位付け。
3.Selected_Confirmation: 上位1件の確認日時を抽出。
4.Closest_Purchase: 購入イベントのうち、「かめ」を含む購入アイテムと購入日時が最も近いペアを計算。
5.Ranked_Purchase: 購入イベントを順位付け。
6.Selected_Purchase: 上位1件の購入イベントを抽出。
7.Member_Status: 初回確認日時、初回購入日時、初回ポイント利用日時を計算し、ステータスを判定。
8.最終SELECT: 必要なデータを取得して出力。
条件が複雑なため、テストと検証を行いながら調整してください!
2回目の入力
このSQLの紐づけ部分を見ると、各イベントでは1人のユーザーに1人のメンバーを紐づけられています。しかしメンバーでLEFT JOINする際にこの紐づけが異なるマッピングが発生する可能性があるので、ユーザーとメンバーが1対1の関係にならないと思われます
2回目の出力
ご指摘の通り、このクエリでは、利用規約確認と購入でそれぞれ独立して最も近いイベントを紐づけるため、1人のユーザーに1人のメンバーを厳密に制約できていません。この結果、同じユーザーが異なるイベントで異なるメンバーに紐づくケースが発生し、1対1の関係が崩れる可能性があります。
この問題を解決するには、以下の方法を採用し、ユーザーとメンバー間の一貫した1対1のマッピングを確保する必要があります。
修正版アプローチ
1.全イベントで1人のユーザーに1人のメンバーを紐づける:
- 利用規約確認イベントを主軸に最も近いメンバーを決定。
- そのメンバーとのみ紐づけて以降のイベントを評価。
2.一貫したマッピングの保持: - 利用規約確認イベントと紐づいたメンバーだけを利用して購入やポイント利用を評価。
- 以下は修正版のSQLです。
WITH Closest_Confirmation AS (
-- 利用規約確認イベントと確認日時を最も近いものに紐づける
SELECT
t1.user_id AS user_id,
t2.member_id AS member_id,
t2.confirmation_time AS confirmation_time,
ABS(UNIX_TIMESTAMP(t1.timestamp) - t2.confirmation_time) AS time_diff
FROM
table1 t1
INNER JOIN
table2 t2
ON
t1.event_name = '利用規約確認'
ORDER BY
user_id, member_id, time_diff
),
Ranked_Confirmation AS (
-- 各ユーザーごとに最も近い確認日時を選ぶ
SELECT
user_id,
member_id,
confirmation_time,
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY time_diff ASC) AS rank
FROM
Closest_Confirmation
),
Selected_Confirmation AS (
-- 上位1件のみを抽出
SELECT
user_id,
member_id,
confirmation_time
FROM
Ranked_Confirmation
WHERE
rank = 1
),
Purchases_With_Mapping AS (
-- 利用規約確認で紐づけたメンバーのみを使い、購入イベントを評価
SELECT
sc.user_id AS user_id,
sc.member_id AS member_id,
t2.purchase_time AS purchase_time,
ABS(UNIX_TIMESTAMP(t1.timestamp) - t2.purchase_time) AS time_diff
FROM
Selected_Confirmation sc
INNER JOIN
table1 t1
ON
sc.user_id = t1.user_id AND t1.event_name = '購入' AND t1.event_detail LIKE '%かめ%'
INNER JOIN
table2 t2
ON
sc.member_id = t2.member_id
ORDER BY
user_id, purchase_time, time_diff
),
Ranked_Purchase AS (
-- 各ユーザーごとに最も近い購入日時を選ぶ
SELECT
user_id,
member_id,
purchase_time,
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY time_diff ASC) AS rank
FROM
Purchases_With_Mapping
),
Selected_Purchase AS (
-- 上位1件のみを抽出
SELECT
user_id,
member_id,
purchase_time
FROM
Ranked_Purchase
WHERE
rank = 1
),
Member_Status AS (
-- 各メンバーの初回確認日時、初回購入日時、初回ポイント利用日時を計算
SELECT
sc.member_id,
sc.confirmation_time AS first_confirmation_time,
sp.purchase_time AS first_purchase_time,
MIN(CASE
WHEN sp.purchase_time > sc.confirmation_time AND t2.points_used > 0
THEN sp.purchase_time
END) AS first_points_use_time,
CASE
WHEN sc.confirmation_time IS NOT NULL THEN '利用規約確認'
|| CASE
WHEN sp.purchase_time IS NOT NULL THEN '、購入経験あり'
END
|| CASE
WHEN MIN(CASE
WHEN sp.purchase_time > sc.confirmation_time AND t2.points_used > 0
THEN sp.purchase_time
END) IS NOT NULL THEN '、ポイント利用あり'
END
ELSE 'なし'
END AS member_status
FROM
Selected_Confirmation sc
LEFT JOIN
Selected_Purchase sp
ON
sc.member_id = sp.member_id
LEFT JOIN
table2 t2
ON
sc.member_id = t2.member_id
GROUP BY
sc.member_id, sc.confirmation_time, sp.purchase_time
)
-- 必要なデータを取得
SELECT
sc.user_id AS user_unique_id,
ms.member_id,
ms.member_status,
ms.first_confirmation_time,
ms.first_purchase_time,
ms.first_points_use_time
FROM
Member_Status ms
LEFT JOIN
Selected_Confirmation sc
ON
ms.member_id = sc.member_id;
修正ポイント
1.Selected_ConfirmationのメンバーIDを基準に、そのメンバーのみで後続イベントを評価します。
2.これにより、購入イベントとポイント利用は一致したメンバーIDに限定され、ユーザーとメンバーが1対1の関係を保持します。
3.最後のMember_Statusで、ユーザーのステータスと初回イベント情報を算出しています。
この修正版SQLは、要件に基づいてユーザーとメンバーの一貫した関係性を確保します。
3回目の入力
このロジックだと利用規約確認イベントで紐づかないユーザは購入イベントでも紐づかないです。必ずしもテーブル1に利用規約確認イベントが登録されない可能性もあるので、購入イベントのみでも紐づくようにしてもらいたいです
3回目の出力
確かにその点は考慮が不足していました。利用規約確認イベントが登録されていない場合でも購入イベントのみで紐づけを行えるように修正します。この修正版では、次のようにロジックを調整します:
修正ポイント
1.購入イベントで独立して紐づけを行う:
- 利用規約確認イベントがない場合でも、購入イベントで紐づけを試みます。
2.全体の紐づけロジックを統一:
- 利用規約確認イベントがある場合はそのメンバーを基準に購入イベントを紐づけ。
- 利用規約確認イベントがない場合は購入イベントを単独で紐づけ。
以下は修正版のSQLです。
WITH Closest_Confirmation AS (
-- 利用規約確認イベントと確認日時を最も近いものに紐づける
SELECT
t1.user_id AS user_id,
t2.member_id AS member_id,
t2.confirmation_time AS confirmation_time,
ABS(UNIX_TIMESTAMP(t1.timestamp) - t2.confirmation_time) AS time_diff
FROM
table1 t1
INNER JOIN
table2 t2
ON
t1.event_name = '利用規約確認'
ORDER BY
user_id, member_id, time_diff
),
Ranked_Confirmation AS (
-- 各ユーザーごとに最も近い確認日時を選ぶ
SELECT
user_id,
member_id,
confirmation_time,
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY time_diff ASC) AS rank
FROM
Closest_Confirmation
),
Selected_Confirmation AS (
-- 上位1件のみを抽出
SELECT
user_id,
member_id,
confirmation_time
FROM
Ranked_Confirmation
WHERE
rank = 1
),
Closest_Purchase AS (
-- 購入イベントを紐づけ(利用規約確認がない場合でも考慮)
SELECT
t1.user_id AS user_id,
t2.member_id AS member_id,
t2.purchase_time AS purchase_time,
ABS(UNIX_TIMESTAMP(t1.timestamp) - t2.purchase_time) AS time_diff
FROM
table1 t1
INNER JOIN
table2 t2
ON
t1.event_name = '購入'
AND t1.event_detail LIKE '%かめ%'
ORDER BY
user_id, member_id, time_diff
),
Ranked_Purchase AS (
-- 各ユーザーごとに最も近い購入日時を選ぶ
SELECT
user_id,
member_id,
purchase_time,
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY time_diff ASC) AS rank
FROM
Closest_Purchase
),
Selected_Purchase AS (
-- 上位1件のみを抽出
SELECT
user_id,
member_id,
purchase_time
FROM
Ranked_Purchase
WHERE
rank = 1
),
Mapped_Members AS (
-- 利用規約確認イベントまたは購入イベントで紐づいたメンバーを集約
SELECT
COALESCE(sc.user_id, sp.user_id) AS user_id,
COALESCE(sc.member_id, sp.member_id) AS member_id,
sc.confirmation_time,
sp.purchase_time
FROM
Selected_Confirmation sc
FULL OUTER JOIN
Selected_Purchase sp
ON
sc.user_id = sp.user_id
),
Member_Status AS (
-- 各メンバーの初回確認日時、初回購入日時、初回ポイント利用日時を計算
SELECT
mm.member_id,
MIN(mm.confirmation_time) AS first_confirmation_time,
MIN(mm.purchase_time) AS first_purchase_time,
MIN(CASE
WHEN t2.points_used > 0 THEN mm.purchase_time
END) AS first_points_use_time,
CASE
WHEN MIN(mm.confirmation_time) IS NOT NULL THEN '利用規約確認'
|| CASE
WHEN MIN(mm.purchase_time) IS NOT NULL THEN '、購入経験あり'
END
|| CASE
WHEN MIN(CASE
WHEN t2.points_used > 0 THEN mm.purchase_time
END) IS NOT NULL THEN '、ポイント利用あり'
END
ELSE 'なし'
END AS member_status
FROM
Mapped_Members mm
LEFT JOIN
table2 t2
ON
mm.member_id = t2.member_id
GROUP BY
mm.member_id
)
-- 必要なデータを取得
SELECT
mm.user_id AS user_unique_id,
ms.member_id,
ms.member_status,
ms.first_confirmation_time,
ms.first_purchase_time,
ms.first_points_use_time
FROM
Member_Status ms
LEFT JOIN
Mapped_Members mm
ON
ms.member_id = mm.member_id;
修正版の特徴
1.Mapped_Membersで、利用規約確認イベントが紐づかない場合でも購入イベントでの紐づけを考慮します。
2.FULL OUTER JOINを使用して、利用規約確認または購入イベントのどちらかで紐づいたデータを集約します。
3.必要に応じて確認イベントと購入イベントを統一的に扱います。
これにより、利用規約確認が登録されていないユーザーも購入イベントを基に紐づけられます。
4回目の入力
ROW_NUMBER()でrankを計算するのはBigQueryではあまり計算効率が良くないと聞いたことがあります。ROW_NUBER()の代わりにARRAY_AGGでOFFSET(0)を取る方法で実装するとどうなりますか?
4回目の出力
BigQueryでは、ROW_NUMBER()を使ったランキングの代わりに、ARRAY_AGG()を使用して最小値や特定の条件に基づく1件を抽出する方法が一般的です。このアプローチは計算効率が良いだけでなく、クエリ全体の構造を簡潔に保てる利点があります。
以下に、ROW_NUMBER()をARRAY_AGG()に置き換えたSQLの例を示します。
WITH Closest_Confirmation AS (
-- 利用規約確認イベントと確認日時を最も近いものに紐づける
SELECT
t1.user_id AS user_id,
t2.member_id AS member_id,
t2.confirmation_time AS confirmation_time,
ABS(UNIX_TIMESTAMP(t1.timestamp) - t2.confirmation_time) AS time_diff
FROM
table1 t1
INNER JOIN
table2 t2
ON
t1.event_name = '利用規約確認'
),
Selected_Confirmation AS (
-- 各ユーザーごとに最も近い確認日時を持つメンバーを選択
SELECT
user_id,
ARRAY_AGG(STRUCT(member_id, confirmation_time) ORDER BY time_diff ASC LIMIT 1)[OFFSET(0)].member_id AS member_id,
ARRAY_AGG(STRUCT(member_id, confirmation_time) ORDER BY time_diff ASC LIMIT 1)[OFFSET(0)].confirmation_time AS confirmation_time
FROM
Closest_Confirmation
GROUP BY
user_id
),
Closest_Purchase AS (
-- 購入イベントを紐づけ(利用規約確認がない場合でも考慮)
SELECT
t1.user_id AS user_id,
t2.member_id AS member_id,
t2.purchase_time AS purchase_time,
ABS(UNIX_TIMESTAMP(t1.timestamp) - t2.purchase_time) AS time_diff
FROM
table1 t1
INNER JOIN
table2 t2
ON
t1.event_name = '購入'
AND t1.event_detail LIKE '%かめ%'
),
Selected_Purchase AS (
-- 各ユーザーごとに最も近い購入日時を持つメンバーを選択
SELECT
user_id,
ARRAY_AGG(STRUCT(member_id, purchase_time) ORDER BY time_diff ASC LIMIT 1)[OFFSET(0)].member_id AS member_id,
ARRAY_AGG(STRUCT(member_id, purchase_time) ORDER BY time_diff ASC LIMIT 1)[OFFSET(0)].purchase_time AS purchase_time
FROM
Closest_Purchase
GROUP BY
user_id
),
Mapped_Members AS (
-- 利用規約確認イベントまたは購入イベントで紐づいたメンバーを集約
SELECT
COALESCE(sc.user_id, sp.user_id) AS user_id,
COALESCE(sc.member_id, sp.member_id) AS member_id,
sc.confirmation_time,
sp.purchase_time
FROM
Selected_Confirmation sc
FULL OUTER JOIN
Selected_Purchase sp
ON
sc.user_id = sp.user_id
),
Member_Status AS (
-- 各メンバーの初回確認日時、初回購入日時、初回ポイント利用日時を計算
SELECT
mm.member_id,
MIN(mm.confirmation_time) AS first_confirmation_time,
MIN(mm.purchase_time) AS first_purchase_time,
MIN(CASE
WHEN t2.points_used > 0 THEN mm.purchase_time
END) AS first_points_use_time,
CASE
WHEN MIN(mm.confirmation_time) IS NOT NULL THEN '利用規約確認'
|| CASE
WHEN MIN(mm.purchase_time) IS NOT NULL THEN '、購入経験あり'
END
|| CASE
WHEN MIN(CASE
WHEN t2.points_used > 0 THEN mm.purchase_time
END) IS NOT NULL THEN '、ポイント利用あり'
END
ELSE 'なし'
END AS member_status
FROM
Mapped_Members mm
LEFT JOIN
table2 t2
ON
mm.member_id = t2.member_id
GROUP BY
mm.member_id
)
-- 必要なデータを取得
SELECT
mm.user_id AS user_unique_id,
ms.member_id,
ms.member_status,
ms.first_confirmation_time,
ms.first_purchase_time,
ms.first_points_use_time
FROM
Member_Status ms
LEFT JOIN
Mapped_Members mm
ON
ms.member_id = mm.member_id;
主な変更点と説明
1.ARRAY_AGGで最小値を取得:
- ARRAY_AGG(STRUCT(...)) ORDER BY time_diff ASC LIMIT 1で、各ユーザーの最も近いメンバーやイベントを1件取得します。
- OFFSET(0)でリストから最初の要素を取り出します。
2.GROUP BYで効率化:
- ユーザー単位でグループ化して最小値を計算するため、計算負荷が分散されます。
3.効率的なデータ処理:
- ROW_NUMBER()を使わず、BigQueryのネイティブなARRAY_AGGを用いることで、クエリ実行速度が向上します。
この修正版では、ROW_NUMBER()の代替として効率的かつ簡潔にイベントの最小値を取得できます。BigQuery特有の最適化手法を活用する形になっていますので、実行速度が向上することが期待されます。
レビュー時の入力
私もこのテーマでSQLを実装しました。どのような問題があるか教えて下さい
WITH confirmation_mapping AS (
SELECT
user_id,
timestamp,
member_id,
ABS(confirm_time - timestamp / 1000000) AS time_diff,
confirm_time,
FROM table1
INNER JOIN table2
ON ABS(confirm_time - timestamp / 1000000) < 60
WHERE event_name = '利用規約確認'
),
-- 時間差が最小の1件のみを取得
confirmation_mapping_one AS (
SELECT c.*
FROM (
SELECT ARRAY_AGG(cm ORDER BY time_diff LIMIT 1)[OFFSET(0)] c
FROM (
SELECT ARRAY_AGG(cma ORDER BY time_diff) cm
FROM confirmation_mapping cms
GROUP BY user_id, timestamp
)
GROUP BY member_id
)
),
-- ユーザーごとに最初の1件のみに絞る
confirmation_one_user AS (
SELECT c.*
FROM (
SELECT ARRAY_AGG(cm ORDER BY timestamp LIMIT 1)[OFFSET(0)] c
FROM confirmation_mapping_one cm
GROUP BY user_id
)
),
-- 購入イベントと紐づけ
purchase_mapping AS (
SELECT
user_id,
timestamp,
member_id,
ABS(purchase_time - timestamp / 1000000) AS time_diff,
confirm_time
FROM table1
INNER JOIN table2
ON ABS(confirm_time - timestamp / 1000000) < 60
WHERE event_name = '購入'
),
-- 時間差が最小の1件のみを取得
purchase_mapping_one AS (
SELECT p.*
FROM (
SELECT ARRAY_AGG(pm ORDER BY time_diff LIMIT 1)[OFFSET(0)] p
FROM (
SELECT ARRAY_AGG(pma ORDER BY time_diff) pm
FROM purchase_mapping pma
GROUP BY user_id, timestamp
)
GROUP BY member_id
)
),
-- ユーザーごとに最初の1件のみに絞る
purchase_mapping_one_user AS (
SELECT p.*
FROM (
SELECT ARRAY_AGG(tm ORDER BY timestamp LIMIT 1)[OFFSET(0)] p
FROM purchase_mapping_one pm
GROUP BY user_id
)
),
-- 2つの条件を結合
merge_mappings AS (
SELECT
user_id,
COALESCE(c.timestamp, p.timestamp) AS timestamp,
COALESCE(c.member_id, p.member_id) AS member_id,
COALESCE(c.confirm_time, p.confirm_time) AS confirm_time
FROM confirmation_one_user c
FULL OUTER JOIN purchase_mapping_one_user p
USING(user_id)
),
-- メンバーごとに最初の1件のみに絞る
merge_mappings_one AS (
SELECT m.*
FROM (
SELECT ARRAY_AGG(mm ORDER BY confirm_time LIMIT 1)[OFFSET(0)] m
FROM merge_mappings mm
GROUP BY member_id
)
),
-- テーブル2と紐づけ
table2_member AS (
SELECT
m.member_id,
m.user_id,
m.timestamp,
m.confirm_time,
t2.purchase_time,
t2.points_used
FROM merge_mappings_one m
LEFT JOIN table2 t2
USING(member_id)
WHERE m.confirm_time <= t2.purchase_time OR t2.purchase_time IS NULL
),
purchase_agg_table AS (
SELECT
member_id,
user_id,
timestamp,
confirm_time,
MIN(purchase_time) AS first_purchase_time
FROM table2_member
GROUP BY member_id, user_id, timestamp, confirm_time
)
points_agg_table AS (
SELECT
purchase_agg_table.*,
first_points_time
FROM purchase_agg_table
LEFT JOIN (
SELECT
member_id,
user_id,
timestamp,
confirm_time,
MIN(purchase_time) AS first_points_time
FROM table2_member
WHERE max_points_used > 0
GROUP BY member_id, user_id, timestamp, confirm_time
)
)
SELECT
member_id,
user_id,
timestamp,
confirm_time,
first_purchase_time,
first_points_time,
CASE WHEN first_points_time IS NOT NULL THEN "ポイント利用あり"
WHEN first_purchase_time IS NOT NULL THEN "購入経験あり"
ELSE "利用規約確認"
END AS member_status
FROM points_agg_table
レビュー時の出力
あなたが提示したSQLには、多くの論理が適切に組み込まれていますが、いくつかの潜在的な問題点や改善点があります。以下に詳しく説明します。
問題点と改善点
- 複雑でネストが深いクエリ
クエリのネストが非常に深く、可読性が低下しています。
ARRAY_AGGを使ったネストが多重に使用されており、構造が理解しづらい。
例: confirmation_mapping_oneのサブクエリでARRAY_AGGを二重に使う部分は冗長です。
改善案:
必要以上にネストせず、WITH句を増やしてステップごとに分割する。
各ステップで計算されたデータを再利用する形に整理する。
2. 同じロジックの重複
confirmation_mappingとpurchase_mappingでほぼ同じ処理を行っています。
繰り返しのコードがあると、メンテナンス性が低下します。
改善案:
event_nameを変数化するか、動的に扱うことで共通化する。
たとえば、一度に全イベントを対象にして後からフィルタリングする方法を検討できます。
3. 結合条件の潜在的な欠陥
ABS(confirm_time - timestamp / 1000000) < 60の条件は、時間差が秒単位で60秒以内のデータを結合していますが、最も近いデータを取得するロジックが完全ではない可能性があります。
結合条件だけでデータが複数件返される場合、意図せず同一ユーザーが複数メンバーに紐づけられるリスクがあります。
改善案:
まず、全候補を取得し、その中から時間差が最小のものを選ぶ明確なロジックに変更します。
条件付き結合での曖昧さを避けるため、ARRAY_AGGで直接最小値を取得する方法を使います。
4. 非効率なグループ化の使い方
サブクエリ内で頻繁にGROUP BYとARRAY_AGGを繰り返し使用しています。
特にGROUP BY user_id, timestampなどが何度も登場しており、計算が重複している可能性があります。
改善案:
最初のconfirmation_mappingやpurchase_mappingで最小値を計算し、以降の処理では再利用する形に整理します。
5. フルアウトER結合の使い方
merge_mappingsでFULL OUTER JOINを使用していますが、このステップは無駄な行を生む可能性があります。
confirmation_one_userとpurchase_mapping_one_userがそれぞれの段階で一意な行を選択している場合、FULL OUTER JOINは冗長です。
改善案:
LEFT JOINかUNIONを適切に使用して、結合ロジックを簡潔にする。
6. ポイント利用日時の取得ロジックの曖昧さ
ポイント利用日時(first_points_time)の取得ロジックにおいて、max_points_used > 0の条件を使用していますが、明確ではありません。
条件が適切に評価されない場合、誤ったデータが返される可能性があります。
改善案:
ポイント利用の条件を明確に定義し、可能であれば事前にフィルタリングを行います。
7. 可読性を意識したリネーミング
merge_mappings_oneなど、一部のテーブル名が具体的でないため可読性が低いです。
テーブル名や列名を見ただけで意味が分かるようにするべきです。
改善案:
テーブル名を意味のある名前に変更する(例: mapped_events、final_resultsなど)。
改善後のクエリ例(部分)
以下は、改善案を取り入れた一部のサンプルクエリです。
WITH Events_Mapping AS (
SELECT
t1.user_id,
t1.timestamp,
t2.member_id,
t2.confirm_time,
t2.purchase_time,
t2.points_used,
t1.event_name,
ABS(t2.confirm_time - t1.timestamp / 1000000) AS confirm_time_diff,
ABS(t2.purchase_time - t1.timestamp / 1000000) AS purchase_time_diff
FROM table1 t1
LEFT JOIN table2 t2
ON ABS(t2.confirm_time - t1.timestamp / 1000000) < 60
OR ABS(t2.purchase_time - t1.timestamp / 1000000) < 60
),
Ranked_Events AS (
SELECT
user_id,
member_id,
event_name,
MIN(IF(event_name = '利用規約確認', confirm_time_diff, purchase_time_diff)) AS min_time_diff,
MIN(confirm_time) AS first_confirm_time,
MIN(purchase_time) AS first_purchase_time,
MIN(CASE WHEN points_used > 0 THEN purchase_time END) AS first_points_time
FROM Events_Mapping
GROUP BY user_id, member_id, event_name
),
Final_Results AS (
SELECT
user_id,
member_id,
first_confirm_time,
first_purchase_time,
first_points_time,
CASE
WHEN first_points_time IS NOT NULL THEN "ポイント利用あり"
WHEN first_purchase_time IS NOT NULL THEN "購入経験あり"
ELSE "利用規約確認"
END AS member_status
FROM Ranked_Events
)
SELECT * FROM Final_Results;
このクエリの特徴
可読性を向上し、ネストを浅く整理。
GROUP BYを最小化し、効率的に最小値を取得。
ロジックを統一化して冗長なコードを削除。
必要であれば、さらに詳細な改善を加えることが可能です。
やってみた感想
- 当たり前ですが最初にチャットでの質問から始まるので、その前に仕様をちゃんと決める必要があります。見切り発車でも気付かずに進み後で認識が誤っていたということもよくあるのですが、生成AIを用いる際はより注意深く意識する必要があります。
- 自力でSQLを書きたくない気分の場合は代わりに書いてもらうことも可能に思えました。ただし人力でのレビューは必要なので他人のSQLを読みたい気分の時に向いていそうです。SQLを読みたくも書きたくも無いときはそもそもこのような作業自体が向いてないですね。
- 個人的にはChatGPTの書いたSQLの方がClaudeよりも読みやすく感じましたが、問題設定やモデルの進化によっても変わるかもしれないです。ちなみにGeminiは長文のSQLを自分で書きたくない雰囲気がプンプンしていたので、専用のやる気スイッチ(ならぬスクリプト)が必要な気がしました。
- 今回は検証のために2種類の生成AIを試しながら比較しましたが、レビューの観点とダメ出し数が2倍になったように思えました。そのため品質を高めたい場合と寂しくて孤独な時には有用です。一方でスピード重視のときやコミュニケーションに疲れているシーンでは1つでも十分です。
- 会話する度にSQLを生成し直してくれるのですが、どの部分が変わったか差分が見づらいと感じました。クエリに添えて変更点を日本語で説明してくれるのでそこから読み解けるのですが、GitHubのchangesのような見た目になってほしいですね。
今後試したいこと
そもそも仕様を言語化するタイミングで間違えると手戻りも大きくなり辛いです。なので言語化の部分を生成AIで試すのもアリだと思い、例えば元のデータセットと欲しいデータだけ示して仕様を言語化するという実験も面白そうに思えました。まだアドベントカレンダーは始まったばかりなので、もし機会があったら2025年あたりに書くかもしれないです〜
Discussion