技術記事

HotXLSのPivotCacheサブストリームとSXDBレコード解釈

BIFFのPivotCacheサブストリームは、ピボットテーブルのキャッシュ済みデータセットを、それを表示するビューとは別に保存します。HotXLSはこのサブストリームを読み書きする際、レコード番号を信用するのではなく、レコード本体を検査します。この区別が話の全部です。同じレコード番号が、どのライターがファイルを作ったかに応じて、互換性のない2種類の本体レイアウトを運ぶため、リーダーは最初に見たレコード本体からフレーミングを決めるのです

この層と出会うのは、ピボットテーブルを往復に耐えさせなければならない瞬間です。キャッシュを欠いたピボットビューは抜け殻であり、Excelはファイルを開く際にソース範囲からキャッシュを再構築します。それで済むのは、ソース範囲がまだ存在し、データがクエリ由来の貼り付けでなく、ワークブックが開いても変わってはならないアーカイブ済みの決算でない場合までです

2つの構造、ファイル内の2つの場所

キャッシュデータとキャッシュ定義は、ワークブックの別々の部分に住んでおり、両者を混同しないことが最初の関門です。キャッシュレコードは独自のサブストリームを成し、[MS-XLS] の§2.1.7.12ではPIVOTCACHE = SXDB SXDBEx *SXFORMULA *FDB *DBB EOFと与えられています。欠けているものに注目してください。この生成規則の先頭にBOFはないのです

定義のほうは代わりにワークブックグローバルに座っており、PIVOTCACHEDEFINITION = SXStreamID SXVS [SXSRC] [SXADDLCACHE](§2.1.7.20.3)として、書式レコードの後、BoundSheetレコードとCountryレコードの前に置かれます。つまり1つのキャッシュが、数百レコード隔たった2つの場所で記述され、その橋渡しは、3つの場所で同時に一致しなければならないストリーム識別子です

BIFF8におけるHotXLSのPivotCacheフレーミング。SXStreamIDを持つPIVOTCACHEDEFINITIONはワークブックグローバルの、書式の後でBoundSheetの前に座り、キャッシュレコードは_SX_DB_CURストレージの下の、4桁の大文字16進で名付けられたストリームに住み、BOFなしでSXDB、SXDBEx、SXFORMULA、FDB、DBBの各レコードを保持する。SXStreamID.idStm、SXDBのidstmフィールド、ストリーム名の3つは一致しなければならない
1つのピボットキャッシュが数百レコード隔てた2つの場所で記述され、グローバル、SXDBヘッダー、サブストリーム名で同時に一致しなければならないストリーム識別子が両者を結びます

各キャッシュは_SX_DB_CURの下のストリームに属し、その名前は識別子の4桁大文字16進表記です。SXStreamID.idStmSXDBヘッダーに繰り返し現れるidstmフィールド、そしてそのストリーム名は、すべて一致しなければなりません。新しい識別子を割り当てるときは、ファイルから既に読んだ番号をすべて先に予約してください。さもないと、新しいキャッシュが、リーダーがまだ辿り着いていない古いキャッシュの番号を主張しかねません

もう1つ、人を引っかける識別子があります。ピボットビューのiCache値は、グローバル列における対応するSXStreamIDのゼロ始まりの位置であって、自分で選べるキャッシュ識別子ではありません。書き込み時にはキャッシュオブジェクトから実際の出力位置へのマッピングが必要で、既存のビューも一緒に番号を振り直さなければなりません。さもないと、1つのキャッシュの更新が、ビューを黙って別のキャッシュに向けてしまいます

var
  Book: TXLSWorkbook;
  Cache: TXLSPivotCache;
  Field: TXLSPivotCacheField;
  V: TXLSPivotCacheValue;
