module FlexColumns::Definition::FlexColumnContentsClass

When you declare a flex column, we actually generate a brand-new Class for that column; instances of that flex column are instances of this new Class. This class acquires functionality from two places: FlexColumnContentsBase, which defines its instance methods, and FlexColumnContentsClass, which defines its class methods. (While FlexColumnContentsBase is an actual Class, FlexColumnContentsClass is a Module that FlexColumnContentsBase +extend+s. Both could be combined, but, simply for readability and maintainability, it was better to make them separate.)

This Module therefore defines the methods that are available on a flex-column class – directly from inside the block passed to flex_column, for example.

Constants

DEFAULT_MAX_JSON_LENGTH_BEFORE_COMPRESSION

By default, how long does the generated JSON have to be before we’ll try compressing it?

Attributes

column[R]
custom_methods[R]
field_set[R]
fields[R]
model_class[R]
options[R]

Public Instance Methods

_flex_columns_create_column_data(storage_string, data_source) click to toggle source

Given a string from storage in storage_string, and an object that responds to ColumnData’s data_source protocol for describing where data came from, create the appropriate ColumnData object to represent that data. (storage_string can absolutely be nil, in case there is no data yet.)

This is used by instances of the generated Class to create the ColumnData object that does most of the work of actually serializing/deserializing JSON and storing data for that instance.

# File lib/flex_columns/definition/flex_column_contents_class.rb, line 24
def _flex_columns_create_column_data(storage_string, data_source)
  ensure_setup!

  storage = case column.type
  when :binary, :text, :json then column.type
  when :string then :text
  else raise "Unknown storage type: #{column.type.inspect}"
  end

  create_options = {
    :storage_string => storage_string,
    :data_source    => data_source,
    :unknown_fields => options[:unknown_fields] || :preserve,
    :length_limit   => column.limit,
    :storage        => storage,
    :binary_header  => true,
    :null           => column.null
  }

  create_options[:binary_header] = false if options.has_key?(:header) && (! options[:header])

  if (! options.has_key?(:compress))
    create_options[:compress_if_over_length] = DEFAULT_MAX_JSON_LENGTH_BEFORE_COMPRESSION
  elsif options[:compress]
    create_options[:compress_if_over_length] = options[:compress]
  end

  FlexColumns::Contents::ColumnData.new(field_set, create_options)
end
all_field_names() click to toggle source

What are the names of all fields defined on this flex column?

# File lib/flex_columns/definition/flex_column_contents_class.rb, line 145
def all_field_names
  field_set.all_field_names
end
column_name() click to toggle source

What’s the name of the actual model column this flex-column uses? Returns a Symbol.

# File lib/flex_columns/definition/flex_column_contents_class.rb, line 139
def column_name
  ensure_setup!
  column.name.to_sym
end
delegation_prefix() click to toggle source

When we delegate methods, what should we prefix them with (if anything)?

# File lib/flex_columns/definition/flex_column_contents_class.rb, line 118
def delegation_prefix
  ensure_setup!
  options[:prefix].try(:to_s)
end
delegation_type() click to toggle source

When we delegate methods, should we delegate them at all (returns nil), publicly (:public), or privately (:private)?

# File lib/flex_columns/definition/flex_column_contents_class.rb, line 125
def delegation_type
  ensure_setup!
  return :public if (! options.has_key?(:delegate))

  case options[:delegate]
  when nil, false then nil
  when true, :public then :public
  when :private then :private
  # OK to raise an untyped error here -- we should've caught this in #validate_options.
  else raise "Impossible value for :delegate: #{options[:delegate]}"
  end
end
field(name, *args) click to toggle source

This is what gets called when you declare a field inside a flex column.

# File lib/flex_columns/definition/flex_column_contents_class.rb, line 55
def field(name, *args)
  ensure_setup!
  field_set.field(name, *args)
end
field_named(name) click to toggle source

Returns the field with the given name, or nil if there is no such field.

# File lib/flex_columns/definition/flex_column_contents_class.rb, line 61
def field_named(name)
  ensure_setup!
  field_set.field_named(name)
end
field_with_json_storage_name(json_storage_name) click to toggle source

