hashie 4.1.0 → 5.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +138 -53
  3. data/CONTRIBUTING.md +38 -1
  4. data/LICENSE +1 -1
  5. data/README.md +920 -205
  6. data/Rakefile +32 -1
  7. data/UPGRADING.md +56 -0
  8. data/hashie.gemspec +10 -5
  9. data/lib/hashie/array.rb +15 -12
  10. data/lib/hashie/clash.rb +59 -4
  11. data/lib/hashie/dash.rb +160 -32
  12. data/lib/hashie/extensions/active_support/core_ext/hash.rb +38 -0
  13. data/lib/hashie/extensions/array/pretty_inspect.rb +20 -0
  14. data/lib/hashie/extensions/block_access.rb +71 -0
  15. data/lib/hashie/extensions/coercion.rb +180 -26
  16. data/lib/hashie/extensions/dash/coercion.rb +34 -3
  17. data/lib/hashie/extensions/dash/indifferent_access.rb +90 -3
  18. data/lib/hashie/extensions/dash/lazy.rb +170 -0
  19. data/lib/hashie/extensions/dash/predefined_values.rb +148 -0
  20. data/lib/hashie/extensions/dash/property_translation.rb +138 -15
  21. data/lib/hashie/extensions/deep_fetch.rb +21 -9
  22. data/lib/hashie/extensions/deep_find.rb +20 -0
  23. data/lib/hashie/extensions/deep_grep.rb +69 -0
  24. data/lib/hashie/extensions/deep_locate.rb +16 -29
  25. data/lib/hashie/extensions/deep_merge.rb +16 -0
  26. data/lib/hashie/extensions/deep_merge_initializer.rb +42 -0
  27. data/lib/hashie/extensions/ignore_undeclared.rb +24 -7
  28. data/lib/hashie/extensions/indifferent_access.rb +124 -5
  29. data/lib/hashie/extensions/kernel_coercion.rb +58 -0
  30. data/lib/hashie/extensions/key_conflict_warning.rb +39 -4
  31. data/lib/hashie/extensions/key_conversion.rb +9 -0
  32. data/lib/hashie/extensions/mash/define_accessors.rb +71 -8
  33. data/lib/hashie/extensions/mash/keep_original_keys.rb +13 -9
  34. data/lib/hashie/extensions/mash/permissive_respond_to.rb +2 -0
  35. data/lib/hashie/extensions/mash/required_keys.rb +111 -0
  36. data/lib/hashie/extensions/mash/safe_assignment.rb +22 -4
  37. data/lib/hashie/extensions/mash/strict_key_access.rb +65 -0
  38. data/lib/hashie/extensions/mash/symbolize_keys.rb +11 -7
  39. data/lib/hashie/extensions/mash/transform_keys.rb +86 -0
  40. data/lib/hashie/extensions/mash/underscore_keys.rb +32 -0
  41. data/lib/hashie/extensions/merge_initializer.rb +9 -0
  42. data/lib/hashie/extensions/method_access.rb +94 -8
  43. data/lib/hashie/extensions/parsers/yaml_erb_parser.rb +28 -35
  44. data/lib/hashie/extensions/parsers/yaml_parser.rb +81 -0
  45. data/lib/hashie/extensions/persistable.rb +137 -0
  46. data/lib/hashie/extensions/pretty_inspect.rb +14 -0
  47. data/lib/hashie/extensions/ruby_version.rb +18 -1
  48. data/lib/hashie/extensions/ruby_version_check.rb +17 -0
  49. data/lib/hashie/extensions/strict_key_access.rb +59 -5
  50. data/lib/hashie/extensions/stringify_keys.rb +72 -17
  51. data/lib/hashie/extensions/symbolize_keys.rb +84 -17
  52. data/lib/hashie/hash.rb +18 -2
  53. data/lib/hashie/logger.rb +18 -1
  54. data/lib/hashie/mash.rb +314 -82
  55. data/lib/hashie/railtie.rb +27 -1
  56. data/lib/hashie/rash.rb +64 -4
  57. data/lib/hashie/trash.rb +8 -0
  58. data/lib/hashie/utils.rb +17 -6
  59. data/lib/hashie/version.rb +3 -1
  60. data/lib/hashie.rb +46 -0
  61. metadata +22 -9
