SQLにも色を付ける

今日はSQLに色をつけてみる。

と簡単にいうが、実はSQLにも色々ある。 データベースエンジンによる方言があるし、標準規格も世代によってちょっと違うし、なかなか一つで全てを網羅するのは難しい。 なので、ここでは自分的に使用頻度が最も高いOracleのSQLを対象とし、以下に対して色を付けることにする。

ブロックコメント

「/*」 から 「*/」 まで。 改行を含むことができる。

ラインコメント

「--」 から行末まで。 定義から明らかだが、改行を含まない。

文字列

「'」 で囲まれた領域。 改行を含まない。

文字列中に 「'」 を含める場合は、シングルクォーテーションを2つ繋げて 「''」 とする。

識別子文字列

「"」 で囲まれた領域。 改行を含まない。

空白やマルチバイト文字を含んだテーブル名や列名を使用するときに使われる。

予約語

ACCESS, ADD, ALL, ALTER, AND, ANY, AS, ASC, AUDIT, BETWEEN, BY, CHAR, CHECK, CLUSTER, COLUMN, COLUMN_VALUE, COMMENT, COMPRESS, CONNECT, CREATE, CURRENT, DATE, DECIMAL, DEFAULT, DELETE, DESC, DISTINCT, DROP, ELSE, EXCLUSIVE, EXISTS, FILE, FLOAT, FOR, FROM, GRANT, GROUP, HAVING, IDENTIFIED, IMMEDIATE, IN, INCREMENT, INDEX, INITIAL, INSERT, INTEGER, INTERSECT, INTO, IS, LEVEL, LIKE, LOCK, LONG, MAXEXTENTS, MINUS, MLSLABEL, MODE, MODIFY, NESTED_TABLE_ID, NOAUDIT, NOCOMPRESS, NOT, NOWAIT, NULL, NUMBER, OF, OFFLINE, ON, ONLINE, OPTION, OR, ORDER, PCTFREE, PRIOR, PUBLIC, RAW, RENAME, RESOURCE, REVOKE, ROW, ROWID, ROWNUM, ROWS, SELECT, SESSION, SET, SHARE, SIZE, SMALLINT, START, SUCCESSFUL, SYNONYM, SYSDATE, TABLE, THEN, TO, TRIGGER, UID, UNION, UNIQUE, UPDATE, USER, VALIDATE, VALUES, VARCHAR, VARCHAR2, VIEW, WHENEVER, WHERE, WITH

予約語はOracle12cのオンラインマニュアルから拾ってきた。 いっぱいあるが、他のメジャーどころの PostgreSQL や SQL Server とは結構違うんだね。 当たり前のように使っていた inner join や within group がOracleでは予約語じゃなかったのは、ちょっとした衝撃だったよ。

歴史を振り返ると、かつてのOracleはテーブルの結合をfrom句ではなくwhere句でやっていた。 だからOracleには inner join なんて存在しなかった。 それが標準化の煽りを受けて方針転換し、今ではfrom句での結合を推奨している。 しているのだが、方針変換してから何年も経つのにまだ inner join が予約語扱いでないとか、中の人も色々思うことがあるのだろう。

で、色を付ける処理だが、エンジン部分はそのまま。 色付けする場所を判別するための正規表現をSQL用に準備するだけ。

その正規表現も、基本的な考え方はJavaScriptと同じでだいたいいける。 コンパイルのためのパースだともっと難しくなるのだろうが、構文的に問題ないことを前提としているので、改行の有無とかをあまり厳密に考えなくてもだいたい上手くいくのだ。

一瞬悩んだのは文字列。 文字列中の 「'」 は 「''」 として表現する。 これを正規表現でどう表すか。

しかしこれは一瞬で解決した。 「''」 を含むように文字列全体をちゃんと判別できるのが理想だが、できなくても問題ないんだよな。 「''」 のせいで文字列が二つに別れても、色をつけた結果の見え方は何も変わらないのだから、単純に 「'」 に挟まれた領域でいいのだ。

という方針で、色を付ける処理。

