はじめに

Snapmap は位置情報検索と全文検索の機能が備わっています。

位置情報検索や全文検索を機能実装する場合、通常は Elasticsearch などが候補に上がるかと思いますが、初期の要件などを検討した結果、今回は Mroonga を採用することにしました1

mroonga_command

Mroonga は全文検索のレスポンスがとても速く検索条件の柔軟性も高いため、ほとんど困らないのですが、かなり複雑なことをしようとすると Groonga も直接呼び出したくなります。
その場合 mroonga_command を利用すればOKです。

例えば、こんな spots テーブルがあったとして

カラム 長さ
id int 11
name varchar 256
location geometry -

特定の位置から半径 1000m 以内に location が存在するデータを取得するにはこんな SQL を実行することになります。

SELECT mroonga_command(' select spots --filter \'geo_in_circle(lat_lon, \"35.6812x139.76624\", 1000)\' --sortby \'geo_distance(lat_lon, \"35.6812x139.76624\")\' --limit 100 ')

結果はちょっと変わっていて、こんなフォーマットで取得されます。

# refs: http://mroonga.org/ja/blog/2013/03/29/release.html
[
  [
    # 取得件数
    [2],
    # ヘッダー
    [
      ["_id","UInt32"],
      ["_key","Int32"],
      ["id","Int32"],
      ["location","WGS84GeoPoint"],
      ["name","LongText"]
    ],
    # 以下データ
    [1,1,1,"128563247x503268584","Shop A"],
    [2,2,2,"128331724x502961461","Shop B"]
  ]
]

ActiveRecordで扱う

mroonga_command を使えばいい感じに取得できるのですが、ActiveRecord で扱えるようにしたい。というわけでこんな感じのメソッドを作りました。

# ActiveSupport::Concernに生やすのでも可
class ApplicationRecord < ActiveRecord::Base
  self.abstract_class = true

  class << self
    def mroonga_command(sql)
      # mroonga_commandは改行を入れてはいけないので注意
      chunk = ActiveRecord::Base.connection.select_all(sql)

      chunk     = JSON.parse(chunk[0].values[0])[0]
      row_count = chunk[0][0]
      schema    = chunk[1]
      rows      = chunk[2..-2]

      return [] if row_count == 0
      rows.each_with_object([]) do |row, records|
        record = self.new
        schema.each_with_index do |column, i|
          attr_name = column[0]
          attr_type = column[1]
          val       = row[i]

          # ActiveRecordと同名のattributeがあれば格納
          next unless method_defined?(attr_name)
          record.send("#{attr_name}=", mr2ar(attr_type, val))
        end
        records << record
      end
    end

    #
    # Mroongaから送られた型に従いActiveRecordで使用可能な値に変換する
    #
    # Time:          1487840400          → 2017/02/23 18:00:00
    # WGS84GeoPoint: 127606000x502738000 → POINT(35.44611111111111 139.6494444444444)
    def mr2ar(attr_type, val)
      case attr_type
      when "Time"
        Time.zone.at(val)
      when "WGS84GeoPoint"
        md = /(\d+)x(\d+)/.match(val)
        return nil if md.size < 2
        # 日本測地系(ミリ秒)を世界測地系(度)に変換
        "POINT(#{md[2].to_f / 3_600_000} #{md[1].to_f / 3_600_000})"
      else
        val
      end
    end
  end
end

これで mroonga_command を ActiveRecord として扱えるようになりました。
めでたしめでたし。(が、最終的には使いませんでした…)

[1] pry(main)> Spot.mroonga_command("SELECT mroonga_command(' select spots --filter \\'geo_in_circle(lat_lon, \"35.6812x139.76624\", 1000)\\' --sortby \\'geo_distance(lat_lon, \"35.6812x139.76624\")\\' --limit 100 ')")
   (0.5ms)  SELECT mroonga_command(' select spots --filter \'geo_in_circle(lat_lon, "35.6812x139.76624", 1000)\' --sortby \'geo_distance(lat_lon, "35.6812x139.76624")\' --limit 100 ')
=> [#<Spot:0x007f7944294ae0
  id: 6519,
  name: "KITTEガーデン",
  location: #<RGeo::Cartesian::PointImpl:0x3fbcaecb3e28 "POINT (139.76487666666668 35.67979972222222)">>,

  1. ちなみに、Rails で緯度経度情報を持った DB を扱いたい場合、rgeo/rgeo の利用がほぼ必須になります。