data/Rakefile CHANGED
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'rubygems'
2
4
  require 'bundler'
3
5
  Bundler.setup
@@ -10,12 +12,39 @@ RSpec::Core::RakeTask.new do |spec|
10
12
  spec.exclude_pattern = 'spec/integration/**/*_spec.rb'
11
13
  end
12
14
 
15
+ namespace :docs do
16
+ desc 'Validate executable YARD examples and public API documentation coverage'
17
+ task :examples do
18
+ ruby '-Ilib', 'script/test_documentation_examples.rb'
19
+ end
20
+
21
+ desc 'Validate every README Ruby example with the isolated documentation bundle'
22
+ task :readme do
23
+ Bundler.with_unbundled_env do
24
+ sh({ 'BUNDLE_GEMFILE' => 'spec/documentation/Gemfile' }, 'bundle', 'exec', 'ruby', '-Ilib', 'script/test_readme_examples.rb')
25
+ end
26
+ end
27
+ end
28
+
13
29
  require 'rubocop/rake_task'
14
30
  RuboCop::RakeTask.new(:rubocop)
31
+ if Gem::Version.new(RUBY_VERSION) >= Gem::Version.new('3.2')
32
+ RuboCop::RakeTask.new(:rubocop_exception_messages) do |task|
33
+ task.patterns = ['lib/**/*.rb', 'spec/**/*.rb']
34
+ task.options = [
35
+ '--only',
36
+ 'ExceptionMessages/Casing,ExceptionMessages/Punctuation,' \
37
+ 'ExceptionMessages/QuoteStyle,ExceptionMessages/RedundantExceptionName'
38
+ ]
39
+ end
40
+ Rake::Task[:rubocop].enhance([:rubocop_exception_messages])
41
+ end
15
42
 
16
43
  require_relative 'spec/support/integration_specs'
44
+ desc 'Install integration dependencies and run integration specs'
17
45
  task :integration_specs do
18
46
  next if ENV['CI']
47
+
19
48
  status_codes = []
20
49
  handler = lambda do |status_code|
21
50
  status_codes << status_code unless status_code.zero?
@@ -29,4 +58,6 @@ task :integration_specs do
29
58
  end
30
59
  end
31
60
 
32
- task default: %i[rubocop spec integration_specs]
61
+ default_tasks = %i[spec integration_specs]
62
+ default_tasks.unshift(:rubocop) if Gem::Version.new(RUBY_VERSION) >= Gem::Version.new('3.2')
63
+ task default: default_tasks
data/UPGRADING.md CHANGED
@@ -1,6 +1,62 @@
1
1
  Upgrading Hashie
2
2
  ================
3
3
 
4
+ ### Upgrading to 5.2.0
5
+
6
+ #### Safer YAML loading
7
+
8
+ `Hashie::Mash.load` continues to use `YamlErbParser` by default for compatibility, and that parser evaluates ERB. Do not load untrusted input with the default parser. To opt in to YAML parsing without ERB evaluation, pass `parser: Hashie::Extensions::Parsers::YamlParser`.
9
+
10
+ #### Ruby version
11
+
12
+ Hashie now requires Ruby 3.0 or newer. Ruby 2.7 is no longer supported; upgrade Ruby before updating Hashie.
13
+
14
+ #### Exception messages
15
+
16
+ Exception messages now begin with lowercase words, omit trailing periods, quote interpolated values
17
+ with backticks, and use consistent wording. Exception classes and the conditions under which
18
+ exceptions are raised have not changed.
19
+
20
+ Applications and tests that compare exact exception message strings must update their expectations.
21
+
22
+ ### Upgrading to 5.0.0
23
+
24
+ #### Mash initialization key conversion
25
+
26
+ Mash initialization now only converts to string keys which can be represented as symbols.
27
+
28
+ ```ruby
29
+ Hashie::Mash.new(
30
+ {foo: "bar"} => "baz",
31
+ "1" => "one string",
32
+ :"1" => "one sym",
33
+ 1 => "one num"
34
+ )
35
+
36
+ # Before
37
+ {"{:foo=>\"bar\"}"=>"baz", "1"=>"one num"}
38
+
39
+ # After
40
+ {{:foo=>"bar"}=>"baz", "1"=>"one sym", 1=>"one num"}
41
+ ```
42
+
43
+ #### Mash#dig with numeric keys
44
+
45
+ `Hashie::Mash#dig` no longer considers numeric keys for indifferent access.
46
+
47
+ ```ruby
48
+ my_mash = Hashie::Mash.new("1" => "a") # => {"1"=>"a"}
49
+
50
+ my_mash.dig("1") # => "a"
51
+ my_mash.dig(:"1") # => "a"
52
+
53
+ # Before
54
+ my_mash.dig(1) # => "a"
55
+
56
+ # After
57
+ my_mash.dig(1) # => nil
58
+ ```
59
+
4
60
  ### Upgrading to 4.0.0
