freeeアクセシビリティー・ガイドラインVer. 202201.0を公開しました

こんにちは、freeeのアクセシビリティー・ガイドラインおじさんの中根です。 気づけばこのブログ、今年最初のエントリーのようです。ということで、皆様今年もこのブログをよろしくお願いします。

さて、今回もfreeeアクセシビリティー・ガイドラインの更新情報をお届けします。

前回ここに投稿した更新情報は、昨年10月のVer. 202110.0リリース時でした。そのリリースと今回のリリースの間に、こちらでは特にお知らせしませんでしたが4回リリースがありました。 と言うことで今回は、前回の投稿より後の更新内容の中からいくつか紹介します。

Android関連の情報を追加

前回のエントリーで、モバイル・アプリのチェック・リストとiOSの参考情報の追加について紹介しました。 その後、Androidについても参考情報を追加しました。

また、Androidでの拡大表示の方法、グレースケール表示の方法についても追加しました。

ガイドラインの変更

ガイドラインに関する以下の変更をしました。

[削除]画像化されたテキスト:隣接領域とのコントラスト比の確保

画像化されたテキスト中の「隣接領域とのコントラスト比の確保」を削除しました。

このガイドラインは元々、テキストを画像化したものについてても他の画像と同様の条件を満たすべきだろうという考えで作ったものです。 しかし、画像化されたテキストについては、画像とその周囲とのコントラスト比がどうであれ、テキストの文字列とその背景色のコントラスト比が確保されていることが重要です。 そこで、このガイドラインを削除することにしました。

[文言変更]クリック/タッチのターゲット・サイズに関するガイドライン

ガイドラインの適用対象が明確になるように、文言を変更しました。

ARIAランドマークに関する情報を追加

これまでもARIAランドマークの必要性などについては参考情報で紹介していましたが、既存のWebページ上でどのように確認すれば良いのか分からないなどの疑問が寄せられることがしばしばありました。 そこで、ARIAランドマークの利用状況を可視化するブラウザーの拡張機能に関する情報、これを活用したチェック方法などを追加しました。

チェック・リストの更新

前述した0682のチェックの追加の他、ここまで触れたガイドラインの変更を反映する形でのチェックの見直しや、その他内容の変更を伴わない全般的な文言の見直しなどを行いました。

主な変更に関してはチェック実施用Googleスプレッドシートのページに記載するようにしましたので、こちらもご確認ください。 なお、チェック・リストにはバージョン番号を記していますが、このバージョン番号の付け方に関する方針についても合わせて記載しています。

ちょっとお知らせ

さて、今回の更新内容とは直接関係ありませんが、前回のエントリーで紹介したモバイル・アプリのチェック追加に関連したイベントを昨年11月12日に開催しました。 アーカイブを公開していますので、モバイル・アプリのアクセシビリティーに興味がある方はぜひご覧ください:

また、このアーカイブの公開に合わせてfreeeのアクセシビリティーに関連する動画を配信するチャンネルを開設しました。 今後イベントのアーカイブに加えて、デモ動画なども掲載する予定です。

引き続きご意見などお待ちしています

今回の変更についても、それ以外の部分についても、ご意見やお気付きの点など、GitHubリポジトリーのIssuesやPull Requestsでお知らせください。

freee の API では BigDecimal をどう扱うべきなのか?

この記事は freee Developers Advent Calendar 2021 の最終日の記事です🎄

普段は freee会計 や freee人事労務 といった、freee におけるコアサービスの開発の面倒を見る、プロダクトコア開発本部の本部長をしています id:yo_waka です。

前回書いたときは Webpack でビルドしていましたが、昨日も Webpack でビルドしていました。

今回何を書こうかなーと悩んでいたのですが、最近 BigDecimal の扱いについて社内で議論する機会があったので共有してみます。

