SQL データ整形・変換 — LPAD/CONCAT_WS・REGEXPの応用

応用変換関数LPAD / CONCAT_WSAGE / INTERVALCASE + GREATESTREGEXP_MATCHPostgreSQL対応全5問
1 / 5 · 学習中 0 / 5 問完了
QUESTION 6

LPAD / RPAD / CONCAT_WS — ゼロ埋め・固定長整形で帳票コードを生成する

LPAD/RPADCONCAT_WS帳票生成NULL安全結合
前提知識

文字列を固定長にパディング(埋め込み)する関数と、NULL安全な文字列連結関数です。

LPAD('5', 6, '0')                        -- → '000005'    左に '0' を補填して6文字に
LPAD('ABCDEFGH', 6, '0')                -- → 'ABCDEF'    元が指定長を超えると右側を切り捨てる
RPAD('FOOD', 8, '.')                     -- → 'FOOD....'   右に '.' を補填して8文字に
UPPER('tokyo')                            -- → 'TOKYO'      全て大文字
CONCAT_WS('-', '2024', 'TKY', '000005')  -- → '2024-TKY-000005'  区切り文字で連結
CONCAT_WS('-', '2024', NULL, '000005')   -- → '2024-000005'      NULL はスキップ ✓
CONCAT('2024', '-', NULL, '-', '000005')   -- → '2024--000005'      NULL は無視するが区切りが重複 ✗
CONCAT_WS の強み:CONCAT_WS(区切り文字, a, b, c) は NULL 引数を自動スキップし、残った値の間だけに区切り文字を挿入します。通常の CONCAT も NULL を無視しますが、区切り文字を個別の引数として渡すと '2024--000005' のように区切りが重複します。実データには NULL が混在することが多いため、区切り付きの文字列連結では CONCAT_WS を優先しましょう。
問題

invoices テーブルから以下の列を生成してください。
① padded_seq: seq_no(整数)を6桁ゼロ埋めしたシーケンスコード
② invoice_no: {fiscal_year}-{UPPER(branch_code)}-{padded_seq} 形式の帳票番号
③ display_label: customer_name を右側ドット埋めで16文字に整形したラベル

使用テーブル
▸ invoices
invoice_idfiscal_yearbranch_codeseq_nocustomer_name
12024tky5Alpine Cafe
22024osk123Tech Corp
32024tky42Garden Shop
42025ngy1Book World
期待出力
invoice_idpadded_seqinvoice_nodisplay_label
10000052024-TKY-000005Alpine Cafe.....
20001232024-OSK-000123Tech Corp.......
30000422024-TKY-000042Garden Shop.....
40000012025-NGY-000001Book World......
QUESTION 7

AGE / INTERVAL / NOW() — 有効期限・残日数・更新日をリアルタイムで算出する

AGE/INTERVALNOW()日付算術有効期限管理
前提知識

現在日時を基準にした期間計算に使う関数群です。

DATE '2024-12-15'                              -- 再現可能な固定基準日(DATE型)
NOW()                                            -- → '2024-12-15 09:30+09'  現在日時(TIMESTAMP WITH TIME ZONE)

-- DATE 同士の引き算 → INTEGER (日数)
'2025-01-14'::DATE - '2024-12-15'::DATE       -- → 30  (整数・合計日数)

-- AGE → INTERVAL (人間が読みやすい相対表示)
AGE('2025-11-30'::DATE, '2024-12-15'::DATE)  -- → '11 mons 15 days'(合計日数≠350!)
EXTRACT(DAY FROM AGE(...))                    -- → 15  ← 日成分のみ!合計日数ではない

-- INTERVAL 加算・減算で未来/過去の日付を計算
'2025-01-14'::DATE + INTERVAL '1 year'       -- → '2026-01-14 00:00:00'
NOW() - INTERVAL '30 days'                      -- → 30日前の日時
落とし穴 — EXTRACT(DAY FROM AGE(...)) は合計日数ではない:AGE('2025-11-30', '2024-12-15')'11 mons 15 days' という INTERVAL を返します。EXTRACT(DAY FROM ...) を適用すると 15(日の成分のみ)が返り、合計日数の 350 にはなりません。合計日数の整数が必要なときは expires_at::DATE - DATE '2024-12-15' を使いましょう。
問題