5
61
 
6
62
  #### Non-destructive Hash methods called on Mash
data/hashie.gemspec CHANGED
@@ -1,4 +1,6 @@
1
- require File.expand_path('../lib/hashie/version', __FILE__)
1
+ # frozen_string_literal: true
2
+
3
+ require File.expand_path('lib/hashie/version', __dir__)
2
4
 
3
5
  Gem::Specification.new do |gem|
4
6
  gem.name = 'hashie'
@@ -17,12 +19,15 @@ Gem::Specification.new do |gem|
17
19
 
18
20
  if gem.respond_to?(:metadata)
19
21
  gem.metadata = {
20
- 'bug_tracker_uri' => 'https://github.com/hashie/hashie/issues',
21
- 'changelog_uri' => 'https://github.com/hashie/hashie/blob/master/CHANGELOG.md',
22
+ 'bug_tracker_uri' => 'https://github.com/hashie/hashie/issues',
23
+ 'changelog_uri' => 'https://github.com/hashie/hashie/blob/master/CHANGELOG.md',
22
24
  'documentation_uri' => 'https://www.rubydoc.info/gems/hashie',
23
- 'source_code_uri' => 'https://github.com/hashie/hashie'
25
+ 'source_code_uri' => 'https://github.com/hashie/hashie',
26
+ 'rubygems_mfa_required' => 'true'
24
27
  }
25
28
  end
26
29
 
27
- gem.add_development_dependency 'bundler'
30
+ gem.required_ruby_version = '>= 3.0'
31
+
32
+ gem.add_dependency 'logger' # for Ruby >= 3.4
28
33
  end
data/lib/hashie/array.rb CHANGED
@@ -1,21 +1,24 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'hashie/extensions/array/pretty_inspect'
2
- require 'hashie/extensions/ruby_version_check'
3
4
 
4
5
  module Hashie
6
+ # An array with readable inspection and string-index digging.
7
+ #
8
+ # @example Build a Hashie array
9
+ # Hashie::Array[10, 20].dig('1') # => 20
5
10
  class Array < ::Array
6
11
  include Hashie::Extensions::Array::PrettyInspect
7
- include Hashie::Extensions::RubyVersionCheck
8
- with_minimum_ruby('2.3.0') do
9
- def dig(*indexes)
10
- converted_indexes = indexes.map do |idx|
11
- begin
12
- Integer(idx)
13
- rescue ArgumentError
14
- idx
15
- end
16
- end
17
- super(*converted_indexes)
12
+
13
+ # @example Accept string indexes when traversing nested arrays
14
+ # Hashie::Array[Hashie::Array['first', 'second']].dig('0', '1') # => 'second'
15
+ def dig(*indexes)
16
+ converted_indexes = indexes.map do |idx|
17
+ Integer(idx)
18
+ rescue ArgumentError
19
+ idx
18
20
  end
21
+ super(*converted_indexes)
19
22
  end
20
23
  end
21
24
  end
data/lib/hashie/clash.rb CHANGED
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'hashie/hash'
2
4
 
