Module: HoboFields

Extended by:
HoboFields
Included in:
HoboFields
Defined in:
lib/hobo_fields.rb,
lib/hobo_fields/text.rb,
lib/hobo_fields/field_spec.rb,
lib/hobo_fields/index_spec.rb,
lib/hobo_fields/enum_string.rb,
lib/hobo_fields/html_string.rb,
lib/hobo_fields/email_address.rb,
lib/hobo_fields/sanitize_html.rb,
lib/hobo_fields/textile_string.rb,
lib/hobo_fields/lifecycle_state.rb,
lib/hobo_fields/markdown_string.rb,
lib/hobo_fields/password_string.rb,
lib/hobo_fields/raw_html_string.rb,
lib/hobo_fields/model_extensions.rb,
lib/hobo_fields/serialized_object.rb,
lib/hobo_fields/fields_declaration.rb,
lib/hobo_fields/migration_generator.rb,
lib/hobo_fields/raw_markdown_string.rb,
lib/hobo_fields/field_declaration_dsl.rb

Defined Under Namespace

Modules: SanitizeHtml Classes: EmailAddress, EnumString, FieldDeclarationDsl, FieldSpec, HabtmModelShim, HtmlString, IndexSpec, LifecycleState, MarkdownString, MigrationGenerator, MigrationGeneratorError, PasswordString, RawHtmlString, RawMarkdownString, SerializedObject, Text, TextileString

Constant Summary collapse

VERSION =
"1.1.0"
PLAIN_TYPES =
{
  :boolean       => Hobo::Boolean,
  :date          => Date,
  :datetime      => (defined?(ActiveSupport::TimeWithZone) ? ActiveSupport::TimeWithZone : Time),
  :time          => Time,
  :integer       => Integer,
  :decimal       => BigDecimal,
  :float         => Float,
  :string        => String
}
ALIAS_TYPES =
{
  Fixnum => "integer",
  Bignum => "integer"
}
STANDARD_TYPES =

Provide a lookup for these rather than loading them all preemptively

