SQL データ整形・変換 — TO_CHAR・SPLIT_PARTの応用

応用変換関数TO_CHAR / SPLIT_PARTROUND × 軽減税率NULL安全集計複合変換PostgreSQL対応全5問
1 / 5 · 学習中 0 / 5 問完了
QUESTION 1

TO_CHAR / DATE_TRUNC — タイムスタンプを日本語表示・月次集計キーに変換する

TO_CHARDATE_TRUNC日付フォーマット集計クエリ
前提知識

TO_CHAR(値, フォーマット) は日付・タイムスタンプを任意の文字列に変換する関数です。DATE_TRUNC('単位', タイムスタンプ) はタイムスタンプを指定単位(月・日・時など)の先頭に切り捨てます。基礎編の CAST とは逆方向(日付型 → 文字列)の変換です。

TO_CHAR('2024-05-15 14:30:00'::TIMESTAMP, 'YYYY年MM月DD日')  -- → '2024年05月15日'
TO_CHAR('2024-05-15 14:30:00'::TIMESTAMP, 'HH24:MI')         -- → '14:30'
DATE_TRUNC('month', '2024-05-15 14:30:00'::TIMESTAMP)       -- → 2024-05-01 00:00:00
DATE_TRUNC('day', '2024-05-15 14:30:00'::TIMESTAMP)         -- → 2024-05-15 00:00:00
主なフォーマット文字:YYYY=4桁年、MM=2桁月、DD=2桁日、HH24=24時間制の時、MI=分、SS=秒。DATE_TRUNC の単位には 'year'/'month'/'week'/'day'/'hour' 等が使えます。
DATE_TRUNC の返却型は TIMESTAMP:DATE_TRUNC は DATE ではなく TIMESTAMP 型を返します。月次集計キーとして使う場合は TO_CHAR(DATE_TRUNC(...), 'YYYY-MM') で文字列化、または ::DATE でキャストするのが実務の定番パターンです。
問題

orders テーブルには TIMESTAMP 型の ordered_at 列があります。次の3列を追加して取得してください。①ordered_at_jp: 日本語日付形式 ('YYYY年MM月DD日')、②ordered_time: 時刻部分 ('HH24:MI')、③order_month: 月次集計キー ('YYYY-MM' 形式)。order_id 昇順で出力してください。

使用テーブル
▸ orders
order_idamountordered_at (TIMESTAMP)
180002024-05-01 09:15:00
2125002024-05-15 14:30:00
332002024-06-02 10:00:00
467002024-06-18 16:45:00
54502024-06-30 23:59:00
期待出力
order_idamountordered_at_jpordered_timeorder_month
180002024年05月01日09:152024-05
2125002024年05月15日14:302024-05
332002024年06月02日10:002024-06
467002024年06月18日16:452024-06
54502024年06月30日23:592024-06
模範解答コード
SELECT
  order_id,
  amount,
  TO_CHAR(ordered_at, 'YYYY年MM月DD日')  AS ordered_at_jp, -- TIMESTAMPを日本語日付文字列に変換
  TO_CHAR(ordered_at, 'HH24:MI')          AS ordered_time,  -- 'HH24'=24時間制の時、'MI'=分
  TO_CHAR(
    DATE_TRUNC('month', ordered_at),       -- 月の先頭(1日 00:00:00)に切り捨て
    'YYYY-MM'                              -- 月次キー文字列に変換(GROUP BYで使用可能)
  )                                        AS order_month
FROM   orders
ORDER BY order_id;

/*
  実行順序(SQLの論理的な評価順):
  1. FROM orders                         → 全5行のTIMESTAMPデータを読み込む
  2. TO_CHAR(ordered_at, 'YYYY年MM月DD日')  → 'YYYY'
  3. TO_CHAR(ordered_at, 'HH24:MI')      → 時刻部分を24時間制で抽出
  4. DATE_TRUNC('month', ordered_at)
  5. ORDER BY order_id                   → order_id 昇順で出力
  */