3
5
  module Hashie
@@ -21,14 +23,34 @@ module Hashie
21
23
  # all keys are converted to symbols since many libraries expect symbols explicitly
22
24
  # for keys.
23
25
  #
26
+ # @example Build an options hash through chained calls
27
+ # options = Hashie::Clash.new.conditions!.active(true)._end!.limit(10)
28
+ # options # => { conditions: { active: true }, limit: 10 }
24
29
  class Clash < ::Hash
30
+ # Raised when bang chaining encounters a value that is not a hash.
31
+ #
32
+ # @example Reject chaining into a scalar
33
+ # error = begin
34
+ # Hashie::Clash.new(limit: 10).limit!
35
+ # rescue Hashie::Clash::ChainError => exception
36
+ # exception
37
+ # end
38
+ # error.class # => Hashie::Clash::ChainError
39
+ # error.message # => 'tried to chain into a non-hash key'
25
40
  class ChainError < ::StandardError; end
26
41
  # The parent Clash if this Clash was created via chaining.
42
+ #
43
+ # @example Find the containing options hash
44
+ # root = Hashie::Clash.new
45
+ # root.conditions!._parent.equal?(root) # => true
27
46
  attr_reader :_parent
28
47
 
29
48
  # Initialize a new clash by passing in a Hash to
30
49
  # convert and, optionally, the parent to which this
31
50
  # Clash is chained.
51
+ #
52
+ # @example Normalize initial keys to symbols
53
+ # Hashie::Clash.new('limit' => 10) # => { limit: 10 }
32
54
  def initialize(other_hash = {}, parent = nil)
33
55
  @_parent = parent
34
56
  other_hash.each_pair do |k, v|
@@ -42,15 +64,40 @@ module Hashie
42
64
  # c = Hashie::Clash.new.foo('bar')
43
65
  # c.baz!.foo(123) # => c[:baz]
44
66
  # c.baz!._end! # => c
67
+ #
68
+ # @example Continue building the parent hash
69
+ # Hashie::Clash.new.conditions!.active(true)._end!.limit(10) # => { conditions: { active: true }, limit: 10 }
45
70
  def _end!
46
71
  _parent
47
72
  end
48
73
 
49
- def id(*args) #:nodoc:
74
+ # @example Serialize a Clash through YAML's encoding callback
75
+ # require 'yaml'
76
+ # options = Hashie::Clash.new.limit(10)
77
+ # YAML.unsafe_load(YAML.dump(options)) # => { limit: 10 }
78
+ def encode_with(coder)
79
+ coder['hash'] = to_h
80
+ coder['parent'] = _parent
81
+ end
82
+
83
+ # @example Restore a chained Clash through YAML's loading callback
84
+ # require 'yaml'
85
+ # root = Hashie::Clash.new.conditions!.active(true)._end!
86
+ # restored = YAML.unsafe_load(YAML.dump(root))
87
+ # restored[:conditions]._parent.equal?(restored) # => true
88
+ def init_with(coder)
89
+ initialize(coder['hash'], coder['parent'])
90
+ end
91
+
92
+ # @example Build an id option without colliding with object identity
93
+ # Hashie::Clash.new.id(42) # => { id: 42 }
94
+ def id(*args) # :nodoc:
50
95
  method_missing(:id, *args)
51
96
  end
52
97
 
53
- def merge_store(key, *args) #:nodoc:
98
+ # @example Merge repeated hash options through chained builder calls
99
+ # Hashie::Clash.new.conditions(active: true).conditions(role: 'admin') # => { conditions: { active: true, role: 'admin' } }
100
+ def merge_store(key, *args) # :nodoc:
54
101
  case args.length
55
102
  when 1
56
103
  val = args.first
@@ -63,7 +110,9 @@ module Hashie
63
110
  self
64
111
  end
65
112
 
66
- def method_missing(name, *args) #:nodoc:
113
+ # @example Build nested options with dynamic method names
114
+ # Hashie::Clash.new.conditions!.active(true)._end!.limit(10) # => { conditions: { active: true }, limit: 10 }
115
+ def method_missing(name, *args) # :nodoc:
67
116
  if args.empty? && name.to_s.end_with?('!')
