session_store.rb 11.4 KB
Newer Older
J
Joshua Peek 已提交
1
module ActiveRecord
2 3
  # = Active Record Session Store
  #
J
Joshua Peek 已提交
4 5 6 7 8 9 10 11
  # 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).
12
  # 
J
Joshua Peek 已提交
13 14 15 16 17 18
  # 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.
19
  # For example, at the end of <tt>config/application.rb</tt>:
20
  #
J
Joshua Peek 已提交
21 22 23
  #   ActiveRecord::SessionStore::Session.table_name = 'legacy_session_table'
  #   ActiveRecord::SessionStore::Session.primary_key = 'session_id'
  #   ActiveRecord::SessionStore::Session.data_column_name = 'legacy_session_data'
24
  #
J
Joshua Peek 已提交
25 26 27 28 29 30 31 32 33 34 35 36
  # 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
37
  #
J
Joshua Peek 已提交
38
  #   ActiveRecord::SessionStore.session_class = MySessionClass
39
  #
J
Joshua Peek 已提交
40
  # You must implement these methods:
41
  #
J
Joshua Peek 已提交
42 43 44 45 46 47 48 49 50
  #   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.
51
  class SessionStore < ActionDispatch::Session::AbstractStore
J
Joshua Peek 已提交
52 53 54 55 56 57 58 59 60 61 62 63 64
    # The default Active Record class.
    class Session < ActiveRecord::Base
      ##
      # :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
65
          @data_column_size_limit ||= columns_hash[data_column_name].limit
J
Joshua Peek 已提交
66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84
        end

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

        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 create_table!
          connection.execute <<-end_sql
            CREATE TABLE #{table_name} (
85
              id #{connection.type_to_sql(:primary_key)},
86
              #{connection.quote_column_name(session_id_column)} VARCHAR(255) UNIQUE,
87
              #{connection.quote_column_name(data_column_name)} VARCHAR(255)
J
Joshua Peek 已提交
88 89 90 91 92 93 94 95 96
            )
          end_sql
        end

        def drop_table!
          connection.execute "DROP TABLE #{table_name}"
        end

        private
97 98 99 100
          def session_id_column
            'session_id'
          end

J
Joshua Peek 已提交
101 102 103 104 105 106 107 108 109 110 111 112
          # 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
113 114
              class << self; remove_method :find_by_session_id; end

J
Joshua Peek 已提交
115
              def self.find_by_session_id(session_id)
116
                find :first, :conditions => {:session_id=>session_id}
J
Joshua Peek 已提交
117 118 119 120 121
              end
            end
          end
      end

122 123 124 125 126
      def initialize(attributes = nil)
        @data = nil
        super
      end

J
Joshua Peek 已提交
127 128 129 130 131 132 133 134 135
      # 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?
136
        @data
J
Joshua Peek 已提交
137 138 139 140
      end

      private
        def marshal_data!
141 142
          return false unless loaded?
          write_attribute(@@data_column_name, self.class.marshal(data))
J
Joshua Peek 已提交
143 144 145 146 147 148
        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!
149
          return false unless loaded?
J
Joshua Peek 已提交
150
          limit = self.class.data_column_size_limit
151
          if limit and read_attribute(@@data_column_name).size > limit
J
Joshua Peek 已提交
152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205
            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
      ##
      # :singleton-method:
      # Use the ActiveRecord::Base.connection by default.
      cattr_accessor :connection

      ##
      # :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
        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)
206
          if record = connection.select_one("SELECT * FROM #{@@table_name} WHERE #{@@session_id_column}=#{connection.quote(session_id)}")
J
Joshua Peek 已提交
207 208 209 210 211 212 213 214 215 216 217 218 219
            new(:session_id => session_id, :marshaled_data => record['data'])
          end
        end

        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 create_table!
220
          connection.execute <<-end_sql
J
Joshua Peek 已提交
221
            CREATE TABLE #{table_name} (
222
              id #{connection.type_to_sql(:primary_key)},
223
              #{connection.quote_column_name(session_id_column)} VARCHAR(255) UNIQUE,
224
              #{connection.quote_column_name(data_column)} TEXT
J
Joshua Peek 已提交
225 226 227 228 229
            )
          end_sql
        end

        def drop_table!
230
          connection.execute "DROP TABLE #{table_name}"
J
Joshua Peek 已提交
231 232 233
        end
      end

234 235 236
      attr_reader :session_id, :new_record
      alias :new_record? :new_record

J
Joshua Peek 已提交
237 238 239 240 241 242
      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 已提交
243 244 245 246
        @session_id     = attributes[:session_id]
        @data           = attributes[:data]
        @marshaled_data = attributes[:marshaled_data]
        @new_record     = @marshaled_data.nil?
J
Joshua Peek 已提交
247 248 249 250 251 252 253 254 255 256 257 258 259 260 261
      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?
262
        @data
J
Joshua Peek 已提交
263 264 265
      end

      def save
266
        return false unless loaded?
J
Joshua Peek 已提交
267
        marshaled_data = self.class.marshal(data)
268
        connect        = connection
J
Joshua Peek 已提交
269 270 271

        if @new_record
          @new_record = false
272 273 274 275
          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 已提交
276
            VALUES (
277 278
              #{connect.quote(session_id)},
              #{connect.quote(marshaled_data)} )
J
Joshua Peek 已提交
279 280
          end_sql
        else
281 282 283 284
          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 已提交
285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308
          end_sql
        end
      end

      def destroy
        unless @new_record
          @@connection.delete <<-end_sql, 'Destroy session'
            DELETE FROM #{@@table_name}
            WHERE #{@@connection.quote_column_name(@@session_id_column)}=#{@@connection.quote(session_id)}
          end_sql
        end
      end
    end

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

    SESSION_RECORD_KEY = 'rack.session.record'.freeze

    private
      def get_session(env, sid)
        Base.silence do
309
          session = find_session(sid)
J
Joshua Peek 已提交
310 311 312 313 314 315 316
          env[SESSION_RECORD_KEY] = session
          [sid, session.data]
        end
      end

      def set_session(env, sid, session_data)
        Base.silence do
317
          record = get_session_model(env, sid)
J
Joshua Peek 已提交
318 319 320 321 322 323 324 325 326 327 328
          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

329
        sid
J
Joshua Peek 已提交
330
      end
331
      
332 333 334 335 336 337 338 339
      def destroy(env)
        if sid = current_session_id(env)
          Base.silence do
            get_session_model(env, sid).destroy
          end
        end
      end

340 341 342 343 344 345 346
      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
347 348 349 350 351

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