From 452442dde8e8ea5949c387ea5c78387bff330f2a Mon Sep 17 00:00:00 2001 From: Jeremy Kemper Date: Mon, 4 Jul 2005 18:30:27 +0000 Subject: [PATCH] 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 --- .../session/active_record_store.rb | 288 ++++++++++++++---- .../controller/active_record_store_test.rb | 91 ++++++ 2 files changed, 325 insertions(+), 54 deletions(-) create mode 100644 actionpack/test/controller/active_record_store_test.rb diff --git a/actionpack/lib/action_controller/session/active_record_store.rb b/actionpack/lib/action_controller/session/active_record_store.rb index 6238b8cbee8..e0a46204016 100644 --- a/actionpack/lib/action_controller/session/active_record_store.rb +++ b/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. # - # 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. + # 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. # - # 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. + # 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. # - # It's highly recommended to have an index on the sessid column to improve performance. + # 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, 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. + # A barebones session store which duck-types with the default session + # store but bypasses Active Record and issues SQL directly. # - # +session+ is the session for which this instance is being created. + # 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))+. # - # +option+ is currently ignored as no options are recognized. - # - # 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 + + # 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 - # Update and close the session's ActiveRecord object. + # 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. The session model handles unmarshaling. + def restore + @session.data + end + + # Save session store. + def update + @session.save! + end + + # Save and close the session store. def close - return unless @session update @session = nil end - # Close and destroy the session's ActiveRecord object. + # Delete and close the session store. def delete - return unless @session - @session.destroy + @session.destroy rescue nil @session = nil end + end - # Restore session state from the session's ActiveRecord object. - def restore - return unless @session - @data = unmarshalize(@session.data) - end - - # Save session state in the session's ActiveRecord object. - def update - return unless @session - ActiveRecord::Base.silence { @session.update_attribute "data", marshalize(@data) } - end - - private - def unmarshalize(data) - Marshal.load(Base64.decode64(data)) - end - - def marshalize(data) - Base64.encode64(Marshal.dump(data)) - end - end #ActiveRecordStore - end #Session -end #CGI - -rescue LoadError - # Couldn't load Active Record, so don't make this store available + end end diff --git a/actionpack/test/controller/active_record_store_test.rb b/actionpack/test/controller/active_record_store_test.rb new file mode 100644 index 00000000000..6701b4dd295 --- /dev/null +++ b/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