68
117
  key = name[0...-1].to_sym
69
118
 
@@ -75,7 +124,7 @@ module Hashie
75
124
  when Hash
76
125
  self[key] = self.class.new(self[key], self)
77
126
  else
78
- raise ChainError, 'Tried to chain into a non-hash key.'
127
+ raise ChainError, 'tried to chain into a non-hash key'
79
128
  end
80
129
  elsif args.any?
81
130
  merge_store(name, *args)
@@ -84,8 +133,14 @@ module Hashie
84
133
  end
85
134
  end
86
135
 
136
+ # @example Discover supported builder calls
137
+ # options = Hashie::Clash.new(limit: 10)
138
+ # options.respond_to?(:conditions!) # => true
139
+ # options.respond_to?(:limit!) # => false
140
+ # options.respond_to?(:order) # => true
87
141
  def respond_to_missing?(method_name, _include_private = false)
88
142
  method_name = method_name.to_s
143
+ return super if %w[marshal_dump marshal_load _dump _load].include?(method_name)
89
144
 
90
145
  if method_name.end_with?('!')
91
146
  key = method_name[0...-1].to_sym
data/lib/hashie/dash.rb CHANGED
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'hashie/hash'
2
4
  require 'set'
3
5
 
@@ -10,29 +12,42 @@ module Hashie
10
12
  # lightweight data object that needs even fewer options and
11
13
  # resources than something like a DataMapper resource.
12
14
  #
13
- # It is preferrable to a Struct because of the in-class
15
+ # It is preferable to a Struct because of the in-class
14
16
  # API for defining properties as well as per-property defaults.
17
+ #
18
+ # @example Define a lightweight data object
19
+ # klass = Class.new(Hashie::Dash) { property :name, default: 'Ada' }
20
+ # klass.new.name # => 'Ada'
15
21
  class Dash < Hash
16
22
  include Hashie::Extensions::PrettyInspect
17
23
 
24
+ # @example Inspect declared properties
25
+ # klass = Class.new(Hashie::Dash) { property :name }
26
+ # klass.new(name: 'Ada').to_s.include?('name="Ada"') # => true
18
27
  alias to_s inspect
19
28
 
20
29
  # Defines a property on the Dash. Options are
21
30
  # as follows:
22
31
  #
23
- # * <tt>:default</tt> - Specify a default value for this property,
32
+ # * `:default` - Specify a default value for this property,
24
33
  # to be returned before a value is set on the property in a new
25
34
  # Dash.
26
35
  #
27
- # * <tt>:required</tt> - Specify the value as required for this
36
+ # * `:required` - Specify the value as required for this
28
37
  # property, to raise an error if a value is unset in a new or
29
38
  # existing Dash. If a Proc is provided, it will be run in the
30
39
  # context of the Dash instance. If a Symbol is provided, the
31
40
  # property it represents must not be nil. The property is only
32
41
  # required if the value is truthy.
33
42
  #
34
- # * <tt>:message</tt> - Specify custom error message for required property
43
+ # * `:message` - Specify custom error message for required property
35
44
  #
45
+ # @example Declare a required property and a default
46
+ # klass = Class.new(Hashie::Dash) do
47
+ # property :name, required: true
48
+ # property :active, default: true
49
+ # end
50
+ # klass.new(name: 'Ada').to_h # => { name: 'Ada', active: true }
36
51
  def self.property(property_name, options = {})
37
52
  properties << property_name
38
53
 
@@ -49,16 +64,32 @@ module Hashie
49
64
 
50
65
  condition = options.delete(:required)
51
66
  if condition
52
- message = options.delete(:message) || "is required for #{name}."
67
+ message = options.delete(:message) || "is required for `#{name}`"
53
68
  required_properties[property_name] = { condition: condition, message: message }
54
69
  elsif options.key?(:message)
55
- raise ArgumentError, 'The :message option should be used with :required option.'
70
+ raise ArgumentError, 'the :message option should be used with :required option'
56
71
  end