{
  :raw_html      => "RawHtmlString",
  :html          => "HtmlString",
  :raw_markdown  => "RawMarkdownString",
  :markdown      => "MarkdownString",
  :textile       => "TextileString",
  :password      => "PasswordString",
  :text          => "Text",
  :email_address => "EmailAddress",
  :serialized    => "SerializedObject"
}
ModelExtensions =
classy_module do

  # ignore the model in the migration until somebody sets
  # @include_in_migration via the fields declaration
  inheriting_cattr_reader :include_in_migration => false

  # attr_types holds the type class for any attribute reader (i.e. getter
  # method) that returns rich-types
  inheriting_cattr_reader :attr_types => HashWithIndifferentAccess.new
  inheriting_cattr_reader :attr_order => []

  # field_specs holds FieldSpec objects for every declared
  # field. Note that attribute readers are created (by ActiveRecord)
  # for all fields, so there is also an entry for the field in
  # attr_types. This is redundant but simplifies the implementation
  # and speeds things up a little.
  inheriting_cattr_reader :field_specs => HashWithIndifferentAccess.new

  # index_specs holds IndexSpec objects for all the declared indexes.
  inheriting_cattr_reader :index_specs => []
  inheriting_cattr_reader :ignore_indexes => []

  def self.inherited(klass)
    fields do |f|
      f.field(inheritance_column, :string)
    end
    index(inheritance_column)
    super
  end

  def self.index(fields, options = {})
    # don't double-index fields
    index_specs << HoboFields::IndexSpec.new(self, fields, options) unless index_specs.*.fields.include?(Array.wrap(fields).*.to_s)
  end

  # tell the migration generator to ignore the named index. Useful for existing indexes, or for indexes
  # that can't be automatically generated (for example: an prefix index in MySQL)
  def self.ignore_index(index_name)
    ignore_indexes << index_name.to_s
  end

  private

  # Declares that a virtual field that has a rich type (e.g. created
  # by attr_accessor :foo, :type => :email_address) should be subject
  # to validation (note that the rich types know how to validate themselves)
  def self.validate_virtual_field(*args)
    validates_each(*args) {|record, field, value| msg = value.validate and record.errors.add(field, msg) if value.respond_to?(:validate) }
  end


  # This adds a ":type => t" option to attr_accessor, where t is
  # either a class or a symbolic name of a rich type. If this option
  # is given, the setter will wrap values that are not of the right
  # type.
  def self.attr_accessor_with_rich_types(*attrs)
    options = attrs.extract_options!
    type = options.delete(:type)
    attrs << options unless options.empty?
    public
    attr_accessor_without_rich_types(*attrs)
    private

    if type
      type = HoboFields.to_class(type)
      attrs.each do |attr|
        declare_attr_type attr, type, options
        type_wrapper = attr_type(attr)
        define_method "#{attr}=" do |val|
          if type_wrapper.not_in?(HoboFields::PLAIN_TYPES.values) && !val.is_a?(type) && HoboFields.can_wrap?(type, val)
            val = type.new(val.to_s)
          end
          instance_variable_set("@#{attr}", val)
        end
      end
    end
  end


  # Extend belongs_to so that it creates a FieldSpec for the foreign key
  def self.belongs_to_with_field_declarations(name, options={}, &block)
    column_options = {}
    column_options[:null] = options.delete(:null) if options.has_key?(:null)
    column_options[:comment] = options.delete(:comment) if options.has_key?(:comment)

    index_options = {}
    index_options[:name] = options.delete(:index) if options.has_key?(:index)
    bt = belongs_to_without_field_declarations(name, options, &block)
    refl = reflections[name.to_sym]
    fkey = refl.primary_key_name
    declare_field(fkey.to_sym, :integer, column_options)
    if refl.options[:polymorphic]
      declare_polymorphic_type_field(name, column_options)
      index(["#{name}_type", fkey], index_options) if index_options[:name]!=false
    else
      index(fkey, index_options) if index_options[:name]!=false
    end
    bt
  end
  class << self
    alias_method_chain :belongs_to, :field_declarations
  end


  # Declares the "foo_type" field that accompanies the "foo_id"
  # field for a polyorphic belongs_to
  def self.declare_polymorphic_type_field(name, column_options)
    type_col = "#{name}_type"
    declare_field(type_col, :string, column_options)
    # FIXME: Before hobofields was extracted, this used to now do:
    # never_show(type_col)
    # That needs doing somewhere
  end


  # Declare a rich-type for any attribute (i.e. getter method). This
  # does not effect the attribute in any way - it just records the
  # metadata.
  def self.declare_attr_type(name, type, options={})
    klass = HoboFields.to_class(type)
    attr_types[name] = HoboFields.to_class(type)
    klass.try.declared(self, name, options)
  end


  # Declare named field with a type and an arbitrary set of
  # arguments. The arguments are forwarded to the #field_added
  # callback, allowing custom metadata to be added to field
  # declarations.
  def self.declare_field(name, type, *args)
    options = args.extract_options!
    try.field_added(name, type, args, options)
    add_formatting_for_field(name, type, args)
    add_validations_for_field(name, type, args)
    add_index_for_field(name, args, options)
    declare_attr_type(name, type, options) unless HoboFields.plain_type?(type)
    field_specs[name] = HoboFields::FieldSpec.new(self, name, type, options)
    attr_order << name unless name.in?(attr_order)
  end


  # Add field validations according to arguments in the
  # field declaration
  def self.add_validations_for_field(name, type, args)
    validates_presence_of   name if :required.in?(args)
    validates_uniqueness_of name, :allow_nil => !:required.in?(args) if :unique.in?(args)

    type_class = HoboFields.to_class(type)
    if type_class && type_class.public_method_defined?("validate")
      self.validate do |record|
        v = record.send(name)._?.validate
        record.errors.add(name, v) if v.is_a?(String)
      end
    end
  end

  def self.add_formatting_for_field(name, type, args)
    type_class = HoboFields.to_class(type)
    if type_class && "format".in?(type_class.instance_methods)
      self.before_validation do |record|
        record.send("#{name}=", record.send(name)._?.format)
      end
    end
  end

  def self.add_index_for_field(name, args, options)
    to_name = options.delete(:index)
    return unless to_name
    index_opts = {}
    index_opts[:unique] = :unique.in?(args) || options.delete(:unique)
    # support :index => true declaration
    index_opts[:name] = to_name unless to_name == true
    index(name, index_opts)
  end


  # Extended version of the acts_as_list declaration that
  # automatically delcares the 'position' field
  def self.acts_as_list_with_field_declaration(options = {})
    declare_field(options.fetch(:column, "position"), :integer)
    acts_as_list_without_field_declaration(options)
  end


  # Returns the type (a class) for a given field or association. If
  # the association is a collection (has_many or habtm) return the
  # AssociationReflection instead
  def self.attr_type(name)
    if attr_types.nil? && self != self.name.constantize
      raise RuntimeError, "attr_types called on a stale class object (#{self.name}). Avoid storing persistent references to classes"
    end

    attr_types[name] or

      if (refl = reflections[name.to_sym])
        if refl.macro.in?([:has_one, :belongs_to]) && !refl.options[:polymorphic]
          refl.klass
        else
          refl
        end
      end or

      (col = column(name.to_s) and HoboFields::PLAIN_TYPES[col.type] || col.klass)
  end


  # Return the entry from #columns for the named column
  def self.column(name)
    return unless (@table_exists ||= table_exists?)
    name = name.to_s
    columns.find {|c| c.name == name }
  end

  class << self
    alias_method_chain :acts_as_list,  :field_declaration if defined?(ActiveRecord::Acts::List)
    alias_method_chain :attr_accessor, :rich_types
  end

