1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ブラウザで動くSQLエンジンを書いたら、CSVに型がないことが全部の元凶だった

1
Posted at

手元に20万行のCSVがある。列ごとに集計したい。

BigQueryに入れるほどではない。Excelは開くだけで機嫌が悪くなる。オンラインのCSVツールは
アップロードを要求してくるが、中身は顧客データなので上げたくない。pandasを立ち上げるのも、
「この列のユニーク数だけ知りたい」には大げさすぎる。

結局やりたいのは1行だ。

SELECT city, COUNT(*) FROM data GROUP BY city ORDER BY 2 DESC

これがブラウザの中だけで動けば、アップロードの問題は消える。サーバーが無ければ、
送信先も無い。というわけでSQLエンジンを書いた。738行、依存ゼロ、<script> タグ1つで動く。

作ってみると、詰まったところがほぼ全部「CSVには型がない」に還元できたので、その話を書く。


全体の形

素直な3段構成にした。

SQL文字列
  → tokenize()      文字列 → トークン列
  → Parser.parse()  トークン列 → AST(再帰下降)
  → run()           AST + 行データ → 結果テーブル

対応しているのは、人がデータファイルに対して実際に打つ形だけ。

SELECT city, COUNT(*) AS n, AVG(amount) AS avg
FROM data
WHERE status = 'shipped' AND amount > 100
GROUP BY city
HAVING n > 10
ORDER BY n DESC
LIMIT 20

JOINは無い。サブクエリも無い。ファイルは1つしか開いていないので FROM の中身は読み飛ばす。
SELECT ... FROM data の data は飾りで、何と書いても同じものを見る。

再帰下降パーサは教科書どおりなので、面白いのはここから先だった。


罠1: CSVには型がない

CSVパーサが返してくるのは、全部文字列である。"100" であって 100 ではない。

つまり WHERE amount > 100 を評価する時点で、amount が数値なのか
「100個入り」みたいな文字列なのかを、エンジンが決めなければならない。

列全体をサンプリングして型を推論する手もあるが、CSVは平気で
amount 列に 1,200 や 未定 や N/A を混ぜてくる。列に型を1つ与えると、必ず裏切られる。

そこで列に型を持たせるのをやめて、比較のたびに両辺を見て決めることにした。

var NUM_RE = /^-?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?$/;

function asNumber(v) {
  if (typeof v === 'number') return v;
  if (isBlank(v)) return null;
  var s = String(v).trim();
  return NUM_RE.test(s) ? parseFloat(s) : null;
}

function compare(a, b) {
  if (isBlank(a) || isBlank(b)) return null;      // NULL との比較は真でも偽でもない
  var x = asNumber(a), y = asNumber(b);
  if (x !== null && y !== null) return x < y ? -1 : x > y ? 1 : 0;   // 両方数値 → 数値比較
  var sa = String(a), sb = String(b);
  return sa < sb ? -1 : sa > sb ? 1 : 0;                             // それ以外 → 文字列比較
}

両辺が数値に見えるときだけ数値として比較し、そうでなければ文字列として比較する。
"100" > "20" は数値比較で true。"apple" > "100" は文字列比較になる。

parseFloat を直接使わないのが要点で、parseFloat("100円") は 100 を返してしまう。
正規表現で全体が数値であることを確かめてからでないと、"100円" = 100 が成立する。

空文字はNULLである

もう1つ決めたのは、空セルをNULLとして扱うこと。

function isBlank(v) { return v === null || v === undefined || v === ''; }

これは厳密にはSQLではない。SQLでは空文字列とNULLは別物だからだ。
だがCSVに ,, と書いてある時、人間が意味しているのは「空文字列」ではなく「値が無い」である。
表計算ソフトの空セルと同じ扱いにするのが、驚きが少ない。

この判断が効いてくるのが集計で、SQLと同じく集計関数はNULLを飛ばす。

var v = evalExpr(a.arg, rows[i], ctx, null);
if (isBlank(v)) continue;             // aggregates skip NULLs, as in SQL
count++;

AVG(amount) は、空セルを0として数えない。0を混ぜると平均が静かに狂う。
COUNT(*) は行を数え、COUNT(amount) は値のある行だけを数える。ここもSQLに合わせた。


罠2: GROUP BY のキーを , で作ってはいけない

GROUP BY city, status を実装するとき、素朴にやるとこう書きたくなる。

var key = keyParts.join(',');     // これは壊れる

壊れる。CSVの値にカンマが入るからだ。

city = "Tokyo,Shibuya"   status = "shipped"     → "Tokyo,Shibuya,shipped"
city = "Tokyo"           status = "Shibuya,shipped" → "Tokyo,Shibuya,shipped"

別々のグループが同じキーになって、静かに合算される。
エラーは出ない。数字がちょっと違うだけなので、気づかない。

そこで、CSVのテキストにまず現れないバイトを区切りに使った。

var keyParts = ast.groupBy.map(function (g) {
  var v = evalExpr(g, filtered[r], ctx, null);
  return v === null ? '\x00' : String(v);
});
var key = keyParts.join('\x01');

\x01 で連結し、NULLは \x00 で表す。
NULLを空文字で表すと、今度は空セルのグループと空文字列のグループが衝突する。
別の番兵が要る。

副作用として file コマンドがこのファイルを data と判定するようになったが、実害は無い。


罠3: ORDER BY total が「そんな列は無い」と言う