57
72
  end
58
73
 
59
74
  class << self
60
- attr_reader :properties, :defaults
75
+ # @example List the declared properties
76
+ # klass = Class.new(Hashie::Dash) { property :name }
77
+ # klass.properties.to_a # => [:name]
78
+ attr_reader :properties
79
+
80
+ # @example Read declared default values
81
+ # klass = Class.new(Hashie::Dash) { property :active, default: true }
82
+ # klass.defaults # => { active: true }
83
+ attr_reader :defaults
84
+
85
+ # @example List generated getter names
86
+ # klass = Class.new(Hashie::Dash) { property :name }
87
+ # klass.getters.to_a # => [:name]
61
88
  attr_reader :getters
89
+
90
+ # @example Inspect the requirement configured for a property
91
+ # klass = Class.new(Hashie::Dash) { property :name, required: true, message: 'must be supplied' }
92
+ # klass.required_properties # => { name: { condition: true, message: 'must be supplied' } }
62
93
  attr_reader :required_properties
63
94
  end
64
95
  instance_variable_set('@properties', Set.new)
@@ -66,6 +97,11 @@ module Hashie
66
97
  instance_variable_set('@defaults', {})
67
98
  instance_variable_set('@required_properties', {})
68
99
 
100
+ # @example Subclasses inherit independent property definitions
101
+ # parent = Class.new(Hashie::Dash) { property :name, default: 'Ada' }
102
+ # child = Class.new(parent) { property :active, default: true }
103
+ # child.new.to_h # => { name: 'Ada', active: true }
104
+ # parent.properties.to_a # => [:name]
69
105
  def self.inherited(klass)
70
106
  super
71
107
  (@subclasses ||= Set.new) << klass
@@ -77,46 +113,52 @@ module Hashie
77
113
 
78
114
  # Check to see if the specified property has already been
79
115
  # defined.
116
+ #
117
+ # @example Check declared and undeclared properties
118
+ # klass = Class.new(Hashie::Dash) { property :name }
119
+ # klass.property?(:name) # => true
120
+ # klass.property?(:missing) # => false
80
121
  def self.property?(name)
81
122
  properties.include? name
82
123
  end
83
124
 
84
125
  # Check to see if the specified property is
85
126
  # required.
127
+ #
128
+ # @example Identify a required declaration
129
+ # klass = Class.new(Hashie::Dash) { property :name, required: true }
130
+ # klass.required?(:name) # => true
131
+ # klass.required?(:missing) # => false
86
132
  def self.required?(name)
87
133
  required_properties.key? name
88
134
  end
89
135
 
90
136
  private_class_method def self.define_getter_for(property_name)
91
137
  return if getters.include?(property_name)
138
+
92
139
  define_method(property_name) { |&block| self.[](property_name, &block) }
93
140
  getters << property_name
94
141
  end
95
142
 
96
143
  private_class_method def self.define_setter_for(property_name)
97
144
  setter = :"#{property_name}="
98
- return if instance_methods.include?(setter)
145
+ return if method_defined?(setter)
146
+
99
147
  define_method(setter) { |value| self.[]=(property_name, value) }
100
148
  end
101
149
 
102
150
  # You may initialize a Dash with an attributes hash
103
151
  # just like you would many other kinds of data objects.
152
+ #
153
+ # @example Initialize declared attributes and defaults
154
+ # klass = Class.new(Hashie::Dash) do
155
+ # property :name
156
+ # property :active, default: true
157
+ # end
158
+ # klass.new(name: 'Ada').to_h # => { name: 'Ada', active: true }
104
159
  def initialize(attributes = {}, &block)
105
160
  super(&block)
106
161
 
107
- self.class.defaults.each_pair do |prop, value|
108
- self[prop] = begin
109
- val = value.dup
110
- if val.is_a?(Proc)
111
- val.arity == 1 ? val.call(self) : val.call
112
- else
113
- val
114
- end
115
- rescue TypeError
116
- value
117
- end
118
- end
119
-
120
162
  initialize_attributes(attributes)
121
163
  assert_required_attributes_set!