Returns the field that stores its JSON under the given key (json_storage_name), or nil if there is no such field.

# File lib/flex_columns/definition/flex_column_contents_class.rb, line 68
def field_with_json_storage_name(json_storage_name)
  ensure_setup!
  field_set.field_with_json_storage_name(json_storage_name)
end
fields_are_private_by_default?() click to toggle source

Are fields in this flex column private by default?

# File lib/flex_columns/definition/flex_column_contents_class.rb, line 161
def fields_are_private_by_default?
  ensure_setup!
  options[:visibility] == :private
end
include_fields_into(dynamic_methods_module, association_name, target_class, options) click to toggle source

Tells this flex column that you want to include its methods into the given dynamic_methods_module, which is included in the given target_class. (We only use target_class to make sure we don’t define methods that are already present on the given target_class.) association_name is the name of the association that, from the given target_class, will return a model instance that contains this flex column.

options specifies options for the inclusion; it can specify :visibility to change whether methods are public or private, :delegate to turn off delegation of anything other than the flex column itself, or :prefix to set a prefix for the delegated method names.

# File lib/flex_columns/definition/flex_column_contents_class.rb, line 87
def include_fields_into(dynamic_methods_module, association_name, target_class, options)
  ensure_setup!

  cn = column_name
  mn = column_name.to_s
  mn = "#{options[:prefix]}_#{mn}" if options[:prefix]

  # Make sure we don't overwrite some #method_missing magic that defines a column accessor, or something
  # similar.
  if target_class._flex_columns_safe_to_define_method?(mn)
    dynamic_methods_module.define_method(mn) do
      associated_object = send(association_name) || send("build_#{association_name}")
      associated_object.send(cn)
    end
    dynamic_methods_module.private(mn) if options[:visibility] == :private
  end

  unless options.has_key?(:delegate) && (! options[:delegate])
    add_custom_methods!(dynamic_methods_module, target_class, options)
    field_set.include_fields_into(dynamic_methods_module, association_name, target_class, options)
  end
end
is_flex_column_class?() click to toggle source

Is this a flex-column class? Of course it is, by definition. We just use this for argument validation in some places.

# File lib/flex_columns/definition/flex_column_contents_class.rb, line 75
def is_flex_column_class?
  true
end
object_for(model_instance) click to toggle source

Given an instance of the model that this flex column is defined on, return the appropriate flex-column object for that instance. This simply delegates to #_flex_column_object_for on that model instance.

# File lib/flex_columns/definition/flex_column_contents_class.rb, line 112
def object_for(model_instance)
  ensure_setup!
  model_instance._flex_column_object_for(column.name)
end
requires_serialization_on_save?(model) click to toggle source

Given a model instance, do we need to save this column? This is true under one of two cases:

  • Someone has deserialized the column by accessing it (or calling touch! on it);

  • The column is non-NULL, and there’s no data in it right now. (Saving it will populate it with an empty string.)

# File lib/flex_columns/definition/flex_column_contents_class.rb, line 153
def requires_serialization_on_save?(model)
  maybe_flex_object = model._flex_column_object_for(column_name, false)
  out = true if maybe_flex_object && maybe_flex_object.deserialized?
  out ||= true if ((! column.null) && (! model[column_name]))
  out
end
reset_column_information() click to toggle source

This method gets called when ActiveRecord::Base.reset_column_information is called on the underlying model; this simply updates our notion of what column is present. Most importantly, this will correctly switch us from a table-does-not-exist state to a table-exists state (if you migrate the table in), but it also will correctly switch from one column type to another, etc.

# File lib/flex_columns/definition/flex_column_contents_class.rb, line 227
def reset_column_information
  @column = find_column(column_name)
end
setup!(model_class, column_name, options = { }, &block) click to toggle source

This is, for all intents and purposes, the initializer (constructor) for this module. But because it’s a module (and has to be), this can’t actually be initialize. (Another way of saying it: objects have initializers; classes do not.)

You must call this method exactly once for each class that extends this module, and before you call any other method.

model_class must be the ActiveRecord model class for this flex column. column_name must be the name of the column that you’re using as a flex column. options can contain any of:

