avatar
screenshot
2025年1月〜

sql-100knocks-farm

Denoを使って作った、データサイエンス100本ノックのSQLを解くためのプロジェクトです。

概要

データサイエンス100本ノックのSQLを解くためのプロジェクトです。

このプロジェクトはGitHubにpushされており、クローンして問題を解くことができます。 プロジェクトの中には、問題文を含む空のSQLファイルが含まれているディレクトリ、問題の解答データ、CLIなどが含まれています。 問題文に従ってSQLを書いたあとに、CLIを使ってSQLを実行し、解答データと一致するかを自動で確認することができます。

CLIには、指定された問題のSQLファイルを開くコマンドや、現在開いているSQLファイル実行するコマンドなどがあります。 問題を連続して解けるように、次の問題や前の問題、特定の問題のファイルを開くようなコマンドも存在します。 設定ファイルにSQLを書くのに使用したいエディタのコマンドを設定することで、指定されたエディタでSQLのファイルを開くことができます。

このCLIはワンショット?のCLIではなく、インタラクティブなCLIになっています。 CLIを起動するとメインのプロンプトが表示され、そこでstart <問題の番号>helpのようなコマンドを受け付けます。 start <問題の番号>を実行すると、指定された問題に対するアクションを実行するためのプロンプトが表示されるような流れになっています。

問題を解く流れは以下のようになります。

  1. CLIを起動して、start <問題の番号>を入力する
  2. 問題文を含むSQLファイルが開くので、SQLを書く
  3. CLIでrunを入力し、SQLを実行して解答と一致するかを確認する
  4. 失敗した場合は書いたSQLの実行結果と解答データが保存されたファイルが開くので、それを参考にSQLを修正したあとにrunを実行する
  5. 成功した場合、nextprevmove <問題の番号>で次の問題に進む

このプロジェクトには解答例であるhtmlファイルから問題セットを生成し直すスクリプトも含まれています。 CLIには問題を繰り返し解くような機能は含まれていないのですが、このスクリプトを実行することで、書いたSQLファイルをリセットして解き直すことができます。

使用した技術

実装の詳細

HTMLのパースによるデータの抽出

このプロジェクトがもとにしているデータサイエンス100本ノックでは、問題文や回答例のSQL、回答例の実行結果はHTMLとして提供されています。 このHTMLには規則性があり問題ごとのデータが取得しやすそうだったので、deno-domというライブラリを使用してデータをJSONに変換してファイルに保存するスクリプトを書いています。

現時点ではデータサイエンス100本ノックのうち実行結果がランダムになる問題や複数のSQLの実行を要求する問題には対応しておらず、問題数は74です。 前者は難しいかもしれませんが、後者は回答例のSQLをくっつけて一つのSQLにすることで対応できるかもしれません。

AsyncLocalStorageを使用したコンテキスト管理

CLIの設定ファイルや、上の項目で保存したファイルから読み取ったデータの管理はAsyncLocalStorageで行っています。CLIの起動時に設定ファイルや問題データが書き込まれたファイルから読み取ったデータをAsyncLocalStorageに保存することで、プログラムの様々な箇所から読み取ることができ、テスト用のコンテキストを用意することでグローバル変数を使用するよりも容易にテストを行うことができます。

例えば設定データの管理の実装は以下のようになっています。

ts
// --- コンテキストの定義 --- const configContext = new AsyncLocalStorage<Config>(); async function loadConfig(): Promise<Config> { // 設定ファイルから設定データを読み込む } export async function withConfigContext(callback: () => void) { const config = await loadConfig(); configContext.run(config, callback); } export function getConfig(): Config { const config = configContext.getStore(); if (!config) { logger.error("Config context not found"); Deno.exit(1); } return config; } // --- 使用方法 --- async function main() { const config = getConfig(); // メインの処理... } withConfigContext(main);

このようなコードで、main関数内のすべての関数でgetConifg()を呼び出して設定データにアクセスすることができますし、 withConfigContext()のような関数を他にも定義してラップしていくことで様々な種類のデータを管理できます。

グローバル変数ではないので、上のコードで言うとconfigContextexportしなければ他のファイルから勝手にデータが書き換えられてしまうことも防げます。 また、withConfigContext()を使用せずに、configContext.run()にダミーのデータを渡すことでテストも行いやすくなると思います。 例えば上のコードで言うとmain()のテストを実行する際にI/Oを減らすために、 ファイルからデータを読み取るloadConfg()を使用せず、静的なデータをconfigContext.run()に渡すなどが考えられます。

コマンドのパースと実行

コマンドをパースして実行するために、CliffyというDenoのライブラリを使用しています。 コマンド名と対応する処理を複数渡してコマンドのインスタンスを作成し、入力として受け取った文字列をパースしてコマンドに対応する処理を実行できます。

