SQL LinterのsqruffをDocker環境で試してみた

## はじめに

SQLのLinter Toolの`sqruff`を試してみました

今回は、誰でも簡単に試せるようにDocdkr上にExpress.jsを起動することで、ブラウザアクセスして、結果がわかるようにしてみました。

## 構成イメージ

![architecture.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/284278/0ab03a8b-e7c3-cdd4-fa13-1e60534a80fa.png)

* 画面は最低限のシンプルなHTML、js、CSSを用意
* バックエンドはExpressでシンプル実装
* Expressから`sqruff`はCLIで実行

## 構成しているファイル

## 全体構成

```text
├─config
│  └─ .sqruff　※ここは今回は未作成
├─public
│  │  index.html
│  └─ styles.css
│─src
│  └─ server.js
│─ compose.yml
└─ Dockerfile
```

### Docker関連

まずはDockerFileとcomseposeの用意

```dockerfile:
# ベースイメージとしてNode.js 20を選択
FROM node:20

# sqruffのインストール
RUN apt-get update && \
    apt-get install -y curl

RUN curl -fsSL https://raw.githubusercontent.com/quarylabs/sqruff/main/install.sh | bash

# 作業ディレクトリの設定
WORKDIR /app

# expressのインストール
RUN npm install express

# アプリケーションコードをコピー
COPY src ./src
COPY public ./public

# sqruff設定ファイルをコピー
COPY config .

# サーバーを起動
CMD ["node", "src/server.js"]
```

comseposeは特に何もしていないけど、こっちの方が起動シンプルなので定義してます。

```yaml:compose.yml
services:
  sqruff:
    container_name: sqruff
    build: .
    ports:
      - "3000:3000"
```

### サーバーのソース（Expressで実行するファイル）

```javascript:src/server.js
const express = require('express');
const { spawn } = require('child_process');

const app = express();
const PORT = 3000;

// 静的ファイルを提供するための設定
app.use(express.static('public'));

// JSONデータのパース用（Expressの組み込み機能）
app.use(express.json());

app.post('/lint', (req, res) => {
    const { sql, dbEngine } = req.body;

    if (!sql || !dbEngine) {
        return res.status(400).json({ error: 'SQLとDBエンジンの両方を指定してください' });
    }

    // sqruffをCLIで実行するプロセスを生成（標準入力にSQLを直接渡す）
    const lintProcess = spawn('sqruff', ['lint', '-']);

    let output = '';
    let errorOutput = '';

    // 標準出力からのデータを取得
    lintProcess.stdout.on('data', (data) => {
        output += data.toString();
    });

    // 標準エラーからのデータを取得
    lintProcess.stderr.on('data', (data) => {
        errorOutput += data.toString();
    });

    // プロセスの終了時の処理
    lintProcess.on('close', (code) => {
        if (code !== 0) {
            console.error(`code: ${code}`);
            console.error(`stderr: ${errorOutput}`);
            return res.json({ code, result: errorOutput });
        }

        try {
            console.error(`stdout: ${output}`);
            res.json({ code: 0, result: output });
        } catch (parseError) {
            res.status(500).json({ error: 'Lint結果の解析に失敗しました' });
        }
    });

    // 標準入力にSQLを送信（末尾に改行を追加）
    lintProcess.stdin.write(sql + '\n');
    lintProcess.stdin.end();
});

app.listen(PORT, () => {
    console.log(`サーバーがポート ${PORT} で起動しました`);
});
```

### 画面のソース

DBドライバーの選択は、よく使いそうなのだけに絞ってます。