:visibility

If :private, then all field accessors (readers and writers) will be private by default, unless overridden in their field declaration.

:delegate

If specified and false or nil, then field accessors and custom methods defined in this class will not be automatically delegated to from the model_class.

:prefix

If specified (as a Symbol or String), then field accessors and custom methods delegated from the model_class will be prefixed with this string, followed by an underscore.

:unknown_fields

If specified and :delete, then, if the JSON string for an instance contains fields that aren’t declared in this class, they will be removed from the JSON when saving back out to the database. This is dangerous, but powerful, if you want to keep your data clean.

:compress

If specified and false, this column will never be compressed. If specified as a number, then, when serializing data, we’ll try to compress it if the uncompressed version is at least that many bytes long; we’ll store the compressed version if it’s no more than 95% as long as the uncompressed version. The default is 200. Also note that compression requires a binary storage type for the underlying column.

:header

If the underlying column is of binary storage type, then, by default, we use a tiny header to indicate what kind of data is stored there and whether it’s compressed or not. If this is set to false, disables this header (and therefore also disables compression).

# File lib/flex_columns/definition/flex_column_contents_class.rb, line 193
def setup!(model_class, column_name, options = { }, &block)
  raise ArgumentError, "You can't call setup! twice!" if @model_class || @column

  # Make really sure we're being declared in the right kind of class.
  unless model_class.kind_of?(Class) && model_class.respond_to?(:has_any_flex_columns?) && model_class.has_any_flex_columns?
    raise ArgumentError, "Invalid model class: #{model_class.inspect}"
  end

  raise ArgumentError, "Invalid column name: #{column_name.inspect}" unless column_name.kind_of?(Symbol)

  @model_class = model_class
  @column = find_column(column_name)

  validate_options(options)

  @options = options
  @field_set = FlexColumns::Definition::FieldSet.new(self)

  class_name = "#{column_name.to_s.camelize}FlexContents".to_sym
  @model_class.send(:remove_const, class_name) if @model_class.const_defined?(class_name)
  @model_class.const_set(class_name, self)

  # Keep track of which methods were present before and after calling the block that was passed in; this is how
  # we know which methods were declared custom, so we know which ones to add delegation for.
  methods_before = instance_methods
  block_result = class_eval(&block) if block
  @custom_methods = (instance_methods - methods_before).map(&:to_sym)
  block_result
end
sync_methods!() click to toggle source

Tells this class to re-publish all its methods to the DynamicMethodsModule it uses internally, and to the model class it’s a part of.

Because Rails in development mode is constantly redefining classes, and we don’t want old cruft that you’ve removed to hang around, we use a “remove absolutely all methods, then add back only what’s defined now” strategy.

# File lib/flex_columns/definition/flex_column_contents_class.rb, line 237
def sync_methods!
  @dynamic_methods_module ||= FlexColumns::Util::DynamicMethodsModule.new(self, :FlexFieldsDynamicMethods)
  @dynamic_methods_module.remove_all_methods!

  field_set.add_delegated_methods!(@dynamic_methods_module, model_class._flex_column_dynamic_methods_module, model_class)

  if delegation_type
    add_custom_methods!(model_class._flex_column_dynamic_methods_module, model_class,
      :visibility => (delegation_type == :private ? :private : :public))
  end
end

Private Instance Methods

add_custom_methods!(dynamic_methods_module, target_class, options = { }) click to toggle source

Takes all custom methods defined on this flex-column class, and adds delegates to them to the given dynamic_methods_module. target_class is checked before each one to make sure we don’t have a conflict.

# File lib/flex_columns/definition/flex_column_contents_class.rb, line 256
def add_custom_methods!(dynamic_methods_module, target_class, options = { })
  cn = column_name

  custom_methods.each do |custom_method|
    mn = custom_method.to_s
    mn = "#{options[:prefix]}_#{mn}" if options[:prefix]

    if target_class._flex_columns_safe_to_define_method?(mn)
      dynamic_methods_module.define_method(mn) do |*args, &block|
        flex_object = _flex_column_object_for(cn)
        flex_object.send(custom_method, *args, &block)
      end

      dynamic_methods_module.private(custom_method) if options[:visibility] == :private
    end
  end
