session_store.rb 10.8 KB
Newer Older
1 2
require 'action_controller/metal/exceptions'

J
Joshua Peek 已提交
3
module ActiveRecord
4 5
  # = Active Record Session Store
  #
J
Joshua Peek 已提交
6 7 8 9 10 11 12 13
  # A session store backed by an Active Record class.  A default class is
  # provided, but any object duck-typing to an Active Record Session class
  # with text +session_id+ and +data+ attributes is sufficient.
  #
  # The default assumes a +sessions+ tables with columns:
  #   +id+ (numeric primary key),
  #   +session_id+ (text, or longtext if your session data exceeds 65K), and
  #   +data+ (text or longtext; careful if your session data exceeds 65KB).
14
  #
J
Joshua Peek 已提交
15 16 17 18 19 20
  # The +session_id+ column should always be indexed for speedy lookups.
  # Session data is marshaled to the +data+ column in Base64 format.
  # If the data you write is larger than the column's size limit,
  # ActionController::SessionOverflowError will be raised.
  #
  # You may configure the table name, primary key, and data column.
21
  # For example, at the end of <tt>config/application.rb</tt>:
22
  #
J
Joshua Peek 已提交
23 24 25
  #   ActiveRecord::SessionStore::Session.table_name = 'legacy_session_table'
  #   ActiveRecord::SessionStore::Session.primary_key = 'session_id'
  #   ActiveRecord::SessionStore::Session.data_column_name = 'legacy_session_data'
26
  #
J
Joshua Peek 已提交
27 28 29 30 31 32 33 34 35 36 37 38
  # Note that setting the primary key to the +session_id+ frees you from
  # having a separate +id+ column if you don't want it.  However, you must
  # set <tt>session.model.id = session.session_id</tt> by hand!  A before filter
  # on ApplicationController is a good place.
  #
  # Since the default class is a simple Active Record, you get timestamps
  # for free if you add +created_at+ and +updated_at+ datetime columns to
  # the +sessions+ table, making periodic session expiration a snap.
  #
  # You may provide your own session class implementation, whether a
  # feature-packed Active Record or a bare-metal high-performance SQL
  # store, by setting
39
  #
J
Joshua Peek 已提交
40
  #   ActiveRecord::SessionStore.session_class = MySessionClass
41
  #
J
Joshua Peek 已提交
42
  # You must implement these methods:
43
  #
J
Joshua Peek 已提交
44 45 46 47 48 49 50 51 52
  #   self.find_by_session_id(session_id)
  #   initialize(hash_of_session_id_and_data)
  #   attr_reader :session_id
  #   attr_accessor :data
  #   save
  #   destroy
  #
  # The example SqlBypass class is a generic SQL session store.  You may
  # use it as a basis for high-performance database-specific stores.
53
  class SessionStore < ActionDispatch::Session::AbstractStore
54 55 56 57 58 59 60 61 62 63
    module ClassMethods # :nodoc:
      def marshal(data)
        ActiveSupport::Base64.encode64(Marshal.dump(data)) if data
      end

      def unmarshal(data)
        Marshal.load(ActiveSupport::Base64.decode64(data)) if data
      end

      def drop_table!
64
        connection.drop_table table_name
65
      end
66 67

      def create_table!
68 69 70 71 72
        connection.create_table(table_name) do |t|
          t.string session_id_column, :limit => 255
          t.text data_column_name
        end
        connection.add_index table_name, session_id_column, :unique => true
73
      end
74 75
    end

J
Joshua Peek 已提交
76 77
    # The default Active Record class.
    class Session < ActiveRecord::Base
78 79
      extend ClassMethods

J
Joshua Peek 已提交
80 81 82 83 84 85 86 87 88 89 90
      ##
      # :singleton-method:
      # Customizable data column name.  Defaults to 'data'.
      cattr_accessor :data_column_name
      self.data_column_name = 'data'

      before_save :marshal_data!
      before_save :raise_on_session_data_overflow!

      class << self
        def data_column_size_limit
91
          @data_column_size_limit ||= columns_hash[data_column_name].limit
J
Joshua Peek 已提交
92 93 94 95 96 97 98 99 100
        end

        # Hook to set up sessid compatibility.
        def find_by_session_id(session_id)
          setup_sessid_compatibility!
          find_by_session_id(session_id)
        end

        private