テストを書いていて踏んだ、実際のバグ。

SELECT city, SUM(amount) AS total FROM data GROUP BY city ORDER BY total DESC

これが No such column: total で落ちた。

当然で、total は元データの列名ではない。SELECT が作り出した名前である。
評価器は行の中から total という列を探して、見つけられない。

SQLの実行順序が FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY である以上、
ORDER BY は SELECT より後なのでエイリアスを見られる。HAVING も同様に扱う実装が多い。

対処は、評価する前にASTを書き換えて、エイリアス参照を元の式に差し戻すこと。

/* ORDER BY and HAVING may refer to a name introduced in SELECT
   (`SELECT SUM(x) AS total ... ORDER BY total`). Rewrite those references
   into the expression they stand for before evaluating. */
function resolveAliases(node, aliases) {
  if (!node || typeof node !== 'object') return node;

  if (node.k === 'col') {
    var lower = node.name.toLowerCase();
    if (Object.prototype.hasOwnProperty.call(aliases, lower)) return aliases[lower];
    return node;
  }

  var copy = {};
  Object.keys(node).forEach(function (key) {
    var v = node[key];
    if (Array.isArray(v)) copy[key] = v.map(function (n) { return resolveAliases(n, aliases); });
    else if (v && typeof v === 'object' && 'k' in v) copy[key] = resolveAliases(v, aliases);
    else copy[key] = v;
  });
  return copy;
}

ORDER BY total の total ノードが、SUM(amount) の部分木に置き換わる。

1つ気をつけたのは、同名の実在する列があったらそちらを優先すること。

function aliasMap(ast, colIndex) {
  var map = {};
  ast.select.forEach(function (s) {
    if (s.star || !s.alias) return;
    var lower = s.alias.toLowerCase();
    // a real column of the same name wins, as it would in SQL
    var clash = Object.keys(colIndex).some(function (c) { return c.toLowerCase() === lower; });
    if (!clash) map[lower] = s.expr;
  });
  return map;
}

SELECT foo AS bar と書いたときに bar 列が元から存在したら、ORDER BY bar は元の列を指す。
これはSQL標準の挙動に合わせただけだが、合わせておかないと後で必ず事故る。


罠4: AVG が 550.1643000000001 を返す

これは浮動小数の話で、SQLとは関係ないのだが、体験としては一番目立つ。

AVG(amount) は割り算なので、二進小数の丸め誤差がそのまま出る。
550.1643000000001 と表示された瞬間、道具として信用されなくなる。

かといって固定桁で丸めると、今度は 0.0000012 が 0.00 になって消える。

桁数を値の大きさで変えることにした。

function present(v) {
  if (v === null || v === undefined) return '';
  if (typeof v === 'number') {
    if (!isFinite(v)) return '';
    if (Number.isInteger(v)) return String(v);
    var abs = Math.abs(v);
    var digits = abs >= 1000 ? 2 : abs >= 1 ? 4 : 6;
    var s = v.toFixed(digits);
    return s.indexOf('.') !== -1 ? s.replace(/\.?0+$/, '') : s;
  }
  return String(v);
}

整数はそのまま。1000以上は2桁、1以上は4桁、それ未満は6桁まで見せて、末尾の0は落とす。

表示だけを変えていて、値は丸めていないのが大事なところ。
ORDER BY は生の値で並べる。表示のために計算結果を壊してはいけない。


測った

20万行、4列。WHERE で1/3に絞り、8グループに GROUP BY して、
COUNT / AVG / SUM を計算し、HAVING で絞り、ORDER BY で並べる:

SELECT city, COUNT(*) AS n, AVG(amount) AS avg, SUM(amount) AS total
FROM data
WHERE status = 'shipped' AND amount > 100
GROUP BY city HAVING n > 10 ORDER BY total DESC LIMIT 20
行数     200,000
中央値   77 ms   (5回、最速 74 / 最遅 84)

Node 22、M系Mac。ブラウザでも同じ桁で動く。

速いのは、賢いことを何もしていないからだと思う。インデックスは張らないし、
クエリの並べ替えもしない。全行を1回舐めてMapに放り込むだけで、20万行ならそれで足りる。
数百万行を超えたら、素直にデータベースを使うべきところ。

テストは49件。WHERE のあとが空、閉じていない文字列リテラル、
NULLを含む集計、エイリアスの衝突あたりを埋めている。


コード

MITで置いてある。SQLエンジンは sql.js の1ファイル。

CSVを開くビューア(parse.js / table.js)も同じリポジトリにあって、
こちらは100万行でもDOMには30行しか置かない作りにしてある。

全体を通して fetch も XMLHttpRequest も使っていない。
CIで出荷スクリプトを grep して、通信コードが混ざったらビルドを落とすようにしてある。
主張が本当かどうかは、ページを開いてから機内モードにして、ファイルを落とせば確かめられる。


まとめ

SQLエンジンを書くとき、面倒なのはパーサだと思っていた。実際に面倒だったのは値の扱いだった。

CSVには型が無い。だから

  • 比較のたびに両辺を見て、数値か文字列かを決める
  • 空セルはNULLとして扱い、集計から外す
  • グループキーは、データに出ないバイトで連結する
  • 表示のためだけに丸め、値そのものは丸めない

型のある世界から来ると全部当たり前に見えるが、
その当たり前を全部自分で決めないといけないのがCSVだった、という話。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?