end
create_temporary_fake_column(column_name) click to toggle source

This creates a “fake” column that we can use if the underlying table doesn’t exist yet.

# File lib/flex_columns/definition/flex_column_contents_class.rb, line 307
def create_temporary_fake_column(column_name)
  ::FlexColumns::Definition::FakeColumn.new(column_name)
end
ensure_setup!() click to toggle source

Make sure someone has called setup! previously.

# File lib/flex_columns/definition/flex_column_contents_class.rb, line 353
def ensure_setup!
  unless @model_class
    raise "You must call #setup! on this class before calling this method."
  end
end
find_column(column_name) click to toggle source

Given the name of a column, finds the column on the model and makes sure it complies with our requirements for columns we can store data in.

However, if the underlying table doesn’t currently exist, or the underlying column doesn’t currently exit, this creates a “fake” column object and returns it; this fake column object responds to just enough methods that we can use it successfully in this gem. This is used so that we can define flex columns on a model for a table that doesn’t exist yet (typically, because it hasn’t been migrated in yet), and effectively upgrade it using .reset_column_information, above, when it does exist.

# File lib/flex_columns/definition/flex_column_contents_class.rb, line 282
      def find_column(column_name)
        return create_temporary_fake_column(column_name) if (! @model_class.table_exists?)
        out = model_class.columns.detect { |c| c.name.to_s == column_name.to_s }
        return create_temporary_fake_column(column_name) if (! out)

        unless is_acceptable_column_type?(out)
          raise FlexColumns::Errors::InvalidColumnTypeError, %{You're trying to define a flex column #{column_name.inspect}, but
that column (on model #{model_class.name}) isn't of a type that accepts text.
That column is of type: #{out.type.inspect}.}
        end

        out
      end
is_acceptable_column_type?(column) click to toggle source
# File lib/flex_columns/definition/flex_column_contents_class.rb, line 296
def is_acceptable_column_type?(column)
  if column.type == :binary || column.type == :text || column.type == :string
    true
  elsif column.sql_type == "json" # for PostgreSQL >= 9.2, which has a native JSON data type
    true
  else
    false
  end
end
validate_options(options) click to toggle source

Check all of our options to make sure they’re correct. This is pretty defensive programming, but it is SO much nicer to get an error on startup if you’ve specified anything incorrectly than way on down the line, possibly in production, when it really matters.

# File lib/flex_columns/definition/flex_column_contents_class.rb, line 314
def validate_options(options)
  unless options.kind_of?(Hash)
    raise ArgumentError, "You must pass a Hash, not: #{options.inspect}"
  end

  options.assert_valid_keys(:visibility, :prefix, :delegate, :unknown_fields, :compress, :header)

  unless [ nil, :private, :public ].include?(options[:visibility])
    raise ArgumentError, "Invalid value for :visibility: #{options[:visibility.inspect]}"
  end

  unless [ :delete, :preserve, nil ].include?(options[:unknown_fields])
    raise ArgumentError, "Invalid value for :unknown_fields: #{options[:unknown_fields].inspect}"
  end

  unless [ true, false, nil ].include?(options[:compress]) || options[:compress].kind_of?(Integer)
    raise ArgumentError, "Invalid value for :compress: #{options[:compress].inspect}"
  end

  unless [ true, false, nil ].include?(options[:header])
    raise ArgumentError, "Invalid value for :header: #{options[:header].inspect}"
  end

  case options[:prefix]
  when nil then nil
  when String, Symbol then nil
  else raise ArgumentError, "Invalid value for :prefix: #{options[:prefix].inspect}"
  end

  unless [ nil, true, false, :private, :public ].include?(options[:delegate])
    raise ArgumentError, "Invalid value for :delegate: #{options[:delegate].inspect}"
  end

  if options[:visibility] == :private && options[:delegate] == :public
    raise ArgumentError, "You can't have public delegation if methods in the flex column are private; this makes no sense, as methods in the model class would have *greater* visibility than methods on the flex column itself"
  end
end