解説(テーブル変化・ポイント)
SELECT order_id, amount, TO_CHAR(ordered_at, 'YYYY年MM月DD日') AS ordered_at_jp, TO_CHAR(ordered_at, 'HH24:MI') AS ordered_time, TO_CHAR( DATE_TRUNC('month', ordered_at), 'YYYY-MM' ) AS order_month FROM orders ORDER BY order_id;
LEGEND
データ取得・読込対象
① FROM — タイムスタンプを含む元データ
FROM ordersordered_at が TIMESTAMP 型です。表示・集計に使いやすい文字列形式に変換します。
1 / 4
order_idamountordered_at (TIMESTAMP)
180002024-05-01 09:15:00
2125002024-05-15 14:30:00
332002024-06-02 10:00:00
467002024-06-18 16:45:00
54502024-06-30 23:59:00
5行
WHERE TO_CHAR(ordered_at,'YYYY-MM')='2024-05'; WHERE ordered_at >= '2024-05-01' AND ordered_at < '2024-06-01';
LEGEND
データ取得・読込対象
① FROM
FROM orders5月の注文だけを絞り込みたい場面です。TO_CHAR で変換した文字列に WHERE をかけると何が問題になるでしょうか?
1 / 3
order_idordered_at
12024-05-01 09:15:00
22024-05-15 14:30:00
32024-06-02 10:00:00
42024-06-18 16:45:00
52024-06-30 23:59:00
5行
学習ポイント
TO_CHAR の主なフォーマット文字:'YYYY'=4桁年、'MM'=2桁月(0埋め)、'DD'=2桁日、'HH24'=24時間制の時、'MI'=分、'SS'=秒。組み合わせて 'YYYY/MM/DD HH24:MI:SS' のように使えます。
DATE_TRUNC + GROUP BY で月次・週次集計:GROUP BY DATE_TRUNC('month', ordered_at) で月ごと、DATE_TRUNC('week', ...) で週次集計が実現できます。ダッシュボードのグラフ用集計クエリで頻出のパターンです。
EXTRACT で曜日・時間を数値として取り出す:EXTRACT(DOW FROM ordered_at) は日=0、月=1…土=6 の整数を返します。曜日を日本語化するには CASE WHEN EXTRACT(DOW FROM ts) = 0 THEN '日' WHEN 1 THEN '月' ... が必要です。
アンチパターン
WHERE 句で列に TO_CHAR / DATE_TRUNC を適用する:WHERE TO_CHAR(ordered_at,'YYYY-MM')='2024-05' は結果は正しいですがインデックスが使えません。日付フィルタは必ず >= / < による範囲比較で書きましょう。
DATE_TRUNC の返却型を DATE と思い込む:DATE_TRUNC は TIMESTAMP を返します。DATE_TRUNC('month', ts)::DATE と明示的にキャストしないと、DATE 型の列との JOIN やフィルタで型ミスマッチエラーが起きることがあります。
実務コラム:集計粒度の設計とTO_CHAR
ECサイトやSaaSの売上ダッシュボードでは「日次・週次・月次・年次」の切り替えが求められます。バックエンドで GROUP BY DATE_TRUNC($granularity, ordered_at) のように粒度をパラメータ化すれば、1クエリで全粒度に対応できます。フロントエンドへの表示ラベルは TO_CHAR で生成し、グラフ横軸のキーとして使うのが一般的なパターンです。タイムゾーンが必要な場合は ordered_at AT TIME ZONE 'Asia/Tokyo' を事前に適用してから DATE_TRUNC します。
QUESTION 2

SPLIT_PART / REGEXP_REPLACE — メールアドレス分解と電話番号正規化

SPLIT_PARTREGEXP_REPLACE文字列抽出データクレンジング
前提知識

SPLIT_PART(文字列, 区切り文字, フィールド番号) は文字列を区切り文字で分割し、n番目のフィールドを返します。フィールド番号は1始まりです。REGEXP_REPLACE(文字列, パターン, 置換後, フラグ) は正規表現にマッチした箇所を別の文字に置き換えます。

SPLIT_PART('alice@example.com', '@', 1)        -- → 'alice'        (1番目)
SPLIT_PART('alice@example.com', '@', 2)        -- → 'example.com'  (2番目)
SPLIT_PART('a.b.c', '.', 2)                  -- → 'b'
REGEXP_REPLACE('090-1234-5678', '-', '', 'g')  -- → '09012345678'  ('g'=全件置換)
REGEXP_REPLACE('090-1234-5678', '-', '')       -- → '0901234-5678' (フラグ省略=初回のみ)
REGEXP_REPLACE の第4引数(フラグ):'g'=global(全件置換)、'i'=case-insensitive(大文字小文字無視)、'gi'=両方。フラグを省略すると最初の1件しか置換されません。電話番号のハイフン除去など全件置換が必要なときは 'g' が必須です。
問題