あらまし

  1. Rubyのバージョンアップをするぞ
  2. 依存ライブラリを新しいRubyバージョンに対応したものに上げていくぞ
  3. jbuilder 並びに JSON gem のバージョンを上げていくぞ <= 今日はここの話題です

freee では、Rails で API レスポンスとして JSON を返す際に Rails/jbuilder を使っているのですが、jbuilder のバージョンを上げると BigDecimal で扱っていた値の型が非互換になってしまう問題が分かりました。

これまで使っていたバージョンの jbuilder では、MultiJson という gem を通じて JSON の parse/dump 処理を様々な gem に委譲することができました。
freee会計 では委譲先の gem として、Oj を使って JSON を dump しています。
これが、jbuilder を最新のバージョン(正確にはv2.11.5以上)に上げると、MultiJson を使わずに Ruby にバンドルされる JSON gem を使うようになります。 このdiffがそうです。

以前の
# Encodes the current builder as JSON.
def target!
  ::MultiJson.dump(@attributes)
end

最新バージョンの
# Encodes the current builder as JSON.
def target!
  ::JSON.dump(@attributes)
end

ここで BigDecimal の扱いに差分が出ることが分かりました。
現在使っている Oj では、BigDecimal を JSON dump すると、float値が返る仕様になっています。
一方で JSON gem で JSON dump すると、String値が返るようになります。

hash = { qty: BigDecimal('100.00001') }

Oj.dump(hash)
=> "{\":qty\":100.00001}"

JSON.dump(hash)
=> "{\"qty\":\"100.00001\"}"

という感じで JSON dump 後の型が float から String に変わってしまう問題が起きます。 モバイルアプリのように、クライアント側で API レスポンスの型定義をして扱っている場合、クラッシュしてしまう可能性があります。

一旦休憩

ちょっと疲れてきたので一旦 JSON gem が BigDecimal をどう扱っているか、実装を見てみます。

JSON#dump では value の型チェックをしながら出力していくわけですが、各種型ごとに オープンクラスで as_json メソッドを拡張していて、出力時に呼び出されます。 BigDecimal の場合このように定義されていました(一部引用)。

https://github.com/flori/json/blob/09dd1d7fe35d8e094c22df6eac66331ff010ed66/lib/json/add/bigdecimal.rb#L7

class BigDecimal
  # Marshal the object to JSON.
  #
  # method used for JSON marshalling support.
  def as_json(*)
    {
      JSON.create_id => self.class.name,
      'b'            => _dump,
    }
  end
end

ここで注意なのが、Rails をフレームワークとして採用している場合 ActiveSupport を使うことになりますが、ActiveSupport 側でさらに as_json メソッドが拡張されます。

https://github.com/rails/rails/blob/18707ab17fa492eb25ad2e8f9818a320dc20b823/activesupport/lib/active_support/core_ext/object/json.rb#L118

class BigDecimal
  # A BigDecimal would be naturally represented as a JSON number. Most libraries,
  # however, parse non-integer JSON numbers directly as floats. Clients using
  # those libraries would get in general a wrong number and no way to recover
  # other than manually inspecting the string with the JSON code itself.
  #
  # That's why a JSON string is returned. The JSON literal is not numeric, but
  # if the other end knows by contract that the data is supposed to be a
  # BigDecimal, it still has the chance to post-process the string and get the
  # real value.
  def as_json(options = nil) # :nodoc:
    finite? ? to_s : nil
  end
end

これまで議論があったんだろうなと察する感じのコメントが書かれていて興味深いですね。 「ほとんどのライブラリがfloatとして返しているけど、それは間違ってるので文字列で返しますよ。その文字列を自分たちで処理して使ってね」と書かれています。

というわけで、 BigDecimal#to_s が最終的に呼ばれていることが分かりました。

BigDecimal#to_s の定義場所はここでした。

https://github.com/ruby/bigdecimal/blob/ac7daa5f15c4583b3cdf7db046e0dcad772360af/ext/bigdecimal/bigdecimal.c#L2508

