mirror of https://github.com/rails/rails
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
This commit is contained in:
parent
e7b142aef7
commit
452442dde8
|
@ -1,82 +1,262 @@
|
||||||
begin
|
|
||||||
|
|
||||||
require 'active_record'
|
|
||||||
require 'cgi'
|
require 'cgi'
|
||||||
require 'cgi/session'
|
require 'cgi/session'
|
||||||
|
require 'digest/md5'
|
||||||
require 'base64'
|
require 'base64'
|
||||||
|
|
||||||
# Contributed by Tim Bates
|
|
||||||
class CGI
|
class CGI
|
||||||
class Session
|
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
|
# A default class is provided, but any object duck-typing to an Active
|
||||||
# has a table called +sessions+ with columns +id+ (numeric, primary key), +sessid+ and +data+ (text).
|
# Record +Session+ class with text +session_id+ and +data+ attributes
|
||||||
# The session data is stored in the +data+ column in the binary Marshal format; the user is responsible for ensuring that
|
# may be used as the backing store.
|
||||||
# only data that can be Marshaled is stored in the session.
|
|
||||||
#
|
#
|
||||||
# Adding +created_at+ or +updated_at+ datetime columns to the sessions table will enable stamping of the data, which can
|
# The default assumes a +sessions+ tables with columns +id+ (numeric
|
||||||
# be used to clear out old sessions.
|
# 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
|
class ActiveRecordStore
|
||||||
# The ActiveRecord class which corresponds to the database table.
|
# The default Active Record class.
|
||||||
class Session < ActiveRecord::Base
|
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
|
end
|
||||||
|
|
||||||
# Create a new ActiveRecordStore instance. This constructor is used internally by CGI::Session.
|
# Compatibility with tables using sessid instead of session_id.
|
||||||
# The user does not generally need to call it directly.
|
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
|
||||||
|
|
||||||
|
# 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 marshaling behavior is intended to store the widest range of
|
||||||
#
|
# binary session data in a +text+ column. For higher performance,
|
||||||
# This session's ActiveRecord database row will be created if it does not exist, or opened if it does.
|
# 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
|
||||||
|
|
||||||
|
# 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)
|
def initialize(session, option = nil)
|
||||||
ActiveRecord::Base.silence do
|
session_id = session.session_id
|
||||||
@session = Session.find_by_sessid(session.session_id) || Session.new("sessid" => session.session_id, "data" => marshalize({}))
|
unless @session = @@session_class.find_by_session_id(session_id)
|
||||||
@data = unmarshalize(@session.data)
|
unless session.new_session
|
||||||
|
raise CGI::Session::NoSession, 'uninitialized session'
|
||||||
|
end
|
||||||
|
@session = @@session_class.new(:session_id => session_id, :data => {})
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# Update and close the session's ActiveRecord object.
|
# 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
|
def close
|
||||||
return unless @session
|
|
||||||
update
|
update
|
||||||
@session = nil
|
@session = nil
|
||||||
end
|
end
|
||||||
|
|
||||||
# Close and destroy the session's ActiveRecord object.
|
# Delete and close the session store.
|
||||||
def delete
|
def delete
|
||||||
return unless @session
|
@session.destroy rescue nil
|
||||||
@session.destroy
|
|
||||||
@session = nil
|
@session = nil
|
||||||
end
|
end
|
||||||
|
|
||||||
# Restore session state from the session's ActiveRecord object.
|
|
||||||
def restore
|
|
||||||
return unless @session
|
|
||||||
@data = unmarshalize(@session.data)
|
|
||||||
end
|
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
|
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
|
||||||
|
|
|
@ -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
|
Loading…
Reference in New Issue