module Sequel
module Plugins
# Sequel's built in Serialization plugin allows you to keep serialized
# ruby objects in the database, while giving you deserialized objects
# when you call an accessor.
#
# This plugin works by keeping the serialized value in the values, and
# adding a @deserialized_values hash. The reader method for serialized columns
# will check the @deserialized_values for the value, return it if present,
# or deserialized the entry in @values and return it. The writer method will
# set the @deserialized_values entry. This plugin adds a before_save hook
# that serializes all @deserialized_values to @values.
#
# You can specify the serialization format as a pair of serializer/deserializer
# callable objects. You can also specify the serialization format as a single
# symbol, if such a symbol has a registered serializer/deserializer pair in the
# plugin. By default, the plugin registers the :marshal, :yaml, and :json
# serialization formats. To register your own serialization formats, use
# Sequel::Plugins::Serialization.register_format.
# If you use yaml or json format, you need to require the libraries, Sequel
# does not do the requiring for you.
#
# You can specify the columns to serialize when loading the plugin, or later
# using the serialize_attributes class method.
#
# Because of how this plugin works, it must be used inside each model class
# that needs serialization, after any set_dataset method calls in that class.
# Otherwise, it is possible that the default column accessors will take
# precedence.
#
# == Example
#
# # Require json if you plan to use it, as the plugin doesn't require it for you.
# require 'json'
#
# # Register custom serializer/deserializer pair, if desired
# require 'sequel/plugins/serialization'
# Sequel::Plugins::Serialization.register_format(:reverse,
# lambda{|v| v.reverse},
# lambda{|v| v.reverse})
#
# class User < Sequel::Model
# # Built-in format support when loading the plugin
# plugin :serialization, :json, :permissions
#
# # Built-in format support after loading the plugin using serialize_attributes
# plugin :serialization
# serialize_attributes :marshal, :permissions
#
# # Use custom registered serialization format just like built-in format
# serialize_attributes :reverse, :password
#
# # Use a custom serializer/deserializer pair without registering
# serialize_attributes [lambda{|v| v.reverse}, lambda{|v| v.reverse}], :password
# end
# user = User.create
# user.permissions = { :global => 'read-only' }
# user.save
#
# Note that if you mutate serialized column values without reassigning them,
# those changes won't be picked up by Model#save_changes or
# Model#update. Example:
#
# user = User[1]
# user.permissions[:global] = 'foo'
# user.save_changes # Will not pick up changes to permissions
#
# You can use the +serialization_modification_detection+ plugin to pick
# up such changes.
module Serialization
# The default serializers supported by the serialization module.
# Use register_format to add serializers to this hash.
REGISTERED_FORMATS = {}
# Set up the column readers to do deserialization and the column writers
# to save the value in deserialized_values.
def self.apply(model, *args)
model.instance_eval do
@deserialization_map = {}
@serialization_map = {}
end
end
# Automatically call serialize_attributes with the format and columns unless
# no columns were provided.
def self.configure(model, format=nil, *columns)
model.serialize_attributes(format, *columns) unless columns.empty?
end
# Register a serializer/deserializer pair with a format symbol, to allow
# models to pick this format by name. Both serializer and deserializer
# should be callable objects.
def self.register_format(format, serializer, deserializer)
REGISTERED_FORMATS[format] = [serializer, deserializer]
end
register_format(:marshal, lambda{|v| [Marshal.dump(v)].pack('m')},
lambda do |v|
begin
Marshal.load(v.unpack('m')[0])
rescue => e
begin
# Backwards compatibility for unpacked marshal output.
Marshal.load(v)
rescue
raise e
end
end
end)
register_format(:yaml, lambda{|v| v.to_yaml}, lambda{|v| YAML.load(v)})
register_format(:json, lambda{|v| Sequel.object_to_json(v)}, lambda{|v| Sequel.parse_json(v)})
module ClassMethods
# A hash with column name symbols and callable values, with the value
# called to deserialize the column.
attr_reader :deserialization_map
# A hash with column name symbols and callable values, with the value
# called to serialize the column.
attr_reader :serialization_map
# Module to store the serialized column accessor methods, so they can
# call be overridden and call super to get the serialization behavior
attr_accessor :serialization_module
Plugins.inherited_instance_variables(self, :@deserialization_map=>:dup, :@serialization_map=>:dup)
# Create instance level reader that deserializes column values on request,
# and instance level writer that stores new deserialized values.
def serialize_attributes(format, *columns)
if format.is_a?(Symbol)
unless format = REGISTERED_FORMATS[format]
raise(Error, "Unsupported serialization format: #{format} (valid formats: #{REGISTERED_FORMATS.keys.map{|k| k.inspect}.join})")
end
end
serializer, deserializer = format
raise(Error, "No columns given. The serialization plugin requires you specify which columns to serialize") if columns.empty?
define_serialized_attribute_accessor(serializer, deserializer, *columns)
end
# The columns that will be serialized. This is only for
# backwards compatibility, use serialization_map in new code.
def serialized_columns
serialization_map.keys
end
private
# Add serializated attribute acessor methods to the serialization_module
def define_serialized_attribute_accessor(serializer, deserializer, *columns)
m = self
include(self.serialization_module ||= Module.new) unless serialization_module
serialization_module.class_eval do
columns.each do |column|
m.serialization_map[column] = serializer
m.deserialization_map[column] = deserializer
define_method(column) do
if deserialized_values.has_key?(column)
deserialized_values[column]
elsif frozen?
deserialize_value(column, super())
else
deserialized_values[column] = deserialize_value(column, super())
end
end
define_method("#{column}=") do |v|
if !changed_columns.include?(column) && (new? || send(column) != v)
changed_columns << column
end
deserialized_values[column] = v
end
end
end
end
end
module InstanceMethods
# Hash of deserialized values, used as a cache.
def deserialized_values
@deserialized_values ||= {}
end
# Freeze the deserialized values
def freeze
deserialized_values.freeze
super
end
private
# Serialize deserialized values before saving
def _before_validation
serialize_deserialized_values
super
end
# Clear any cached deserialized values when doing a manual refresh.
def _refresh_set_values(hash)
@deserialized_values.clear if @deserialized_values
super
end
# Deserialize the column value. Called when the model column accessor is called to
# return a deserialized value.
def deserialize_value(column, v)
unless v.nil?
raise Sequel::Error, "no entry in deserialization_map for #{column.inspect}" unless callable = model.deserialization_map[column]
callable.call(v)
end
end
# Dup the deserialized values when duping model instance.
def initialize_copy(other)
super
@deserialized_values = other.deserialized_values.dup
self
end
# Serialize all deserialized values
def serialize_deserialized_values
deserialized_values.each{|k,v| @values[k] = serialize_value(k, v)}
end
# Serialize the column value. Called before saving to ensure the serialized value
# is saved in the database.
def serialize_value(column, v)
unless v.nil?
raise Sequel::Error, "no entry in serialization_map for #{column.inspect}" unless callable = model.serialization_map[column]
callable.call(v)
end
end
end
end
end
end