begin
  Book := TXLSWorkbook.Create(nil);
  try
    Book.LoadFromFile('sales.xls');
    Cache := Book.PivotCaches.Add;
    Cache.SourceRangeSheet := 'Data';
    Cache.SourceFirstRow := 1;  Cache.SourceFirstCol := 1;
    Cache.SourceLastRow := 500; Cache.SourceLastCol := 6;
    Cache.SourceDataType := 1;        // SXVS SHEET、MS-XLS 2.4.317
    Cache.RefreshOnLoad := False;     // キャッシュ済みレコードを信用する
    Cache.SaveData := True;

    Field := Cache.AddField('Region', xlpcftString);
    V.ValueType := xlpcftString;
    V.StrValue := 'North';
    Field.FindOrAddItem(V);

    Cache.SetRecordCount(0);          // クリアしてからレコードグリッドをサイズ指定
    Cache.SetRecordCount(500);
    Book.StorePivotCaches;
  finally
    Book.Free;
  end;
end;

二重のSetRecordCountはおまじないではありません。RecordCountは確保を行わない単なるプロパティ書き込みで、内部の成長パスが初期化するのは新しく追加された行だけです。そのため、ヘッダーパス経由でカウントを設定されたキャッシュは、長さゼロのインデックスグリッドを持ったままになり得ます。するとRecordIndicesへの書き込みは、エラーも出さずに捨てられます。カウントをゼロにしてから戻すことでグリッドが確立されます。そしてこれは、全フィールドを追加し終えた後でなければなりません。行幅がフィールド数から決まるからです

レコード番号から本体レイアウトは分からないのか

レコード番号と本体レイアウトは異なる時期に変わったため、両者の対応は関数になっていないからです。レガシーセットのある番号は、古いライターの作ったファイルにしか現れません。これは一方向には信頼できるシグナルです。別の番号は本当に曖昧で、正しいファイルにも、新しい番号に古い本体レイアウトを組み合わせた中間バージョン群のファイルにも現れます

したがってフレーミングは本体から決めるしかなく、しかもレコード単位ではなく、キャッシュサブストリームごとに1回です。HotXLSは各サブストリームの最初のSXDBBレコードの長さから方言を確定します。仕様のフレーミングでは、1つのSXDBBがキャッシュレコードをちょうど1つ保持するため、長さは1行幅に等しくなります。より古いパック形式のフレーミングでは、最初のレコードが入るだけの行を保持するため、複数行のキャッシュなら長さは少なくとも2行幅です。2つの予測が食い違う限り、この比較は決定的です

HotXLSのSXDBBフレーミング確定。1つのレコード番号が互換性のない2つの本体レイアウトを運ぶため、リーダーは最初のSXDBBレコード長を行幅と比較する。1行幅なら仕様の方言を確定し、2行幅以上ならレガシーのパック形式を確定する。同値の場合は仕様の読みを取り、方言はレコード単位ではなくキャッシュサブストリームごとに1度確定する
レコード番号と本体レイアウトは別の時期に変わったため、番号からレイアウトは決められません。HotXLSはサブストリームごとに最初のSXDBB長から方言を1度だけ確定し、同値の場合は仕様の読みを採用します

食い違わない場合は、リーダーは仕様の読みを採用します。Excelの書いたファイルは、中間ビルドの書いたファイルより数で上回るという原理によるものです。この盲点は構造上狭く、仮に該当しても、ファイルそのものは依然としてバイト単位で再生されます。影響を受けるのは、呼び出し側に公開される型付きインデックスだけです

インデックス幅は別のレコードに住んでいる

SXDBB(§2.4.276)は、重複値フラグの立ったキャッシュフィールドごとに、フィールド順で1つのインデックスを運びます。そして各インデックスの幅は別の場所で決まります。対応するSXFDBフィールドレコード(§2.4.283)がshort-itemsフラグを宣言し、そのフラグが、インデックスが2バイトなのか1バイトなのかを語ります。2つのレコード、1つの暗黙の契約、そして両者を繋ぐ仕様中のたった1文です