ちゃんと指数部を変換してくれていますね。

API レスポンスでは BigDecimal な値をどう返すべきか

サーバサイドでは丸め誤差を防ぐ必要があるので、小数を含む数値を BigDecimal として扱うのは当然として、上記問題が起こる場合にクライアントにどう返すべきなのか。 挙動が変わることで起きるクライアントアプリのクラッシュのリスクを極力増やしたくないこと、上記実装を見ていって BigDecimal#to_s は信頼できる値が返ることが分かったので、クライアントには to_f した float 値を返せばいいんじゃないかと最初は思っていました。

qty = BigDecimal('100.00001')

qty.to_s
=> "100.00001"

qty.to_s.to_f
=> 100.00001

実際、会計や人事労務領域において、数値型、とりわけ小数を扱う機会はあまりないのです。 「金額」がほとんどで、一部業務で「個数」「年齢」「人数」を扱うくらい。あとは enum値でコードを持ったりしますが、いずれも整数です。 こと国内向けのサービスであれば、金額は円で、会計上は小数で扱うことがありません。 個数は小数を扱うケースがありますが、ほとんどのケースで小数点第二位までくらいです。

ということもあり、これまで小数の扱いについてちゃんと考える機会がなかったなと。 上のような議論を社内の Slack でしていて、こんな意見が出てきました。

BigDecimalのユースケース

確かに外貨を扱う場合はありえそう・・! 一気に自分の中で BigDecimal は String で返してクライアントで処理しなさい派に傾いてしまいましたw ActiveSupport に書かれていたコメントが腹落ちできてよかったです。

今後は、モバイルアプリのバージョンアップをお願いするコミュニケーションを取らせていただいて進捗した後に、BigDecimal を String で返す修正を入れて、ライブラリのアップデートを進めていく予定です。

ちなみに JavaScript の小数演算は、IEEE 754(浮動小数点数演算標準) の規格に沿っているため、そのまま扱ってしまうと誤差が出てしまいます。 また、一定の値を超えると正しく表示できなくなります。

0.1 + 0.2
> 0.30000000000000004

70368744177664.01
> 70368744177664.02

ブラウザで正確に扱いたい場合は bugnumber.js などの BigInt, BigDecimal を実装しているライブラリを使いましょう。

最後に

freeeでは毎日各チームで業務ドメインどうする議論が行われています。 それを繰り返していくだけで、ユースケースと実装の紐付けが強くなっていく実感が持てるので、そういうのいいなと思った方がいればぜひ話を聞きにきてみてください。

jobs.freee.co.jp

今年の freee Developers Advent Calendar 2021 はこれで終わりです。また来年もご期待ください。

穴馬を探せ!freee人事労務のAPIで有馬記念を予想する

freeeのPublc APIチームでエンジニアリングマネージャーをしているまっつーです。

この記事はfreee Developers Advent Calendar 2021の24日目の記事です。

クリスマスイブですでにウキウキしている方もいるでしょうが、落ち着いてください。我々にはまだやることがあります。

そう、有馬記念です。

有馬記念は毎年12月に中山競馬場で開催される競馬のレース名です。

年末に行われる大レースということで競馬ファンの中では年末の風物詩になっています。

やるからには当てたい、では早速有馬記念の予想をしていきましょう。

手順1 今年のサインを探す

競馬の予想にはいろいろな方法がありますが、サイン理論とよばれるものがあります。

説明はwikipediaに任せます。

サイン理論(サインりろん)とは、競馬の勝ち馬予想の手法の一つのことである。広義的な意味でケントク買い(見得買い、見徳買い)の一種として扱うこともある。 この手法を用いて買い目を決めた勝馬投票券をサイン馬券ともいう。

サイン理論 - Wikipedia

サイン馬券は過去の有馬記念でも出たことがあります。

2012年には今年の漢字に「」が選ばれ、その年にはゴールドシップという名前の馬が一位になりました。

