summaryrefslogtreecommitdiff
path: root/lib/hashie/dash.rb
blob: cfb151ef901e96fb0e28399534fc49ae94869422 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
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
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
require 'hashie/hash'
require 'set'

module Hashie
  # A Dash is a 'defined' or 'discrete' Hash, that is, a Hash
  # that has a set of defined keys that are accessible (with
  # optional defaults) and only those keys may be set or read.
  #
  # Dashes are useful when you need to create a very simple
  # lightweight data object that needs even fewer options and
  # resources than something like a DataMapper resource.
  #
  # It is preferrable to a Struct because of the in-class
  # API for defining properties as well as per-property defaults.
  class Dash < Hash
    include Hashie::Extensions::PrettyInspect

    alias to_s inspect

    # Defines a property on the Dash. Options are
    # as follows:
    #
    # * <tt>:default</tt> - Specify a default value for this property,
    #   to be returned before a value is set on the property in a new
    #   Dash.
    #
    # * <tt>:required</tt> - Specify the value as required for this
    #   property, to raise an error if a value is unset in a new or
    #   existing Dash. If a Proc is provided, it will be run in the
    #   context of the Dash instance. If a Symbol is provided, the
    #   property it represents must not be nil. The property is only
    #   required if the value is truthy.
    #
    # * <tt>:message</tt> - Specify custom error message for required property
    #
    def self.property(property_name, options = {})
      properties << property_name

      if options.key?(:default)
        defaults[property_name] = options[:default]
      elsif defaults.key?(property_name)
        defaults.delete property_name
      end

      define_getter_for(property_name)
      define_setter_for(property_name)

      @subclasses.each { |klass| klass.property(property_name, options) } if defined? @subclasses

      condition = options.delete(:required)
      if condition
        message = options.delete(:message) || "is required for #{name}."
        required_properties[property_name] = { condition: condition, message: message }
      elsif options.key?(:message)
        raise ArgumentError, 'The :message option should be used with :required option.'
      end
    end

    class << self
      attr_reader :properties, :defaults
      attr_reader :getters
      attr_reader :required_properties
    end
    instance_variable_set('@properties', Set.new)
    instance_variable_set('@getters', Set.new)
    instance_variable_set('@defaults', {})
    instance_variable_set('@required_properties', {})

    def self.inherited(klass)
      super
      (@subclasses ||= Set.new) << klass
      klass.instance_variable_set('@properties', properties.dup)
      klass.instance_variable_set('@getters', getters.dup)
      klass.instance_variable_set('@defaults', defaults.dup)
      klass.instance_variable_set('@required_properties', required_properties.dup)
    end

    # Check to see if the specified property has already been
    # defined.
    def self.property?(name)
      properties.include? name
    end

    # Check to see if the specified property is
    # required.
    def self.required?(name)
      required_properties.key? name
    end

    private_class_method def self.define_getter_for(property_name)
      return if getters.include?(property_name)
      define_method(property_name) { |&block| self.[](property_name, &block) }
      getters << property_name
    end

    private_class_method def self.define_setter_for(property_name)
      setter = :"#{property_name}="
      return if instance_methods.include?(setter)
      define_method(setter) { |value| self.[]=(property_name, value) }
    end

    # You may initialize a Dash with an attributes hash
    # just like you would many other kinds of data objects.
    def initialize(attributes = {}, &block)
      super(&block)

      initialize_attributes(attributes)
      assert_required_attributes_set!
    end

    alias _regular_reader []
    alias _regular_writer []=
    private :_regular_reader, :_regular_writer

    # Retrieve a value from the Dash (will return the
    # property's default value if it hasn't been set).
    def [](property)
      assert_property_exists! property
      value = super(property)
      # If the value is a lambda, proc, or whatever answers to call, eval the thing!
      if value.is_a? Proc
        self[property] = value.call # Set the result of the call as a value
      else
        yield value if block_given?
        value
      end
    end

    # Set a value on the Dash in a Hash-like way. Only works
    # on pre-existing properties.
    def []=(property, value)
      assert_property_required! property, value
      assert_property_exists! property
      super(property, value)
    end

    def merge(other_hash)
      new_dash = dup
      other_hash.each do |k, v|
        new_dash[k] = block_given? ? yield(k, self[k], v) : v
      end
      new_dash
    end

    def merge!(other_hash)
      other_hash.each do |k, v|
        self[k] = block_given? ? yield(k, self[k], v) : v
      end
      self
    end

    def replace(other_hash)
      other_hash = self.class.defaults.merge(other_hash)
      (keys - other_hash.keys).each { |key| delete(key) }
      other_hash.each { |key, value| self[key] = value }
      self
    end

    def to_h
      defaults = ::Hash[self.class.properties.map { |prop| [prop, self.class.defaults[prop]] }]

      defaults.merge(self)
    end
    alias to_hash to_h

    def update_attributes!(attributes)
      update_attributes(attributes)

      self.class.defaults.each_pair do |prop, value|
        next unless fetch(prop, nil).nil?
        self[prop] = begin
          val = value.dup
          if val.is_a?(Proc)
            val.arity == 1 ? val.call(self) : val.call
          else
            val
          end
        rescue TypeError
          value
        end
      end

      assert_required_attributes_set!
    end

    private

    def initialize_attributes(attributes)
      return unless attributes

      cleaned_attributes = attributes.reject { |_attr, value| value.nil? }
      update_attributes!(cleaned_attributes)
    end

    def update_attributes(attributes)
      return unless attributes

      attributes.each_pair do |att, value|
        self[att] = value
      end
    end

    def assert_property_exists!(property)
      fail_no_property_error!(property) unless self.class.property?(property)
    end

    def assert_required_attributes_set!
      self.class.required_properties.each_key do |required_property|
        assert_property_set!(required_property)
      end
    end

    def assert_property_set!(property)
      fail_property_required_error!(property) if send(property).nil? && required?(property)
    end

    def assert_property_required!(property, value)
      fail_property_required_error!(property) if value.nil? && required?(property)
    end

    def fail_property_required_error!(property)
      raise ArgumentError,
            "The property '#{property}' #{self.class.required_properties[property][:message]}"
    end

    def fail_no_property_error!(property)
      raise NoMethodError, "The property '#{property}' is not defined for #{self.class.name}."
    end

    def required?(property)
      return false unless self.class.required?(property)

      condition = self.class.required_properties[property][:condition]
      case condition
      when Proc   then !!instance_exec(&condition)
      when Symbol then !!send(condition)
      else             !!condition
      end
    end
  end
end