contacts テーブルの email を SPLIT_PART で分解し email_user(@左側)と email_domain(@右側)を、phone のハイフンを REGEXP_REPLACE で全て除去した phone_normalized を取得してください。id 昇順で出力してください。

使用テーブル
▸ contacts
idemailphone
1alice@example.com090-1234-5678
2bob.smith@gmail.com03-5678-9012
3carol@company.co.jp06-1234-5678
期待出力
idemail_useremail_domainphone_normalized
1aliceexample.com09012345678
2bob.smithgmail.com0356789012
3carolcompany.co.jp0612345678
模範解答コード
SELECT
  id,
  SPLIT_PART(email, '@', 1)               AS email_user,      -- '@'で分割し1番目(ユーザー名)を取得
  SPLIT_PART(email, '@', 2)               AS email_domain,    -- '@'で分割し2番目(ドメイン)を取得
  REGEXP_REPLACE(phone, '-', '', 'g')    AS phone_normalized  -- '-'を全て空文字に置換('g'=全件)
FROM   contacts
ORDER BY id;

/*
  実行順序(SQLの論理的な評価順):
  1. FROM contacts                        → 全3行を読み込む
  2. SPLIT_PART(email, '@', 1)            → '@' で分割し1番目フィールド(左側)を返す
  3. REGEXP_REPLACE(phone, '-', '', 'g')  → '-' にマッチした全箇所を '' に置換
  4. ORDER BY id                          → id 昇順で出力
  */
解説(テーブル変化・ポイント)
SELECT id, SPLIT_PART(email, '@', 1) AS email_user, SPLIT_PART(email, '@', 2) AS email_domain, REGEXP_REPLACE(phone, '-', '', 'g') AS phone_normalized FROM contacts ORDER BY id;
LEGEND
データ取得・読込対象
① FROM — メール・電話番号を含む元データ
FROM contactsemail は '@' の左右に構造があり、phone はハイフン区切りです。それぞれ SPLIT_PART と REGEXP_REPLACE で処理します。
1 / 4
idemailphone
1alice@example.com090-1234-5678
2bob.smith@gmail.com03-5678-9012
3carol@company.co.jp06-1234-5678
3行
SUBSTRING(email, 1, POSITION('@' IN email) - 1) SPLIT_PART(email, '@', 1)
LEGEND
データ取得・読込対象
① FROM
FROM contacts@ より前のユーザー名を取得する際、POSITION + SUBSTRING で書こうとするとどうなるでしょうか?
1 / 3
idemail
1alice@example.com
2bob.smith@gmail.com
3no-at-sign-here
3行
学習ポイント
SPLIT_PART は区切り文字での分割に最適:CSV形式の値やパス文字列 ('a/b/c/d') から特定フィールドを取得するのに最適です。SPLIT_PART('a/b/c', '/', 3)'c'。存在しないフィールドは空文字で安全に返します。
REGEXP_REPLACE で正規表現パターンを活用する:REGEXP_REPLACE(phone, '[^0-9]', '', 'g') で「数字以外を全て除去」できます。ハイフン・カッコ・空白が混在した電話番号でも1回の REGEXP_REPLACE で正規化可能です。
PostgreSQL の正規表現演算子:~=パターンマッチ、!~=非マッチ、~*=大文字小文字無視マッチ。WHERE email ~ '@gmail\.com$' でGmailユーザーをフィルタできます。
アンチパターン
REGEXP_REPLACE で 'g' フラグを忘れる:フラグ省略は最初の1件のみ置換されます。'090-1234-5678''0901234-5678' になり後半のハイフンが残ります。全件置換が必要なときは必ず 'g' を指定しましょう。
POSITION + SUBSTRING でのメール分解:@ が存在しない不正データで POSITION が 0 を返し SUBSTRING(s, 1, -1) になりエラーが発生します。SPLIT_PART のほうがシンプルかつ堅牢です。
実務コラム:データクレンジングはDB側かアプリ側か
電話番号正規化やメール分解のようなクレンジング処理は、INSERT 時にアプリ側で行い正規化済みの値を保存するのが理想です。しかし既存の汚れたレガシーデータに対しては SQL での変換が必要になります。分析クエリや一括データ移行のときに REGEXP_REPLACE / SPLIT_PART は特に威力を発揮します。新規設計ではアプリ側バリデーション → 正規化済みをDB保存 → SELECTで変換不要、という流れが保守コストを最小化します。
QUESTION 3