subscriptions テーブルから、DATE '2024-12-15' を基準にして以下を算出してください(出力例は 2024-12-15 基準)。
① days_remaining: 有効期限までの残日数(期限切れは負の値)
② status: '期限切れ' / '期限間近'(30日以内)/ '有効' の3段階ラベル
③ renewal_at: 次回更新予定日(現在の有効期限 + 1年)を 'YYYY-MM-DD' 形式で
days_remaining の昇順で出力してください。

使用テーブル
▸ subscriptions
user_idplanstarted_atexpires_at
1basic2024-01-152025-01-14
2pro2023-06-012024-07-31
3premium2024-11-012025-11-30
4basic2024-03-012024-12-31
期待出力
user_idplandays_remainingstatusrenewal_at
2pro-137期限切れ2025-07-31
4basic16期限間近2025-12-31
1basic30期限間近2026-01-14
3premium350有効2026-11-30
QUESTION 8

CASE WHEN + ROUND + GREATEST — 段階割引と最低価格保証を1クエリで実装する

CASE WHENGREATEST/LEAST段階割引CTEで式を再利用
前提知識

複数条件の分岐・上下限値クランプ・CTE(共通テーブル式)の組み合わせです。

-- CASE 値 WHEN 比較値 THEN 結果 の等値比較形式(簡潔版)
CASE customer_rank
  WHEN 'gold'   THEN 0.10   -- customer_rank = 'gold' の場合
  WHEN 'silver' THEN 0.05
  ELSE               0.00   -- どれにも一致しない場合(ELSE は必ず書く習慣を)
END

GREATEST(405, 500)      -- → 500   複数引数の最大値(下限クランプに使用)
GREATEST(NULL, 500)    -- → 500   NULL は無視される(COALESCE と同様の挙動)
LEAST(12000, 10000)   -- → 10000 複数引数の最小値(上限クランプに使用)
CTE(WITH句)で式の重複を排除:CASE WHEN を SELECT の中で3回繰り返すと保守性が落ちます。CTE(Common Table Expression)で先に割引率を計算した仮想テーブルを作り、メインクエリではその値を参照することで 同じ式を1回だけ書く(DRY原則) ことができます。
問題

orders テーブルから、顧客ランクに応じた段階割引を適用した価格を計算してください。
割引率: platinum=20% / gold=10% / silver=5% / bronze=0%
① discount_rate: ランクに対応する割引率
② discounted: 割引後の金額(ROUND で整数に四捨五入)
③ final_price: 最低価格500円を下回らないようクランプした最終価格
CTE(WITH句)を使って CASE WHEN の重複を避けてください

使用テーブル
▸ orders
order_idcustomer_idcustomer_ranksubtotal
1101platinum15000
2102gold8500
3103silver3200
4104bronze980
5105gold450
期待出力
order_idcustomer_ranksubtotaldiscount_ratediscountedfinal_price
1platinum150000.201200012000
2gold85000.1076507650
3silver32000.0530403040
4bronze9800.00980980
5gold4500.10405500
QUESTION 9

FILTER句 + STRING_AGG(DISTINCT) — 条件付き集計と重複排除を同時に行う

FILTERSTRING_AGG(DISTINCT)条件付き集計重複排除集約
前提知識

集計関数に条件フィルタリングを追加する FILTER 句と、重複を除いた文字列集約の組み合わせです。

-- FILTER句: 集計関数の中で WHERE のように絞り込む(GROUP BY の行数は変えない)
COUNT(*) FILTER (WHERE status = 'completed')     -- → 完了した行のみカウント
SUM(amount) FILTER (WHERE status = 'completed')  -- → 完了した行の金額のみ合計

-- STRING_AGG(DISTINCT ...): 重複なしで文字列を連結
STRING_AGG(DISTINCT product_name, ', ' ORDER BY product_name)
  -- → 同じ商品名を1回だけ含めて辞書順に連結
  -- 注: DISTINCT 使用時、ORDER BY 式は集約関数の引数に含める必要がある