101 102 103 104
          def session_id_column
            'session_id'
          end

J
Joshua Peek 已提交
105 106 107 108 109 110 111 112 113 114 115 116
          # Compatibility with tables using sessid instead of session_id.
          def setup_sessid_compatibility!
            # Reset column info since it may be stale.
            reset_column_information
            if columns_hash['sessid']
              def self.find_by_session_id(*args)
                find_by_sessid(*args)
              end

              define_method(:session_id)  { sessid }
              define_method(:session_id=) { |session_id| self.sessid = session_id }
            else
117 118
              class << self; remove_method :find_by_session_id; end

J
Joshua Peek 已提交
119
              def self.find_by_session_id(session_id)
120
                find :first, :conditions => {:session_id=>session_id}
J
Joshua Peek 已提交
121 122 123 124 125
              end
            end
          end
      end

126 127 128 129 130
      def initialize(attributes = nil)
        @data = nil
        super
      end

J
Joshua Peek 已提交
131 132 133 134 135 136 137 138 139
      # Lazy-unmarshal session state.
      def data
        @data ||= self.class.unmarshal(read_attribute(@@data_column_name)) || {}
      end

      attr_writer :data

      # Has the session been loaded yet?
      def loaded?
140
        @data
J
Joshua Peek 已提交
141 142 143 144
      end

      private
        def marshal_data!
145 146
          return false unless loaded?
          write_attribute(@@data_column_name, self.class.marshal(data))
J
Joshua Peek 已提交
147 148 149 150 151 152
        end

        # Ensures that the data about to be stored in the database is not
        # larger than the data storage column. Raises
        # ActionController::SessionOverflowError.
        def raise_on_session_data_overflow!
153
          return false unless loaded?
J
Joshua Peek 已提交
154
          limit = self.class.data_column_size_limit
155
          if limit and read_attribute(@@data_column_name).size > limit
J
Joshua Peek 已提交
156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179
            raise ActionController::SessionOverflowError
          end
        end
    end

    # A barebones session store which duck-types with the default session
    # store but bypasses Active Record and issues SQL directly.  This is
    # an example session model class meant as a basis for your own classes.
    #
    # The database connection, table name, and session id and data columns
    # are configurable class attributes.  Marshaling and unmarshaling
    # are implemented as class methods that you may override.  By default,
    # marshaling data is
    #
    #   ActiveSupport::Base64.encode64(Marshal.dump(data))
    #
    # and unmarshaling data is
    #
    #   Marshal.load(ActiveSupport::Base64.decode64(data))
    #
    # This marshaling behavior is intended to store the widest range of
    # binary session data in a +text+ column.  For higher performance,
    # store in a +blob+ column instead and forgo the Base64 encoding.
    class SqlBypass
180 181
      extend ClassMethods

J
Joshua Peek 已提交
182 183 184
      ##
      # :singleton-method:
      # Use the ActiveRecord::Base.connection by default.
185
      cattr_accessor :connection
J
Joshua Peek 已提交
186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205

      ##
      # :singleton-method:
      # The table name defaults to 'sessions'.
      cattr_accessor :table_name
      @@table_name = 'sessions'

      ##
      # :singleton-method:
      # The session id field defaults to 'session_id'.
      cattr_accessor :session_id_column
      @@session_id_column = 'session_id'

      ##
      # :singleton-method:
      # The data field defaults to 'data'.
      cattr_accessor :data_column
      @@data_column = 'data'

      class << self
206 207
        alias :data_column_name :data_column

208
        remove_method :connection
J
Joshua Peek 已提交
209 210 211 212 213 214
        def connection
          @@connection ||= ActiveRecord::Base.connection
        end

        # Look up a session by id and unmarshal its data if found.
        def find_by_session_id(session_id)
215
          if record = connection.select_one("SELECT * FROM #{@@table_name} WHERE #{@@session_id_column}=#{connection.quote(session_id)}")
J
Joshua Peek 已提交
216 217 218 219 220
            new(:session_id => session_id, :marshaled_data => record['data'])
          end
        end
      end

221 222 223
      attr_reader :session_id, :new_record
      alias :new_record? :new_record

