# frozen_string_literal: true module ActiveRecord module AttributeMethods # = Active Record Attribute Methods \Serialization module Serialization extend ActiveSupport::Concern class ColumnNotSerializableError < StandardError def initialize(name, type) super <<~EOS Column `#{name}` of type #{type.class} does not support `serialize` feature. Usually it means that you are trying to use `serialize` on a column that already implements serialization natively. EOS end end included do class_attribute :default_column_serializer, instance_accessor: false, default: Coders::YAMLColumn end module ClassMethods # If you have an attribute that needs to be saved to the database as a # serialized object, and retrieved by deserializing into the same object, # then specify the name of that attribute using this method and serialization # will be handled automatically. # # The serialization format may be YAML, JSON, or any custom format using a # custom coder class. # # Keep in mind that database adapters handle certain serialization tasks # for you. For instance: +json+ and +jsonb+ types in PostgreSQL will be # converted between JSON object/array syntax and Ruby +Hash+ or +Array+ # objects transparently. There is no need to use #serialize in this # case. # # For more complex cases, such as conversion to or from your application # domain objects, consider using the ActiveRecord::Attributes API. # # ==== Parameters # # * +attr_name+ - The name of the attribute to serialize. # * +coder+ The serializer implementation to use, e.g. +JSON+. # * The attribute value will be serialized # using the coder's dump(value) method, and will be # deserialized using the coder's load(string) method. The # +dump+ method may return +nil+ to serialize the value as +NULL+. # * +type+ - Optional. What the type of the serialized object should be. # * Attempting to serialize another type will raise an # ActiveRecord::SerializationTypeMismatch error. # * If the column is +NULL+ or starting from a new record, the default value # will set to +type.new+ # * +yaml+ - Optional. Yaml specific options. The allowed config is: # * +:permitted_classes+ - +Array+ with the permitted classes. # * +:unsafe_load+ - Unsafely load YAML blobs, allow YAML to load any class. # # ==== Options # # * +:default+ - The default value to use when no value is provided. If # this option is not passed, the previous default value (if any) will # be used. Otherwise, the default will be +nil+. # # ==== Choosing a serializer # # While any serialization format can be used, it is recommended to carefully # evaluate the properties of a serializer before using it, as migrating to # another format later on can be difficult. # # ===== Avoid accepting arbitrary types # # When serializing data in a column, it is heavily recommended to make sure # only expected types will be serialized. For instance some serializer like # +Marshal+ or +YAML+ are capable of serializing almost any Ruby object. # # This can lead to unexpected types being serialized, and it is important # that type serialization remains backward and forward compatible as long # as some database records still contain these serialized types. # # class Address # def initialize(line, city, country) # @line, @city, @country = line, city, country # end # end # # In the above example, if any of the +Address+ attributes is renamed, # instances that were persisted before the change will be loaded with the # old attributes. This problem is even worse when the serialized type comes # from a dependency which doesn't expect to be serialized this way and may # change its internal representation without notice. # # As such, it is heavily recommended to instead convert these objects into # primitives of the serialization format, for example: # # class Address # attr_reader :line, :city, :country # # def self.load(payload) # data = YAML.safe_load(payload) # new(data["line"], data["city"], data["country"]) # end # # def self.dump(address) # YAML.safe_dump( # "line" => address.line, # "city" => address.city, # "country" => address.country, # ) # end # # def initialize(line, city, country) # @line, @city, @country = line, city, country # end # end # # class User < ActiveRecord::Base # serialize :address, coder: Address # end # # This pattern allows to be more deliberate about what is serialized, and # to evolve the format in a backward compatible way. # # ===== Ensure serialization stability # # Some serialization methods may accept some types they don't support by # silently casting them to other types. This can cause bugs when the # data is deserialized. # # For instance the +JSON+ serializer provided in the standard library will # silently cast unsupported types to +String+: # # >> JSON.parse(JSON.dump(Struct.new(:foo))) # => "#" # # ==== Examples # # ===== Serialize the +preferences+ attribute using YAML # # class User < ActiveRecord::Base # serialize :preferences, coder: YAML # end # # ===== Serialize the +preferences+ attribute using JSON # # class User < ActiveRecord::Base # serialize :preferences, coder: JSON # end # # ===== Serialize the +preferences+ +Hash+ using YAML # # class User < ActiveRecord::Base # serialize :preferences, type: Hash, coder: YAML # end # # ===== Serializes +preferences+ to YAML, permitting select classes # # class User < ActiveRecord::Base # serialize :preferences, coder: YAML, yaml: { permitted_classes: [Symbol, Time] } # end # # ===== Serialize the +preferences+ attribute using a custom coder # # class Rot13JSON # def self.rot13(string) # string.tr("a-zA-Z", "n-za-mN-ZA-M") # end # # # Serializes an attribute value to a string that will be stored in the database. # def self.dump(value) # rot13(ActiveSupport::JSON.dump(value)) # end # # # Deserializes a string from the database to an attribute value. # def self.load(string) # ActiveSupport::JSON.load(rot13(string)) # end # end # # class User < ActiveRecord::Base # serialize :preferences, coder: Rot13JSON # end # def serialize(attr_name, class_name_or_coder = nil, coder: nil, type: Object, yaml: {}, **options) unless class_name_or_coder.nil? if class_name_or_coder == ::JSON || [:load, :dump].all? { |x| class_name_or_coder.respond_to?(x) } ActiveRecord.deprecator.warn(<<~MSG) Passing the coder as positional argument is deprecated and will be removed in Rails 7.2. Please pass the coder as a keyword argument: serialize #{attr_name.inspect}, coder: #{class_name_or_coder} MSG coder = class_name_or_coder else ActiveRecord.deprecator.warn(<<~MSG) Passing the class as positional argument is deprecated and will be removed in Rails 7.2. Please pass the class as a keyword argument: serialize #{attr_name.inspect}, type: #{class_name_or_coder.name} MSG type = class_name_or_coder end end coder ||= default_column_serializer unless coder raise ArgumentError, <<~MSG.squish missing keyword: :coder If no default coder is configured, a coder must be provided to `serialize`. MSG end column_serializer = build_column_serializer(attr_name, coder, type, yaml) attribute(attr_name, **options) do |cast_type| if type_incompatible_with_serialize?(cast_type, coder, type) raise ColumnNotSerializableError.new(attr_name, cast_type) end cast_type = cast_type.subtype if Type::Serialized === cast_type Type::Serialized.new(cast_type, column_serializer) end end private def build_column_serializer(attr_name, coder, type, yaml = nil) # When ::JSON is used, force it to go through the Active Support JSON encoder # to ensure special objects (e.g. Active Record models) are dumped correctly # using the #as_json hook. coder = Coders::JSON if coder == ::JSON if coder == ::YAML || coder == Coders::YAMLColumn Coders::YAMLColumn.new(attr_name, type, **(yaml || {})) elsif coder.respond_to?(:new) && !coder.respond_to?(:load) coder.new(attr_name, type) elsif type && type != Object Coders::ColumnSerializer.new(attr_name, coder, type) else coder end end def type_incompatible_with_serialize?(cast_type, coder, type) cast_type.is_a?(ActiveRecord::Type::Json) && coder == ::JSON || cast_type.respond_to?(:type_cast_array, true) && type == ::Array end end end end end