Skip to content

Commit 452442d

Browse files
committed
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
1 parent e7b142a commit 452442d

File tree

2 files changed

+323
-52
lines changed

2 files changed

+323
-52
lines changed
Lines changed: 232 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,82 +1,262 @@
1-
begin
2-
3-
require 'active_record'
41
require 'cgi'
52
require 'cgi/session'
3+
require 'digest/md5'
64
require 'base64'
75

8-
# Contributed by Tim Bates
96
class CGI
107
class Session
11-
# Active Record database-based session storage class.
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.
1213
#
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.
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.
1717
#
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.
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.
2021
#
21-
# It's highly recommended to have an index on the sessid column to improve performance.
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
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
25112
end
26113

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.
29-
#
30-
# +session+ is the session for which this instance is being created.
114+
# A barebones session store which duck-types with the default session
115+
# store but bypasses Active Record and issues SQL directly.
31116
#
32-
# +option+ is currently ignored as no options are recognized.
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))+.
33122
#
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)
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
39131
end
40-
end
41132

42-
# Update and close the session's ActiveRecord object.
43-
def close
44-
return unless @session
45-
update
46-
@session = nil
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
47220
end
48221

49-
# Close and destroy the session's ActiveRecord object.
50-
def delete
51-
return unless @session
52-
@session.destroy
53-
@session = nil
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
54236
end
55237

56-
# Restore session state from the session's ActiveRecord object.
238+
# Restore session state. The session model handles unmarshaling.
57239
def restore
58-
return unless @session
59-
@data = unmarshalize(@session.data)
240+
@session.data
60241
end
61242

62-
# Save session state in the session's ActiveRecord object.
243+
# Save session store.
63244
def update
64-
return unless @session
65-
ActiveRecord::Base.silence { @session.update_attribute "data", marshalize(@data) }
245+
@session.save!
66246
end
67247

68-
private
69-
def unmarshalize(data)
70-
Marshal.load(Base64.decode64(data))
71-
end
248+
# Save and close the session store.
249+
def close
250+
update
251+
@session = nil
252+
end
72253

73-
def marshalize(data)
74-
Base64.encode64(Marshal.dump(data))
75-
end
76-
end #ActiveRecordStore
77-
end #Session
78-
end #CGI
254+
# Delete and close the session store.
255+
def delete
256+
@session.destroy rescue nil
257+
@session = nil
258+
end
259+
end
79260

80-
rescue LoadError
81-
# Couldn't load Active Record, so don't make this store available
261+
end
82262
end

0 commit comments

Comments
 (0)