なんと2021年も今年の漢字は「金」です、つまり「ゴールド」という単語が入る馬が1位になるに違いありません。

しかし残念ながら今年の出馬表をみると名前に「ゴールド」が含まれる馬はいません。

有馬記念の馬柱

有馬記念【2021年12月26日中山11R】出馬表、予想 | 競馬ラボ

では今年の漢字以外に2021年の世相を反映したものはないでしょうか?

そこで僕は新語・流行語大賞に目をつけました。

今年の流行語大賞は「リアル二刀流/ショータイム」です。

MVPを受賞したロサンゼルス・エンゼルスの大谷翔平選手ですね。

まさに2021年を代表する方と言って差し支えないでしょう。

大谷関連のサインに違いない。

となると思いつくのは誕生日、大谷選手の誕生日は1994年7月5日です。

大谷翔平 - Wikipedia

7→5の馬単、普通の人であればこんな予想をするでしょう。

しかし僕もギャンブラーの端くれです。

ギャンブルとは隣のギャンブラーとの知恵と運の競い合い、他人と同じ賭け方をしていては決して勝つことはできません。

考えた結果、一つのアイディアにたどり着きました。

「今年の大谷選手の誕生日である2021年7月5日、その日の自分の勤怠情報を使って予想したら今年の世相も反映しつつ誰ともかぶらない予想ができるんじゃないか?」

良さそう、では早速勤怠情報を取得します。

手順2 freee人事労務から自身の勤怠状態方法を取得する

freeeでは自社のサービスであるfreee人事労務を利用して勤怠管理をしています。

僕の所属がPublic APIチームなので、freee人事労務のPublic APIを利用して勤怠情報を取得したいと思います。

まずはfreeeアプリストアにアクセスし、開発者ページ → 今すぐアプリを作成をクリックします。

freeeアプリストアのトップページのスクリーンショット。上部のグローバルヘッダーの開発者ページと文字が赤い四角で囲われている。
freeeアプリストアのトップページ

freeeアプリストアの開発者ページのスクリーンショット。今すぐアプリを作成と書かれたボタンが赤い刺客で囲われている。
freeeアプリストアの開発者ページ

アプリ名・概要を入力し、アプリストアでの公開予定はないのでプライベートアプリを指定、利用規約同意にチェックをいれて作成をクリックします。

freeアプリストアのアプリ作成画面のスクリーンショット。アプリ名に有馬記念予想、概要に有馬記念を予想するためのアプリです、と入力してありパブリックアプリとプライベートアプリのラジオボタンはプライベートアプリをチェックし、ディベロッパー規約とfreeeAPI規約に同意するのチェックボックスにチェックが入っている。
アプリ作成画面

アプリ管理画面に遷移します。

画面下部にWebアプリ認証用URL、モバイル・JSアプリ認証用URLが表示されています。

freeeアプリストアのアプリ管理画面のスクリーンショット。Client ID、Client Secret、Webアプリ認証用URL、モバイル・JSアプリ認証用URLが黒塗りでマスクしてある。
アプリ管理画面

アプリを作成した直後の状態ではWebアプリ認証用URLにアクセスすると認可コードが、モバイル・JSアプリ認証用URLにアクセスすると直接アクセストークンがそれぞれ取得できます。

認可コードを使ったアクセストークンの取得方法は、freeeのdeveloper siteに説明があるのでそちらを読んでください。

今回はとりあえずアクセストークンが取れればいいので、モバイル・JSアプリ認証用URLにアクセスします。

認可画面に遷移するので、事業所と認可するスコープを確認して許可するを押します。

freeeの認可画面のスクリーンショット。
認可画面

するとアクセストークンが取得できます。

freeeのアクセストークン取得画面のスクリーンショット。
ブラウザ上にアクセストークンが表示される

アクセストークンが取得できたので次に人事労務APIを叩きます。