ROUND / CEIL + CASE WHEN — 軽減税率に対応した税込価格計算と価格帯分類

ROUND/CEILCASE WHEN税込計算EC開発
前提知識

ROUND(数値, 小数点以下桁数) は指定桁数で四捨五入します。桁数を省略すると整数に丸めます。CEIL(数値) は切り上げ、FLOOR(数値) は切り捨てです。

ROUND(734.4)         -- → 734    (四捨五入、桁数省略=整数)
ROUND(734.5)         -- → 735    (0.5は切り上げ)
CEIL(734.1)          -- → 735    (小数があれば必ず+1)
FLOOR(734.9)         -- → 734    (小数を常に切り捨て)
ROUND(9.999, 2)      -- → 10.00  (小数第2位で四捨五入)
整数同士の除算に注意:PostgreSQL では 1 / 30 になります(整数÷整数=整数)。小数点以下を得るには 1::NUMERIC / 3 または 1.0 / 3 のように一方を NUMERIC に変換する必要があります。ROUND を使う前に型を確認しましょう。
CASE WHENをROUNDの中に埋め込める:ROUND(price * (1 + CASE ... END)) のように計算式の中に CASE 式をインライン展開できます。サブクエリなしで1行で税込計算が完結します。
問題

products テーブルから、category が 'food' の商品には軽減税率8%、それ以外には10%を適用した税込価格 (price_with_tax、ROUND で整数化)と、price の価格帯ランク (price_rank: 3000円以上='premium' / 1000円以上='standard' / それ未満='budget') を取得してください。product_id 昇順で出力してください。

使用テーブル
▸ products
product_idnamepricecategory
1コーヒー豆1800food
2マグカップ2200goods
3ケーキ680food
4ギフトセット5400gift
5エコバッグ980goods
期待出力
product_idnamepricetax_rateprice_with_taxprice_rank
1コーヒー豆18000.081944standard
2マグカップ22000.102420standard
3ケーキ6800.08734budget
4ギフトセット54000.105940premium
5エコバッグ9800.101078budget
模範解答コード
SELECT
  product_id,
  name,
  price,
  CASE category                              -- 単純CASE:カテゴリで税率を決定
    WHEN 'food' THEN 0.08                    -- 食品は軽減税率8%
    ELSE            0.10                     -- それ以外は標準税率10%
  END                        AS tax_rate,
  ROUND(                                     -- 小数点以下を四捨五入して整数に
    price * (1 + CASE category              -- CASE WHENを計算式の中にインライン展開
      WHEN 'food' THEN 0.08
      ELSE 0.10
    END)
  )                          AS price_with_tax,
  CASE                                       -- 検索CASE:税抜き価格で帯域を判定
    WHEN price >= 3000 THEN 'premium'        -- 上位条件を先に書く(検索CASEの鉄則)
    WHEN price >= 1000 THEN 'standard'
    ELSE                    'budget'
  END                        AS price_rank
FROM   products
ORDER BY product_id;

/*
  実行順序(SQLの論理的な評価順):
  1. FROM products             → 全5行を読み込む
  2. CASE category             → カテゴリで税率を決定
  3. price * (1 + 税率) / ROUND  → 税込金額を計算し四捨五入
  4. CASE WHEN price …         → 価格でランク付け
  5. ORDER BY product_id       → 昇順で出力
  */