122
164
  end
@@ -127,9 +169,13 @@ module Hashie
127
169
 
128
170
  # Retrieve a value from the Dash (will return the
129
171
  # property's default value if it hasn't been set).
172
+ #
173
+ # @example Read a declared value by key
174
+ # klass = Class.new(Hashie::Dash) { property :name, default: 'Ada' }
175
+ # klass.new[:name] # => 'Ada'
130
176
  def [](property)
131
177
  assert_property_exists! property
132
- value = super(property)
178
+ value = super
133
179
  # If the value is a lambda, proc, or whatever answers to call, eval the thing!
134
180
  if value.is_a? Proc
135
181
  self[property] = value.call # Set the result of the call as a value
@@ -141,12 +187,23 @@ module Hashie
141
187
 
142
188
  # Set a value on the Dash in a Hash-like way. Only works
143
189
  # on pre-existing properties.
190
+ #
191
+ # @example Assign a declared property
192
+ # klass = Class.new(Hashie::Dash) { property :name }
193
+ # dash = klass.new
194
+ # dash[:name] = 'Ada'
195
+ # dash.name # => 'Ada'
144
196
  def []=(property, value)
145
197
  assert_property_required! property, value
146
198
  assert_property_exists! property
147
- super(property, value)
199
+ super
148
200
  end
149
201
 
202
+ # @example Merge into a copy with conflict resolution
203
+ # klass = Class.new(Hashie::Dash) { property :count }
204
+ # dash = klass.new(count: 2)
205
+ # dash.merge(count: 3) { |_key, old, new| old + new }.to_h # => { count: 5 }
206
+ # dash.count # => 2
150
207
  def merge(other_hash)
151
208
  new_dash = dup
152
209
  other_hash.each do |k, v|
@@ -155,6 +212,11 @@ module Hashie
155
212
  new_dash
156
213
  end
157
214
 
215
+ # @example Merge declared values in place
216
+ # klass = Class.new(Hashie::Dash) { property :name }
217
+ # dash = klass.new(name: 'Ada')
218
+ # dash.merge!(name: 'Grace')
219
+ # dash.name # => 'Grace'
158
220
  def merge!(other_hash)
159
221
  other_hash.each do |k, v|
160
222
  self[k] = block_given? ? yield(k, self[k], v) : v
@@ -162,6 +224,13 @@ module Hashie
162
224
  self
163
225
  end
164
226
 
227
+ # @example Restore defaults for omitted attributes
228
+ # klass = Class.new(Hashie::Dash) do
229
+ # property :name
230
+ # property :active, default: true
231
+ # end
232
+ # dash = klass.new(name: 'Ada', active: false)
233
+ # dash.replace(name: 'Grace').to_h # => { name: 'Grace', active: true }
165
234
  def replace(other_hash)
166
235
  other_hash = self.class.defaults.merge(other_hash)
167
236
  (keys - other_hash.keys).each { |key| delete(key) }
@@ -169,17 +238,61 @@ module Hashie
169
238
  self
170
239
  end
171
240
 
