Browse files

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.


git-svn-id: http://svn-commit.rubyonrails.org/rails/trunk@1671 5ecf4fe2-1ee6-0310-87b1-e25e094e27de
  • Loading branch information...
1 parent e7b142a commit 452442dde8e8ea5949c387ea5c78387bff330f2a @jeremy jeremy committed Jul 4, 2005
View
284 actionpack/lib/action_controller/session/active_record_store.rb
@@ -1,82 +1,262 @@
-begin
-
-require 'active_record'
require 'cgi'
require 'cgi/session'
+require 'digest/md5'
require 'base64'
-# Contributed by Tim Bates
class CGI
class Session
- # Active Record database-based session storage class.
+ # 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
+ # may be used as the backing store.
#
- # Implements session storage in a database using the ActiveRecord ORM library. Assumes that the database
- # has a table called +sessions+ with columns +id+ (numeric, primary key), +sessid+ and +data+ (text).
- # The session data is stored in the +data+ column in the binary Marshal format; the user is responsible for ensuring that
- # only data that can be Marshaled is stored in the session.
+ # The default assumes a +sessions+ tables with columns +id+ (numeric
+ # primary key), +session_id+ (text), and +data+ (text). Session data is
+ # marshaled to +data+. +session_id+ should be indexed for speedy lookups.
#
- # Adding +created_at+ or +updated_at+ datetime columns to the sessions table will enable stamping of the data, which can
- # be used to clear out old sessions.
+ # 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.
#
- # It's highly recommended to have an index on the sessid column to improve performance.
+ # You may provide your own session class, whether a feature-packed
+ # Active Record or a bare-metal high-performance SQL store, by setting
+ # +CGI::Session::ActiveRecordStore.session_class = MySessionClass+
+ # You must implement these methods:
+ # self.find_by_session_id(session_id)
+ # initialize(hash_of_session_id_and_data)
+ # attr_reader :session_id
+ # attr_accessor :data
+ # save!
+ # destroy
+ #
+ # The fast SqlBypass class is a generic SQL session store. You may
+ # use it as a basis for high-performance database-specific stores.
class ActiveRecordStore
- # The ActiveRecord class which corresponds to the database table.
+ # The default Active Record class.
class Session < ActiveRecord::Base
+ self.table_name = 'sessions'
+ before_create :marshal_data!
+ before_update :marshal_data_if_changed!
+ after_save :clear_data_cache!
+
+ class << self
+ # Hook to set up sessid compatibility.
+ def find_by_session_id(session_id)
+ setup_sessid_compatibility!
+ find_by_session_id(session_id)
+ end
+
+ # Compatibility with tables using sessid instead of session_id.
+ def setup_sessid_compatibility!
+ if !@sessid_compatibility_checked
+ if columns_hash['sessid']
+ def self.find_by_session_id(*args)
+ find_by_sessid(*args)
+ end
+
+ alias_method :session_id, :sessid
+ define_method(:session_id) { sessid }
+ define_method(:session_id=) { |session_id| self.sessid = session_id }
+ else
+ def self.find_by_session_id(session_id)
+ find :first, :conditions => ["session_id #{attribute_condition(session_id)}", session_id]
+ end
+ end
+ @sessid_compatibility_checked = true
+ end
+ end
+
+ def marshal(data) Base64.encode64(Marshal.dump(data)) end
+ def unmarshal(data) Marshal.load(Base64.decode64(data)) end
+ def fingerprint(data) Digest::MD5.hexdigest(data) end
+
+ def create_table!
+ connection.execute <<-end_sql
+ CREATE TABLE #{table_name} (
+ id INTEGER PRIMARY KEY,
+ #{connection.quote_column_name('session_id')} TEXT UNIQUE,
+ #{connection.quote_column_name('data')} TEXT
+ )
+ end_sql
+ end
+
+ def drop_table!
+ connection.execute "DROP TABLE #{table_name}"
+ end
+ end
+
+ # Lazy-unmarshal session state.
+ def data
+ unless @data
+ @data = self.class.unmarshal(read_attribute('data'))
+ @fingerprint = self.class.fingerprint(@data)
+ end
+ @data
+ end
+
+ private
+ def marshal_data!
+ write_attribute('data', self.class.marshal(@data || {}))
+ end
+
+ def marshal_data_if_changed!
+ if @data and @fingerprint != self.class.fingerprint(@data)
+ marshal_data!
+ end
+ end
+
+ def clear_data_cache!
+ @data = @fingerprint = nil
+ end
end
- # Create a new ActiveRecordStore instance. This constructor is used internally by CGI::Session.
- # The user does not generally need to call it directly.
- #
- # +session+ is the session for which this instance is being created.
+ # A barebones session store which duck-types with the default session
+ # store but bypasses Active Record and issues SQL directly.
#
- # +option+ is currently ignored as no options are recognized.
+ # 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 +Base64.encode64(Marshal.dump(data))+ and
+ # unmarshaling data is +Marshal.load(Base64.decode64(data))+.
#
- # This session's ActiveRecord database row will be created if it does not exist, or opened if it does.
- def initialize(session, option=nil)
- ActiveRecord::Base.silence do
- @session = Session.find_by_sessid(session.session_id) || Session.new("sessid" => session.session_id, "data" => marshalize({}))
- @data = unmarshalize(@session.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
+ # Use the ActiveRecord::Base.connection by default.
+ cattr_accessor :connection
+ def self.connection
+ @@connection ||= ActiveRecord::Base.connection
end
- end
- # Update and close the session's ActiveRecord object.
- def close
- return unless @session
- update
- @session = nil
+ # The table name defaults to 'sessions'.
+ cattr_accessor :table_name
+ @@table_name = 'sessions'
+
+ # The session id field defaults to 'session_id'.
+ cattr_accessor :session_id_column
+ @@session_id_column = 'session_id'
+
+ # The data field defaults to 'data'.
+ cattr_accessor :data_column
+ @@data_column = 'data'
+
+ class << self
+ # Look up a session by id and unmarshal its data if found.
+ def find_by_session_id(session_id)
+ if record = @@connection.select_one("SELECT * FROM #{@@table_name} WHERE #{@@session_id_column}=#{@@connection.quote(session_id)}")
+ new(:session_id => session_id, :marshaled_data => record['data'])
+ end
+ end
+
+ def marshal(data) Base64.encode64(Marshal.dump(data)) end
+ def unmarshal(data) Marshal.load(Base64.decode64(data)) end
+ def fingerprint(data) Digest::MD5.hexdigest(data) end
+
+ def create_table!
+ @@connection.execute <<-end_sql
+ CREATE TABLE #{table_name} (
+ #{@@connection.quote_column_name(session_id_column)} TEXT PRIMARY KEY,
+ #{@@connection.quote_column_name(data_column)} TEXT
+ )
+ end_sql
+ end
+
+ def drop_table!
+ @@connection.execute "DROP TABLE #{table_name}"
+ end
+ end
+
+ attr_reader :session_id
+ 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)
+ @session_id, @data, @marshaled_data = attributes[:session_id], attributes[:data], attributes[:marshaled_data]
+ @new_record = !@marshaled_data.nil?
+ end
+
+ # Lazy-unmarshal session state. Take a fingerprint so we can detect
+ # whether to save changes later.
+ def data
+ if @marshaled_data
+ @data, @marshaled_data = self.class.unmarshal(@marshaled_data), nil
+ @fingerprint = self.class.fingerprint(@data)
+ end
+ @data
+ end
+
+ def save!
+ if @new_record
+ @new_record = false
+ @@connection.update <<-end_sql, 'Create session'
+ INSERT INTO #{@@table_name} (
+ #{@@connection.quote_column_name(@@session_id_column)},
+ #{@@connection.quote_column_name(@@data_column)} )
+ VALUES (
+ #{@@connection.quote(session_id)},
+ #{@@connection.quote(self.class.marshal(data))} )
+ end_sql
+ elsif self.class.fingerprint(data) != @fingerprint
+ @@connection.update <<-end_sql, 'Update session'
+ UPDATE #{@@table_name}
+ SET #{@@connection.quote_column_name(@@data_column)}=#{@@connection.quote(self.class.marshal(data))}
+ WHERE #{@@connection.quote_column_name(@@session_id_column)}=#{@@connection.quote(session_id)}
+ 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
- # Close and destroy the session's ActiveRecord object.
- def delete
- return unless @session
- @session.destroy
- @session = nil
+ # The class used for session storage. Defaults to
+ # CGI::Session::ActiveRecordStore::Session.
+ cattr_accessor :session_class
+ @@session_class = Session
+
+ # Find or instantiate a session given a CGI::Session.
+ def initialize(session, option = nil)
+ session_id = session.session_id
+ unless @session = @@session_class.find_by_session_id(session_id)
+ unless session.new_session
+ raise CGI::Session::NoSession, 'uninitialized session'
+ end
+ @session = @@session_class.new(:session_id => session_id, :data => {})
+ end
end
- # Restore session state from the session's ActiveRecord object.
+ # Restore session state. The session model handles unmarshaling.
def restore
- return unless @session
- @data = unmarshalize(@session.data)
+ @session.data
end
- # Save session state in the session's ActiveRecord object.
+ # Save session store.
def update
- return unless @session
- ActiveRecord::Base.silence { @session.update_attribute "data", marshalize(@data) }
+ @session.save!
end
- private
- def unmarshalize(data)
- Marshal.load(Base64.decode64(data))
- end
+ # Save and close the session store.
+ def close
+ update
+ @session = nil
+ end
- def marshalize(data)
- Base64.encode64(Marshal.dump(data))
- end
- end #ActiveRecordStore
- end #Session
-end #CGI
+ # Delete and close the session store.
+ def delete
+ @session.destroy rescue nil
+ @session = nil
+ end
+ end
-rescue LoadError
- # Couldn't load Active Record, so don't make this store available
+ end
end
View
91 actionpack/test/controller/active_record_store_test.rb
@@ -0,0 +1,91 @@
+# Unfurl the safety net.
+path_to_ar = File.dirname(__FILE__) + '/../../../activerecord'
+if Object.const_defined?(:ActiveRecord) or File.exist?(path_to_ar)
+ begin
+
+# These tests exercise CGI::Session::ActiveRecordStore, so you're going to
+# need AR in a sibling directory to AP and have SQLite3 installed.
+
+unless Object.const_defined?(:ActiveRecord)
+ require "#{File.dirname(__FILE__)}/../../../activerecord/lib/active_record"
+end
+
+require File.dirname(__FILE__) + '/../abstract_unit'
+require 'action_controller/session/active_record_store'
+
+CGI::Session::ActiveRecordStore::Session.establish_connection(:adapter => 'sqlite3', :dbfile => ':memory:')
+
+def setup_session_schema(connection, table_name = 'sessions', session_id_column_name = 'sessid', data_column_name = 'data')
+ connection.execute <<-end_sql
+ create table #{table_name} (
+ id integer primary key,
+ #{connection.quote_column_name(session_id_column_name)} text unique,
+ #{connection.quote_column_name(data_column_name)} text
+ )
+ end_sql
+end
+
+class ActiveRecordStoreTest < Test::Unit::TestCase
+ def session_class
+ CGI::Session::ActiveRecordStore::Session
+ end
+
+ def setup
+ session_class.create_table!
+
+ ENV['REQUEST_METHOD'] = 'GET'
+ CGI::Session::ActiveRecordStore.session_class = session_class
+
+ @new_session = CGI::Session.new(CGI.new, :database_manager => CGI::Session::ActiveRecordStore, :new_session => true)
+ @new_session['foo'] = 'bar'
+ end
+
+ def teardown
+ session_class.drop_table!
+ end
+
+ def test_basics
+ session_id = @new_session.session_id
+ @new_session.close
+ found = session_class.find_by_session_id(session_id)
+ assert_not_nil found
+ assert_equal 'bar', found.data['foo']
+ end
+end
+
+
+class SqlBypassActiveRecordStoreTest < Test::Unit::TestCase
+ def session_class
+ CGI::Session::ActiveRecordStore::SqlBypass
+ end
+
+ def setup
+ session_class.connection = CGI::Session::ActiveRecordStore::Session.connection
+ session_class.create_table!
+
+ ENV['REQUEST_METHOD'] = 'GET'
+ CGI::Session::ActiveRecordStore.session_class = session_class
+
+ @new_session = CGI::Session.new(CGI.new, :database_manager => CGI::Session::ActiveRecordStore, :new_session => true)
+ end
+
+ def teardown
+ session_class.drop_table!
+ end
+
+ def test_basics
+ session_id = @new_session.session_id
+ @new_session.close
+ found = session_class.find_by_session_id(session_id)
+ assert_not_nil found
+ assert_equal 'bar', found.data['foo']
+ end
+end
+
+
+# End of safety net.
+ rescue Object => e
+ $stderr.puts "Skipping CGI::Session::ActiveRecordStore tests: #{e}"
+ #$stderr.puts " #{e.backtrace.join("\n ")}"
+ end
+end

0 comments on commit 452442d

Please sign in to comment.