summaryrefslogtreecommitdiff
path: root/lib/hashie/extensions/dash/property_translation.rb
blob: 69a2d714642febd93b79255762271d8b83033546 (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
module Hashie
  module Extensions
    module Dash
      # Extends a Dash with the ability to remap keys from a source hash.
      #
      # Property translation is useful when you need to read data from another
      # application -- such as a Java API -- where the keys are named
      # differently from Ruby conventions.
      #
      # == Example from inconsistent APIs
      #
      #   class PersonHash < Hashie::Dash
      #     include Hashie::Extensions::Dash::PropertyTranslation
      #
      #     property :first_name, from :firstName
      #     property :last_name, from: :lastName
      #     property :first_name, from: :f_name
      #     property :last_name, from: :l_name
      #   end
      #
      #   person = PersonHash.new(firstName: 'Michael', l_name: 'Bleigh')
      #   person[:first_name]  #=> 'Michael'
      #   person[:last_name]   #=> 'Bleigh'
      #
      # You can also use a lambda to translate the value. This is particularly
      # useful when you want to ensure the type of data you're wrapping.
      #
      # == Example using translation lambdas
      #
      #   class DataModelHash < Hashie::Dash
      #     include Hashie::Extensions::Dash::PropertyTranslation
      #
      #     property :id, transform_with: ->(value) { value.to_i }
      #     property :created_at, from: :created, with: ->(value) { Time.parse(value) }
      #   end
      #
      #   model = DataModelHash.new(id: '123', created: '2014-04-25 22:35:28')
      #   model.id.class          #=> Integer (Fixnum if you are using Ruby 2.3 or lower)
      #   model.created_at.class  #=> Time
      module PropertyTranslation
        def self.included(base)
          base.instance_variable_set(:@transforms, {})
          base.instance_variable_set(:@translations_hash, ::Hash.new { |hash, key| hash[key] = {} })
          base.extend(ClassMethods)
          base.send(:include, InstanceMethods)
        end

        module ClassMethods
          attr_reader :transforms, :translations_hash

          # Ensures that any inheriting classes maintain their translations.
          #
          # * <tt>:default</tt> - The class inheriting the translations.
          def inherited(klass)
            super
            klass.instance_variable_set(:@transforms, transforms.dup)
            klass.instance_variable_set(:@translations_hash, translations_hash.dup)
          end

          def permitted_input_keys
            @permitted_input_keys ||=
              properties
              .map { |property| inverse_translations.fetch property, property }
          end

          # Defines a property on the Trash. 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>:from</tt> - Specify the original key name that will be write only.
          # * <tt>:with</tt> - Specify a lambda to be used to convert value.
          # * <tt>:transform_with</tt> - Specify a lambda to be used to convert value
          # without using the :from option. It transform the property itself.
          def property(property_name, options = {})
            super

            from = options[:from]
            converter = options[:with]
            transformer = options[:transform_with]

            if from
              fail_self_transformation_error!(property_name) if property_name == from
              define_translation(from, property_name, converter || transformer)
              define_writer_for_source_property(from)
            elsif valid_transformer?(transformer)
              transforms[property_name] = transformer
            end
          end

          def transformed_property(property_name, value)
            transforms[property_name].call(value)
          end

          def transformation_exists?(name)
            transforms.key? name
          end

          def translation_exists?(name)
            translations_hash.key? name
          end

          def translations
            @translations ||= {}.tap do |translations|
              translations_hash.each do |(property_name, property_translations)|
                translations[property_name] =
                  if property_translations.size > 1
                    property_translations.keys
                  else
                    property_translations.keys.first
                  end
              end
            end
          end

          def inverse_translations
            @inverse_translations ||= {}.tap do |translations|
              translations_hash.each do |(property_name, property_translations)|
                property_translations.each_key do |key|
                  translations[key] = property_name
                end
              end
            end
          end

          private

          def define_translation(from, property_name, translator)
            translations_hash[from][property_name] = translator
          end

          def define_writer_for_source_property(property)
            define_method "#{property}=" do |val|
              __translations[property].each do |name, with|
                self[name] = with.respond_to?(:call) ? with.call(val) : val
              end
            end
          end

          def fail_self_transformation_error!(property_name)
            raise ArgumentError,
                  "Property name (#{property_name}) and :from option must not be the same"
          end

          def valid_transformer?(transformer)
            transformer.respond_to? :call
          end
        end

        module InstanceMethods
          # Sets a value on the Dash in a Hash-like way.
          #
          # Note: Only works on pre-existing properties.
          def []=(property, value)
            if self.class.translation_exists? property
              send("#{property}=", value)

              if self.class.transformation_exists? property
                super property, self.class.transformed_property(property, value)
              elsif self.class.properties.include?(property)
                super(property, value)
              end
            elsif self.class.transformation_exists? property
              super property, self.class.transformed_property(property, value)
            elsif property_exists? property
              super
            end
          end

          # Deletes any keys that have a translation
          def initialize_attributes(attributes)
            return unless attributes
            attributes_copy = attributes.dup.delete_if do |k, v|
              if self.class.translations_hash.include?(k)
                self[k] = v
                true
              end
            end
            super attributes_copy
          end

          # Raises an NoMethodError if the property doesn't exist
          def property_exists?(property)
            fail_no_property_error!(property) unless self.class.property?(property)
            true
          end

          private

          def __translations
            self.class.translations_hash
          end
        end
      end
    end
  end
end