-- 整数同士の除算でゼロにならないよう 100.0 を掛ける
100.0 * COUNT(*) FILTER (...) / COUNT(*)  -- → 66.7 (NUMERIC除算)
100   * COUNT(*) FILTER (...) / COUNT(*)  -- → 66   (整数除算で小数点以下が切り捨て!)
FILTER と WHERE の決定的な違い:WHERE は GROUP BY の前に行を絞り込むため、グループの全体行数が変わります。FILTER は GROUP BY 後の集計時に条件を適用するため、同一グループで「completed の合計」と「全体の件数」を同時に計算できます。
問題

sales テーブルから、カテゴリごとに以下を集計してください。
① total_sales: 全件数(ステータス問わず)
② completed_count: status = 'completed' の件数(FILTER 使用)
③ completed_amount: 完了した取引の合計金額(FILTER 使用)
④ completion_rate: 完了率(%、小数1桁)
⑤ products: 重複排除・辞書順の商品名リスト(STRING_AGG(DISTINCT) 使用)
category の昇順で出力してください。

使用テーブル
▸ sales
sale_idcategoryproduct_nameamountstatus
1foodCoffee500completed
2foodSandwich800completed
3foodCoffee500cancelled
4techMouse3000completed
5techKeyboard8500completed
6techMouse3000refunded
7booksSQL Guide1500completed
8booksDesign Book2200cancelled
期待出力
categorytotal_salescompleted_countcompleted_amountcompletion_rateproducts
books21150050.0Design Book, SQL Guide
food32130066.7Coffee, Sandwich
tech321150066.7Keyboard, Mouse
QUESTION 10

REGEXP_MATCHES / REGEXP_REPLACE — 構造化ログからデータを抽出・変換する

REGEXP_MATCHESREGEXP_REPLACEログ解析キャプチャグループ
前提知識

正規表現の「キャプチャグループ ()」を使ったデータ抽出と書式変換の応用です。

-- REGEXP_MATCHES: マッチしたグループを配列で返す (setof text[]) 
REGEXP_MATCHES('user=42; action=login', 'user=(\d+); action=(\w+)')
  -- → {'42','login'}  各グループが配列要素に

-- 配列インデックスで各グループを取り出す(1始まり)
(REGEXP_MATCHES(text_value, 'user=(\d+); action=(\w+)'))[1]   -- → '42'     1番目のグループ
(REGEXP_MATCHES(text_value, 'user=(\d+); action=(\w+)'))[2]   -- → 'login'  2番目のグループ

-- REGEXP_REPLACE: キャプチャグループを参照して書式変換
REGEXP_REPLACE('2024/03/15', '^(\d{4})/(\d{2})/(\d{2})$', '\1-\2-\3')
  -- → '2024-03-15'  \1=年 \2=月 \3=日 として参照

-- ~ 演算子でパターンマッチング(BOOLEAN を返す)
'NOTICE job complete' ~ '^(NOTICE|DEBUG)'      -- → true  (大文字小文字を区別)
'notice job complete' ~* '^(notice|debug)'     -- → true  (~* は大文字小文字を区別しない)
REGEXP_MATCHES と REGEXP_REPLACE の使い分け:REGEXP_MATCHES は「マッチ部分を取り出す(抽出)」、REGEXP_REPLACE は「マッチ部分を置き換える(変換)」です。どちらもキャプチャグループ () が使えます。REGEXP_MATCHES はマッチしない行では行を返さないため、LEFT JOIN や CASE で扱う必要があります。
問題

app_logs テーブルには構造化されていないログ文字列が保存されています。正規表現で解析し、以下の列を生成してください。
① log_level: ログレベル(ERROR, WARN, INFO)
② status_code: HTTPステータスコード(数値)
③ endpoint: APIエンドポイントパス
④ formatted_date: log_date('YYYYMMDD' 形式)を 'YYYY-MM-DD' 形式に変換
⑤ is_error: ログレベルが ERROR か WARN なら true(~ 演算子使用)
log_id の昇順で出力してください。

使用テーブル
▸ app_logs
log_idlog_datelog_message
120240315ERROR 500 /api/users
220240315INFO 200 /api/health
320240316WARN 429 /api/orders
420240316ERROR 404 /api/products
520240317INFO 200 /api/users
期待出力
log_idlog_levelstatus_codeendpointformatted_dateis_error
1ERROR500/api/users2024-03-15true
2INFO200/api/health2024-03-15false
3WARN429/api/orders2024-03-16true
4ERROR404/api/products2024-03-16true
5INFO200/api/users2024-03-17false