# frozen-string-literal: true module Sequel module Plugins # The 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 deserialize the entry in values and return it. The writer method will # set the deserialized_values entry. This plugin adds a before_validation 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, :reverse.to_proc, :reverse.to_proc) # # 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 [:reverse.to_proc, :reverse.to_proc], :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_exec 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) Sequel.synchronize{REGISTERED_FORMATS[format] = [serializer, deserializer].freeze} end register_format(:marshal, lambda{|v| [Marshal.dump(v)].pack('m')}, lambda do |v| # Handle unpacked marshalled data for backwards compat v = v.unpack('m')[0] unless v[0..1] == "\x04\x08" Marshal.load(v) end) register_format(:yaml, :to_yaml.to_proc, lambda{|s| YAML.load(s)}) register_format(:json, Sequel.method(:object_to_json), Sequel.method(:parse_json)) 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 Plugins.inherited_instance_variables(self, :@deserialization_map=>:dup, :@serialization_map=>:dup) # Freeze serialization metadata when freezing model class. def freeze @deserialization_map.freeze @serialization_map.freeze @serialization_module.freeze if @serialization_module super end # 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 = Sequel.synchronize{REGISTERED_FORMATS[format]} raise(Error, "Unsupported serialization format: #{format} (valid formats: #{Sequel.synchronize{REGISTERED_FORMATS.keys}.map(&: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 private # Add serializated attribute acessor methods to the serialization_module def define_serialized_attribute_accessor(serializer, deserializer, *columns) m = self include(@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? || get_column_value(column) != v) changed_columns << column will_change_column(column) if respond_to?(:will_change_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 # Serialize deserialized values before saving def before_validation serialize_deserialized_values super end private # 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 = Hash[other.deserialized_values] 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