Skip to content
Browse files

Initial extract from rails/rails@0cda0b3

  • Loading branch information...
0 parents commit 512fadebf3a01d27f7ff443e94094311137d2b9c @sikachu committed
Showing with 668 additions and 0 deletions.
  1. +17 −0 .gitignore
  2. +3 −0 .travis.yml
  3. +14 −0 Gemfile
  4. +22 −0 LICENSE
  5. +51 −0 README.md
  6. +11 −0 Rakefile
  7. +24 −0 activerecord-session_store.gemspec
  8. +365 −0 lib/active_record/session_store.rb
  9. +6 −0 test/helper.rb
  10. +80 −0 test/session_test.rb
  11. +75 −0 test/sql_bypass_test.rb
17 .gitignore
@@ -0,0 +1,17 @@
+*.gem
+*.rbc
+.bundle
+.config
+.yardoc
+Gemfile.lock
+InstalledFiles
+_yardoc
+coverage
+doc/
+lib/bundler/man
+pkg
+rdoc
+spec/reports
+test/tmp
+test/version_tmp
+tmp
3 .travis.yml
@@ -0,0 +1,3 @@
+language: ruby
+rvm:
+ - 1.9.3
14 Gemfile
@@ -0,0 +1,14 @@
+source :rubygems
+
+gemspec
+
+if ENV['RAILS']
+ gem 'activerecord', path: ENV['RAILS']
+ gem 'actionpack', path: ENV['RAILS']
+else
+ gem 'activerecord', github: 'rails/rails'
+ gem 'actionpack', github: 'rails/rails'
+end
+
+gem 'journey', github: 'rails/journey'
+gem 'active_record_deprecated_finders', github: 'rails/active_record_deprecated_finders'
22 LICENSE
@@ -0,0 +1,22 @@
+Copyright (c) 2012 David Heinemeier Hansson
+
+MIT License
+
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of this software and associated documentation files (the
+"Software"), to deal in the Software without restriction, including
+without limitation the rights to use, copy, modify, merge, publish,
+distribute, sublicense, and/or sell copies of the Software, and to
+permit persons to whom the Software is furnished to do so, subject to
+the following conditions:
+
+The above copyright notice and this permission notice shall be
+included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
+LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
51 README.md
@@ -0,0 +1,51 @@
+Active Record Session Store
+===========================
+
+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 is sufficient.
+
+The default assumes a `sessions` tables with columns:
+
+* `id` (numeric primary key),
+* `session_id` (string, usually varchar; maximum length is 255), and
+* `data` (text or longtext; careful if your session data exceeds 65KB).
+
+The `session_id` column should always be indexed for speedy lookups.
+Session data is marshaled to the `data` column in Base64 format.
+If the data you write is larger than the column's size limit,
+ActionController::SessionOverflowError will be raised.
+
+You may configure the table name, primary key, and data column.
+For example, at the end of `config/application.rb`:
+
+ ActiveRecord::SessionStore::Session.table_name = 'legacy_session_table'
+ ActiveRecord::SessionStore::Session.primary_key = 'session_id'
+ ActiveRecord::SessionStore::Session.data_column_name = 'legacy_session_data'
+
+Note that setting the primary key to the `session_id` frees you from
+having a separate `id` column if you don't want it. However, you must
+set `session.model.id = session.session_id` by hand! A before filter
+on ApplicationController is a good place.
+
+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 implementation, whether a
+feature-packed Active Record or a bare-metal high-performance SQL
+store, by setting
+
+ ActiveRecord::SessionStore.session_class = MySessionClass
+
+You must implement these methods:
+
+* `self.find_by_session_id(session_id)`
+* `initialize(hash_of_session_id_and_data, options_hash = {})`
+* `attr_reader :session_id`
+* `attr_accessor :data`
+* `save`
+* `destroy`
+
+The example SqlBypass class is a generic SQL session store. You may
+use it as a basis for high-performance database-specific stores.
11 Rakefile
@@ -0,0 +1,11 @@
+#!/usr/bin/env rake
+require "bundler/gem_tasks"
+require 'rake/testtask'
+
+Rake::TestTask.new do |t|
+ t.libs = ["test"]
+ t.pattern = "test/**/*_test.rb"
+ t.ruby_opts = ['-w']
+end
+
+task :default => :test
24 activerecord-session_store.gemspec
@@ -0,0 +1,24 @@
+Gem::Specification.new do |s|
+ s.platform = Gem::Platform::RUBY
+ s.name = 'activerecord-session_store'
+ s.version = '0.0.1'
+ s.summary = 'An Action Dispatch session store backed by an Active Record class.'
+
+ s.required_ruby_version = '>= 1.9.3'
+ s.license = 'MIT'
+
+ s.author = 'David Heinemeier Hansson'
+ s.email = 'david@loudthinking.com'
+ s.homepage = 'http://www.rubyonrails.org'
+
+ s.files = Dir['CHANGELOG.md', 'MIT-LICENSE', 'README.rdoc', 'lib/**/*']
+ s.require_path = 'lib'
+
+ s.extra_rdoc_files = %w( README.rdoc )
+ s.rdoc_options.concat ['--main', 'README.rdoc']
+
+ s.add_dependency('activerecord', '~> 4.0.0.beta')
+ s.add_dependency('actionpack', '~> 4.0.0.beta')
+
+ s.add_development_dependency('sqlite3')
+end
365 lib/active_record/session_store.rb
@@ -0,0 +1,365 @@
+require 'action_dispatch/middleware/session/abstract_store'
+
+module ActiveRecord
+ # = Active Record Session Store
+ #
+ # 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 is sufficient.
+ #
+ # The default assumes a +sessions+ tables with columns:
+ # +id+ (numeric primary key),
+ # +session_id+ (string, usually varchar; maximum length is 255), and
+ # +data+ (text or longtext; careful if your session data exceeds 65KB).
+ #
+ # The +session_id+ column should always be indexed for speedy lookups.
+ # Session data is marshaled to the +data+ column in Base64 format.
+ # If the data you write is larger than the column's size limit,
+ # ActionController::SessionOverflowError will be raised.
+ #
+ # You may configure the table name, primary key, and data column.
+ # For example, at the end of <tt>config/application.rb</tt>:
+ #
+ # ActiveRecord::SessionStore::Session.table_name = 'legacy_session_table'
+ # ActiveRecord::SessionStore::Session.primary_key = 'session_id'
+ # ActiveRecord::SessionStore::Session.data_column_name = 'legacy_session_data'
+ #
+ # Note that setting the primary key to the +session_id+ frees you from
+ # having a separate +id+ column if you don't want it. However, you must
+ # set <tt>session.model.id = session.session_id</tt> by hand! A before filter
+ # on ApplicationController is a good place.
+ #
+ # 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 implementation, whether a
+ # feature-packed Active Record or a bare-metal high-performance SQL
+ # store, by setting
+ #
+ # ActiveRecord::SessionStore.session_class = MySessionClass
+ #
+ # You must implement these methods:
+ #
+ # self.find_by_session_id(session_id)
+ # initialize(hash_of_session_id_and_data, options_hash = {})
+ # attr_reader :session_id
+ # attr_accessor :data
+ # save
+ # destroy
+ #
+ # The example SqlBypass class is a generic SQL session store. You may
+ # use it as a basis for high-performance database-specific stores.
+ class SessionStore < ActionDispatch::Session::AbstractStore
+ module ClassMethods # :nodoc:
+ def marshal(data)
+ ::Base64.encode64(Marshal.dump(data)) if data
+ end
+
+ def unmarshal(data)
+ Marshal.load(::Base64.decode64(data)) if data
+ end
+
+ def drop_table!
+ connection.schema_cache.clear_table_cache!(table_name)
+ connection.drop_table table_name
+ end
+
+ def create_table!
+ connection.schema_cache.clear_table_cache!(table_name)
+ connection.create_table(table_name) do |t|
+ t.string session_id_column, :limit => 255
+ t.text data_column_name
+ end
+ connection.add_index table_name, session_id_column, :unique => true
+ end
+ end
+
+ # The default Active Record class.
+ class Session < ActiveRecord::Base
+ extend ClassMethods
+
+ ##
+ # :singleton-method:
+ # Customizable data column name. Defaults to 'data'.
+ cattr_accessor :data_column_name
+ self.data_column_name = 'data'
+
+ attr_accessible :session_id, :data, :marshaled_data
+
+ before_save :marshal_data!
+ before_save :raise_on_session_data_overflow!
+
+ class << self
+ def data_column_size_limit
+ @data_column_size_limit ||= columns_hash[data_column_name].limit
+ end
+
+ # Hook to set up sessid compatibility.
+ def find_by_session_id(session_id)
+ setup_sessid_compatibility!
+ find_by_session_id(session_id)
+ end
+
+ private
+ def session_id_column
+ 'session_id'
+ end
+
+ # Compatibility with tables using sessid instead of session_id.
+ def setup_sessid_compatibility!
+ # Reset column info since it may be stale.
+ reset_column_information
+ if columns_hash['sessid']
+ def self.find_by_session_id(*args)
+ find_by_sessid(*args)
+ end
+
+ define_method(:session_id) { sessid }
+ define_method(:session_id=) { |session_id| self.sessid = session_id }
+ else
+ class << self; remove_possible_method :find_by_session_id; end
+
+ def self.find_by_session_id(session_id)
+ where(session_id: session_id).first
+ end
+ end
+ end
+ end
+
+ def initialize(attributes = nil, options = {})
+ @data = nil
+ super
+ end
+
+ # Lazy-unmarshal session state.
+ def data
+ @data ||= self.class.unmarshal(read_attribute(@@data_column_name)) || {}
+ end
+
+ attr_writer :data
+
+ # Has the session been loaded yet?
+ def loaded?
+ @data
+ end
+
+ private
+ def marshal_data!
+ return false unless loaded?
+ write_attribute(@@data_column_name, self.class.marshal(data))
+ end
+
+ # Ensures that the data about to be stored in the database is not
+ # larger than the data storage column. Raises
+ # ActionController::SessionOverflowError.
+ def raise_on_session_data_overflow!
+ return false unless loaded?
+ limit = self.class.data_column_size_limit
+ if limit and read_attribute(@@data_column_name).size > limit
+ raise ActionController::SessionOverflowError
+ end
+ end
+ end
+
+ # A barebones session store which duck-types with the default session
+ # store but bypasses Active Record and issues SQL directly. This is
+ # an example session model class meant as a basis for your own classes.
+ #
+ # 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 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
+ extend ClassMethods
+
+ ##
+ # :singleton-method:
+ # The table name defaults to 'sessions'.
+ cattr_accessor :table_name
+ @@table_name = 'sessions'
+
+ ##
+ # :singleton-method:
+ # The session id field defaults to 'session_id'.
+ cattr_accessor :session_id_column
+ @@session_id_column = 'session_id'
+
+ ##
+ # :singleton-method:
+ # The data field defaults to 'data'.
+ cattr_accessor :data_column
+ @@data_column = 'data'
+
+ class << self
+ alias :data_column_name :data_column
+
+ # Use the ActiveRecord::Base.connection by default.
+ attr_writer :connection
+
+ # Use the ActiveRecord::Base.connection_pool by default.
+ attr_writer :connection_pool
+
+ def connection
+ @connection ||= ActiveRecord::Base.connection
+ end
+
+ def connection_pool
+ @connection_pool ||= ActiveRecord::Base.connection_pool
+ end
+
+ # 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 #{connection.quote_column_name(data_column)} AS data FROM #{@@table_name} WHERE #{connection.quote_column_name(@@session_id_column)}=#{connection.quote(session_id.to_s)}")
+ new(:session_id => session_id, :marshaled_data => record['data'])
+ end
+ end
+ end
+
+ delegate :connection, :connection=, :connection_pool, :connection_pool=, :to => self
+
+ attr_reader :session_id, :new_record
+ alias :new_record? :new_record
+
+ 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 = attributes[:session_id]
+ @data = attributes[:data]
+ @marshaled_data = attributes[:marshaled_data]
+ @new_record = @marshaled_data.nil?
+ end
+
+ # Returns true if the record is persisted, i.e. it's not a new record
+ def persisted?
+ !@new_record
+ end
+
+ # Lazy-unmarshal session state.
+ def data
+ unless @data
+ if @marshaled_data
+ @data, @marshaled_data = self.class.unmarshal(@marshaled_data) || {}, nil
+ else
+ @data = {}
+ end
+ end
+ @data
+ end
+
+ def loaded?
+ @data
+ end
+
+ def save
+ return false unless loaded?
+ marshaled_data = self.class.marshal(data)
+ connect = connection
+
+ if @new_record
+ @new_record = false
+ connect.update <<-end_sql, 'Create session'
+ INSERT INTO #{table_name} (
+ #{connect.quote_column_name(session_id_column)},
+ #{connect.quote_column_name(data_column)} )
+ VALUES (
+ #{connect.quote(session_id)},
+ #{connect.quote(marshaled_data)} )
+ end_sql
+ else
+ connect.update <<-end_sql, 'Update session'
+ UPDATE #{table_name}
+ SET #{connect.quote_column_name(data_column)}=#{connect.quote(marshaled_data)}
+ WHERE #{connect.quote_column_name(session_id_column)}=#{connect.quote(session_id)}
+ end_sql
+ end
+ end
+
+ def destroy
+ return if @new_record
+
+ connect = connection
+ connect.delete <<-end_sql, 'Destroy session'
+ DELETE FROM #{table_name}
+ WHERE #{connect.quote_column_name(session_id_column)}=#{connect.quote(session_id.to_s)}
+ end_sql
+ end
+ end
+
+ # The class used for session storage. Defaults to
+ # ActiveRecord::SessionStore::Session
+ cattr_accessor :session_class
+ self.session_class = Session
+
+ SESSION_RECORD_KEY = 'rack.session.record'
+ ENV_SESSION_OPTIONS_KEY = Rack::Session::Abstract::ENV_SESSION_OPTIONS_KEY
+
+ private
+ def get_session(env, sid)
+ Base.silence do
+ unless sid and session = @@session_class.find_by_session_id(sid)
+ # If the sid was nil or if there is no pre-existing session under the sid,
+ # force the generation of a new sid and associate a new session associated with the new sid
+ sid = generate_sid
+ session = @@session_class.new(:session_id => sid, :data => {})
+ end
+ env[SESSION_RECORD_KEY] = session
+ [sid, session.data]
+ end
+ end
+
+ def set_session(env, sid, session_data, options)
+ Base.silence do
+ record = get_session_model(env, sid)
+ record.data = session_data
+ return false unless record.save
+
+ session_data = record.data
+ if session_data && session_data.respond_to?(:each_value)
+ session_data.each_value do |obj|
+ obj.clear_association_cache if obj.respond_to?(:clear_association_cache)
+ end
+ end
+ end
+
+ sid
+ end
+
+ def destroy_session(env, session_id, options)
+ if sid = current_session_id(env)
+ Base.silence do
+ get_session_model(env, sid).destroy
+ env[SESSION_RECORD_KEY] = nil
+ end
+ end
+
+ generate_sid unless options[:drop]
+ end
+
+ def get_session_model(env, sid)
+ if env[ENV_SESSION_OPTIONS_KEY][:id].nil?
+ env[SESSION_RECORD_KEY] = find_session(sid)
+ else
+ env[SESSION_RECORD_KEY] ||= find_session(sid)
+ end
+ end
+
+ def find_session(id)
+ @@session_class.find_by_session_id(id) ||
+ @@session_class.new(:session_id => id, :data => {})
+ end
+ end
+end
6 test/helper.rb
@@ -0,0 +1,6 @@
+require 'bundler/setup'
+
+require 'active_record'
+require 'minitest/autorun'
+
+ActiveRecord::Base.establish_connection(adapter: 'sqlite3', database: ':memory:')
80 test/session_test.rb
@@ -0,0 +1,80 @@
+require 'helper'
+require 'action_dispatch'
+require 'active_record/session_store'
+
+module ActiveRecord
+ class SessionStore
+ class SessionTest < ActiveSupport::TestCase
+
+ attr_reader :session_klass
+
+ def setup
+ super
+ ActiveRecord::Base.connection.schema_cache.clear!
+ Session.drop_table! if Session.table_exists?
+ @session_klass = Class.new(Session)
+ end
+
+ def test_data_column_name
+ # default column name is 'data'
+ assert_equal 'data', Session.data_column_name
+ end
+
+ def test_table_name
+ assert_equal 'sessions', Session.table_name
+ end
+
+ def test_accessible_attributes
+ assert Session.accessible_attributes.include?(:session_id)
+ assert Session.accessible_attributes.include?(:data)
+ assert Session.accessible_attributes.include?(:marshaled_data)
+ end
+
+ def test_create_table!
+ assert !Session.table_exists?
+ Session.create_table!
+ assert Session.table_exists?
+ Session.drop_table!
+ assert !Session.table_exists?
+ end
+
+ def test_find_by_sess_id_compat
+ Session.reset_column_information
+ klass = Class.new(Session) do
+ def self.session_id_column
+ 'sessid'
+ end
+ end
+ klass.create_table!
+
+ assert klass.columns_hash['sessid'], 'sessid column exists'
+ session = klass.new(:data => 'hello')
+ session.sessid = "100"
+ session.save!
+
+ found = klass.find_by_session_id("100")
+ assert_equal session, found
+ assert_equal session.sessid, found.session_id
+ ensure
+ klass.drop_table!
+ Session.reset_column_information
+ end
+
+ def test_find_by_session_id
+ Session.create_table!
+ session_id = "10"
+ s = session_klass.create!(:data => 'world', :session_id => session_id)
+ t = session_klass.find_by_session_id(session_id)
+ assert_equal s, t
+ assert_equal s.data, t.data
+ Session.drop_table!
+ end
+
+ def test_loaded?
+ Session.create_table!
+ s = Session.new
+ assert !s.loaded?, 'session is not loaded'
+ end
+ end
+ end
+end
75 test/sql_bypass_test.rb
@@ -0,0 +1,75 @@
+require 'helper'
+require 'action_dispatch'
+require 'active_record/session_store'
+
+module ActiveRecord
+ class SessionStore
+ class SqlBypassTest < ActiveSupport::TestCase
+ def setup
+ super
+ Session.drop_table! if Session.table_exists?
+ end
+
+ def test_create_table
+ assert !Session.table_exists?
+ SqlBypass.create_table!
+ assert Session.table_exists?
+ SqlBypass.drop_table!
+ assert !Session.table_exists?
+ end
+
+ def test_new_record?
+ s = SqlBypass.new :data => 'foo', :session_id => 10
+ assert s.new_record?, 'this is a new record!'
+ end
+
+ def test_persisted?
+ s = SqlBypass.new :data => 'foo', :session_id => 10
+ assert !s.persisted?, 'this is a new record!'
+ end
+
+ def test_not_loaded?
+ s = SqlBypass.new({})
+ assert !s.loaded?, 'it is not loaded'
+ end
+
+ def test_loaded?
+ s = SqlBypass.new :data => 'hello'
+ assert s.loaded?, 'it is loaded'
+ end
+
+ def test_save
+ SqlBypass.create_table! unless Session.table_exists?
+ session_id = 20
+ s = SqlBypass.new :data => 'hello', :session_id => session_id
+ s.save
+ t = SqlBypass.find_by_session_id session_id
+ assert_equal s.session_id, t.session_id
+ assert_equal s.data, t.data
+ end
+
+ def test_destroy
+ SqlBypass.create_table! unless Session.table_exists?
+ session_id = 20
+ s = SqlBypass.new :data => 'hello', :session_id => session_id
+ s.save
+ s.destroy
+ assert_nil SqlBypass.find_by_session_id session_id
+ end
+
+ def test_data_column
+ SqlBypass.drop_table! if exists = Session.table_exists?
+ old, SqlBypass.data_column = SqlBypass.data_column, 'foo'
+ SqlBypass.create_table!
+
+ session_id = 20
+ SqlBypass.new(:data => 'hello', :session_id => session_id).save
+ assert_equal 'hello', SqlBypass.find_by_session_id(session_id).data
+ ensure
+ SqlBypass.drop_table!
+ SqlBypass.data_column = old
+ SqlBypass.create_table! if exists
+ end
+ end
+ end
+end

0 comments on commit 512fade

Please sign in to comment.
Something went wrong with that request. Please try again.