Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add configurable serializer #61

Merged
merged 12 commits into from May 13, 2016
11 changes: 9 additions & 2 deletions README.md
Expand Up @@ -38,20 +38,27 @@ 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`:
You may configure the table name, primary key, data column, and
serializer type. For example, at the end of `config/application.rb`:

```ruby
ActiveRecord::SessionStore::Session.table_name = 'legacy_session_table'
ActiveRecord::SessionStore::Session.primary_key = 'session_id'
ActiveRecord::SessionStore::Session.data_column_name = 'legacy_session_data'
ActiveRecord::SessionStore::Session.serializer = :json
```

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.

The serializer may be one of `marshal`, `json`, or `hybrid`. `marshal` is
the default and uses the built-in Marshal methods coupled with Base64
encoding. `json` does what it says on the tin, using the `parse()` and
`generate()` methods of the JSON module. `hybrid` will read either type
but write as JSON.

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.
Expand Down
1 change: 1 addition & 0 deletions Rakefile
@@ -1,4 +1,5 @@
#!/usr/bin/env rake
require 'bundler/setup'
require "bundler/gem_tasks"
require 'rake/testtask'

Expand Down
1 change: 1 addition & 0 deletions activerecord-session_store.gemspec
Expand Up @@ -21,6 +21,7 @@ Gem::Specification.new do |s|
s.add_dependency('actionpack', '>= 4.0', '< 5.1')
s.add_dependency('railties', '>= 4.0', '< 5.1')
s.add_dependency('rack', '>= 1.5.2', '< 3')
s.add_dependency('multi_json', '~> 1.11', '>= 1.11.2')

s.add_development_dependency('sqlite3')
s.add_development_dependency('appraisal', '~> 2.1.0')
Expand Down
65 changes: 61 additions & 4 deletions lib/active_record/session_store.rb
@@ -1,15 +1,19 @@
require 'action_dispatch/session/active_record_store'
require "active_record/session_store/extension/logger_silencer"
require 'active_support/core_ext/hash/keys'
require 'multi_json'

module ActiveRecord
module SessionStore
module ClassMethods # :nodoc:
def marshal(data)
::Base64.encode64(Marshal.dump(data)) if data
mattr_accessor :serializer

def serialize(data)
serializer_class.dump(data) if data
end

def unmarshal(data)
Marshal.load(::Base64.decode64(data)) if data
def deserialize(data)
serializer_class.load(data) if data
end

def drop_table!
Expand All @@ -33,6 +37,59 @@ def create_table!
end
connection.add_index table_name, session_id_column, :unique => true
end

def serializer_class
case self.serializer
when :marshal, nil then
MarshalSerializer
when :json then
JsonSerializer
when :hybrid then
HybridSerializer
else
self.serializer
end
end

# Use Marshal with Base64 encoding
class MarshalSerializer
def self.load(value)
Marshal.load(::Base64.decode64(value))
end

def self.dump(value)
::Base64.encode64(Marshal.dump(value))
end
end

# Uses built-in JSON library to encode/decode session
class JsonSerializer
def self.load(value)
hash = MultiJson.load(value)
hash.is_a?(Hash) ? hash.with_indifferent_access[:value] : hash
end

def self.dump(value)
MultiJson.dump(value: value)
end
end

# Transparently migrates existing session values from Marshal to JSON
class HybridSerializer < JsonSerializer
MARSHAL_SIGNATURE = 'BAh'.freeze

def self.load(value)
if needs_migration?(value)
Marshal.load(::Base64.decode64(value))
else
super
end
end

def self.needs_migration?(value)
value.start_with?(MARSHAL_SIGNATURE)
end
end
end
end
end
Expand Down
10 changes: 5 additions & 5 deletions lib/active_record/session_store/session.rb
Expand Up @@ -14,7 +14,7 @@ class Session < ActiveRecord::Base
cattr_accessor :data_column_name
self.data_column_name = 'data'

before_save :marshal_data!
before_save :serialize_data!
before_save :raise_on_session_data_overflow!

# This method is defiend in `protected_attributes` gem. We can't check for
Expand Down Expand Up @@ -66,9 +66,9 @@ def initialize(*)
super
end

# Lazy-unmarshal session state.
# Lazy-deserialize session state.
def data
@data ||= self.class.unmarshal(read_attribute(@@data_column_name)) || {}
@data ||= self.class.deserialize(read_attribute(@@data_column_name)) || {}
end

attr_writer :data
Expand All @@ -79,9 +79,9 @@ def loaded?
end

private
def marshal_data!
def serialize_data!
return false unless loaded?
write_attribute(@@data_column_name, self.class.marshal(data))
write_attribute(@@data_column_name, self.class.serialize(data))
end

# Ensures that the data about to be stored in the database is not
Expand Down
32 changes: 16 additions & 16 deletions lib/active_record/session_store/sql_bypass.rb
Expand Up @@ -7,17 +7,17 @@ module SessionStore
# 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 configurable class attributes. Serializing and deserializeing
# are implemented as class methods that you may override. By default,
# marshaling data is
# serializing data is
#
# ::Base64.encode64(Marshal.dump(data))
#
# and unmarshaling data is
# and deserializing data is
#
# Marshal.load(::Base64.decode64(data))
#
# This marshaling behavior is intended to store the widest range of
# This serializing 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
Expand Down Expand Up @@ -58,10 +58,10 @@ def connection_pool
@connection_pool ||= ActiveRecord::Base.connection_pool
end

# Look up a session by id and unmarshal its data if found.
# Look up a session by id and deserialize 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'])
new(:session_id => session_id, :serialized_data => record['data'])
end
end
end
Expand All @@ -73,26 +73,26 @@ def find_by_session_id(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.
# Look for normal and serialized data, self.find_by_session_id's way of
# telling us to postpone deserializing 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?
@serialized_data = attributes[:serialized_data]
@new_record = @serialized_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.
# Lazy-deserialize session state.
def data
unless @data
if @marshaled_data
@data, @marshaled_data = self.class.unmarshal(@marshaled_data) || {}, nil
if @serialized_data
@data, @serialized_data = self.class.deserialize(@serialized_data) || {}, nil
else
@data = {}
end
Expand All @@ -106,7 +106,7 @@ def loaded?

def save
return false unless loaded?
marshaled_data = self.class.marshal(data)
serialized_data = self.class.serialize(data)
connect = connection

if @new_record
Expand All @@ -117,12 +117,12 @@ def save
#{connect.quote_column_name(data_column)} )
VALUES (
#{connect.quote(session_id)},
#{connect.quote(marshaled_data)} )
#{connect.quote(serialized_data)} )
end_sql
else
connect.update <<-end_sql, 'Update session'
UPDATE #{table_name}
SET #{connect.quote_column_name(data_column)}=#{connect.quote(marshaled_data)}
SET #{connect.quote_column_name(data_column)}=#{connect.quote(serialized_data)}
WHERE #{connect.quote_column_name(session_id_column)}=#{connect.quote(session_id)}
end_sql
end
Expand Down
1 change: 1 addition & 0 deletions test/action_controller_test.rb
Expand Up @@ -2,6 +2,7 @@

class ActionControllerTest < ActionDispatch::IntegrationTest
class TestController < ActionController::Base
protect_from_forgery
def no_session_access
head :ok
end
Expand Down
51 changes: 51 additions & 0 deletions test/session_test.rb
@@ -1,5 +1,6 @@
require 'helper'
require 'active_record/session_store'
require 'active_support/core_ext/hash/keys'

module ActiveRecord
module SessionStore
Expand All @@ -12,6 +13,7 @@ def setup
ActiveRecord::Base.connection.schema_cache.clear!
Session.drop_table! if Session.table_exists?
@session_klass = Class.new(Session)
ActiveRecord::SessionStore::Session.serializer = :json
end

def test_data_column_name
Expand All @@ -31,6 +33,55 @@ def test_create_table!
assert !Session.table_exists?
end

def test_json_serialization
Session.create_table!
ActiveRecord::SessionStore::Session.serializer = :json
s = session_klass.create!(:data => 'world', :session_id => '7')

sessions = ActiveRecord::Base.connection.execute("SELECT * FROM #{Session.table_name}")
data = Session.deserialize(sessions[0][Session.data_column_name])
assert_equal s.data, data
end

def test_hybrid_serialization
Session.create_table!
# Star with marshal, which will serialize with Marshal
ActiveRecord::SessionStore::Session.serializer = :marshal
s1 = session_klass.create!(:data => 'world', :session_id => '1')

# Switch to hybrid, which will serialize as JSON
ActiveRecord::SessionStore::Session.serializer = :hybrid
s2 = session_klass.create!(:data => 'world', :session_id => '2')

# Check that first was serialized with Marshal and second as JSON
sessions = ActiveRecord::Base.connection.execute("SELECT * FROM #{Session.table_name}")
assert_equal ::Base64.encode64(Marshal.dump(s1.data)), sessions[0][Session.data_column_name]
assert_equal s2.data, Session.deserialize(sessions[1][Session.data_column_name])
end

def test_hybrid_deserialization
Session.create_table!
# Star with marshal, which will serialize with Marshal
ActiveRecord::SessionStore::Session.serializer = :marshal
s = session_klass.create!(:data => 'world', :session_id => '1')

# Switch to hybrid, which will deserialize with Marshal if needed
ActiveRecord::SessionStore::Session.serializer = :hybrid

# Check that it was serialized with Marshal,
sessions = ActiveRecord::Base.connection.execute("SELECT * FROM #{Session.table_name}")
assert_equal sessions[0][Session.data_column_name], ::Base64.encode64(Marshal.dump(s.data))

# deserializes properly,
session = Session.find_by_session_id(s.id)
assert_equal s.data, session.data

# and reserializes as JSON
session.save
sessions = ActiveRecord::Base.connection.execute("SELECT * FROM #{Session.table_name}")
assert_equal s.data,Session.deserialize(sessions[0][Session.data_column_name])
end

def test_find_by_sess_id_compat
# Force class reload, as we need to redo the meta-programming
ActiveRecord::SessionStore.send(:remove_const, :Session)
Expand Down