解説(テーブル変化・ポイント)
SELECT product_id, name, price, CASE category WHEN 'food' THEN 0.08 ELSE 0.10 END AS tax_rate, ROUND(price * (1 + CASE category WHEN 'food' THEN 0.08 ELSE 0.10 END)) AS price_with_tax, CASE WHEN price >= 3000 THEN 'premium' WHEN price >= 1000 THEN 'standard' ELSE 'budget' END AS price_rank FROM products ORDER BY product_id;
LEGEND
データ取得・読込対象
① FROM — 元データ読み込み
FROM productscategory によって適用する税率が異なります。CASE WHEN で税率を動的に決定し、ROUND で税込価格を整数化します。
1 / 5
product_idnamepricecategory
1コーヒー豆1800food
2マグカップ2200goods
3ケーキ680food
4ギフトセット5400gift
5エコバッグ980goods
5行
SELECT product_id, price * 1.08 AS raw, ROUND(price * 1.08) AS rounded, CEIL(price * 1.08) AS ceiled, FLOOR(price * 1.08) AS floored FROM products WHERE category = 'food';
LEGEND
データ取得・読込対象
① 端数が発生する食品の例
food カテゴリ (tax=8%)食品に8%の税率を掛けると端数が出やすくなります。ROUND・CEIL・FLOORのどれを選ぶかで金額が変わります。仕様書で端数処理ルールを必ず確認しましょう。
1 / 2
product_idnameprice税込(生の値)
1コーヒー豆18001800×1.08=1944.0
3ケーキ680680×1.08=734.4
6チョコレート150150×1.08=162.0
3行 (food のみ)
学習ポイント
ROUND / CEIL / FLOOR の使い分け:EC サイトでは ROUND(四捨五入)が最も一般的です。CEIL は駐車料金・宿泊費など「端数は切り上げ」のビジネスルールに使います。FLOOR は「1円値引き」のような消費者有利の計算に使います。仕様書に端数処理の記載があるか必ず確認しましょう。
CASE WHEN を ROUND の中にインライン展開できる:SQL の CASE 式は SELECT リスト内のどこでも使えます。ROUND(price * (1 + CASE category WHEN 'food' THEN 0.08 ELSE 0.10 END)) のように計算式の中に埋め込めるため、サブクエリなしで1行で税込計算が完結します。
INTEGER × NUMERIC の演算型:PostgreSQL では INTEGER * NUMERIC の結果は NUMERIC になります。0.08 は NUMERIC リテラルなので 1800 * 0.08 = 144.00 (NUMERIC) となり精度は保たれます。一方 1800 * 8 / 100 は INTEGER 演算で 144 (INTEGER) になり小数が消えます。
アンチパターン
整数÷整数で精度を失う:8 / 100 * price は PostgreSQL では 0 * price = 0 になります(整数÷整数=整数)。必ず 0.08(NUMERIC リテラル)か 8::NUMERIC / 100 で書きましょう。
検索CASEのWHEN条件の順序ミス:WHEN price >= 1000 THEN 'standard' を先に書くと、5400も 'standard' に分類されます。検索CASEは必ず上位の条件(より厳しい条件)を先に書きましょう。
実務コラム:消費税計算のDB責務
税込価格の計算をDB側で行うかアプリ側で行うかは設計判断ですが、CSVエクスポートや帳票・集計など「税込額が求められる場面が多い場合」はDB側で計算してビューに持つのが効率的です。税率が頻繁に変わる可能性があるなら、税率をDBのマスタテーブルに持ち JOIN で参照することで、SQLの書き換えなしに税率変更へ対応できます。CASE category WHEN 'food' THEN 0.08 ELSE 0.10 END のハードコードは設計初期段階では有効ですが、税率マスタ管理は後続フェーズで検討する価値があります。
QUESTION 4

COALESCE + NULLIF + GROUP BY — NULL が混在するデータを安全に集計する

GROUP BYCOALESCENULL安全ゼロ除算
前提知識

基礎編の COALESCE / NULLIF を GROUP BY 集計と組み合わせる応用パターンです。SUM / AVG はグループ内の NULL を自動で無視しますが、グループ全体が NULL の場合は SUM 自体が NULL を返します。また NULLIF はゼロ除算防止パターンとして集計クエリで特に頻出します。

SUM(points)                             -- NULL行を無視して合計(全行NULLならNULLを返す)
COALESCE(SUM(points), 0)              -- SUM がNULL(全行NULL)なら 0 に置換
SUM(CASE WHEN completed THEN 1 ELSE 0 END) -- 条件付きカウント
numerator / NULLIF(denominator, 0)   -- 分母が0のときNULLを返しゼロ除算を防ぐ
AVG と NULL の挙動:AVG はグループ内の NULL 行を分母からも除外します。AVG に (0, NULL, 10) を渡すと (0+10)/2 = 5 になります。NULL を 0 として平均に含めたい場合は AVG(COALESCE(col, 0)) と書きます。どちらが意図どおりかを常に確認しましょう。
INTEGER ÷ INTEGER の精度問題:PostgreSQL では 2 / 30 になります。完了率など小数が必要な計算では、分子または分母を ::NUMERIC に変換してから割り算してください。
問題

tasks テーブルを project_id でグループ化し、①task_count (全タスク数)、②completed_count (完了タスク数)、③total_points (NULLは0として合算)、④completion_rate (完了率%、ROUNDで整数化・ゼロ除算防止) を求めてください。project_id 昇順で出力してください。