この結合こそ、自前のエンコーディングが壊れる場所です。以前のHotXLSライターは各フィールドを最小ビット数に詰め込み、行の境界でバイトにパディングしていました。単体では弁護できるやり方ですが、同じライターが直前にSXFDBで宣言した幅と真っ向から矛盾します。3つの重複しない値を持つフィールドが、片方のレコードでは1バイト幅と記述され、もう片方では2ビットを占有していたのです。修正は算術を直すことではなく、幅の決定を両エミッタが呼ぶ1つの関数へ括り出すことでした。これで2つのレコードはもう食い違えません。宣言されたサイズと実際の本体が別れ始める欠陥として、これはBIFFレコード長宣言のドリフトで扱ったのと同じ類型です

これらのレコードをまったく読まなかった場合の帰結は、あえて言葉にしておく価値があります。過小評価されやすいからです。リーダーがレコードインデックスをスキップしていた頃は、ファイルからロードされたすべてのキャッシュが、全行全フィールドについてインデックスゼロを報告していました。つまりすべての行が、全フィールドの最初の値を指していたわけです。これは単なる内省機能の低下では済みません。ピボット評価パスもキャッシュからセルへの充填パスも、このグリッドを消費するからです。しかも往復テストでは検出できません。まだ生再生のままのキャッシュは、元のバイトから書き戻されるからです

// 出所フラグは、手元にあるものと、書き換えてよいものを教えてくれる
if Cache.FromRawBlobs then
begin
  Writeln('stream id        : ', IntToHex(Cache.StreamId, 4));
  Writeln('legacy framing   : ', Cache.RawFramingIsLegacy);
  Writeln('own storage      : ', Cache.RawHasStorageStream);
  Writeln('model complete   : ', Cache.RawModelIsComplete);
  // 再エミットが無損失なのは、すべてのレコードにモデルがあるときだけ
  if Cache.CanUpgradeFraming then
    Writeln('safe to rewrite with the current emitters');
end;

キャッシュの書き換えが無損失になるのはいつか

3つの条件が揃ったときだけです。そしてその問いに答えるのがCanUpgradeFramingというたった1つのプロパティです。キャッシュがまだ生再生の状態であること、サブストリームが、このライブラリが過去に誤って書いていたフレーミングのどれかに当てはまること、そしてリーダーが中の全レコードの完全な型付きモデルを構築済みであること。Excelが書いたキャッシュは決して条件を満たしません。サブストリームがHotXLSがモデルを持たないレコードを運んでおり、モデルからの再エミットではそれらが落ちてしまうからです

完全性のテストは一見より厳格です。リーダーが不透明なバイトとしてしか保持しなかったレコードは、モデルを不完全と印付けます。エミッタが再現できない数式レコードの宣言カウントも同様です。再エミットすると、複数の数式レコードがあるという宣言が、ゼロという宣言へ書き換わってしまうからです。再現できないファイル中の値は、再現できないレコードと同じことです

意図的な保守主義はライターにも貫かれています。インデックスは、帯域外のセンチネルとして符号化されるのではなく、正しい範囲へクランプされます。仕様が定義しているのは重複値列へのインデックスだけで、空セルもその列の中の1つの値だからです。BIFFレコード上限を越えるキャッシュレコード本体はまったく書き出されません。これに必要なのは数千のキャッシュフィールドであり、BIFF8の列数上限の中ではそもそも到達不能です。フォールバックは、Excelがソース範囲からリフレッシュするというもので、破損ファイルではなく定義済みの挙動です

日付が最後のレコード間依存を運びます。シリアル値から日付への変換はワークブックの日付システムに依存しますが、レコードエミッタにワークブックは見えないため、基準日の選択はデフォルトが1900システムのパラメータとして渡され、ワークブックレベルの保存パスが供給します。1900システムではシリアル値がそのまま値であり、1904システムとは1462日ずれます。日付シリアルのより広い扱いは、日付シリアル、1904システム、表示形式にあります

キャッシュ層ではなくビュー層で作業するなら、見えているピボットを記述するレコードはBIFF8ピボットテーブルレコードセットで、計算側の挙動は集計フィールド、集計アイテム、リフレッシュで扱っています。3層すべてがHotXLS Delphiスプレッドシートコンポーネントに同梱されており、レガシーワークブックをロードし、キャッシュに実際何が入っているかを検査し、書き換える前に安全かどうかを判断できるのはこのためです