Skip to content

Commit

Permalink
Merge pull request #61 from dinomite/configurable-serialization
Browse files Browse the repository at this point in the history
Add configurable serializer
  • Loading branch information
sikachu committed May 13, 2016
2 parents 14a86b7 + a8fc2b3 commit ff064f8
Show file tree
Hide file tree
Showing 8 changed files with 145 additions and 27 deletions.
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

0 comments on commit ff064f8

Please sign in to comment.