241
+ # @example Include declared but unset properties in a plain hash
242
+ # klass = Class.new(Hashie::Dash) { property :name }
243
+ # klass.new.to_h # => { name: nil }
244
+ # klass.new.to_h.class # => Hash
245
+ def to_h
246
+ defaults = self.class.properties.to_h { |prop| [prop, self.class.defaults[prop]] }
247
+
248
+ defaults.merge(self)
249
+ end
250
+ # @example Convert a Dash to a plain hash
251
+ # klass = Class.new(Hashie::Dash) { property :name }
252
+ # klass.new(name: 'Ada').to_hash # => { name: 'Ada' }
253
+ alias to_hash to_h
254
+
255
+ # ActiveSupport's Hash#deep_transform_keys builds up its result via
256
+ # `self.class.new`, expecting to be able to start from an empty hash
257
+ # and fill in keys one at a time. Since Dash enforces required
258
+ # properties at initialization and restricts which keys may be set,
259
+ # this breaks for Dash (and Trash) - see
260
+ # https://github.com/hashie/hashie/issues/559. Deep transforming a
261
+ # Dash's keys no longer produces a valid Dash anyway (the resulting
262
+ # keys may not correspond to any defined property), so fall back to
263
+ # operating on a plain Hash instead.
264
+ #
265
+ # @example Transform nested keys without Dash's property restrictions
266
+ # klass = Class.new(Hashie::Dash) { property :user }
267
+ # dash = klass.new(user: { name: 'Ada' })
268
+ # dash.deep_transform_keys(&:to_s) # => { 'user' => { 'name' => 'Ada' } }
269
+ def deep_transform_keys(&block)
270
+ _deep_transform_keys_in_object(to_h, &block)
271
+ end
272
+
273
+ # @example Update attributes and reapply defaults for nil values
274
+ # klass = Class.new(Hashie::Dash) { property :name, default: 'Ada' }
275
+ # dash = klass.new(name: 'Grace')
276
+ # dash.update_attributes!(name: nil)
277
+ # dash.name # => 'Ada'
172
278
  def update_attributes!(attributes)
173
279
  update_attributes(attributes)
174
280
 
175
281
  self.class.defaults.each_pair do |prop, value|
176
- next unless self[prop].nil?
282
+ next unless fetch(prop, nil).nil?
283
+
177
284
  self[prop] = begin
178
- value.dup
285
+ val = value.dup
286
+ if val.is_a?(Proc)
287
+ val.arity == 1 ? val.call(self) : val.call
288
+ else
289
+ val
290
+ end
179
291
  rescue TypeError
180
292
  value
181
293
  end
182
294
  end
295
+
183
296
  assert_required_attributes_set!
184
297
  end
185
298
 
@@ -188,8 +301,8 @@ module Hashie
188
301
  def initialize_attributes(attributes)
189
302
  return unless attributes
190
303
 
191
- cleaned_attributes = attributes.reject { |_attr, value| value.nil? }
192
- update_attributes(cleaned_attributes)
304
+ cleaned_attributes = attributes.compact
305
+ update_attributes!(cleaned_attributes)
193
306
  end
194
307
 
195
308
  def update_attributes(attributes)
@@ -211,23 +324,25 @@ module Hashie
211
324
  end
212
325
 
213
326
  def assert_property_set!(property)
214
- fail_property_required_error!(property) if send(property).nil? && required?(property)
327
+ fail_property_required_error!(property) if send(property).nil? && _required?(property)
215
328
  end
216
329
 
217
330
  def assert_property_required!(property, value)
218
- fail_property_required_error!(property) if value.nil? && required?(property)
331
+ fail_property_required_error!(property) if value.nil? && _required?(property)
219
332
  end
220
333
 
221
334
  def fail_property_required_error!(property)
335
+ message = self.class.required_properties[property][:message]
222
336
  raise ArgumentError,
223
- "The property '#{property}' #{self.class.required_properties[property][:message]}"
337
+ "the property `#{property}` " + message
224
338
  end
225
339
 
226
340
  def fail_no_property_error!(property)
227
- raise NoMethodError, "The property '#{property}' is not defined for #{self.class.name}."
341
+ raise NoMethodError,
342
+ "the property `#{property}` is not defined for `#{self.class.name || 'anonymous class'}`"
228
343
  end
229
344
 
230
- def required?(property)
345
+ def _required?(property)
231
346
  return false unless self.class.required?(property)
232
347
 
233
348
  condition = self.class.required_properties[property][:condition]
@@ -237,5 +352,18 @@ module Hashie
237
352
  else !!condition
238
353
  end
239
354
  end
355
+
356
+ def _deep_transform_keys_in_object(object, &block)
357
+ case object
358
+ when ::Hash
359
+ object.each_with_object({}) do |(key, value), result|
360
+ result[yield(key)] = _deep_transform_keys_in_object(value, &block)
361
+ end
362
+ when ::Array
363
+ object.map { |e| _deep_transform_keys_in_object(e, &block) }
364
+ else
365
+ object
366
+ end
367
+ end
240
368
  end
241
369
  end