使用テーブル
▸ tasks
task_idproject_idpointscompleted
1A10true
2ANULLfalse
3A5true
4B8false
5BNULLfalse
6C3true
期待出力
project_idtask_countcompleted_counttotal_pointscompletion_rate
A321567
B2080
C113100
模範解答コード
SELECT
  project_id,
  COUNT(*)                                               AS task_count,
  SUM(CASE WHEN completed THEN 1 ELSE 0 END)           AS completed_count, -- 完了タスクだけ1を加算
  COALESCE(SUM(points), 0)                              AS total_points,    -- NULL行は無視、全行NULLなら0
  ROUND(
    SUM(CASE WHEN completed THEN 1 ELSE 0 END)::NUMERIC  -- INTEGER→NUMERIC変換で小数演算を確保
    / NULLIF(COUNT(*), 0) * 100                           -- COUNT=0のときNULLを返しゼロ除算防止
  )                                                       AS completion_rate
FROM   tasks
GROUP BY project_id
ORDER BY project_id;

/*
  実行順序(SQLの論理的な評価順):
  1. FROM tasks                                     → 全6行を読み込む
  2. GROUP BY project_id
  3. COUNT(*)                                       → グループ内の全行数 (NULLも含む)
  4. SUM(CASE WHEN completed THEN 1 ELSE 0 END)     → true行のみ1を加算
  5. COALESCE(SUM(points), 0)                       → NULL行は SUM が自動で無視
  6. SUM(...)::NUMERIC / NULLIF(COUNT(*), 0) * 100  → 完了率を小数精度で計算
  7. ROUND(...)                                     → 小数点以下を四捨五入して整数
  8. ORDER BY project_id                            → project_id 昇順で出力
  */
解説(テーブル変化・ポイント)
SELECT project_id, COUNT(*) AS task_count, SUM(CASE WHEN completed THEN 1 ELSE 0 END) AS completed_count, COALESCE(SUM(points), 0) AS total_points, ROUND( SUM(CASE WHEN completed THEN 1 ELSE 0 END)::NUMERIC / NULLIF(COUNT(*), 0) * 100 ) AS completion_rate FROM tasks GROUP BY project_id ORDER BY project_id;
LEGEND
データ取得・読込対象
① FROM — NULL と BOOLEAN を含む元データ
FROM taskspoints に NULL が含まれます。completed は BOOLEAN です。GROUP BY で集計する前に、NULL の扱いを把握しておくことが重要です。
1 / 6
task_idproject_idpointscompleted
1A10true
2ANULLfalse
3A5true
4B8false
5BNULLfalse
6C3true
6行
total_points / completed_count total_points / NULLIF(completed_count, 0)
LEGEND
データ取得・読込対象
① 集計後 — 完了が0件のグループが存在
GROUP BY 後の仮想集計結果プロジェクト B の completed_count が 0 です。この値を分母にした計算を試みるとどうなるでしょうか?
1 / 3
project_idtotal_pointscompleted_count
A152
B80
C31
3グループ
学習ポイント
SUM と AVG の NULL 挙動の違い:SUM・AVG・COUNT(列名) はいずれも NULL 行を無視します。ただしグループ全行が NULL のとき、SUM/AVG は NULL を返します(COUNT は 0 を返す)。COALESCE(SUM(...), 0) は全行 NULL のときの防衛的実装として有効です。
条件付き集計の書き方2通り:SUM(CASE WHEN completed THEN 1 ELSE 0 END) はSQL標準で移植性が高い書き方です。PostgreSQL では COUNT(*) FILTER (WHERE completed) というより簡潔な書き方も使えます(PostgreSQL 9.4以降)。
COUNT(*) と COUNT(列名) の違い:COUNT(*) は NULL を含む全行を数えます。COUNT(points) は NULL でない行だけを数えます。タスク総数には COUNT(*)、有効ポイント数には COUNT(points) と使い分けましょう。
アンチパターン
NULLIF なしで割り算する:GROUP BY の結果で分母が 0 になるグループが存在する可能性があれば、/ NULLIF(denominator, 0) を必ず使いましょう。WHERE で除外していても新しいデータで発生する可能性があります。防衛的に書く癖が重要です。
INTEGER ÷ INTEGER の精度問題:完了率 SUM(...) / COUNT(*) は整数同士の演算で小数が消えます。2 / 30 です。::NUMERIC または * 1.0 で一方を NUMERIC に変換してから割り算しましょう。
実務コラム:KPIダッシュボードクエリのベストプラクティス
プロジェクト管理ツールやSaaSのダッシュボードでは「タスク完了率・平均スコア・達成ポイント」のような KPI を1クエリで返すことが求められます。実務のコツは「分母が0になるケースを常に想定すること」です。新規ユーザー(実績0件)、空のプロジェクト、フィルタ後の空グループなど、本番では必ず発生します。NULLIF によるゼロ除算防止と COALESCE による NULL 置換をセットで書く習慣が、本番障害を未然に防ぎます。
QUESTION 5

