Closed11

100日チャレンジ day20 (SQL Parser & Validator)

riddle_tecriddle_tec

昨日
https://zenn.dev/gin_nazo/scraps/0b7c4232cf4a15


https://blog.framinal.life/entry/2025/04/14/154104

100日チャレンジに感化されたので、アレンジして自分でもやってみます。

やりたいこと

  • 世の中のさまざまなドメインの簡易実装をつくり、バックエンドの実装に慣れる(dbスキーマ設計や、関数の分割、使いやすいインターフェイスの切り方に慣れる
  • 設計力(これはシステムのオーバービューを先に自分で作ってaiに依頼できるようにする
  • 生成aiをつかったバイブコーティングになれる
  • 実際にやったことはzennのスクラップにまとめ、成果はzennのブログにまとめる(アプリ自体の公開は必須ではないかコードはgithubにおく)

できたもの

https://github.com/lirlia/100day_challenge_backend/tree/main/day20_sql_parser

riddle_tecriddle_tec

承知しました。
「Go製SQLクエリパーサー&バリデーター」 の仕様書を以下にまとめます。


仕様書:Go製SQLクエリパーサー&バリデーター

1. 概要

本アプリケーションは、SQLクエリ文字列をGoでパースし、AST(抽象構文木)を生成・可視化し、スキーマ定義に基づくバリデーションを行うツールです。
主な目的は、Go言語でのパーサー・AST設計・バリデーションロジックの学習です。


2. 機能要件

2.1 SQLパース機能

  • 対応SQL文:
    • SELECT 文(WHERE, JOIN, GROUP BY, HAVING, ORDER BY, LIMIT を含む)
  • 字句解析(トークナイザ/レキサー)を自作
  • 構文解析(パーサー)は再帰下降方式で実装
  • ASTノードをGoの構造体で表現

2.2 バリデーション機能

  • 構文エラーの検出(例:括弧の不一致、予約語の誤用)
  • スキーマ定義(テーブル・カラム情報)に基づく
    • テーブル名・カラム名の存在チェック
    • 型チェック(例:WHERE句の比較対象の型整合性)
    • JOIN条件の妥当性
  • バリデーションエラーは詳細なメッセージで返却

2.3 AST可視化機能

  • ASTをJSON形式で出力
  • ASTをMermaid.js記法でグラフ化し、Web UIで可視化

2.4 スキーマ定義

  • スキーマはJSONまたはYAMLで定義
  • サンプルスキーマ(ECサイト風)をあらかじめ用意
  • Web UIからスキーマの編集・切り替えが可能

2.5 Web UI

  • SQLエディタ(シンタックスハイライト付き)
  • ASTグラフ表示(Mermaid.js)
  • バリデーションエラー表示
  • スキーマ定義の表示・編集

2.6 API

  • POST /api/parse
    • SQLクエリとスキーマを受け取り、AST・バリデーション結果・Mermaidグラフを返す
  • GET /api/schema
    • 現在のスキーマ定義を返す
  • POST /api/schema
    • スキーマ定義を更新

3. 非機能要件

  • フロントエンド:Next.js (App Router), TypeScript, Tailwind CSS, Mermaid.js, Monaco Editor
  • バックエンド:Go(APIサーバーとして動作、Next.jsからAPI呼び出し)
  • DB不要(スキーマはファイル or メモリ管理)
  • レスポンシブデザイン

4. データ構造例

4.1 スキーマ定義例(JSON)

{
  "tables": [
    {
      "name": "users",
      "columns": [
        { "name": "id", "type": "INTEGER", "primary": true },
        { "name": "name", "type": "TEXT" },
        { "name": "email", "type": "TEXT" }
      ]
    },
    {
      "name": "orders",
      "columns": [
        { "name": "id", "type": "INTEGER", "primary": true },
        { "name": "user_id", "type": "INTEGER" },
        { "name": "total_amount", "type": "INTEGER" }
      ]
    }
  ]
}

4.2 ASTノード例(Go構造体)

type SelectStmt struct {
    Columns    []Expr
    From       Table
    Where      *Expr
    Joins      []Join
    GroupBy    []Expr
    Having     *Expr
    OrderBy    []Order
    Limit      *int
}

5. 画面イメージ

  • SQLエディタ:SQL文を入力
  • ASTグラフ:Mermaid.jsでASTを可視化
  • バリデーションエラー:エラーがあればリスト表示
  • スキーマ編集:JSON/YAMLで編集可能

6. 学習ポイント

  • Goでの字句解析・構文解析・AST設計
  • スキーマ駆動のバリデーション
  • ASTの可視化・表現
  • Next.jsとのAPI連携
riddle_tecriddle_tec

rate limit になっていても 4o が単発の依頼を高速に返してくれるな〜。逆に rate limit になると sonnet が激おそになる。ただ一回動き始めると自律的に結構動いてくれる

riddle_tecriddle_tec

レキサー:入力文字列を読み取りトークンに分割するやつ


	// --- SQL 検証プロセス --- //
	// 1. Lexing
	l := lexer.New(sql)

	// 2. Parsing
	p := parser.New(l)
	program := p.ParseProgram()
	parseErrs := p.Errors()
	response.ParseErrs = parseErrs

	// 3. Validation (パースが成功した場合のみ)
	if len(parseErrs) == 0 && program != nil {
		v := validator.NewValidator(sampleSchema)
		validationErrs := v.Validate(program)

		if len(validationErrs) == 0 {
			response.IsValid = true
		} else {
			response.IsValid = false
			for _, vErr := range validationErrs {
				response.Errors = append(response.Errors, vErr.Error())
			}
		}
	} else {
		response.IsValid = false
		// パースエラーは response.ParseErrs にセット済み
		if len(parseErrs) == 0 {
			response.ParseErrs = append(response.ParseErrs, "Parsing failed without specific errors.")
		}
	}

パーサー:レキサーで生成したトークンをASTに変換する

今回の実装は SELECT のみを対象にしている。
SELECTを解釈したあとは、SELECT のあとに取りうるいろんな命令をそれぞれ実装してハンドリングしている。

// parseSelectStatement は SELECT 文をパースします。
func (p *Parser) parseSelectStatement() *ast.SelectStatement {
	stmt := &ast.SelectStatement{Token: p.curToken}

	p.nextToken() // SELECT を消費

	stmt.Columns = p.parseSelectList()

	// parseSelectListの後、curTokenはリストの最後の要素のはず
	// 次のトークンが FROM であることを期待する
	if !p.expectPeek(token.FROM) {
		// FROM が必須でなければエラーメッセージのみ記録
		p.peekError(token.FROM) // FROMがない場合のエラーを具体的に
		// return nil // FROM句がない場合でも解析を続ける場合
	} else {
		// FROM を消費したので、p.curToken は FROM
		// 次のトークン (p.peekToken) がテーブル名 (IDENT) であることを期待
		if !p.peekTokenIs(token.IDENT) {
			p.peekError(token.IDENT) // テーブル名がないエラー
			return nil             // FROM の後にはテーブル名が必須
		}
		p.nextToken() // テーブル名 (IDENT) へ進む
		stmt.From = &ast.Identifier{Token: p.curToken, Value: p.curToken.Literal}
	}

	// WHERE句 (オプション)
	if p.peekTokenIs(token.WHERE) {
		p.nextToken() // WHEREを消費
		p.nextToken() // WHEREの次のトークン (式の開始) へ
		stmt.Where = p.parseExpression(LOWEST)
	}

	// ORDER BY 句のパース (オプション)
	if p.peekTokenIs(token.ORDER) {
		p.nextToken() // ORDER
		if !p.expectPeek(token.BY) {
			return nil // ORDER の後は BY が必須
		}
		// BY を消費したので、curToken は BY
		p.nextToken() // 式の開始へ
		stmt.OrderBy = p.parseOrderByExpressions()
		if stmt.OrderBy == nil { // パースエラーがあればnilが返る
			return nil
		}
	}

	// LIMIT 句のパース (オプション)
	if p.peekTokenIs(token.LIMIT) {
		p.nextToken() // LIMIT へ進む
		stmt.Limit = p.parseLimitClause()
		if stmt.Limit == nil { // パースエラーがあればnilが返る
			return nil
		}
	}

	// 文の終わり (SEMICOLON) を確認する (オプション)
	if p.peekTokenIs(token.SEMICOLON) {
		p.nextToken() // セミコロンを消費
	}

	return stmt // エラーがあってもなくても stmt を返す (エラーは p.errors に蓄積)
}

こういうのを生成している

// SelectStatement は SELECT 文を表します。
// SELECT columns FROM table WHERE condition ORDER BY order LIMIT limit;
type SelectStatement struct {
	Token   token.Token    // SELECT トークン
	Columns []Expression   // SELECT句のカラムリスト (Expression に変更)
	From    *Identifier    // FROM句のテーブル名 ( Identifier のポインタに変更)
	Where   Expression     // WHERE句の条件式 (nil の場合あり)
	OrderBy []*OrderByExpression // ORDER BY 句 (nil の場合あり) - スライスに変更
	Limit   *LimitClause   // LIMIT 句 (nil の場合あり)
	// TODO: JOIN句などを追加
}

さらにそれを、Program にいれて返している。

// Node はASTのすべてのノードが実装するインターフェースです。
type Node interface {
	TokenLiteral() token.Token // デバッグとテストのためにトークン全体を返すように変更
	String() string
}

// Statement はSQLステートメントを表すノードです。
// (例: SELECT, INSERT, UPDATE, DELETE)
type Statement interface {
	Node
	statementNode()
}

// Expression は値を生成するノードです。
// (例: リテラル, 識別子, 算術式, 比較式)
type Expression interface {
	Node
	expressionNode()
}

// --- Program --- //

// Program はASTのルートノードです。
// 一つ以上のSQLステートメントを含みます。
type Program struct {
	Statements []Statement
}
riddle_tecriddle_tec

静的解析: AST を用いて validation を行う


// Walk は指定されたビジターでASTノードを巡回します。
func Walk(node ast.Node, visitor Visitor) {
	if node == nil {
		return
	}
	switch node := node.(type) {
	case *ast.Program:
		for _, stmt := range node.Statements {
			if stmt != nil {
				Walk(stmt, visitor)
			}
		}

	case *ast.SelectStatement:
		if !visitor.VisitSelectStatement(node) {
			return // falseが返されたら、子要素の巡回は行わない
		}
		// FROM句を先にvisitしてcurrentTableを設定
		if node.From != nil {
			Walk(node.From, visitor)
		}
		// その他の要素をvisit
		for _, col := range node.Columns {
			Walk(col, visitor)
		}
		if node.Where != nil {
			Walk(node.Where, visitor)
		}
		// OrderBy, Limit を巡回
		for _, orderBy := range node.OrderBy {
			Walk(orderBy, visitor)
		}
		if node.Limit != nil {
			Walk(node.Limit, visitor)
		}
		// TODO: GroupBy, Having の巡回を追加

	case *ast.Identifier:
		visitor.VisitIdentifier(node)
	case *ast.IntegerLiteral:
		visitor.VisitIntegerLiteral(node)
	case *ast.StringLiteral:
		visitor.VisitStringLiteral(node)
	case *ast.BooleanLiteral:
		visitor.VisitBooleanLiteral(node)
	case *ast.PrefixExpression:
		// Prefix は右辺を先に評価してから自身を評価
		Walk(node.Right, visitor)
		if !visitor.VisitPrefixExpression(node) {
			return
		}
	case *ast.InfixExpression:
		// 中置式は左右を先に評価してから自身を評価する(型チェックのため)
		Walk(node.Left, visitor)
		Walk(node.Right, visitor)
		if !visitor.VisitInfixExpression(node) {
			return
		}
	case *ast.OrderByExpression:
		// OrderBy は式を先に評価
		Walk(node.Column, visitor)
		if !visitor.VisitOrderByExpression(node) {
			return
		}
	case *ast.LimitClause:
		// Limit は値を先に評価
		Walk(node.Value, visitor)
		if !visitor.VisitLimitClause(node) {
			return
		}
	case *ast.AllColumns:
		visitor.VisitAllColumns(node)
	case *ast.FunctionCall:
		// 関数は引数を先に評価してから自身を評価
		// Walk(node.Name, visitor) // 関数名は識別子だが、ここでVisitする必要はないかも
		for _, arg := range node.Arguments {
			Walk(arg, visitor)
		}
		if !visitor.VisitFunctionCall(node) {
			return
		}
	case *ast.AliasExpression:
		// AS は元となる式を先に評価
		Walk(node.Expression, visitor)
		if !visitor.VisitAliasExpression(node) {
			return
		}
		// Walk(node.Alias, visitor) // エイリアス名自体は識別子だが、VisitIdentifierで処理される

	// 他のノードタイプもここに追加
	}
}
このスクラップは4ヶ月前にクローズされました