人事労務APIのリファレンスから、勤怠上を取得するエンドポイントを探します。

勤怠のエンドポイントで欲しい情報が取得できそうです。

freee developerサイトの中にある人事労務APIの勤怠のgetのスキーマのスクリーンショット。
勤怠のエンドポイント

requiredとなっているパラメータのうちdateは2021-07-05で確定ですが、company_idとemp_idがわかりません。

まずはそれを調べるためにログインユーザのエンドポイントを叩きます。

freee developerサイトの中にある人事労務APIのログインユーザのgetのスキーマのスクリーンショット。
ログインユーザのエンドポイント

以下はAPIのリクエストとレスポンスの結果です。実際のアクセストークンとidはマスクしてあります。

$  curl -H 'Authorization: Bearer foobarhogefuga' https://api.freee.co.jp/hr/api/v1/users/me

{
  "id": xxxxxx,
  "companies": [
    {
      "id": yyyyyy,
      "name": "フリー株式会社",
      "role": "self_only",
      "external_cid": "zzzzzz",
      "employee_id": aaaaaaa,
      "display_name": "松澤伸一郎"
    }
}

これでcompany_idがyyyyyy、employee_idがaaaaaaaであることがわかったので次は勤怠のAPIを叩きます。レスポンスは一部省略しています。

$ curl -H 'Authorization: Bearer foobarhogefuga' 'https://api.freee.co.jp/hr/api/v1/employees/aaaaaa/work_records/2021-07-05?company_id=yyyyyy'

{
  "break_records": [
    {
      "clock_in_at": "2021-07-05T12:00:00.000+09:00",
      "clock_out_at": "2021-07-05T13:00:00.000+09:00"
    }
  ],
  "clock_in_at": "2021-07-05T11:02:00.000+09:00",
  "clock_out_at": "2021-07-05T20:14:00.000+09:00",
  "date": "2021-07-05",
  "day_pattern": "normal_day",
  "normal_work_mins": 480,
  "total_overtime_work_mins": 12,
  .
  .
  .
}

情報が取れました!

手順3 取得した情報から馬券を予想する

無事7月5日の勤怠情報が取れたので、ここから本題の馬券予想に入ります。

レスポンスとリファレンスを照らし合わせると

  • clock_in_at: 出勤時間
  • clock_out_at: 退勤時間
  • normal_work_mins: 所定労働時間
  • total_overtime_work_mins: 時間外労働時間

であることがわかります。

つまり僕は今年の7月5日、11時2分に出勤し20時14分に退勤しています。

労働時間は480分+12分 = 8時間12分です。

11時、20時、8時間。。。。

11→2→8

もうわかりましたね。

今年の有馬記念は

  • 1着: 11番 アリストテレス
  • 2着: 2番 パンサラッサ
  • 3着: 8番 ユーキャンスマイル

に間違いありません。

僕はもちろん11→2→8の三連単一点買い、今年はこいつで勝負します。

12/24の午前時点ではまだ馬券販売が始まっていませんが、競馬情報サイトの予想オッズを見る限りこの買い目は

8番人気 → 9番人気 → 16番人気 (最下位人気)

なんでいわゆる超大穴馬券、有馬記念の過去最高額985,580円を超えそうです。

当たれば一足早い年末ジャンボになりそうです。アツい。

有馬記念(G1) オッズ | 2021年12月26日 中山11R レース情報(JRA) - netkeiba.com

最後に

freeeでは一緒に競馬 (あと仕事も)を楽しんでくれる仲間を募集しています! 少しでもご興味持っていただいた方は、ご応募よろしくお願いします!

jobs.freee.co.jp

そして一番大事なことですが馬券は20歳になってから、ほどよく楽しむ大人の遊びです。またこの記事はあくまで著者の勝手な想像による予想なので、いかなるクレームも受け付けませんのでご了承ください。

アドベントカレンダーも明日で終わり。

最終日はfreeeの誇る雀鬼、wakaharaさんです。

pingcap/parser (MySQL互換) で SQL を手軽に解析

この記事は freee Developers Advent Calendar 2021 の23日目の記事です🎄

freee の DBRE チームに所属している caterpillar です. なんだか大きなデータベースを眺める仕事をしています.

突然ですが, pingcap/parser を使って SQL を簡単に解析していきたいと思います. Go 製 の SQL Parser で, MySQL への高い互換性を謳う TiDB で利用されています. この parser の嬉しい点はこんな感じです.

  • シンプルで使いやすい
  • TiDB に利用されていることから, ある程度結果を信頼できる
  • mask 済 SQL もおおよそ構文解析可能

3つ目について, mask済の SQL は select * from users where id = ? のように一部が別の文字に置き換わっているものを指します. freee では, ORM が生成するクエリのパフォーマンス改善のため, セキュリティ観点で扱いやすいよう pt-fingerprint で masking を施したクエリを活用しています. pingcap/parser はおおよそ (後述) この mask 済 SQL を構文解析できました*1.

なおこの記事のソースコードは, github.com/pingcap/parser@6870058 のバージョンで動作確認しています*2.

利用例1: SQL に含まれるテーブル一覧を取得する

公式のquickstart を見るのが早いですが, まずは簡単な例で使い方を紹介します. 作成するのは「SQL中に出現したテーブル名の一覧を取得する」クエリ解析器です.

まず, チュートリアルに従って parse 関数を用意します(下記コード①). この関数にクエリ文字列を渡すと, AST (abstract syntax tree🎄) を得られます(③). 今回入力に使用した pt-fingerprint を用いて mask したクエリでは, 現状出会った範囲では where id in (?+) という形式が parse に失敗するため, 愚直に修正しています(②).

package main

import (
    "fmt"
    "github.com/pingcap/parser"
    "github.com/pingcap/parser/ast"
    _ "github.com/pingcap/parser/test_driver"
    "strings"
)

func parse(sql string) (*ast.StmtNode, error) { // ① 受け取った sql を Parse() に渡し, 結果ノードを返す
    p := parser.New()

    stmtNodes, _, err := p.Parse(sql, "", "")
    if err != nil {
        return nil, err
    }

    return &stmtNodes[0], nil
}

func main() {
    input := "select * from users where id = ?"
    sql := strings.ReplaceAll(input, "?+", "?")     // ② in (?+) が parser できないので置換
    astNode, _ := parse(sql)                        // ③ AST 生成
    ...
}

次に, AST を探索するインターフェース ast.Visitor が提供されているので, これを実装して AST を解析します.

Visitor を利用するには, 2つの関数 EnterLeave を実装する必要があります. Visitor は AST を深さ優先に巡回するわけですが, Enter は 親ノードから自身に到達した際に行う処理, Leave は子ノードの探索が終了した後に行う処理を記述します.

各ノードの型は parser/ast 以下でそれぞれ定義されていて, 型アサーションを利用することで判別できます.

Enter 関数内で, テーブル名を表すノード ast.TableName を見つけたら, 実際のテーブル名を記録しています(③). 欲しい情報を持つノードの型は, parse.y を眺めたり, 定義されている構造体を眺めていくと発見できます. TiDB document で確認できるものもあります.

// 見つけたテーブル名を格納する struct
type table struct {
    Names []string
}

func (v *table) Enter(in ast.Node) (ast.Node, bool) {
    if name, ok := in.(*ast.TableName); ok { // ③ 型アサーションで識別
        v.Names = append(v.Names, name.Name.O) // O は original, L は lower case を取得できる
    }
    return in, false
}

func (v *table) Leave(in ast.Node) (ast.Node, bool) {
    return in, true
}

func extract(rootNode *ast.StmtNode) []string {
    v := &table{}
    (*rootNode).Accept(v)
    return v.Names
}

作成した table Visitor を使用して, 以下のように SQL に登場するテーブルの一覧を取得できました.

func main() {
    input := "select `authors`.* from `authors` inner join `books` on `authors`.`id` = `books`.`author_id`" +
        " where `books`.`published_at` < ? order by `authors`.`id` limit ?"
    sql := strings.ReplaceAll(input, "?+", "?")     // ② in (?+) が parser できないので置換
    astNode, _ := parse(sql)                      // ③ AST 生成
    fmt.Printf(strings.Join(extract(astNode), ",")) // => authors,books
}

利用例2: Join しているテーブルを取得する

次に, SQL 中で Join が発生しているテーブルのペアを順に取得する Visitor パターンを実装してみます. 実際に Join される順番は Optimizer の実行計画に依存しますが, テーブル整理や怪しいクエリの監視に役立つかもしれません.

まずは Join を扱う ast.Node を探します.

・・・

・・・

・・・・・・

ありました! parser.y#L8836-L8876

基本 ast.Join に置き換えられ, メンバ LeftRight に Join するテーブル情報が子ノードとして加わるようです.

JoinTable:
    /* Use %prec to evaluate production TableRef before cross join */
    TableRef CrossOpt TableRef %prec tableRefPriority
    {
        $$ = ast.NewCrossJoin($1.(ast.ResultSetNode), $3.(ast.ResultSetNode))
    }
|   TableRef CrossOpt TableRef "ON" Expression
    {
        on := &ast.OnCondition{Expr: $5}
        $$ = &ast.Join{Left: $1.(ast.ResultSetNode), Right: $3.(ast.ResultSetNode), Tp: ast.CrossJoin, On: on}
    }
|   TableRef CrossOpt TableRef "USING" '(' ColumnNameList ')'
    {
        $$ = &ast.Join{Left: $1.(ast.ResultSetNode), Right: $3.(ast.ResultSetNode), Tp: ast.CrossJoin, Using: $6.([]*ast.ColumnName)}
    }

ast.Join を見ると, LeftRight はそれぞれ ast.TableSource か JoinNode(ast.Join) と書かれています. TableSource のコメントを参考に, LeftRight はそれぞれ以下に派生していくようです.

  • ast.TableName: テーブル名
  • ast.SelectStmt: Select文
  • ast.SetOprStmt: Union句
  • ast.Join
// Join represents table join.
type Join struct {
    node

    // Left table can be TableSource or JoinNode.
    Left ResultSetNode
    // Right table can be TableSource or JoinNode or nil.
    Right ResultSetNode
    ...
}

// TableSource represents table source with a name.
type TableSource struct {
    node

    // Source is the source of the data, can be a TableName,
    // a SelectStmt, a SetOprStmt, or a JoinNode.
    Source ResultSetNode

    // AsName is the alias name of the table source.
    AsName model.CIStr
}

ここで大変申し訳ないのですが, 簡単のため Left, Right に入りうる ast.SelectStmt, ast.SetOprStmt ノードについて「Join が発生しない場合, 使用されるテーブルは1つ」と仮定します. 例えば, 今回紹介するコードでは以下のようなクエリは正しく検査できません.

SELECT * FROM table_a
INNER JOIN
  (SELECT *
   FROM table_b
   WHERE (EXISTS
            (SELECT 1
             FROM table_c
             WHERE table_c.id = 1 )) ) b ON table_a.some_id = b.id ;

さて, 探索結果を保存する struct として 2種類用意します. Joinされるテーブル を二分木で管理する tablePair と, 二分木の root を管理する result です. tablePair は, 木の leaf を兼用していて, Name にテーブル名が入ります.

type result struct {
    Joins []*tablePair
}

type tablePair struct {
    Name string
    Left *tablePair
    Right *tablePair
}

Visitor を実装していきます. まずは AST の root から順にたどり, Join を見つける Visitor です. ast.JoinNode を見つけると, Left, Right それぞれの子ノードを新たな Visitor に探索させます(④). この Visitor の実装は次の項で紹介します. Left, Right 両者の探索が終わると, 結果として得られた tablePair を保存し, (⑤)終了します. 重複探索を防ぐため, 返り値の二要素目 skipChildren を true にし, 子ノードの探索を skip しています(⑥).

func (v *result) Enter(in ast.Node) (ast.Node, bool) {
    switch node := in.(type) {
    case *ast.Join: // ④
        left, right := &tablePair{}, &tablePair{}
        node.Left.Accept(left)
        if node.Right != nil { // Right は nil の可能性あり
            node.Right.Accept(right)
        } else {
            right = nil
        }
        v.Joins = append(v.Joins, &tablePair{Left: left, Right: right}) // ⑤ 結果を格納
        return in, true                                                 // ⑥ 子ノードを探索しない
    default:
        return in, false
    }
}

func (v *result) Leave(in ast.Node) (ast.Node, bool) {
    return in, true
}

次に, ast.JoinNodeLeft, Right をそれぞれ探索する tablePair Visitor を実装します.

ast.SelectStmt, ast.SetOprStmt ノードについて「Join が発生しない場合, 使用されるテーブルは1つ」

という仮定を踏まえると, チェックすべきノードは ast.Joinast.TableName の2つです. ast.TableName は テーブル名を保存し, ast.Join は再帰的に Left, Right を辿っていきます.

func (v *tablePair) Enter(in ast.Node) (ast.Node, bool) {
    switch node := in.(type) {
    case *ast.Join:
        left, right := &tablePair{}, &tablePair{}
        node.Left.Accept(left)
        v.Left = left
        if node.Right != nil { // Right は nil の可能性あり
            node.Right.Accept(right)
            v.Right = right
        } else {
            v.Right = nil
        }
        return in, true
    case *ast.TableName:
        v.Name = node.Name.O
        return in, true
    default:
        return in, false
    }
}

func (v *tablePair) Leave(in ast.Node) (ast.Node, bool) {
    return in, true
}

作成した Visitor を利用して, 次のように結果を得られます. より現実的な解析では, "LEFT OUTER JOIN", "STRAIGHT JOIN" といった Join の種類などテーブル名以外の情報も収集することで, 便利に活用できそうです.

func main() {
    input := "SELECT DISTINCT `A`.* FROM `A`" +
        " INNER JOIN `B` ON `B`.`a_id` = `A`.`id`" +
        " INNER JOIN `C` ON `C`.`b_id` = `B`.`id`" +
        " WHERE `A`.`some_id` = ?" +
        " AND NOT (EXISTS" +
        " (SELECT ? FROM `B` INNER JOIN `C` ON `B`.`id` = `C`.`b_id`" +
        " WHERE `B`.`A_id` = `A`.`id` AND `C`.`some_type` IN(?+)))" +
        " AND `C`.`some_id` = ?" +
        " ORDER BY A.created_at DESC LIMIT ? OFFSET ?"
    sql := strings.ReplaceAll(input, "?+", "?") // ② in (?+) が parser できないので置換
    astNode, _ := parse(sql)                    // ③ AST 生成
    // extract の実装は省略
    fmt.Printf("%+v\n", extract(astNode)) // => [((A, B), C) (B, C)]
}

まとめ

pingcap/parser で手軽に AST を探索することができました. サービスの規模拡大に伴い, データベースに対して叩かれるクエリの把握は難しくなっていきますが, 機械的に立ち向かっていけたら楽しいですね.

さて, アドベントカレンダーも残すところあと2日となりました. 明日は酒のあるところに行けばだいたい会える, Public API チームのリーダー matz パイセンの記事です. お楽しみに!!!

*1:Prepared Statement として受け入れられているようです. ref: ast.ParamMakerExpr

*2:最近, pingcap/parser は pingcap/tidb に移動 (出戻り) したようです(記事). 最新に追従したい場合はご注意ください.