本稿の執筆にあたっては、内容整理および文章推敲のため AI ツールの支援を利用しています。
技術的に誤りや不正確な点があれば、ぜひご指摘いただけると幸いです。
緒言
背景
CSV は古典的なデータ交換フォーマットであるが、その単純性に反して実業務で極めて広く利用されている。レポーティング、データ分析、バッチ処理、外部システム連携など用途は多岐に渡り、特に 大量データを安全に高速で書き出す ため、アプリケーション側で結果を逐次ストリーミングしながらファイルへ書き込む構成が一般的である。
一方で、RDBMS とその標準的なクエリ言語 SQL は 行指向モデル を基本としており、CSV のような横方向への可変的な列展開とは立場が異なる。とりわけ、ユーザが任意に定義する属性を 列として動的に横持ち生成する(動的列ピボット) 処理は、SQL が前提とする 静的なスキーマ と根本的に相容れない。
代表的な PIVOT 演算子でさえ、SQL Server や Oracle 等に共通して 列名を実行前に列挙する必要がある。従って、属性数・属性名が実行時に決まるケースでは、 PIVOT の列集合はクエリ解析時に確定している必要があり、実行時に列数・列名が変化するケースでは直接適用できない。実務では Jeff Moden の動的クロス集計のように、列名を文字列連結して動的 SQL を生成するアプローチの利用が考えらえるが、これは SQL の静的列前提を回避するための実装テクニックに過ぎない。
さらに実運用では、SQL の結果をファイルに出力する際に、全件をメモリに保持せずに逐次処理するため、Java Stream や Kotlin Sequence のような 遅延ストリーミング API を利用することが望ましい。本稿における「ストリーミング」とは、全件保持せず逐次処理するという意味で用いる。
しかし Stream/Sequence は JOIN や動的列生成といった関係データ処理を表現する機能を持たない。加えて遅延評価モデルの特性上、「未来に現れる列を事前に確保する」ことができない。
その結果、SQL PIVOT と同様に Stream/Sequence も動的列ピボットと相性が悪く、アプリケーション層でも特有の工夫が必要となる。
以上のように、動的列ピボット問題は
- SQL 側(静的列前提 &
PIVOTの制約) - アプリケーション側(ストリーミング API の構造的制約)
という二重の非対称性を抱える。本稿ではこの問題に対する理解を深めるため、JVM 上の動的列ピボット処理の設計空間を整理し、理論的・実装的観点から比較検討する。
既存の制約と技術的背景
SQL の静的列前提と限界
多くの RDBMS における PIVOT やクロス集計は、事前に列集合が固定されていることを前提とする。これは SQL のクエリ最適化・解析モデルが「列名を実行前に確定させる」ことを要求するためであり、実行時に生成される列を直接扱うことはできない。
この制約により動的列ピボットは多くの場合、動的 SQL(クエリ文字列の生成と実行)に依存する実装が採用される。実務的な例として Jeff Moden の動的クロス集計実装[Moden 2008]があり、月リストの生成・動的 CASE 文の構築・EXEC によるクエリ評価という典型的な手法が知られている。
JVM 上の Stream / Sequence における join の不在
Kotlin Sequence も Java Stream も、複数シーケンスのキー結合(join)を表現する標準 API を持たない。このため関係データの結合には事前ロード・内部バッファリング・窓処理といった追加の仕組みが必要となる。
加えて、遅延評価モデルの特性上、列集合を実行中に拡張するといった構造を自然に表現することは困難で、SQL と同様に「動的列ピボットとの相性が悪い」という性質を持つ。
本稿の焦点と位置づけ
本稿では 注文エンティティに付随するユーザ定義属性 を対象に、以下のような問いを立てる:
JVM 上の一般的なコレクション処理抽象化(
Stream/Sequence)を用いて、動的列ピボットをどのように実装できるか?
この問いに答えるため、本稿では次の観点から設計空間を整理する:
-
理論モデル
- 計算量の下限
- メモリと I/O のトレードオフ
- 遅延評価モデルと評価戦略
-
戦略分類
-
PRELOAD(全属性の事前ロード) -
MULTISET(DB 側でネスト取得) -
SEQUENCE_WINDOW(遅延評価 + 窓処理) -
SPLITERATOR_WINDOW(Stream API 上の窓処理)
-
-
実装観点と性能評価
理論的分析と実装戦略を 明示的に比較することで、読者が戦略選択の根拠を得られることを意識する。
問題定義
本章では、本稿で扱う「動的列 CSV 出力問題」を形式的に定義する。目的は解法の提示ではなく、入力データの構造と出力形式の要請から、どのような制約が必然的に発生するかを明確化することである。
データモデル
対象とするエンティティは以下の三つである。
-
注文(Order)
- 出力対象の主レコード
-
注文属性定義(OrderAttributeDefinition)
- ユーザが追加可能なカスタム項目の定義
-
注文属性値(OrderAttributeValue)
- 各注文に付与される属性値
各注文は 0 個以上の属性値を持ち、属性値は対応する属性定義を参照する。属性定義の総数を A とすると、1注文に対する属性値数は高々 A である。一方で属性値の存在はスパースであり、すべての注文がすべての属性を持つとは限らない。
ここで重要なのは、内部表現が疎であるにもかかわらず、CSV 出力は「全注文に対して同一の列集合」を要求する点である。すなわち、属性値が存在しない場合であっても当該列は省略できず、空欄として出力されなければならない。
動的列ピボット問題
本稿でいう動的列ピボットとは、属性定義集合(すなわち列集合)が事前に固定されず、実行時に決定される条件下で、行指向の属性値表現を列指向の CSV 形式へ再構成する処理を指す。
典型的には、属性定義は運用中にユーザ操作により増減する。このため、列数 A は実行時に初めて確定し、静的スキーマ前提の集計・ピボット手法では扱いにくい。また、欠損値を含む列整合性を保証するため、「存在する属性値のみを処理する」方式では不十分となる。
具体例として、以下のような状況を考える:
- 属性定義: ギフト包装、配送指示、キャンペーンID の3種類(A = 3)
- 注文A: ギフト包装のみ設定
- 注文B: 属性値なし
- 注文C: 配送指示とキャンペーンIDを設定
CSV 出力では、すべての注文に対して3列を出力し、未設定の列は空欄とする必要がある。
この構造により、動的列ピボット問題は単なる結合や集約ではなく、欠損値を含む行列再構成として扱わなければならない。
Stream / Sequence における前提制約
本稿では、Kotlin Sequence および Java Stream を用いた逐次処理を前提とする。両者はいずれも遅延評価を基盤とするが、標準 API としてキー結合(join)操作を持たない。
このため、注文列と属性値列をキーで結合し、さらに列方向に再配置する処理は、ストリーム抽象の外側で評価戦略を設計する必要がある。すなわち、結合処理はライブラリ機能として与えられるのではなく、データ取得形態とメモリ使用の制約のもとで、実装者が選択する設計論点として現れる。
SQL においても、列数 A が実行時決定であるため、静的な PIVOT 演算子をそのまま適用することは困難であり、動的 SQL 生成などの追加的な工夫が必要となる。
本章のまとめ
以上の整理により、本問題の本質的制約は次のように要約できる:
- 出力形式として N × A のセルを必ず生成する必要がある(疎 → 密変換)
- 列数 A は実行時に決定され、事前に固定できない
- Stream/Sequence では結合処理が抽象化されず、評価戦略が設計判断となる
これらの条件のもと、次章では記号体系を導入し、計算量の下界と設計空間を理論的に整理する。
分析手法(Analysis Model)
本章では、動的列 CSV 出力問題を計算量モデルとして定式化し、各戦略が「どこでコストを支払うか」を比較可能な形に落とし込む。特に、本問題が純粋な CPU 計算ではなく、出力量(I/O)とデータ整形コストに支配されやすい点を明示する。
記号定義
以降、次の記号を用いる。
- $N$: 注文数(出力行数)
- $A$: 属性定義数(動的列の列数)
- $V$: 属性値の総数(
order_attribute_valueの行数) - $\bar{a} = V/N$: 1注文あたり平均属性数
- $a_{\text{max}}$: 1注文あたり属性値数の最大($a_{\text{max}} \le A$)
- $N_0$: 属性値が 0 件の注文数
- $R$: 縦持ち JOIN 結果(ストリーム)の行数
- $S$: 生成される CSV の総出力サイズ(文字数または bytes)
- $B$: 基本列数(
order_id,customer_id等の固定列、定数)
本稿の前提では、属性値を持たない注文も CSV 行として出力されるため、縦持ち取得に LEFT JOIN を用いる。このとき、JOIN 結果の行数 $R$ は
$$
R = V + N_0 \quad (0 \le N_0 \le N)
$$
を満たす。したがって
$$
V \le R \le V + N
$$
である。以後、窓処理(windowing)を用いる戦略はこの $R$ を基準に評価する。
出力下界(I/O による支配)
本問題の中心は「疎な属性値表現を、密な CSV 表へ再配置する」点にある。CSV は行ごとに列数が固定されるため、属性列だけでも 各注文につき $A$ セルを必ず出力する。
したがって、属性列に関して出力セル数は常に
$$
N \times A
$$
となり、少なくともこの数だけのセル生成・書き込みが必要である。よって、属性列展開に関して
$$
\Omega(N \cdot A)
$$
が下界となる。
さらに、CSV への書き込みは物理的な出力量 $S$ に比例するため、より根源的には
$$
\Omega(S)
$$
が避けられない下界である。
基本列数を $B$(定数)とすると、出力サイズは概ね
$$
S = \Theta(N(A + B))
$$
に比例する。ここでは各セルの値の平均文字数を定数とみなす近似のもとで議論する。値長そのものは出力サイズ $S$ に影響するが、戦略間の相対比較には影響しないためである。
したがって $\Omega(S)$ は $\Omega(N \cdot A)$ を包含する。
すなわち本問題は、アルゴリズム改善によって「列展開コスト」を消し去るタイプの問題ではない。設計上の論点は、不可避なコスト(特に $N \cdot A$ と $S$)を「どこで・どの形で」支払うかに集約される。
計測対象の分解:DB / JVM / I/O
戦略間の違いを明確にするため、総実行時間 $T$ を次の 3 成分に分解する。
$$
T = T_{\text{DB}} + T_{\text{app}} + T_{\text{IO}}
$$
- $T_{\text{DB}}$: DB 側処理(JOIN、ORDER BY、ネスト化/集約、materialization 等)
- $T_{\text{app}}$: JVM 側処理(Map 構築、窓処理、列埋め、オブジェクト生成等)
- $T_{\text{IO}}$: CSV 出力(文字列生成+write)
メモリ使用量 $M$ はピーク使用量(peak memory usage)として扱い、特に JVM 側の保持量 $M_{\text{app}}$ を比較対象とする。
戦略の設計空間(分類)
本稿では、結合と列再配置の方法により戦略を次の 4 つに分類する。これらは実務上自然に現れる代表的な実装パターンを抽出し、設計空間を比較可能な形で整理したものである。本稿で扱う戦略は理論的に網羅的なものではなく、実務において実際に選択肢として現れやすい代表例に限定している。
1. PRELOAD
属性値を全件取得し、Map<OrderId, Map<DefId, Value>> に展開して参照
2. MULTISET
DB 側で「注文 → 属性値群」をネスト化して取得し、1注文単位で処理
3. SEQUENCE_WINDOW
縦持ち JOIN 結果を Sequence で順序前提の窓処理により 1注文=1要素へ昇格
4. SPLITERATOR_WINDOW
縦持ち JOIN 結果を Spliterator により 1注文=1要素へ昇格(Stream 世界で窓処理)
各戦略の理論モデル(概要)
ここではアプリ側の主要支配項を示し、戦略間の「形」を揃える。
1. PRELOAD
- 属性値 Map 構築: $O(V)$
- 注文走査+列埋め: $O(N \cdot A)$
$$
T_{\text{app}} = O(V + N \cdot A)
$$
$$
M_{\text{app}} = O(V)
$$
全属性値を保持するためメモリ支配は $V$ になる。
2. WINDOW(SEQUENCE / SPLITERATOR)
窓処理の概念を擬似コードで示す:
buffer = empty map
previousOrderId = null
for row in joinedRowsSortedByOrderId:
if row.orderId != previousOrderId and previousOrderId != null:
emit(previousOrderId, buffer, allDefinitionIds)
buffer.clear()
if row.definitionId is not null: // LEFT JOIN により属性の無い注文行をスキップ
buffer[row.definitionId] = row.value
previousOrderId = row.orderId
emit(previousOrderId, buffer, allDefinitionIds) // 最後の注文
計算量:
- 縦持ち JOIN 結果を 1行ずつ処理: $O(R)$
- 注文確定時の列埋め: $O(N \cdot A)$
縦持ち行の逐次走査($R$)と、行確定時の列埋め($N \cdot A$)は独立に発生するため、時間計算量は足し合わせで表される。
$$
T_{\text{app}} = O(R + N \cdot A)
$$
$$
M_{\text{app}} = O(a_{\text{max}})
$$
平均は $\bar{a}$ だが、ピークは最大ウィンドウサイズ $a_{\text{max}}$ に支配される。
ここで $R = V + N_0$ であるため、JOIN 結果を逐次処理しつつも、最終的な列展開 $N \cdot A$ からは逃れられないことが明確になる。
3. MULTISET(アプリ側)
MULTISET では 1注文ごとに「属性値リスト(平均 $\bar{a}$)」を受け取り、列展開 $A$ と合わせて処理するため
$$
T_{\text{app}} = O(N \cdot (A + \bar{a}))
$$
である。ただし $\bar{a} = V/N$ を代入すると
$$
N \cdot (A + \bar{a}) = N \cdot A + V
$$
よって
$$
T_{\text{app}} = O(N \cdot A + V)
$$
$$
M_{\text{app}} = O(a_{\text{max}})
$$
となり、アプリ側の漸近形は PRELOAD と同型である。
ただし MULTISET は $T_{\text{DB}}$ の構造が異なり、結果の materialization やネスト集約によりストリーミング性が失われ得る点に注意が必要である。
理論モデルの統一と実測差の帰着
ここまでから、アプリ側に限れば多くの戦略は
$$
O(N \cdot A + V) \quad \text{または} \quad O(N \cdot A + R)
$$
という同一の支配形に収束する。
この意味で、WINDOW 系と MULTISET はアプリ側計算量の観点では同型であり、実測差は主として $T_{\text{DB}}$ と $M_{\text{app}}$ に帰着される。
したがって実測差は Big-O ではなく、主に次の要因に帰着される:
- $T_{\text{DB}}$ の性質の違い(ORDER BY / ネスト化 / materialization の有無)
- $M_{\text{app}}$ のピーク差(全件保持 $O(V)$ vs 逐次処理 $O(a_{\text{max}})$)
- MULTISET における $T_{\text{DB}}$ の別種性(ネスト化に伴う materialization の可能性)
- 定数項(オブジェクト生成、GC、変換、ライブラリのオーバーヘッド)
- 不可避な $T_{\text{IO}} = \Omega(S)$
本章のモデルは、各戦略の漸近的優劣を示すものではなく、不可避なコストがどの層に帰属するかを比較するための枠組みである。
前編のまとめと次稿への問い
本稿(前編)では、動的列 CSV 出力問題を計算量モデルの観点から整理し、 PRELOAD / MULTISET / WINDOW 系戦略がどのような設計判断の差として現れるかを定式化した。
特に、各戦略の漸近的な計算量は概ね同型に収束する一方で、不可避なコストが どの層(DB / JVM / I/O)に帰属するか によって、実装上・運用上の特性が大きく異なり得ることを示した。
重要なのは、本問題が「どの戦略が理論的に速いか」ではなく、「不可避なコストをどこで支払うか」という設計問題である点である。
では、同じ Big-O に収束する戦略同士は、実装・実行環境・データ分布の違いによって、実際にはどのような差として現れるのか。
次稿(後編)では、Kotlin による実装と実測を通して、この問いに答える。