🦁

SOQLのクエリをER図として可視化するツールを作ってみた

2023/05/03に公開

はじめに

私は現在、Salesforceで使われているSOQLというクエリ言語のオープンソースとしての再実装に取り組んでいます。
以前はTypeScriptで実装を行いましたが、直近ではGoによりパーサーを書き直し、実行エンジンの再作成をしています。
Goによるクエリパーサーが主要なクエリへの対応を完了したので、クエリをER図として可視化するツールを作ってみました。
https://github.com/shellyln/go-open-soql-visualizer
https://shellyln.github.io/soql-visualizer/
screenshot

ツールで使用したもの

フロントエンドはVanilla JavaScriptで作っています。
ER図のレンダリングにはMermaidを使用しました。本当はPlantUMLの方が好きなのですが、クライアントだけで何とかしたいのでMermaidを選びました。

SOQLのパーサーははじめに記載した通り、自作のものを使用しています。
https://github.com/shellyln/go-open-soql-parser
パーサーはパーサーコンビネーターとして構築されており、パーサーライブラリも自作のものを使用しています。
https://github.com/shellyln/takenoco
(Tinyではない方の)Goでwasmにビルドしています。バイナリサイズは3.1MB(gzip圧縮後863KB)です。大きいですが、こういう目的ならば、まあいいかと思います。

SOQLパーサーから得られる情報

SOQLパーサーは字句解析、構文解析により各句の情報を構造体に入れた後、後処理として主に以下のことを行っています。

  • オブジェクト名、フィールド名への名前空間の付与とエイリアス名の解決 (オブジェクトグラフの完全修飾名にします)
  • オブジェクト毎のクエリ構築 (項目の抽出とオブジェクト内で完結する検索条件の決定)
  • 結合方法の決定 (1:0-1の結合の右側に検索条件がある場合は内部結合にします)
  • オブジェクトグラフ、クエリグラフの作成 (クエリのネストに基づく親子関係を出力します)

以下の構造体に情報は格納されます。

type SoqlViewGraphLeaf struct {
	Name         string          `json:"name"`                // Name
	ParentViewId int             `json:"parentViewId"`        // View id of parent object on object graph
	QueryId      int             `json:"queryId"`             // Query unique id
	Depth        int             `json:"depth"`               // Depth on object graph
	QueryDepth   int             `json:"queryDepth"`          // Query depth
	Many         bool            `json:"many,omitempty"`      // True if it is one-to-many relationship (subquery)
	InnerJoin    bool            `json:"innerJoin,omitempty"` // Inner join to parent view id
	NonResult    bool            `json:"nonResult,omitempty"` // True if it is subquery on conditions (where | having clause)
	Object       *SoqlObjectInfo `json:"-"`                   // Object
	Query        *SoqlQuery      `json:"-"`                   // Query
}

type SoqlQueryGraphLeaf struct {
	ParentQueryId int        `json:"parentQueryId"` // Query id of parent query
	Depth         int        `json:"depth"`         // Depth on query graph
	IsConditional bool       `json:"isConditional"` // Query is part of filter (where/having) condition or not
	Query         *SoqlQuery `json:"-"`             // Query
}

type SoqlQueryMeta struct {
	Version          string                     `json:"version,omitempty"`          // format version
	Date             time.Time                  `json:"date,omitempty"`             // compiled datetime
	ElapsedTime      time.Duration              `json:"elapsedTime,omitempty"`      // time taken to compile
	Source           string                     `json:"source,omitempty"`           // source
	MaxQueryDepth    int                        `json:"maxQueryDepth,omitempty"`    // max depth of query graph
	MaxViewDepth     int                        `json:"maxViewDepth,omitempty"`     // max depth of object graph
	NextQueryId      int                        `json:"nextQueryId,omitempty"`      // next query id (a number of queries)
	NextViewId       int                        `json:"nextViewId,omitempty"`       // next view id (a number of views)
	NextColumnId     int                        `json:"nextColumnId,omitempty"`     // next column id (a number of columns)
	QueryGraph       map[int]SoqlQueryGraphLeaf `json:"queryGraph,omitempty"`       // query graph (child -> parent)
	ViewGraph        map[int]SoqlViewGraphLeaf  `json:"viewGraph,omitempty"`        // object graph (child -> parent)
	Functions        map[string]struct{}        `json:"functions,omitempty"`        // functions
	Parameters       map[string]struct{}        `json:"parameters,omitempty"`       // parameters
	DateTimeLiterals map[string]struct{}        `json:"dateTimeLiterals,omitempty"` // datetime literals
}

type SoqlQuery struct {
	Fields           []SoqlFieldInfo          `json:"fields,omitempty"`           // Select clause fields; possibly null
	From             []SoqlObjectInfo         `json:"from,omitempty"`             // From clause objects; has at least one element
	Where            []SoqlCondition          `json:"where,omitempty"`            // Where clause conditions; possibly null; Not used in the execution planning phase.
	GroupBy          []SoqlFieldInfo          `json:"groupBy,omitempty"`          // Group by clause fields; possibly null; Not used for "PerObjectQuery"
	Having           []SoqlCondition          `json:"having,omitempty"`           // Having clause conditions; possibly null; Not used for "PerObjectQuery"
	OrderBy          []SoqlOrderByInfo        `json:"orderBy,omitempty"`          // Order by clause fields; possibly null
	OffsetAndLimit   SoqlOffsetAndLimitClause `json:"offsetAndLimit,omitempty"`   // Offset and limit clause
	For              SoqlForClause            `json:"for,omitempty"`              // For clause
	Parent           *SoqlQuery               `json:"-"`                          // Pointer to parent query; Not used for "PerObjectQuery"
	IsAggregation    bool                     `json:"isAggregation,omitempty"`    // It is an aggregation result or not; Not used for "PerObjectQuery"
	IsCorelated      bool                     `json:"isCorelated,omitempty"`      // Co-related query if true
	PostProcessWhere []SoqlCondition          `json:"postProcessWhere,omitempty"` // Post-processing conditions (Conditions to apply after being filtered in the query for each object)
	QueryId          int                      `json:"queryId,omitempty"`          // Query unique id
	Meta             *SoqlQueryMeta           `json:"meta,omitempty"`             // Meta information
}

ER図の描画

ER図への描画は、前章の構造体をシリアライズすると失われる情報(キャッシュとして持っている各句の構造体へのポインタ)があるので、Go側でクエリをパースした後、引き続いてMermaidの定義を文字列として出力しています。
Where句・Having句内のサブクエリの結合先オブジェクトについては、現在検討している実行計画では直接知る必要が無い[1]のでパース結果に持っていません。可視化ライブラリ内で探すようにしました。

https://github.com/shellyln/go-open-soql-visualizer/blob/5cd5642c234fe6e6492490bdb997cbc6ab91b759/soql/visualizer/visualizer.go#L25-L52

さいごに

今年は引き続きSOQL再実装プロジェクトを進めていきたいと考えています。
もし関心をお持ちいただけるなら、スターやフィードバックをいただけると幸いです。

脚注
  1. 条件句内のサブクエリは現在のクエリにおいて結果セットとなるクエリに先んじて行われる、また、クエリ結果はスタックマシンで評価されるので結合先を求める必要が無い。 ↩︎

Discussion