複合変換 — TO_CHAR / LPAD / ROUND / CASE WHEN を組み合わせて請求書ラベルを一括生成する

複合変換TO_CHAR帳票生成FM フラグ
前提知識

実務では複数の変換関数をネストして使うことが多いです。この問題ではこれまで学んだ関数を組み合わせて、請求書に必要な情報を1クエリで整形します。

-- 関数のネスト: 内側から外側へ順に適用される
'INV-' || LPAD(invoice_id::TEXT, 6, '0')  -- → 'INV-000001'
TO_CHAR(ROUND(amount * 1.1), 'FM999,999,999') -- → '8,800'  (FM=先頭スペース除去)
COALESCE(TO_CHAR(paid_at, 'YYYY-MM-DD'), '未払い') -- → 日付 or '未払い'
TO_CHAR の FM プレフィックス:TO_CHAR(1234, '999,999')' 1,234' のように先頭にスペースが入ります。'FM999,999' と FM を付けることで先頭スペースを除去できます(FM = Fill Mode)。数値を文字列として表示する際に重要です。
LPAD に渡す前に必ず TEXT にキャストする:LPAD の第1引数は TEXT 型です。INTEGER をそのまま渡すとエラーになる場合があるため、invoice_id::TEXT と明示的に変換しましょう。
問題

invoices テーブルから以下の列を生成してください。①invoice_no: 'INV-' + 6桁ゼロ埋めのID(例: 'INV-000001')、②amount_with_tax: 税込金額(10%・ROUND・カンマ区切り書式)、③issued_at_jp: 発行日の日本語表示、④paid_status: paid_at が非NULLなら支払日、NULLなら '未払い'、⑤payment_label: CASE WHENで支払区分ラベル('paid'→'支払済'、'pending'→'支払待ち'、'overdue'→'延滞')。invoice_id 昇順で出力してください。

使用テーブル
▸ invoices
invoice_idamountissued_atpaid_atstatus
180002024-05-012024-05-10paid
21250002024-05-15NULLpending
332002024-06-02NULLoverdue
4267002024-06-182024-06-25paid
期待出力
invoice_noamount_with_taxissued_at_jppaid_statuspayment_label
INV-0000018,8002024年05月01日2024-05-10支払済
INV-000002137,5002024年05月15日未払い支払待ち
INV-0000033,5202024年06月02日未払い延滞
INV-0000427,3702024年06月18日2024-06-25支払済
模範解答コード
SELECT
  'INV-' || LPAD(invoice_id::TEXT, 6, '0')  AS invoice_no,      -- ①整数をTEXTにキャスト ②6桁0埋め ③プレフィックス結合
  TO_CHAR(
    ROUND(amount * 1.1),                    -- 税込金額を四捨五入して整数化
    'FM999,999,999'                          -- FM=先頭スペース除去、カンマ区切り書式
  )                                          AS amount_with_tax,
  TO_CHAR(issued_at, 'YYYY年MM月DD日')       AS issued_at_jp,    -- 日付を日本語形式に変換
  COALESCE(
    TO_CHAR(paid_at, 'YYYY-MM-DD'),          -- paid_at が非NULLなら日付文字列に変換
    '未払い'                                   -- paid_at が NULL なら '未払い'
  )                                          AS paid_status,
  CASE status                               -- 単純CASE:英語ステータスを日本語ラベルに変換
    WHEN 'paid'    THEN '支払済'
    WHEN 'pending' THEN '支払待ち'
    WHEN 'overdue' THEN '延滞'
    ELSE               '不明'               -- 想定外の値に備えてELSEを必ず書く
  END                                        AS payment_label
FROM   invoices
ORDER BY invoice_id;