end
FieldsDeclaration =
classy_module do

  def self.fields(include_in_migration = true, &b)
    # Any model that calls 'fields' gets a bunch of other
    # functionality included automatically, but make sure we only
    # include it once
    include HoboFields::ModelExtensions unless HoboFields::ModelExtensions.in?(included_modules)
    @include_in_migration ||= include_in_migration

    if b
      dsl = HoboFields::FieldDeclarationDsl.new(self)
      if b.arity == 1
        yield dsl
      else
        dsl.instance_eval(&b)
      end
    end
  end

end

Instance Attribute Summary collapse

Instance Method Summary collapse

Instance Attribute Details

#field_typesObject (readonly)

Returns the value of attribute field_types.



54
55
56
# File 'lib/hobo_fields.rb', line 54

def field_types
  @field_types
end

Instance Method Details

#can_wrap?(type, val) ⇒ Boolean

Ruby 1.8. 1.8.6 doesn’t include Method#owner. 1.8.7 could use the 1.9 function, but this one is faster.

Returns:

  • (Boolean)


73
74
75
76
77
78
79
80
81
# File 'lib/hobo_fields.rb', line 73

def can_wrap?(type, val)
  col_type = type::COLUMN_TYPE
  return false if val.blank? && (col_type == :integer || col_type == :float || col_type == :decimal)
  klass = Object.instance_method(:class).bind(val).call # Make sure we get the *real* class
  init_method = type.instance_method(:initialize)
  [-1,1].include?(init_method.arity) &&
    init_method.owner != Object.instance_method(:initialize).owner &&
    !@never_wrap_types.any? { |c| klass <= c }
end

#enableObject



115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
# File 'lib/hobo_fields.rb', line 115