J
Joshua Peek 已提交
224 225 226 227 228 229
      attr_writer :data

      # Look for normal and marshaled data, self.find_by_session_id's way of
      # telling us to postpone unmarshaling until the data is requested.
      # We need to handle a normal data attribute in case of a new record.
      def initialize(attributes)
A
Aaron Patterson 已提交
230 231 232 233
        @session_id     = attributes[:session_id]
        @data           = attributes[:data]
        @marshaled_data = attributes[:marshaled_data]
        @new_record     = @marshaled_data.nil?
J
Joshua Peek 已提交
234 235 236 237 238 239 240 241 242 243 244 245 246 247 248
      end

      # Lazy-unmarshal session state.
      def data
        unless @data
          if @marshaled_data
            @data, @marshaled_data = self.class.unmarshal(@marshaled_data) || {}, nil
          else
            @data = {}
          end
        end
        @data
      end

      def loaded?
249
        @data
J
Joshua Peek 已提交
250 251 252
      end

      def save
253
        return false unless loaded?
J
Joshua Peek 已提交
254
        marshaled_data = self.class.marshal(data)
255
        connect        = connection
J
Joshua Peek 已提交
256 257 258

        if @new_record
          @new_record = false
259 260 261 262
          connect.update <<-end_sql, 'Create session'
            INSERT INTO #{table_name} (
              #{connect.quote_column_name(session_id_column)},
              #{connect.quote_column_name(data_column)} )
J
Joshua Peek 已提交
263
            VALUES (
264 265
              #{connect.quote(session_id)},
              #{connect.quote(marshaled_data)} )
J
Joshua Peek 已提交
266 267
          end_sql
        else
268 269 270 271
          connect.update <<-end_sql, 'Update session'
            UPDATE #{table_name}
            SET #{connect.quote_column_name(data_column)}=#{connect.quote(marshaled_data)}
            WHERE #{connect.quote_column_name(session_id_column)}=#{connect.quote(session_id)}
J
Joshua Peek 已提交
272 273 274 275 276
          end_sql
        end
      end

      def destroy
277 278 279 280 281 282 283
        return if @new_record

        connect = connection
        connect.delete <<-end_sql, 'Destroy session'
          DELETE FROM #{table_name}
          WHERE #{connect.quote_column_name(session_id_column)}=#{connect.quote(session_id)}
        end_sql
J
Joshua Peek 已提交
284 285 286 287 288 289 290 291
      end
    end

    # The class used for session storage.  Defaults to
    # ActiveRecord::SessionStore::Session
    cattr_accessor :session_class
    self.session_class = Session

A
Aaron Patterson 已提交
292
    SESSION_RECORD_KEY = 'rack.session.record'
J
Joshua Peek 已提交
293 294 295 296

    private
      def get_session(env, sid)
        Base.silence do
297
          sid ||= generate_sid
298
          session = find_session(sid)
J
Joshua Peek 已提交
299 300 301 302 303 304 305
          env[SESSION_RECORD_KEY] = session
          [sid, session.data]
        end
      end

      def set_session(env, sid, session_data)
        Base.silence do
306
          record = get_session_model(env, sid)
J
Joshua Peek 已提交
307 308 309 310 311 312 313 314 315 316 317
          record.data = session_data
          return false unless record.save

          session_data = record.data
          if session_data && session_data.respond_to?(:each_value)
            session_data.each_value do |obj|
              obj.clear_association_cache if obj.respond_to?(:clear_association_cache)
            end
          end
        end

318
        sid
J
Joshua Peek 已提交
319
      end
A
Aaron Patterson 已提交
320

321 322 323 324 325 326 327 328
      def destroy(env)
        if sid = current_session_id(env)
          Base.silence do
            get_session_model(env, sid).destroy
          end
        end
      end

329 330 331 332 333 334 335
      def get_session_model(env, sid)
        if env[ENV_SESSION_OPTIONS_KEY][:id].nil?
          env[SESSION_RECORD_KEY] = find_session(sid)
        else
          env[SESSION_RECORD_KEY] ||= find_session(sid)
        end
      end
336 337 338 339 340

      def find_session(id)
        @@session_class.find_by_session_id(id) ||
          @@session_class.new(:session_id => id, :data => {})
      end
J
Joshua Peek 已提交
341 342
  end
end