/* クラス XxDef 内で定義する各要素判定用の配列要素は以下の形式。 [ 正規表現 , 要素表示用cssクラス名 , 判定用補助関数 ], ここで、 正規表現 /(最初の要素の前)(最初の要素)(残り)/ 要素表示用cssクラス名 <span class="要素表示用cssクラス名">判定要素</span> 判定用補助関数 正規表現だけでは結果を確定できない場合に使用する。 boolean 判定用補助関数( それまでの処理結果, 今回のマッチの結果 ) */ class BaseDef { static toObj( lst ) { return { regexp : lst[0], cssCls : lst[1], subChk : lst[2], } } } class JsDef extends BaseDef { static getDefLst( regexpMode ) { return ( ( regexpSubCheck ) => [ [ /^(.*?)(\/\*.*?\*\/)(.*)$/s , "comment" ], [ /^(.*?)(\/\/.*?)(\n.*)$/s , "comment" ], [ /^([^"]*?)("(?:\\.|[^"\\])*")(.*)$/s , "string" ], [ /^([^']*?)('(?:\\.|[^'\\])*')(.*)$/s , "string" ], [ /^([^`]*?)(`(?:\\.|[^`\\])*`)(.*)$/s , "string" ], [ /^([^\/]*?)(\/[^\/\*](?:\\.|[^\/\\])*\/)(.*)$/s , "regexp", regexpSubCheck ], [ /^(.*?)\b(break)\b(.*)$/s , "keyword" ], [ /^(.*?)\b(case|catch|class|const|continue)\b(.*)$/s , "keyword" ], [ /^(.*?)\b(debugger|default|delete|do)\b(.*)$/s , "keyword" ], [ /^(.*?)\b(else|enum|export|extends)\b(.*)$/s , "keyword" ], [ /^(.*?)\b(finally|for|function)\b(.*)$/s , "keyword" ], [ /^(.*?)\b(if|import|in|instanceof)\b(.*)$/s , "keyword" ], [ /^(.*?)\b(let)\b(.*)$/s , "keyword" ], [ /^(.*?)\b(new)\b(.*)$/s , "keyword" ], [ /^(.*?)\b(return)\b(.*)$/s , "keyword" ], [ /^(.*?)\b(super|switch)\b(.*)$/s , "keyword" ], [ /^(.*?)\b(this|throw|try|typeof)\b(.*)$/s , "keyword" ], [ /^(.*?)\b(var|void)\b(.*)$/s , "keyword" ], [ /^(.*?)\b(while|with)\b(.*)$/s , "keyword" ], [ /^(.*?)\b(yield)\b(.*)$/s , "keyword" ], [ /^(.*?)\b(false|null|true|undefined)\b(.*)$/s , "keyvalue" ], ].map( d => super.toObj( d ) ) )( regexpMode === "strict" ? regexpSubCheckStrict : regexpSubCheckLoose ); // 正規表現リテラルの補助判定:「/」の前の文字が割り算ならあり得ない場合にtrueを返す。 function regexpSubCheckLoose( acc, matched ) { const text = ( acc + matched[1] ).replace( /<span class="comment">.+?<\/span>/g, '' ) .replace( /<span class=".+?">(.+?)<\/span>/g, '$1' ) .replace( /\s*$/, '' ); return text.length === 0 || /[-+*\/%!~<>=&^|?:,[{(;]$/.test( text ); } // 正規表現リテラルの補助判定:「/」を「/^」にしても構文エラーにならない場合にtrueを返す。 function regexpSubCheckStrict( acc, matched ) { const src = ( acc + matched[1] ).replace( /<span class=".+?">(.+?)<\/span>/g, '$1' ) + matched[2].replace( /^\//, '/^' ) + matched[3]; try { Function( src.replace( /&lt;/g, '<' ) .replace( /&gt;/g, '>' ) .replace( /&amp;/g, '&' ) .replace( /&quot;/g, '"' ) ); return true; } catch( e ) { return false; } } } } class SqlDef extends BaseDef { static getDefLst() { return [ [ /^(.*?)(\/\*.*?\*\/)(.*)$/s , "comment" ], [ /^(.*?)(--.*?)(\n.*)$/s , "comment" ], [ /^([^']*?)('[^']*')(.*)$/s , "string" ], [ /^([^"]*?)("[^"]*")(.*)$/s , "idstring" ], [ /^(.*?)\b(ACCESS|ADD|ALL|ALTER|AND|ANY|AS|ASC|AUDIT)\b(.*)$/is , "keyword" ], [ /^(.*?)\b(BETWEEN|BY)\b(.*)$/is , "keyword" ], [ /^(.*?)\b(CHAR|CHECK|CLUSTER|COLUMN|COLUMN_VALUE|COMMENT|COMPRESS|CONNECT|CREATE|CURRENT)\b(.*)$/is , "keyword" ], [ /^(.*?)\b(DATE|DECIMAL|DEFAULT|DELETE|DESC|DISTINCT|DROP)\b(.*)$/is , "keyword" ], [ /^(.*?)\b(ELSE|EXCLUSIVE|EXISTS)\b(.*)$/is , "keyword" ], [ /^(.*?)\b(FILE|FLOAT|FOR|FROM)\b(.*)$/is , "keyword" ], [ /^(.*?)\b(GRANT|GROUP)\b(.*)$/is , "keyword" ], [ /^(.*?)\b(HAVING)\b(.*)$/is , "keyword" ], [ /^(.*?)\b(IDENTIFIED|IMMEDIATE|IN|INCREMENT|INDEX|INITIAL|INSERT|INTEGER|INTERSECT|INTO|IS)\b(.*)$/is , "keyword" ], [ /^(.*?)\b(LEVEL|LIKE|LOCK|LONG)\b(.*)$/is , "keyword" ], [ /^(.*?)\b(MAXEXTENTS|MINUS|MLSLABEL|MODE|MODIFY)\b(.*)$/is , "keyword" ], [ /^(.*?)\b(NESTED_TABLE_ID|NOAUDIT|NOCOMPRESS|NOT|NOWAIT|NULL|NUMBER)\b(.*)$/is , "keyword" ], [ /^(.*?)\b(OF|OFFLINE|ON|ONLINE|OPTION|OR|ORDER)\b(.*)$/is , "keyword" ], [ /^(.*?)\b(PCTFREE|PRIOR|PUBLIC)\b(.*)$/is , "keyword" ], [ /^(.*?)\b(RAW|RENAME|RESOURCE|REVOKE|ROW|ROWID|ROWNUM|ROWS)\b(.*)$/is , "keyword" ], [ /^(.*?)\b(SELECT|SESSION|SET|SHARE|SIZE|SMALLINT|START|SUCCESSFUL|SYNONYM|SYSDATE)\b(.*)$/is , "keyword" ], [ /^(.*?)\b(TABLE|THEN|TO|TRIGGER)\b(.*)$/is , "keyword" ], [ /^(.*?)\b(UID|UNION|UNIQUE|UPDATE|USER)\b(.*)$/is , "keyword" ], [ /^(.*?)\b(VALIDATE|VALUES|VARCHAR|VARCHAR2|VIEW)\b(.*)$/is , "keyword" ], [ /^(.*?)\b(WHENEVER|WHERE|WITH)\b(.*)$/is , "keyword" ], ].map( d => super.toObj( d ) ); } } class Parser { constructor( defs ) { this.finders = defs.map( d => ( text, acc ) => { const matched = text.match( d.regexp ); return !matched ? { pre : text, got : "", pst : "" } : d.subChk && !d.subChk( acc, matched ) ? { pre : text, got : "", pst : "" } : { pre : matched[1], got : `<span class="${d.cssCls}">${matched[2]}</span>`, pst : matched[3] } } ); } findFirst( text, acc ) { return this.finders.map( f => f( text, acc ) ) .reduce( ( r0, r1 ) => r0.pre.length < r1.pre.length ? r0 : r1 ) } parse( text, acc = "" ) { const r = this.findFirst( text, acc ); if ( !r.got ) { return acc + r.pre; } return this.parse( r.pst, acc + r.pre + r.got ); } } const setColor = () => { const jsElm = document.getElementById( "smpl01" ); jsElm.innerHTML = ( new Parser( JsDef.getDefLst() ) ).parse( jsElm.innerHTML ); const sqlElm = document.getElementById( "smpl02" ); sqlElm.innerHTML = ( new Parser( SqlDef.getDefLst() ) ).parse( sqlElm.innerHTML ); document.getElementById( "btn01" ).disabled = true; }

そして色を付けられるSQLのサンプル。

select w.BOOK_ID as "書籍ID" , b.BOOK_NAME as "書籍名" , listagg( a.AUTHOR_NAME, '/' ) within group ( order by a.AUTHOR_NAME ) as "著者一覧" from BOOK_AUTHOR w inner join BOOK b on w.BOOK_ID = b.BOOK_ID inner join AUTHOR a on w.AUTHOR_ID = a.AUTHOR_ID group by w.BOOK_ID , b.BOOK_NAME

上のボタンをクリックすると、上記処理を上記ソースコード表示に適用して色を付ける。

Oracleの定義がそうなってるのだからしょうがないのだが、 on に色が付いているのに inner join がそのままとか、違和感が半端ない。

SQL用の定義追加以外の昨日からの変化点だが、コメントを入れた。

なんてことはどうでもよくて、JavaScriptの正規表現判定の補助判定ロジックを選択できるようにするために、判定定義をクラスの中に入れた。 静的メソッドが一つだけのクラス。 インターフェースを揃えるために、SQLの方も同様に。 ただしこちらは今のところ判定処理を切り替える必要がないので、ただクラスの皮を被せただけになっている。

あと、判定の定義リストの要素が配列なので、パーサーの中で使う時に、正規表現なら d[0] と書いてた。 でもこれじゃ後から見た時に判り難いと反省して d.regexp と、見れば判るような書き方にした。 が、たくさんある定義を一々オブジェクト形式で書くのは面倒なので、配列をオブジェクトに変換するメソッド toObj を親クラスに定義して、定義リストを返すときにオブジェクトのリストに変換するようにした。

継承しているのは、この変換メソッドのため。 親子じゃなくて赤の他人、例えば Util みたいなクラスでもよかったのだが、なんとなく親子に。 その一方で、インターフェースを揃えるとか言って作ったメソッド getDefList は、サブクラスにしか定義がなかったりする。 割とカオス。

さて、とりあえず二つ、JavaScriptとSQLでやってみた結果、他の言語でもだいたいなんとかなりそうって実感が湧いてきた。

扱う言語を増やす場合は、上記同様に定義情報のクラスを作る。

言語によっては正規表現でちょっと悩むことになるかもしれないが、正規表現だけじゃ判定できない場合に対応する口を持たせることができたので、きっとなんとかなるだろう。

ということで、色を付けるあれこれは一旦ここまで。