def enable
  require "hobo_fields/enum_string"
  require "hobo_fields/fields_declaration"

  # Add the fields do declaration to ActiveRecord::Base
  ActiveRecord::Base.send(:include, HoboFields::FieldsDeclaration)

  # automatically load other rich types from app/rich_types/*.rb
  # don't assume we're in a Rails app
  if defined?(::Rails)
    plugins = Rails.configuration.plugin_loader.new(HoboFields.rails_initializer).plugins
    ([::Rails.root] + plugins.map(&:directory)).each do |dir|
      if ActiveSupport::Dependencies.respond_to?(:autoload_paths)
        ActiveSupport::Dependencies.autoload_paths << File.join(dir, 'app', 'rich_types')
      else
        ActiveSupport::Dependencies.load_paths << File.join(dir, 'app', 'rich_types')
      end
      Dir[File.join(dir, 'app', 'rich_types', '*.rb')].each do |f|
        # TODO: should we complain if field_types doesn't get a new value? Might be useful to warn people if they're missing a register_type
        require_dependency f
      end
    end

  end

  # Monkey patch ActiveRecord so that the attribute read & write methods
  # automatically wrap richly-typed fields.
  ActiveRecord::AttributeMethods::ClassMethods.class_eval do

    # Define an attribute reader method.  Cope with nil column.
    def define_read_method(symbol, attr_name, column)
      cast_code = column.type_cast_code('v') if column
      access_code = cast_code ? "(v=@attributes['#{attr_name}']) && #{cast_code}" : "@attributes['#{attr_name}']"

      unless attr_name.to_s == self.primary_key.to_s
        access_code = access_code.insert(0, "missing_attribute('#{attr_name}', caller) " +
                                         "unless @attributes.has_key?('#{attr_name}'); ")
      end

      # This is the Hobo hook - add a type wrapper around the field
      # value if we have a special type defined
      src = if connected? && (type_wrapper = try.attr_type(symbol)) &&
                type_wrapper.is_a?(Class) && type_wrapper.not_in?(HoboFields::PLAIN_TYPES.values)
              "val = begin; #{access_code}; end; wrapper_type = self.class.attr_type(:#{attr_name}); " +
                "if HoboFields.can_wrap?(wrapper_type, val); wrapper_type.new(val); else; val; end"
            else
              access_code
            end

      evaluate_attribute_method(attr_name,
                                "def #{symbol}; @attributes_cache['#{attr_name}'] ||= begin; #{src}; end; end")
    end

    def define_write_method(attr_name)
      src = if connected? && (type_wrapper = try.attr_type(attr_name)) &&
                type_wrapper.is_a?(Class) && type_wrapper.not_in?(HoboFields::PLAIN_TYPES.values)
              "begin; wrapper_type = self.class.attr_type(:#{attr_name}); " +
                "if !val.is_a?(wrapper_type) && HoboFields.can_wrap?(wrapper_type, val); wrapper_type.new(val); else; val; end; end"
            else
              "val"
            end
      evaluate_attribute_method(attr_name,
                                "def #{attr_name}=(val); write_attribute('#{attr_name}', #{src});end", "#{attr_name}=")

    end

  end

end

#never_wrap(type) ⇒ Object



95
96
97
# File 'lib/hobo_fields.rb', line 95

def never_wrap(type)
  @never_wrap_types << type
end

#plain_type?(type_name) ⇒ Boolean

Returns:

  • (Boolean)


105
106
107
# File 'lib/hobo_fields.rb', line 105

def plain_type?(type_name)
  type_name.in?(PLAIN_TYPES)
end

#register_type(name, klass) ⇒ Object



100
101
102
# File 'lib/hobo_fields.rb', line 100

def register_type(name, klass)
  field_types[name] = klass
end

#standard_class(name) ⇒ Object



110
111
112
113
# File 'lib/hobo_fields.rb', line 110

def standard_class(name)
  class_name = STANDARD_TYPES[name]
  "HoboFields::#{class_name}".constantize if class_name
end

#to_class(type) ⇒ Object



56
57
58
59
60
61
62
63
# File 'lib/hobo_fields.rb', line 56

def to_class(type)
  if type.is_one_of?(Symbol, String)
    type = type.to_sym
    field_types[type] || standard_class(type)
  else
    type # assume it's already a class
  end
end

#to_name(type) ⇒ Object



66
67
68
# File 'lib/hobo_fields.rb', line 66

def to_name(type)
  field_types.key(type) || ALIAS_TYPES[type]
end