DISTINCT + COUNT(DISTINCT) — 重複排除とユニークカウントで実態を把握する
DISTINCT は SELECT の結果から重複する行を取り除きます。COUNT(DISTINCT 列名) はNULLを除いたユニークな値の数を数えます。「何件のデータがあるか」ではなく「何種類の値があるか」を知りたいときの基本です。
SELECT DISTINCT col1, col2 -- col1+col2の組み合わせが重複する行を除去 FROM table_name; SELECT COUNT(DISTINCT col1) -- col1 のユニーク値の数を数える(NULLは除外) FROM table_name;
「サイトに何人のユニークユーザーがアクセスしたか」「何種類の商品が購入されたか」など、重複を除いた実態把握はAPI・バッチのあらゆる集計で頻出です。
COUNT(*) は全行数、COUNT(DISTINCT col) はユニークな値の数。COUNT(col)(DISTINCTなし)はNULLを除いた行数。3つの違いを正確に理解すること。access_logs テーブルから、2024年5月の①ユニークユーザー数(user_id の重複を除いた件数)と②アクセス総件数を1行で取得してください。また別のクエリで、アクセスしたユーザーID一覧(重複なし)を user_id の昇順で取得してください。
| log_id | user_id | page | accessed_at |
|---|---|---|---|
| 1 | U01 | /home | 2024-05-01 |
| 2 | U02 | /items | 2024-05-03 |
| 3 | U01 | /items | 2024-05-05 |
| 4 | U03 | /home | 2024-05-10 |
| 5 | U01 | /cart | 2024-05-12 |
| 6 | U02 | /home | 2024-05-20 |
| 7 | U04 | /items | 2024-06-01 |
期待する出力①:
| unique_users | total_access |
|---|---|
| 3 | 6 |
期待する出力②:
| user_id |
|---|
| U01 |
| U02 |
| U03 |
-- ① ユニークユーザー数とアクセス総件数を集計 SELECT COUNT(DISTINCT user_id) AS unique_users, -- 重複を除いた user_id の種類数 COUNT(*) AS total_access -- 全行数(総アクセス数) FROM access_logs WHERE accessed_at >= '2024-05-01' -- 5月以降 AND accessed_at < '2024-06-01'; -- 5月末まで -- ② アクセスしたユーザーID一覧(重複排除・昇順) SELECT DISTINCT user_id -- 重複する user_id を除去 FROM access_logs WHERE accessed_at >= '2024-05-01' -- 5月以降 AND accessed_at < '2024-06-01' -- 5月末まで ORDER BY user_id ASC; /* 実行順序(① 集計クエリ): 1. FROM access_logs → 行を読み込む 2. WHERE accessed_at ... → 行を絞り込む 3. SELECT → 集計関数を評価(unique_users, total_access) 実行順序(② DISTINCT クエリ): 1. FROM access_logs → 行を読み込む 2. WHERE accessed_at ... → 行を絞り込む 3. SELECT DISTINCT user_id → 重複を除去 4. ORDER BY user_id ASC → 並び替えて出力 */
LEGEND
① FROM
FROM access_logsaccess_logs テーブル全体(7行)を読み込みます。次の WHERE ステップで5月の行のみに絞り込みます。| log_id | user_id | page | accessed_at |
|---|---|---|---|
| 1 | U01 | /home | 2024-05-01 |
| 2 | U02 | /items | 2024-05-03 |
| 3 | U01 | /items | 2024-05-05 |
| 4 | U03 | /home | 2024-05-10 |
| 5 | U01 | /cart | 2024-05-12 |
| 6 | U02 | /home | 2024-05-20 |
| 7 | U04 | /items | 2024-06-01 |
LEGEND
① FROM + WHERE
FROM access_logs WHERE 5月access_logs から WHERE で5月の行に絞り込みます。U04(6月)は除外され6行が残ります。| log_id | user_id | accessed_at |
|---|---|---|
| 1 | U01 | 2024-05-01 |
| 2 | U02 | 2024-05-03 |
| 3 | U01 | 2024-05-05 |
| 4 | U03 | 2024-05-10 |
| 5 | U01 | 2024-05-12 |
| 6 | U02 | 2024-05-20 |
SELECT DISTINCT user_id, page はユーザー+ページの組み合わせ単位で重複排除)。COUNT(*)=NULLを含む全行数、COUNT(col)=NULLを除いた行数、COUNT(DISTINCT col)=ユニークな値の数。アクセス解析やKPIダッシュボードAPIでは3つすべて使うことがある。SELECT DISTINCT user_id が簡潔。「ユーザーごとのアクセス件数も欲しい」なら SELECT user_id, COUNT(*) ... GROUP BY user_id を使う。SELECT DISTINCT user_id のように絞ること。COUNT(*) は重複を含む全行数なので「ユニークユーザー数=COUNT(*)」は誤り。U01が3回アクセスしていたら3がカウントされる。ユニーク数には必ず COUNT(DISTINCT user_id) を使うこと。COUNT(DISTINCT user_id) で正確に算出できます。一方でゲストユーザーを含む場合や、同一ユーザーが複数デバイスでアクセスする場合は過大・過少カウントが発生します。KPI定義とSQL実装は必ずセットで仕様を確認する習慣が重要です。
COALESCE / IS NULL / NULLIF — NULLを安全に扱いAPIレスポンスを壊さない
SQLのNULLは「値が存在しない・不明」を表す特殊な状態です。NULLへの算術演算・文字列結合の結果はすべてNULLになります。APIレスポンスにNULLが混入すると予期しない動作を引き起こすため、明示的なNULL処理が必須です。
COALESCE(col, 'デフォルト値') -- 左から評価し、最初にNULLでない値を返す col IS NULL -- NULL の判定(= NULL は不可、必ず IS NULL) col IS NOT NULL -- NULLでないことの判定 NULLIF(col, 0) -- col が 0 なら NULL を返す(ゼロ除算防止等に使用)
WHERE col = NULL は常にFALSEになり1行もヒットしない。NULLの比較には必ず IS NULL または IS NOT NULL を使うこと。これはSQLの最重要注意事項の1つ。users テーブルから全ユーザーを取得してください。phone が NULL のユーザーには '未登録' を表示し、display_name が NULL の場合は name を代わりに使用してください。また phone が NULL であるユーザーの件数も別のクエリで取得してください。
| user_id | name | display_name | phone |
|---|---|---|---|
| 1 | 田中 太郎 | タナカ | 090-1111-2222 |
| 2 | 佐藤 花子 | NULL | 080-3333-4444 |
| 3 | 鈴木 一郎 | スズキ | NULL |
| 4 | 山田 次郎 | NULL | NULL |
期待する出力①:
| user_id | shown_name | phone_display |
|---|---|---|
| 1 | タナカ | 090-1111-2222 |
| 2 | 佐藤 花子 | 080-3333-4444 |
| 3 | スズキ | 未登録 |
| 4 | 山田 次郎 | 未登録 |
期待する出力②:
| null_phone_count |
|---|
| 2 |
-- ① NULLを安全なデフォルト値に置き換えて全ユーザーを取得 SELECT user_id, COALESCE(display_name, name) AS shown_name, -- NULL なら name を使う COALESCE(phone, '未登録') AS phone_display -- NULL なら '未登録' FROM users ORDER BY user_id; -- ② phone が NULL のユーザー数を集計 SELECT COUNT(*) AS null_phone_count -- IS NULL で絞った後の件数 FROM users WHERE phone IS NULL; -- NULL は IS NULL で判定(= NULL は不可) /* 実行順序(① NULLデフォルト変換クエリ): 1. FROM users → 行を読み込む 2. SELECT → 列を評価(COALESCE で NULL を補正) 3. ORDER BY user_id → 並び替えて出力 実行順序(② NULL件数カウントクエリ): 1. FROM users → 行を読み込む 2. WHERE phone IS NULL → 行を絞り込む 3. SELECT null_phone_count → 集計関数を評価 */
LEGEND
① FROM
FROM usersusers テーブル全体(4行)を読み込みます。display_name と phone にそれぞれ NULL が含まれています。| user_id | name | display_name | phone |
|---|---|---|---|
| 1 | 田中 太郎 | タナカ | 090-1111-2222 |
| 2 | 佐藤 花子 | NULL | 080-3333-4444 |
| 3 | 鈴木 一郎 | スズキ | NULL |
| 4 | 山田 次郎 | NULL | NULL |
LEGEND
① FROM
FROM usersusers テーブル全体(4行)を読み込みます。phone が NULL の行(user_id=3,4)を WHERE で特定します。| user_id | name | phone |
|---|---|---|
| 1 | 田中 太郎 | 090-1111-2222 |
| 2 | 佐藤 花子 | 080-3333-4444 |
| 3 | 鈴木 一郎 | NULL |
| 4 | 山田 次郎 | NULL |
NULL = NULL はTRUEではなくNULL(= 不明)になります。NULLを比較するには必ず IS NULL / IS NOT NULL を使います。COALESCE(nickname, display_name, name, '名無し') のように複数フォールバックを書ける。左から評価して最初のNULLでない値を返す。全部NULLなら最後の固定値が使われる。SUM(amount) / NULLIF(COUNT(*), 0) と書くと、COUNT(*)が0のとき NULLIF が NULLを返し、ゼロ除算エラーを防げる。DBが空の場合でも安全に平均を計算できる。SUM、AVG、COUNT(col) はNULLを無視して集計する。例えばphone列に5行あってNULLが2行あれば COUNT(phone) は3になる。これを利用して COUNT(phone) でNULL以外の件数を数えることもできる。= NULL は論理的に「NULLとNULLが等しいか不明」なので常にFALSEになり、0件ヒットになる。必ず IS NULL を使うこと。price * quantity のどちらかがNULLなら結果もNULLになる。APIで合計金額をNULLのまま返すとフロントエンドで表示崩れや計算エラーが発生する。COALESCE(price, 0) * COALESCE(quantity, 0) のようにデフォルト値で保護すること。LIKE / ILIKE — 文字列パターン検索で検索APIを実装する
LIKE は文字列の部分一致・前方一致・後方一致を行う演算子です。ILIKE(PostgreSQL拡張)は大文字・小文字を区別しないLIKEです。検索API(GET /products?q=キーワード)の基本実装です。
WHERE col LIKE '%keyword%' -- 部分一致(% = 0文字以上の任意文字列) WHERE col LIKE 'keyword%' -- 前方一致(keywordで始まる) WHERE col LIKE '%keyword' -- 後方一致(keywordで終わる) WHERE col LIKE 'k_yword' -- _ = 任意の1文字(1文字ワイルドカード) WHERE col ILIKE '%Keyword%' -- 大文字/小文字を区別しない部分一致(PostgreSQL)
LIKE '%keyword%'(前方にも%)はインデックスが使えず全件スキャンになる。大規模テーブルでの本格的な全文検索には、pg_trgm拡張のGINインデックスや全文検索エンジン(Elasticsearch等)を検討すること。products テーブルから、name に 'コーヒー' を含む商品を price の安い順で取得してください(大文字小文字区別なし)。また、SKUコード(sku)が 'BEV-' で始まる商品数を別のクエリで取得してください。
| product_id | name | sku | price |
|---|---|---|---|
| 1 | アイスコーヒー | BEV-001 | 350 |
| 2 | 緑茶 | BEV-002 | 250 |
| 3 | ホットコーヒー | BEV-003 | 380 |
| 4 | チョコレートケーキ | FOOD-001 | 480 |
| 5 | コーヒーゼリー | FOOD-002 | 320 |
| 6 | オレンジジュース | BEV-004 | 280 |
期待する出力①:
| product_id | name | sku | price |
|---|---|---|---|
| 5 | コーヒーゼリー | FOOD-002 | 320 |
| 1 | アイスコーヒー | BEV-001 | 350 |
| 3 | ホットコーヒー | BEV-003 | 380 |
期待する出力②:
| bev_count |
|---|
| 4 |
-- ① name に 'コーヒー' を含む商品を price 昇順で取得 SELECT product_id, name, sku, price FROM products WHERE name ILIKE '%コーヒー%' -- 大小文字を区別しない部分一致(PostgreSQL) ORDER BY price ASC; -- ② sku が 'BEV-' で始まる商品数をカウント SELECT COUNT(*) AS bev_count FROM products WHERE sku LIKE 'BEV-%'; -- 前方一致('BEV-' で始まる) /* 実行順序(① ILIKE部分一致クエリ): 1. FROM products → 行を読み込む 2. WHERE name ILIKE ... → 行を絞り込む 3. SELECT → 列を評価 4. ORDER BY price ASC → 並び替えて出力 実行順序(② LIKE前方一致カウントクエリ): 1. FROM products → 行を読み込む 2. WHERE sku LIKE 'BEV-%' → 行を絞り込む 3. SELECT bev_count → 集計関数を評価 */
LEGEND
① FROM
FROM productsproducts テーブル全体(6行)を読み込みます。次の WHERE で name に 'コーヒー' を含む行に絞り込みます。| product_id | name | sku | price |
|---|---|---|---|
| 1 | アイスコーヒー | BEV-001 | 350 |
| 2 | 緑茶 | BEV-002 | 250 |
| 3 | ホットコーヒー | BEV-003 | 380 |
| 4 | チョコレートケーキ | FOOD-001 | 480 |
| 5 | コーヒーゼリー | FOOD-002 | 320 |
| 6 | オレンジジュース | BEV-004 | 280 |
LEGEND
① FROM
FROM productsproducts テーブル全体(6行)を読み込みます。次の WHERE で sku が 'BEV-' で始まる行に絞り込みます。| product_id | name | sku | price |
|---|---|---|---|
| 1 | アイスコーヒー | BEV-001 | 350 |
| 2 | 緑茶 | BEV-002 | 250 |
| 3 | ホットコーヒー | BEV-003 | 380 |
| 4 | チョコレートケーキ | FOOD-001 | 480 |
| 5 | コーヒーゼリー | FOOD-002 | 320 |
| 6 | オレンジジュース | BEV-004 | 280 |
% は「0文字以上の任意文字列」、_ は「任意の1文字だけ」に対応します。例えば 'C_T' は "CAT" や "CUT" にはマッチしますが "COAT" にはマッチしません。?q=コーヒー を受け取ったサーバーが WHERE name ILIKE '%' || $1 || '%' のようにプレースホルダーを使ってSQLに組み込む。この際SQLインジェクション対策としてプレースホルダー(バインド変数)は必須。LOWER(col) LIKE LOWER('%key%') と書くことが多い。'prefix%' はB-treeインデックスが使える場合がある。しかし両端に % がある部分一致 '%keyword%' は通常インデックスが使えない。大量データの検索には pg_trgm(トライグラムインデックス)の利用を検討すること。"WHERE name LIKE '%" + userInput + "%'" のようにユーザー入力を直接埋め込むのは危険。'; DROP TABLE products; -- のような入力で任意SQLが実行されてしまう。必ずプレースホルダー($1 等)を使うこと。LIKE '%keyword%' をかけると全件スキャンになりAPIがタイムアウトする。本格的な全文検索は pg_trgm + GINインデックスや別途全文検索エンジンを導入すること。IN / NOT IN / EXISTS — 集合演算子でバッチ対象の効率的な絞り込み
IN は列の値が指定したリストまたはサブクエリの結果セットに含まれるかを判定します。NOT IN は含まれない行を取得します。EXISTS はサブクエリが1件以上の行を返すかどうかをチェックします。
WHERE col IN ('a', 'b', 'c') -- col が 'a' 'b' 'c' のいずれかに一致 WHERE col IN (SELECT id FROM t) -- サブクエリの結果セットに含まれる WHERE col NOT IN ('x', 'y') -- col が 'x' 'y' のいずれでもない WHERE EXISTS ( -- サブクエリが1件以上ヒットするか確認 SELECT 1 FROM t WHERE t.id = outer.id -- 外側クエリと内側クエリを相関させる )
NOT IN (SELECT ...) のサブクエリ結果に NULL が1件でも含まれると、すべての行がFALSEになり0件ヒットになる。NOT INのサブクエリには必ず WHERE col IS NOT NULL を付けるか、NOT EXISTS を使うことを推奨。①ordersテーブルから status が 'completed' または 'pending' の注文を取得してください。② 同テーブルから purchases テーブルに purchase_id が存在しない(未処理の)注文を取得してください。
| order_id | user_id | amount | status |
|---|---|---|---|
| 1 | U01 | 3000 | completed |
| 2 | U02 | 5000 | pending |
| 3 | U01 | 8000 | cancelled |
| 4 | U03 | 2000 | completed |
| 5 | U02 | 1200 | pending |
| purchase_id | processed_at |
|---|---|
| 1 | 2024-05-10 |
| 4 | 2024-05-11 |
期待する出力①:
| order_id | user_id | amount | status |
|---|---|---|---|
| 1 | U01 | 3000 | completed |
| 2 | U02 | 5000 | pending |
| 4 | U03 | 2000 | completed |
| 5 | U02 | 1200 | pending |
期待する出力②:
| order_id | user_id | amount | status |
|---|---|---|---|
| 2 | U02 | 5000 | pending |
| 3 | U01 | 8000 | cancelled |
| 5 | U02 | 1200 | pending |
-- ① status が completed または pending の注文を取得(IN を使用) SELECT order_id, user_id, amount, status FROM orders WHERE status IN ('completed', 'pending') -- いずれかに一致(OR を IN で簡潔に) ORDER BY order_id; -- ② purchases テーブルに存在しない注文(NOT EXISTS を使用) SELECT o.order_id, o.user_id, o.amount, o.status FROM orders o WHERE NOT EXISTS ( -- サブクエリが0件=存在しない注文 SELECT 1 -- 存在確認なので値は何でもよい FROM purchases p WHERE p.purchase_id = o.order_id -- 外側と相関(相関サブクエリ) ) ORDER BY o.order_id; /* 実行順序(① IN クエリ): 1. FROM orders → 行を読み込む 2. WHERE status IN (...) → 行を絞り込む 3. SELECT → 列を評価 4. ORDER BY order_id → 並び替えて出力 実行順序(② NOT EXISTS クエリ): 1. FROM orders o → 行を読み込む 2. WHERE NOT EXISTS → サブクエリを評価して行を絞り込む 3. SELECT → 列を評価 4. ORDER BY o.order_id → 並び替えて出力 */
LEGEND
① FROM
FROM ordersorders テーブル全体(5行)を読み込みます。status は completed/pending/cancelled の3種類があります。| order_id | user_id | amount | status |
|---|---|---|---|
| 1 | U01 | 3000 | completed |
| 2 | U02 | 5000 | pending |
| 3 | U01 | 8000 | cancelled |
| 4 | U03 | 2000 | completed |
| 5 | U02 | 1200 | pending |
LEGEND
① FROM orders
FROM orders o外側クエリの対象テーブル orders 全体(5行)を読み込みます。各行に対してサブクエリで purchases 内の存在確認を行います。| order_id | user_id | amount | status |
|---|---|---|---|
| 1 | U01 | 3000 | completed |
| 2 | U02 | 5000 | pending |
| 3 | U01 | 8000 | cancelled |
| 4 | U03 | 2000 | completed |
| 5 | U02 | 1200 | pending |
WHERE status IN ('completed', 'pending') は WHERE status = 'completed' OR status = 'pending' と同じ意味です。INを使うとOR条件が増えても読みやすく保てます。NOT IN (SELECT purchase_id FROM purchases) は purchases に NULL が含まれると全件0件になる。NOT EXISTS はNULLの影響を受けないため安全。実務では NOT EXISTS または LEFT JOIN + IS NULL が推奨される。SELECT 1 や SELECT NULL が書かれる(意味は同じ)。WHERE order_id NOT IN (SELECT purchase_id FROM purchases) の purchases に NULL があると「order_id != NULL」が常にFALSEになり結果が0件になる。WHERE purchase_id IS NOT NULL をサブクエリに追加するか NOT EXISTS を使うこと。WHERE id IN (1,2,3,...,10000) のように数万件のリストをIN句に直接書くと、SQLが非常に長くなりパース処理が重くなる。この場合は一時テーブルに INSERT してJOINする方が効率的。日付・時刻関数 — DATE_TRUNC / EXTRACT / NOW() で日次・月次集計APIを作る
日付・時刻関数はログの期間集計やバッチスケジュール処理に不可欠です。PostgreSQLの主要関数を押さえましょう。
NOW() -- 現在のタイムスタンプ(タイムゾーン付き) CURRENT_DATE -- 今日の日付(時刻なし) DATE_TRUNC('month', col) -- colの月の先頭日時に切り捨て(年/月/日/hourも可) EXTRACT(YEAR FROM col) -- colから「年」だけ取り出す(MONTH, DAY, DOW等も可) col ::DATE -- タイムスタンプを日付型にキャスト col + INTERVAL '7 days' -- 7日後のタイムスタンプ col - INTERVAL '1 month' -- 1ヶ月前のタイムスタンプ
DATE_TRUNC('month', NOW()) 以上 かつ DATE_TRUNC('month', NOW()) + INTERVAL '1 month' 未満 とすると確実。orders テーブルから、月ごとの注文件数と合計金額を集計してください。月は 'YYYY-MM' 形式で表示し、新しい月を先に表示してください。また、過去30日以内の注文のみを返す別のクエリも作成してください。
| order_id | user_id | amount | ordered_at |
|---|---|---|---|
| 1 | U01 | 3000 | 2024-04-05 |
| 2 | U02 | 5000 | 2024-04-20 |
| 3 | U01 | 8000 | 2024-05-10 |
| 4 | U03 | 2000 | 2024-05-15 |
| 5 | U02 | 1200 | 2024-05-28 |
| 6 | U01 | 4500 | 2024-06-03 |
期待する出力①:
| month | order_count | total_amount |
|---|---|---|
| 2024-06 | 1 | 4500 |
| 2024-05 | 3 | 11200 |
| 2024-04 | 2 | 8000 |
期待する出力②:
| order_id | user_id | amount | ordered_at |
|---|---|---|---|
| 6 | U01 | 4500 | 2024-06-03 |
| 5 | U02 | 1200 | 2024-05-28 |
| 4 | U03 | 2000 | 2024-05-15 |
| 3 | U01 | 8000 | 2024-05-10 |
-- ① 月ごとの注文件数と合計金額を集計(新しい月順) SELECT TO_CHAR(DATE_TRUNC('month', ordered_at), 'YYYY-MM') AS month, -- 月初に切り捨て→'YYYY-MM' 文字列に COUNT(*) AS order_count, SUM(amount) AS total_amount FROM orders GROUP BY DATE_TRUNC('month', ordered_at) -- 月単位でグループ化 ORDER BY DATE_TRUNC('month', ordered_at) DESC; -- 新しい月順 -- ② 過去30日以内の注文のみ取得 SELECT order_id, user_id, amount, ordered_at FROM orders WHERE ordered_at >= DATE '2024-06-03' - INTERVAL '30 days' -- 直近30日 ORDER BY ordered_at DESC; /* 実行順序(① 月次集計クエリ): 1. FROM orders → 行を読み込む 2. GROUP BY DATE_TRUNC(月) → グループ化 3. SELECT → 集計関数を評価(COUNT, SUM) 4. ORDER BY ... DESC → 並び替えて出力 実行順序(② 過去30日クエリ): 1. FROM orders → 行を読み込む 2. WHERE ordered_at >= ...→ 行を絞り込む 3. SELECT → 列を評価 4. ORDER BY ordered_at DESC → 並び替えて出力 */
LEGEND
① FROM
FROM ordersorders テーブル全体(6行)を読み込みます。ordered_at には2024年4月〜6月のデータが混在しています。| order_id | user_id | amount | ordered_at |
|---|---|---|---|
| 1 | U01 | 3000 | 2024-04-05 |
| 2 | U02 | 5000 | 2024-04-20 |
| 3 | U01 | 8000 | 2024-05-10 |
| 4 | U03 | 2000 | 2024-05-15 |
| 5 | U02 | 1200 | 2024-05-28 |
| 6 | U01 | 4500 | 2024-06-03 |
LEGEND
① FROM
FROM ordersorders テーブル全体(6行)を読み込みます。WHERE で「現在時刻から30日以内」の行のみに絞り込みます。| order_id | user_id | amount | ordered_at |
|---|---|---|---|
| 1 | U01 | 3000 | 2024-04-05 |
| 2 | U02 | 5000 | 2024-04-20 |
| 3 | U01 | 8000 | 2024-05-10 |
| 4 | U03 | 2000 | 2024-05-15 |
| 5 | U02 | 1200 | 2024-05-28 |
| 6 | U01 | 4500 | 2024-06-03 |
DATE_TRUNC('month', '2024-05-15') は 2024-05-01 00:00:00 になります。同じ月のデータは全て同じ値に切り捨てられるため、GROUP BY のキーとして使えます。'year'/'week'/'day'/'hour'等にも対応。EXTRACT(MONTH FROM ordered_at)→5)。DATE_TRUNC は「タイムスタンプを切り捨てて同じ単位の集計キーを作る」用途。GROUP BYには DATE_TRUNC が適している。NOW() - INTERVAL '30 days' はコードに日付をハードコードせず済むため、毎日実行するバッチに最適。'1 month'、'1 year'、'6 hours' 等も使える。WHERE EXTRACT(YEAR FROM ordered_at) = 2024 はordered_at列への関数適用のため、インデックスが無効になる。代わりに WHERE ordered_at >= '2024-01-01' AND ordered_at < '2025-01-01' と範囲で書くとインデックスが使える。AT TIME ZONE 'Asia/Tokyo'等)を行うこと。CURRENT_DATE - INTERVAL '1 day' のように相対日付を使うのが標準です。ただし、バッチが深夜0時をまたいで実行された場合、NOW()で取得される「今日」が変わってしまい集計対象がズレることがあります。このため、バッチ開始時点の「処理基準日時」を変数として受け取り、それをWHERE句に使う設計(バッチパラメーター化)が堅牢なアーキテクチャとして推奨されます。