/*
  実行順序(SQLの論理的な評価順):
  1. FROM invoices                           → 全4行を読み込む
  2. invoice_id::TEXT                        → 整数をTEXTにキャスト(LPAD引数の型合わせ)
  3. amount * 1.1                            → 税込金額を計算 (INTEGER×NUMERIC
  4. TO_CHAR(issued_at, 'YYYY年MM月DD日')       → DATE型を日本語文字列に変換
  5. TO_CHAR(paid_at, 'YYYY-MM-DD')          → paid_atが非NULLなら日付文字列に変換
  6. CASE status WHEN 'paid' THEN '支払済' ...  → ステータスコードを日本語に変換
  7. ORDER BY invoice_id                     → invoice_id 昇順で出力
  */
解説(テーブル変化・ポイント)
SELECT 'INV-' || LPAD(invoice_id::TEXT, 6, '0') AS invoice_no, TO_CHAR( ROUND(amount * 1.1), 'FM999,999,999' ) AS amount_with_tax, TO_CHAR(issued_at, 'YYYY年MM月DD日') AS issued_at_jp, COALESCE( TO_CHAR(paid_at, 'YYYY-MM-DD'), '未払い' ) AS paid_status, CASE status WHEN 'paid' THEN '支払済' WHEN 'pending' THEN '支払待ち' WHEN 'overdue' THEN '延滞' ELSE '不明' END AS payment_label FROM invoices ORDER BY invoice_id;
LEGEND
データ取得・読込対象
① FROM — 請求書の元データ
FROM invoicesinvoice_id は整数、issued_at は DATE 型、paid_at は NULL を含む DATE 型、status は英語コードです。これらを複数の変換関数で帳票表示用に整形します。
1 / 6
invoice_idamountissued_atpaid_atstatus
180002024-05-012024-05-10paid
21250002024-05-15NULLpending
332002024-06-02NULLoverdue
4267002024-06-182024-06-25paid
4行
SELECT TO_CHAR(ROUND(amount*1.1), '999,999,999') AS no_fm, TO_CHAR(ROUND(amount*1.1), 'FM999,999,999') AS with_fm FROM invoices;
LEGEND
データ取得・読込対象
① 数値フォーマットの確認
FROM invoices (amount × 1.1 の整数値)ROUND(amount * 1.1) で整数化した税込金額に TO_CHAR でカンマ区切り書式を適用します。FM フラグの有無で出力が変わります。
1 / 2
invoice_idROUND(amount*1.1)
18800
2137500
33520
427370
4行
学習ポイント
TO_CHAR の FM フラグ:FM999,999,999 と FM を付けると先頭スペースを除去できます。また FM は末尾の余分なゼロも除去します。TO_CHAR(1.50, 'FM0.99')'1.5'。帳票やCSV出力では FM を付けることを習慣にしましょう。
COALESCE(TO_CHAR(date_col, ...), '代替文字') のパターン:TO_CHAR(NULL, 'YYYY-MM-DD') は NULL を返します。COALESCE でラップすることで「日付があれば日付文字列、なければ代替テキスト」という表示ロジックを1行で書けます。NULL を含む日付列の帳票表示で頻出のパターンです。
LPAD のゼロ埋め桁数は仕様に合わせる:将来的に ID が6桁を超える場合は桁数を増やす必要があります。大量データが見込まれるシステムでは LPAD(id::TEXT, 10, '0') のように余裕を持った桁数にするか、LPAD を使わずアプリ側でフォーマットする設計も検討しましょう。
アンチパターン
LPAD に INTEGER をそのまま渡す:LPAD(invoice_id, 6, '0') は PostgreSQL でエラーになります。必ず invoice_id::TEXT で TEXT にキャストしてから渡しましょう。
TO_CHAR の FM フラグを忘れる:TO_CHAR(8800, '999,999,999')' 8,800' と先頭にスペースが入ります。CSVエクスポートで文字列として出力される場合、このスペースが文字列比較や表示ズレを引き起こします。カンマ区切り書式では FM を付けましょう。
実務コラム:SQLで帳票整形するメリットとデメリット
請求書番号・税込金額・支払状況のような帳票データは、SQLで整形してからアプリが受け取ることで「バックエンドのビジネスロジックを薄くできる」メリットがあります。特にCSVエクスポートや帳票PDFの直接生成(PostgreSQL + PL/pgSQL)では、SQL側での整形が必須になります。一方、フォーマット仕様が変わるたびにSQLを修正する必要があるため、表示用フォーマットの責務はアプリ層に持ち、SQLは集計・加工に集中させる設計もあります。チームの規模・変更頻度・使用するフレームワークに合わせて使い分けましょう。