Ruby on Rails | Screencasts | Download | Documentation | Weblog | Community | Source

Changeset 1671

Show
Ignore:
Timestamp:
07/04/05 18:30:27 (3 years ago)
Author:
bitsweat
Message:

r2790@asus: jeremy | 2005-07-04 16:30:58 -0700
smart active record session class. session class is pluggable; a basic SqlBypass class is provided. set CGI::Session::ActiveRecordStore.session_class = SqlBypass and set SqlBypass.connection = SomeARConnection. Further tests pending.

Files:

Legend:

Unmodified
Added
Removed
Modified
Copied
Moved
  • trunk/actionpack/lib/action_controller/session/active_record_store.rb

    r866 r1671  
    1 begin 
    2  
    3 require 'active_record' 
    41require 'cgi' 
    52require 'cgi/session' 
     3require 'digest/md5' 
    64require 'base64' 
    75 
    8 # Contributed by Tim Bates 
    96class CGI 
    107  class Session 
    11     # Active Record database-based session storage class. 
    12     # 
    13     # Implements session storage in a database using the ActiveRecord ORM library. Assumes that the database 
    14     # has a table called +sessions+ with columns +id+ (numeric, primary key), +sessid+ and +data+ (text). 
    15     # The session data is stored in the +data+ column in the binary Marshal format; the user is responsible for ensuring that 
    16     # only data that can be Marshaled is stored in the session. 
    17     # 
    18     # Adding +created_at+ or +updated_at+ datetime columns to the sessions table will enable stamping of the data, which can 
    19     # be used to clear out old sessions. 
    20     # 
    21     # It's highly recommended to have an index on the sessid column to improve performance. 
     8    # A session store backed by an Active Record class. 
     9    # 
     10    # A default class is provided, but any object duck-typing to an Active 
     11    # Record +Session+ class with text +session_id+ and +data+ attributes 
     12    # may be used as the backing store. 
     13    # 
     14    # The default assumes a +sessions+ tables with columns +id+ (numeric 
     15    # primary key), +session_id+ (text), and +data+ (text).  Session data is 
     16    # marshaled to +data+.  +session_id+ should be indexed for speedy lookups. 
     17    # 
     18    # Since the default class is a simple Active Record, you get timestamps 
     19    # for free if you add +created_at+ and +updated_at+ datetime columns to 
     20    # the +sessions+ table, making periodic session expiration a snap. 
     21    # 
     22    # You may provide your own session class, whether a feature-packed 
     23    # Active Record or a bare-metal high-performance SQL store, by setting 
     24    #   +CGI::Session::ActiveRecordStore.session_class = MySessionClass+ 
     25    # You must implement these methods: 
     26    #   self.find_by_session_id(session_id) 
     27    #   initialize(hash_of_session_id_and_data) 
     28    #   attr_reader :session_id 
     29    #   attr_accessor :data 
     30    #   save! 
     31    #   destroy 
     32    # 
     33    # The fast SqlBypass class is a generic SQL session store.  You may 
     34    # use it as a basis for high-performance database-specific stores. 
    2235    class ActiveRecordStore 
    23       # The ActiveRecord class which corresponds to the database table
     36      # The default Active Record class
    2437      class Session < ActiveRecord::Base 
    25       end 
    26  
    27       # Create a new ActiveRecordStore instance. This constructor is used internally by CGI::Session. 
    28       # The user does not generally need to call it directly. 
     38        self.table_name = 'sessions' 
     39        before_create :marshal_data! 
     40        before_update :marshal_data_if_changed! 
     41        after_save    :clear_data_cache! 
     42 
     43        class << self 
     44          # Hook to set up sessid compatibility. 
     45          def find_by_session_id(session_id) 
     46            setup_sessid_compatibility! 
     47            find_by_session_id(session_id) 
     48          end 
     49 
     50          # Compatibility with tables using sessid instead of session_id. 
     51          def setup_sessid_compatibility! 
     52            if !@sessid_compatibility_checked 
     53              if columns_hash['sessid'] 
     54                def self.find_by_session_id(*args) 
     55                  find_by_sessid(*args) 
     56                end 
     57 
     58                alias_method :session_id, :sessid 
     59                define_method(:session_id)  { sessid } 
     60                define_method(:session_id=) { |session_id| self.sessid = session_id } 
     61              else 
     62                def self.find_by_session_id(session_id) 
     63                  find :first, :conditions => ["session_id #{attribute_condition(session_id)}", session_id] 
     64                end 
     65              end 
     66              @sessid_compatibility_checked = true 
     67            end 
     68          end 
     69 
     70          def marshal(data)     Base64.encode64(Marshal.dump(data)) end 
     71          def unmarshal(data)   Marshal.load(Base64.decode64(data)) end 
     72          def fingerprint(data) Digest::MD5.hexdigest(data)         end 
     73 
     74          def create_table! 
     75            connection.execute <<-end_sql 
     76              CREATE TABLE #{table_name} ( 
     77                id INTEGER PRIMARY KEY, 
     78                #{connection.quote_column_name('session_id')} TEXT UNIQUE, 
     79                #{connection.quote_column_name('data')} TEXT 
     80              ) 
     81            end_sql 
     82          end 
     83 
     84          def drop_table! 
     85            connection.execute "DROP TABLE #{table_name}" 
     86          end 
     87        end 
     88 
     89        # Lazy-unmarshal session state. 
     90        def data 
     91          unless @data 
     92            @data = self.class.unmarshal(read_attribute('data')) 
     93            @fingerprint = self.class.fingerprint(@data) 
     94          end 
     95          @data 
     96        end 
     97 
     98        private 
     99          def marshal_data! 
     100            write_attribute('data', self.class.marshal(@data || {})) 
     101          end 
     102 
     103          def marshal_data_if_changed! 
     104            if @data and @fingerprint != self.class.fingerprint(@data) 
     105              marshal_data! 
     106            end 
     107          end 
     108 
     109          def clear_data_cache! 
     110            @data = @fingerprint = nil 
     111          end 
     112      end 
     113 
     114      # A barebones session store which duck-types with the default session 
     115      # store but bypasses Active Record and issues SQL directly. 
    29116      # 
    30       # +session+ is the session for which this instance is being created. 
     117      # The database connection, table name, and session id and data columns 
     118      # are configurable class attributes.  Marshaling and unmarshaling 
     119      # are implemented as class methods that you may override.  By default, 
     120      # marshaling data is +Base64.encode64(Marshal.dump(data))+ and 
     121      # unmarshaling data is +Marshal.load(Base64.decode64(data))+. 
    31122      # 
    32       # +option+ is currently ignored as no options are recognized. 
    33       # 
    34       # This session's ActiveRecord database row will be created if it does not exist, or opened if it does. 
    35       def initialize(session, option=nil) 
    36         ActiveRecord::Base.silence do 
    37           @session = Session.find_by_sessid(session.session_id) || Session.new("sessid" => session.session_id, "data" => marshalize({})) 
    38           @data    = unmarshalize(@session.data) 
    39         end 
    40       end 
    41  
    42       # Update and close the session's ActiveRecord object. 
     123      # This marshaling behavior is intended to store the widest range of 
     124      # binary session data in a +text+ column.  For higher performance, 
     125      # store in a +blob+ column instead and forgo the Base64 encoding. 
     126      class SqlBypass 
     127        # Use the ActiveRecord::Base.connection by default. 
     128        cattr_accessor :connection 
     129        def self.connection 
     130          @@connection ||= ActiveRecord::Base.connection 
     131        end 
     132 
     133        # The table name defaults to 'sessions'. 
     134        cattr_accessor :table_name 
     135        @@table_name = 'sessions' 
     136 
     137        # The session id field defaults to 'session_id'. 
     138        cattr_accessor :session_id_column 
     139        @@session_id_column = 'session_id' 
     140 
     141        # The data field defaults to 'data'. 
     142        cattr_accessor :data_column 
     143        @@data_column = 'data' 
     144 
     145        class << self 
     146          # Look up a session by id and unmarshal its data if found. 
     147          def find_by_session_id(session_id) 
     148            if record = @@connection.select_one("SELECT * FROM #{@@table_name} WHERE #{@@session_id_column}=#{@@connection.quote(session_id)}") 
     149              new(:session_id => session_id, :marshaled_data => record['data']) 
     150            end 
     151          end 
     152 
     153          def marshal(data)     Base64.encode64(Marshal.dump(data)) end 
     154          def unmarshal(data)   Marshal.load(Base64.decode64(data)) end 
     155          def fingerprint(data) Digest::MD5.hexdigest(data)         end 
     156 
     157          def create_table! 
     158            @@connection.execute <<-end_sql 
     159              CREATE TABLE #{table_name} ( 
     160                #{@@connection.quote_column_name(session_id_column)} TEXT PRIMARY KEY, 
     161                #{@@connection.quote_column_name(data_column)} TEXT 
     162              ) 
     163            end_sql 
     164          end 
     165 
     166          def drop_table! 
     167            @@connection.execute "DROP TABLE #{table_name}" 
     168          end 
     169        end 
     170 
     171        attr_reader :session_id 
     172        attr_writer :data 
     173 
     174        # Look for normal and marshaled data, self.find_by_session_id's way of 
     175        # telling us to postpone unmarshaling until the data is requested. 
     176        # We need to handle a normal data attribute in case of a new record. 
     177        def initialize(attributes) 
     178          @session_id, @data, @marshaled_data = attributes[:session_id], attributes[:data], attributes[:marshaled_data] 
     179          @new_record = !@marshaled_data.nil? 
     180        end 
     181 
     182        # Lazy-unmarshal session state.  Take a fingerprint so we can detect 
     183        # whether to save changes later. 
     184        def data 
     185          if @marshaled_data 
     186            @data, @marshaled_data = self.class.unmarshal(@marshaled_data), nil 
     187            @fingerprint = self.class.fingerprint(@data) 
     188          end 
     189          @data 
     190        end 
     191 
     192        def save! 
     193          if @new_record 
     194            @new_record = false 
     195            @@connection.update <<-end_sql, 'Create session' 
     196              INSERT INTO #{@@table_name} ( 
     197                #{@@connection.quote_column_name(@@session_id_column)}, 
     198                #{@@connection.quote_column_name(@@data_column)} ) 
     199              VALUES ( 
     200                #{@@connection.quote(session_id)}, 
     201                #{@@connection.quote(self.class.marshal(data))} ) 
     202            end_sql 
     203          elsif self.class.fingerprint(data) != @fingerprint 
     204            @@connection.update <<-end_sql, 'Update session' 
     205              UPDATE #{@@table_name} 
     206              SET #{@@connection.quote_column_name(@@data_column)}=#{@@connection.quote(self.class.marshal(data))} 
     207              WHERE #{@@connection.quote_column_name(@@session_id_column)}=#{@@connection.quote(session_id)} 
     208            end_sql 
     209          end 
     210        end 
     211 
     212        def destroy 
     213          unless @new_record 
     214            @@connection.delete <<-end_sql, 'Destroy session' 
     215              DELETE FROM #{@@table_name} 
     216              WHERE #{@@connection.quote_column_name(@@session_id_column)}=#{@@connection.quote(session_id)} 
     217            end_sql 
     218          end 
     219        end 
     220      end 
     221 
     222      # The class used for session storage.  Defaults to 
     223      # CGI::Session::ActiveRecordStore::Session. 
     224      cattr_accessor :session_class 
     225      @@session_class = Session 
     226 
     227      # Find or instantiate a session given a CGI::Session. 
     228      def initialize(session, option = nil) 
     229        session_id = session.session_id 
     230        unless @session = @@session_class.find_by_session_id(session_id) 
     231          unless session.new_session 
     232            raise CGI::Session::NoSession, 'uninitialized session' 
     233          end 
     234          @session = @@session_class.new(:session_id => session_id, :data => {}) 
     235        end 
     236      end 
     237 
     238      # Restore session state.  The session model handles unmarshaling. 
     239      def restore 
     240        @session.data 
     241      end 
     242 
     243      # Save session store. 
     244      def update 
     245        @session.save! 
     246      end 
     247 
     248      # Save and close the session store. 
    43249      def close 
    44         return unless @session 
    45250        update 
    46251        @session = nil 
    47252      end 
    48253 
    49       # Close and destroy the session's ActiveRecord object
     254      # Delete and close the session store
    50255      def delete 
    51         return unless @session 
    52         @session.destroy 
     256        @session.destroy rescue nil 
    53257        @session = nil 
    54258      end 
    55  
    56       # Restore session state from the session's ActiveRecord object. 
    57       def restore 
    58         return unless @session 
    59         @data = unmarshalize(@session.data) 
    60       end 
    61  
    62       # Save session state in the session's ActiveRecord object. 
    63       def update 
    64         return unless @session 
    65         ActiveRecord::Base.silence { @session.update_attribute "data", marshalize(@data) } 
    66       end 
    67  
    68       private 
    69         def unmarshalize(data) 
    70           Marshal.load(Base64.decode64(data)) 
    71         end 
    72  
    73         def marshalize(data) 
    74           Base64.encode64(Marshal.dump(data)) 
    75         end 
    76     end #ActiveRecordStore 
    77   end #Session 
    78 end #CGI 
    79  
    80 rescue LoadError 
    81   # Couldn't load Active Record, so don't make this store available 
     259    end 
     260 
     261  end 
    82262end