コマンドは入れ子に対応しているため、コマンドごとにファイルを分割することもできます。

ts
const helpCommand = new Command().action(() => { /* ... */ }); const exitCommand = new Command().action(() => { /* ... */ }); const startCommand = new Command() .arguments("<problemNo:number>") .action((_, problemNo) => { // ... }); while(true) { const command = new Command() .command("help", helpCommand) .command("exit", exitCommand) .command("start", startCommand); const input = prompt("skf>"); await command.parse(input.split(" ")); // ... }

このプロジェクトでは、上のように各コマンドを別のファイルに分割してコマンドを実装しています。 それぞれのコマンドで外からデータを受け取りたいときには、コマンドを返す関数として定義し、引数でデータをもらっています。

このライブラリはワンショット型のCLIを想定しているみたいで、コマンドのパースに失敗するとDeno.exit()が呼ばれます。 このプロジェクトではエラーが起きてもできるだけ次のコマンドの入力を受け付けるようにしたかったため、.noExit()で抑制しています。

エラーハンドリングは、想定していないエラーや復旧できなそうなエラーには例外を使用し、それ以外のエラーは自作のResultを使用しています。 関数型ではなく手続き型で書くことを許容しているので、シンプルなResult型と、Result型の値を生成するResult.ok()Result.err()という関数、判定のためのisErr()という関数だけを作っています。

SQLの実行と結果の比較

start <問題の番号>コマンドを実行すると、指定されたエディタで問題のSQLファイルが開かれます。 そのファイルにはコメントとして問題文が書かれているため、それを満たすようなSQLを書いたあと、runコマンドを実行するとSQLが実行できます。

CLIの起動時にdocker composeでPostgreSQLを立ち上げているので、そこに向けてクエリを発行します。 データベースの内容が変更されないように、コマンド実行前にトランザクションを貼って、実行後にロールバックしています。

SQLの実行結果は{ columns: string[]; rows: string[][] }のような型に変換して解答と比較します。 問題の解答データはCSVファイルとして保存されているので、比較する際にそのファイルをパースして列と行を取得します。 Denoは標準ライブラリにcsvのパーサーがあって便利だなぁと思っていました。

実行結果を比較する際には、列名や列の順番は一致していなくても良いようにしました。 まず実行結果と解答データの一行目を取得して値を比較し、対応する列のindexのペアを取得したあと、それを使用して全体を比較しています。 実行結果や解答データの一行目に同じ値が複数ある可能性もあるため、すべてのペアの組み合わせを取得し、それぞれで比較しています。

SQLの実行後には、実行結果と解答データそれぞれをテーブル形式にフォーマットしてファイルに保存します。 これは、Node.jsのconsole.table()が出力する形式の文字列をそのまま利用しています。 Denoにはないのですが、Node.jsにはConsoleクラスというものがあり、そのクラスを作成する際に標準出力を指定できます。 ここに、書き込まれた際に文字列を追記していくWritableStreamを作って渡すと、console.table()の呼び出しで文字列を取得できます。

実行結果と解答データが一致しなかった場合には、それぞれのファイルを開きます。 2つのファイルを比較するオプションが渡されていた場合には、それを使用します。

学び

とにかくDenoの開発者体験が良かったです。 TypeScriptを書くために必要な準備が一切なく、コードを書いてコマンドを入力するだけで実行できてとても便利でした。 また、最近はNode.jsもそうなのですが、テストを書くための準備もほとんど必要なく快適でした。

標準ライブラリもNode.jsと比べると様々なものが含まれており、csvのパーサーや、stableではないですがloggerもあります。 最近はランタイムにOpenTelemetryを組み込むような取り組みもされているようです。 僕はサードパーティに依存するのは少しだけ怖いので、標準ライブラリでサポートしてくれる機能が多いのは嬉しいなぁと感じます。

また、新しい概念の発見でコードが一気に綺麗になるという感覚も掴めました。 ある機能を実現するために様々なデータをバラバラに扱っていると、それらを一時的にまとめたり分割して処理するコードで複雑になりがちです。 そういったデータをまとめるための概念を見つけ、そのモデルという単位でデータをやり取りすることでコードがシンプルになると感じました。

コードを書く前から認識している概念もありますが、発見しにくい概念もあります。 例えば、現実世界に似たような概念が存在する場合や、プログラミングにおいて一般的に使われる概念はコードを書く前の段階から認識できると思います。 一方で、コードを書く中で初めて必要になる、より特定のケースに特化した概念もあります。

発見しにくい概念は、コードの複雑性に向き合うことで初めて見つけることができると思うので、複雑性をただ受け入れるというスタンスは避けないといけないと感じました。 また、コードの変更が容易ではないと、概念をモデルとしてコードに適用するのが難しくなるため、変更容易性の重要性も再認識しました。