```html:public/index.html
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>SQL Linter</title>
    <link rel="stylesheet" href="styles.css">
</head>
<body>
    <div class="container">
        <header>
            <h1>SQL Linter</h1>
            <p>Validate your SQL syntax and style effortlessly</p>
        </header>
        <main>
            <form id="sqlForm" class="form">
                <div class="form-group">
                    <label for="dbEngine">DB Engine:</label>
                    <select id="dbEngine" name="dbEngine" class="input">
                        <option value="mysql">MySQL</option>
                        <option value="postgresql">PostgreSQL</option>
                        <option value="sqlite">SQLite</option>
                    </select>
                </div>
                <div class="form-group">
                    <label for="sql">Enter SQL:</label>
                    <textarea id="sql" name="sql" class="input textarea" rows="8" placeholder="Write your SQL here..."></textarea>
                </div>
                <button type="button" onclick="lintSql()" class="btn">Lint SQL</button>
            </form>
            <div id="result" class="result"></div>
        </main>
    </div>
    <script>
        function lintSql() {
            const dbEngine = document.getElementById("dbEngine").value;
            const sql = document.getElementById("sql").value;
            fetch('/lint', {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/json'
                },
                body: JSON.stringify({ sql, dbEngine })
            })
            .then(response => response.json())
            .then(data => {
                const resultDiv = document.getElementById("result");
                resultDiv.innerHTML = `<pre>code: ${data.code}<br><br>${data.result}</pre>`;
            })
            .catch(error => {
                const resultDiv = document.getElementById("result");
                resultDiv.innerHTML = `<p class="error">Error: ${error.message}</p>`;
            });
        }
    </script>
</body>
</html>
```

```css:public/style.css
/* Reset */
body, h1, p, textarea, select, button {
    margin: 0;
    padding: 0;
    font-family: 'Arial', sans-serif;
    box-sizing: border-box;
}

body {
    background: linear-gradient(135deg, #1e3c72, #2a5298);
    color: #ffffff;
    line-height: 1.6;
    font-size: 16px;
    min-height: 100vh;
    display: flex;
    justify-content: center;
    align-items: center;
}

/* Container */
.container {
    width: 90%;
    max-width: 800px;
    background: #ffffff;
    color: #333333;
    border-radius: 10px;
    box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
    overflow: hidden;
    padding: 20px;
}

/* Header */
header {
    text-align: center;
    margin-bottom: 20px;
}

header h1 {
    font-size: 2rem;
    color: #1e3c72;
}

header p {
    color: #666666;
    font-size: 1rem;
}

/* Form */
.form {
    display: flex;
    flex-direction: column;
    gap: 15px;
}

.form-group {
    display: flex;
    flex-direction: column;
}

label {
    font-weight: bold;
    margin-bottom: 5px;
}

.input {
    padding: 10px;
    border: 1px solid #ccc;
    border-radius: 5px;
    font-size: 1rem;
}

.textarea {
    resize: vertical;
    min-height: 150px;
}

.btn {
    padding: 10px;
    background: #1e3c72;
    color: #ffffff;
    border: none;
    border-radius: 5px;
    font-size: 1rem;
    cursor: pointer;
    transition: background 0.3s ease;
}

.btn:hover {
    background: #2a5298;
}

/* Result */
.result {
    margin-top: 20px;
    padding: 10px;
    background: #f7f7f7;
    border: 1px solid #ddd;
    border-radius: 5px;
    color: #333;
    overflow: auto;
    max-height: 200px;
    white-space: pre-wrap;
    font-family: 'Courier New', Courier, monospace;
}

.error {
    color: #e74c3c;
}
```

## 実行

以下のコマンドでコンテナを起動します。

```bash
docker compose up
```

compose.ymlを作成していない場合は、以下のようにビルドして起動すれば大丈夫なはず（こっちは試していないので少し心配ですが・・）

```bash
# ビルド （少し時間かかる）
docker build -t sqruff-express .

# 起動
docker run -p 3000:3000 -d sqruff-express
```

## 実行イメージ

Dockerが起動したら`http://localhost:3000`でアクセスします。
👇こんな画面が表示されたらSQLを入力して試してみましょう。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/284278/9784a4b9-a69b-2a30-1844-8e7c6a3be61b.png)

構文エラーがある場合は、↓の枠にエラーが表示されました。

- 1つ目：3行目の`As`のキーワードを大文字の`AS`に修正すること
- 2つ目：4行目のインデントはスペース4つにすること

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/284278/ba13ef07-bbbe-a47c-edc3-42b7de2bc1ec.png)


## 最後に

今回は、お試しなので構文チェックのルールはデフォルトで設定していますが、設定ファイルを用意することでインデントのサイズなど調整することができそうです。

https://github.com/quarylabs/sqruff/blob/main/crates/lib/src/core/default_config.cfg

ちなみに、ソースコードの8割ぐらいChatGPT先生に作成してもらいました。（便利ですねー