odeの開発メモ日記

プログラマーやってます。今までの道のり ポケコンbasic→C(DirectX5 ネットやろうぜ)→Perl(ちょろっと)→Java→C#→Ruby→Android(Java)

ActiveRecordで発行するSQLのログをDBに保存する方法

管理ページのSQL(更新系のUPDATEやINSERT等)を保存しておきたい要望があるだろう。
(管理者の操作ログを詳細にとる代わりの代替案として等)
そんなときのやり方です。


ActiveRecordのソースを見た限りそういった機能はなさそうだったので
ActiveRecordの定義を書き換えで対応してます。


ちなみにクラス変数使ってるので非スレッドッセーフです。
そのうちrailsがマルチスレッド対応した場合はスレッドローカルストレージを使ってスレッドセーフにする必要があります。(rubyでできるか知らないですけど)

DB定義

テーブル:sql_logs

id	int ID
sql	longtext SQL文
url	text 発行元URL
client_id	int クライアントのID(ユーザーIDや管理者ID等を保存する)
run_time	float 実行時間
created_at	datetime 作成日
updated_at	timestamp 更新日(いらないですが一応。。)

rubyソース

environment.rbに追加

# sqlのログをDBに保存
module ActiveRecord
  module ConnectionAdapters
    class AbstractAdapter
      def log_info_with_db_log(sql, name, runtime)
        if SqlLogManager.instance.enabled
          # last_insert_idがSqlLogのidになってしまうので退避しておく
          if @db_log_write_before_insert_id_enabled
            @db_log_write_before_insert_id = @connection.insert_id
            @db_log_write_before_insert_id_enabled = false
          end
          SqlLogManager.instance.save_log(sql, runtime)
        end

        return log_info_without_db_log(sql, name, runtime)
      end
      alias_method_chain :log_info, :db_log

    end

    class MysqlAdapter < AbstractAdapter
      def insert_sql(sql, name = nil, pk = nil, id_value = nil, sequence_name = nil)
        if SqlLogManager.instance.enabled
          @db_log_write_before_insert_id_enabled = true
        end
        super sql, name

        insert_id = @db_log_write_before_insert_id
        # SqlLogをとらない場合はinsert_idがとれてないので取得する
        unless SqlLogManager.instance.enabled
          insert_id = @connection.insert_id
        end
        # 退避しておいたlast_insert_idを返す。
        id_value || insert_id
      end
    end

    class MysqlReplicationAdapter < MysqlAdapter
      def insert(sql, name = nil, pk = nil, id_value = nil, sequence_name = nil) #:nodoc:
        if SqlLogManager.instance.enabled
          @db_log_write_before_insert_id_enabled = true
        end
        ensure_master
        execute(sql, name = nil)

        insert_id = @db_log_write_before_insert_id
        # SqlLogをとらない場合はinsert_idがとれてないので取得する
        unless SqlLogManager.instance.enabled
          insert_id = @connection.insert_id
        end
        # 退避しておいたlast_insert_idを返す。
        id_value || insert_id
      end
    end
  end
end


モデル

class SqlLog < ActiveRecord::Base
end

class SqlLogManager
  include Singleton

  attr_accessor :enabled, :client_id, :url

  def save_log(sql, runtime)

    # showとselectの場合はログに書かない。
    # sqlの実行でエラーが出た場合にもログが書かれるため、Mysql::Errorも外す。ここは各DBにあわせて書き換える必要あり
    if sql.starts_with?("SHOW") || sql.starts_with?("SELECT") || sql.starts_with?("Mysql::Error")
      return
    end

    # sqllogをsaveしたときのinser文をまたsqllogとして保存しようとして無限ループするため、一時的に保存を解除する
    enabled_tmp = self.enabled
    self.enabled = false

    log = SqlLog.new(:sql=>sql, :run_time=>runtime, :client_id=>client_id, :url=>url)
    log.save!

    self.enabled = enabled_tmp
  end
end


コントローラ

class ApplicationController < ActionController::Base
  before_filter :bf_application
  def bf_application
    logm = SqlLogManager.instance
    logm.enabled = false

    #adminページのみSQLを保存する場合のif文。
    #ApplicationControllerでなくadminコントローラに書く場合等は不要
    if controller_path.starts_with?("admin") 
      logm.enabled = true
      logm.url = request.request_uri
      logm.client_id = 1#ユーザーIDや管理者ID等をいれれる
    end
  end
end


追記:2008/11/7
last_insert_idがSqlLogのidになってしまう不具合があったので修正しました。
きちゃない修正ですが。。

そもそもですが、この方法をとるのではなく
Railsレシピという本のレシピ59の
実行者と実行内容の記録(ActiveRecordのオブザーバを利用する方法)を採用するほうがスマートだと思いましたorz