dotkey 1.0.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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 59465e04fac7799b3ae896136cf7c2e6d756d3638ce4fd4229b1b69968e42821
4
+ data.tar.gz: 20ce766129ecf3dab53ada7364949f423ddd2d8f287d1812219de3db733a937e
5
+ SHA512:
6
+ metadata.gz: 40abb13a263c3e77daedeb909087115539a66488721272105380f3e33b5c0856c1680ba65f8befadbdeeaf04ea9fca3b73ab0734f9bddcde03e6d48a8f7e729d
7
+ data.tar.gz: 17129e2bc6555e2b9abd4010d07d8e299267d62b2a47e71e59f82dc97d10a55dd16480a4cd1ddbcaa068d3d829540e7929687743ba0b195ce84a32325eafa008
data/CHANGELOG.md ADDED
@@ -0,0 +1,2 @@
1
+ ## 1.0.0 [UNRELEASED]
2
+ - Initial release
data/LICENSE.md ADDED
@@ -0,0 +1,24 @@
1
+ The MIT License (MIT)
2
+
3
+ This software and documentation is built on previous effort in the Laravel
4
+ project
5
+ Original work Copyright (c) Taylor Otwell
6
+ Derivative work Copyright (c) 2024 Simon J
7
+
8
+ Permission is hereby granted, free of charge, to any person obtaining a copy
9
+ of this software and associated documentation files (the "Software"), to deal
10
+ in the Software without restriction, including without limitation the rights
11
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
12
+ copies of the Software, and to permit persons to whom the Software is
13
+ furnished to do so, subject to the following conditions:
14
+
15
+ The above copyright notice and this permission notice shall be included in
16
+ all copies or substantial portions of the Software.
17
+
18
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
19
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
20
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
21
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
22
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
23
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
24
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,284 @@
1
+ # DotKey
2
+
3
+ DotKey is a Ruby gem that allows you to easily interact with Ruby objects using dot notation.
4
+
5
+ ## Getting started
6
+
7
+ Add DotKey to your Rails project by adding it to your Gemfile:
8
+
9
+ ```shell
10
+ gem install dotkey
11
+ ```
12
+
13
+ Or using bundler:
14
+
15
+ ```shell
16
+ bundle add dotkey
17
+ ```
18
+
19
+ ## Usage
20
+
21
+ ### get
22
+
23
+ Retrieves a value from a data structure (Hash, Array, or a nested combination) using a dot-delimited key.
24
+
25
+ ```ruby
26
+ data = {a: {b: [1, 2]}, "c" => [{d: 3}, {e: 4}]}
27
+
28
+ DotKey.get(data, "a") # => {b: [1, 2]}
29
+ DotKey.get(data, "a.b") # => [1, 2]
30
+ DotKey.get(data, "a.b.0") # => 1
31
+ DotKey.get(data, "c.0.d") # => 3
32
+ ```
33
+
34
+ If any values along the path are `nil`, `nil` is returned. However, trying to traverse something that is not a Hash or Array will cause an error:
35
+
36
+ ```ruby
37
+ # `b.c` is nil so the result is nil
38
+ DotKey.get({b: {}}, "b.c.d") # => nil
39
+
40
+ # `c` is not a valid key for an Array, so an error is raised
41
+ DotKey.get({b: []}, "b.c.d") # => raises DotKey::InvalidTypeError
42
+
43
+ # `0` is a valid key for an array, but is nil so the result is nil
44
+ DotKey.get({b: []}, "b.0.d") # => nil
45
+
46
+ # Strings cannot be traversed so an error is raised
47
+ DotKey.get({a: "a string"}, "a.b") # => raises DotKey::InvalidTypeError
48
+ ```
49
+
50
+ This behaviour can be disabled by specifying the `raise_on_invalid` parameter:
51
+
52
+ ```ruby
53
+ DotKey.get({b: []}, "b.c.d", raise_on_invalid: false) # => nil
54
+ DotKey.get({a: "a string"}, "a.b", raise_on_invalid: false) # => nil
55
+ ```
56
+
57
+ ### get_all
58
+
59
+ Retrieves all matching values from a data structure (Hash, Array, or a nested combination) using a dot-delimited key with wildcards.
60
+
61
+ `*` and `**` can be used as wildcards for Array items and Hash keys respectively:
62
+
63
+ ```ruby
64
+ data = {a: [{b: 1}, {b: 2, c: 3}], d: [4, 5]}
65
+
66
+ DotKey.get_all(data, "a.0.b") # => {"a.0.b" => 1}
67
+
68
+ # Use `*` as a wildcard for arrays
69
+ DotKey.get_all(data, "a.*.b") # => {"a.0.b" => 1, "a.1.b" => 2}
70
+
71
+ # Use `**` as a wildcard for Hashes
72
+ DotKey.get_all(data, "a.1.**") # => {"a.1.b" => 2, "a.1.c" => 3}
73
+
74
+ DotKey.get_all(data, "**.*") # => {"a.0" => {b: 1}, "a.1" => {b: 2, c: 3}, "d.0" => 4, "d.1" => 5}
75
+ ```
76
+
77
+ If any values along the path are `nil`, `nil` is returned. However, trying to traverse something that is not a Hash or Array will cause an error:
78
+
79
+ ```ruby
80
+ # `b.c` is nil so the result is nil
81
+ DotKey.get({b: {}}, "b.c.d") # => nil
82
+
83
+ # `c` is not a valid key for an Array, so an error is raised
84
+ DotKey.get({b: []}, "b.c.d") # => raises DotKey::InvalidTypeError
85
+
86
+ # `0` is a valid key for an array, but is nil so the result is nil
87
+ DotKey.get({b: []}, "b.0.d") # => nil
88
+
89
+ # Strings cannot be traversed so an error is raised
90
+ DotKey.get({a: "a string"}, "a.b") # => raises DotKey::InvalidTypeError
91
+ ```
92
+
93
+ This behaviour can be disabled by specifying the `raise_on_invalid` parameter:
94
+
95
+ ```ruby
96
+ DotKey.get({b: []}, "b.c.d", raise_on_invalid: false) # => nil
97
+ DotKey.get({a: "a string"}, "a.b", raise_on_invalid: false) # => nil
98
+ ```
99
+
100
+ Missing values are included in the result as `nil` values, but these can be omitted by specifying the `include_missing` parameter:
101
+
102
+ ```ruby
103
+ data = {a: [{b: 1}, {b: 2, c: 3}], d: 4}
104
+
105
+ DotKey.get_all(data, "a.*.c") # => {"a.0.c" => nil, "a.1.c" => 3}
106
+ DotKey.get_all(data, "a.*.c", include_missing: false) # => {"a.1.c" => 3}
107
+
108
+ # This behaviour also affects `nil` values from invalid paths
109
+ DotKey.get_all(data, "d.*", raise_on_invalid: false) # => {"d.*" => nil}
110
+ DotKey.get_all(data, "d.*", raise_on_invalid: false, include_missing: false) # => {}
111
+
112
+ # Note that existing `nil` values are still included even when `include_missing` is false
113
+ DotKey.get_all({a: nil}, "**", include_missing: false) #=> {"a" => nil})
114
+ ```
115
+
116
+ ### flatten
117
+
118
+ Converts a nested structure into a flat Hash, with the dot-delimited path to the value as the key.
119
+
120
+ ```ruby
121
+ DotKey.flatten({a: {b: [1, 2]}, "c" => [{d: 3}, {e: 4}]})
122
+ # => {
123
+ # "a.b.0" => 1,
124
+ # "a.b.1" => 2,
125
+ # "c.0.d" => 3,
126
+ # "c.1.e" => 4,
127
+ # }
128
+ ```
129
+
130
+ ### set!
131
+
132
+ Sets a value in a data structure (Hash, Array, or a nested combination) using a dot-delimited key.
133
+
134
+ ```ruby
135
+ data = {a: {b: [1]}}
136
+ DotKey.set!(data, "a.b.0", "a")
137
+ DotKey.set!(data, "a.b.1", "b")
138
+ DotKey.set!(data, "c", "d")
139
+ data #=> {a: {b: ["a", "b"]}, :c => "d"}
140
+ ```
141
+
142
+ Intermediate structures are created as needed when traversing a path that includes
143
+ missing elements:
144
+
145
+ ```ruby
146
+ data = {}
147
+ DotKey.set!(data, "a.b.c.0", 42)
148
+ data #=> {a: {b: {c: [42]}}}
149
+
150
+ DotKey.set!(data, "a.b.c.2", 44)
151
+ data #=> {a: {b: {c: [42, nil, 44]}}}
152
+ ```
153
+
154
+ By default, keys are created as symbols, but string keys can by specified using the
155
+ `string_keys` parameter:
156
+
157
+ ```ruby
158
+ data = {}
159
+ DotKey.set!(data, "a", :symbol)
160
+ DotKey.set!(data, "b", "string", string_keys: true)
161
+ data #=> {a: :symbol, "b" => "string"}
162
+ ```
163
+
164
+ If a key along the path refers to a structure that is neither a Hash nor an Array,
165
+ an error is raised:
166
+
167
+ ```ruby
168
+ data = {a: "string"}
169
+ DotKey.set!(data, "a.b", 42) #=> raises `DotKey::InvalidTypeError`
170
+ ```
171
+
172
+ ### delete!
173
+
174
+ Removes a value from a data structure (Hash, Array, or a nested combination) using a dot-delimited key and returns the deleted value.
175
+
176
+ ```ruby
177
+ data = {a: {b: [1, 2]}, "c" => [{d: 3}, {e: 4}]}
178
+
179
+ DotKey.delete!(data, "a.b.0") #=> 1
180
+ data #=> {a: {b: [2]}, "c" => [{d: 3}, {e: 4}]}
181
+
182
+ DotKey.delete!(data, "c.0.d") #=> 3
183
+ data #=> {a: {b: [2]}, "c" => [{}, {e: 4}]}
184
+ ```
185
+
186
+ If any values along the path are `nil`, nothing happens and `nil` is returned. However, if a key along the path refers to a structure that is neither a Hash nor an Array, an error is raised:
187
+
188
+ ```ruby
189
+ # `b.c` is nil so the result is nil
190
+ DotKey.delete!({b: {}}, "b.c.d") #=> nil
191
+
192
+ # `c` is not a valid key for an Array, so an error is raised
193
+ DotKey.delete!({b: []}, "b.c.d") #=> raises DotKey::InvalidTypeError
194
+
195
+ # `0` is a valid key but is nil so the result is nil
196
+ DotKey.delete!({b: []}, "b.0.d") #=> nil
197
+
198
+ # Strings cannot be traversed so an error is raised
199
+ DotKey.delete!({a: "a string"}, "a.b") #=> raises DotKey::InvalidTypeError
200
+ ```
201
+
202
+ This behaviour can be disabled by specifying the `raise_on_invalid` parameter:
203
+
204
+ ```ruby
205
+ DotKey.delete!({b: []}, "b.c.d", raise_on_invalid: false) #=> nil
206
+ DotKey.delete!({a: "a string"}, "a.b", raise_on_invalid: false) #=> nil
207
+ ```
208
+
209
+ ## Configuration
210
+
211
+ The default delimiter for keys is a dot, `.`. However, this can be changed to any other String using the `DotKey.delimiter` option:
212
+
213
+ ```ruby
214
+ DotKey.delimiter = "_"
215
+
216
+ DotKey.get({a: {b: [1]}}, "a_b_0") #=> 1
217
+ DotKey.flatten({a: {b: [1]}}) #=> {"a_b_0" => 1}
218
+ ```
219
+
220
+ ## Performance
221
+
222
+ Due to the parsing of string keys, DotKey won't be the most performant option when accessing data in nested objects:
223
+
224
+ ```ruby
225
+ # Benchmarking DotKey.get vs native alternatives
226
+ object = {a: {b: {c: {d: {e: {f: {g: [[[1]]]}}}}}}}
227
+
228
+ Benchmark.ips do |bm|
229
+ bm.report("dotkey") { DotKey.get(object, "a.b.c.d.e.f.g.0.0.0") }
230
+ bm.report("dig") { object.dig(:a, :b, :c, :d, :e, :f, :g, 0, 0, 0) }
231
+ bm.report("brackets") { object[:a][:b][:c][:d][:e][:f][:g][0][0][0] }
232
+ bm.report("fetch") { object.fetch(:a).fetch(:b).fetch(:c).fetch(:d).fetch(:e).fetch(:f).fetch(:g).fetch(0).fetch(0).fetch(0) }
233
+ bm.compare!
234
+ end
235
+
236
+ # brackets: 12132728.2 i/s
237
+ # dig: 10368408.4 i/s - 1.17x slower
238
+ # fetch: 6080694.0 i/s - 2.00x slower
239
+ # dotkey: 494617.5 i/s - 24.53x slower (!!)
240
+ ```
241
+
242
+ However, DotKey excels at providing a concise and flexible approach to working with nested data structures:
243
+ it offers customisable handling of missing values and error conditions, while seamlessly supporting
244
+ both string and symbol keys without requiring explicit type conversion.
245
+
246
+ While much slower than the alternatives, it is still quick and efficient enough for most use cases. In fact, comparing `DotKey.get` again using Rails' HashWithIndifferentAccess, the performance is comparable to using `dig`:
247
+
248
+ ```ruby
249
+ # Benchmarking DotKey.get vs alternatives using HashWithIndifferentAccess
250
+ object = {a: {"b" => {c: {"d" => {e: {"f" => {g: [[[1]]]}}}}}}}
251
+ indifferent = ActiveSupport::HashWithIndifferentAccess.new(
252
+ {a: {"b" => {c: {"d" => {e: {"f" => {g: [[[1]]]}}}}}}},
253
+ )
254
+
255
+ Benchmark.ips do |bm|
256
+ bm.report("dotkey") { DotKey.get(object, "a.b.c.d.e.f.g.0.0.0") }
257
+ bm.report("indifferent dotkey") { DotKey.get(indifferent, "a.b.c.d.e.f.g.0.0.0") }
258
+ bm.report("indifferent dig") { indifferent.dig(:a, :b, :c, :d, :e, :f, :g, 0, 0, 0) }
259
+ bm.report("indifferent brackets") { indifferent[:a][:b][:c][:d][:e][:f][:g][0][0][0] }
260
+ bm.report("indifferent fetch") { indifferent.fetch(:a).fetch(:b).fetch(:c).fetch(:d).fetch(:e).fetch(:f).fetch(:g).fetch(0).fetch(0).fetch(0) }
261
+ bm.compare!
262
+ end
263
+
264
+ # indifferent brackets: 1738761.7 i/s
265
+ # indifferent fetch: 1026543.3 i/s - 1.69x slower
266
+ # dotkey: 547557.6 i/s - 3.18x slower
267
+ # indifferent dig: 526314.7 i/s - 3.30x slower
268
+ # indifferent dotkey: 477155.6 i/s - 3.64x slower
269
+ ```
270
+
271
+ The performance for setting values is much more comparable, but with significantly more succinct code:
272
+
273
+ ```
274
+ # brackets set:
275
+ # 431898.9 i/s
276
+ # brackets set with missing intermediate values:
277
+ # 394861.3 i/s - 1.09x slower
278
+ # dotkey set:
279
+ # 212933.4 i/s - 2.03x slower
280
+ # dotkey set with missing intermediate values:
281
+ # 168456.3 i/s - 2.56x slower
282
+ ```
283
+
284
+ See the [performance test suite](test/unit/benchmark_test.rb) for more details.
@@ -0,0 +1,428 @@
1
+ class DotKey
2
+ class InvalidTypeError < StandardError; end
3
+
4
+ class << self
5
+ attr_writer :delimiter
6
+
7
+ def delimiter
8
+ defined?(@delimiter) ? @delimiter : "."
9
+ end
10
+
11
+ # = \get
12
+ # Retrieves a value from a data structure (Hash, Array, or a nested combination) using
13
+ # a dot-delimited key.
14
+ #
15
+ # data = {a: {b: [1, 2]}, "c" => [{d: 3}, {e: 4}]}
16
+ #
17
+ # DotKey.get(data, "a") #=> {b: [1, 2]}
18
+ # DotKey.get(data, "a.b") #=> [1, 2]
19
+ # DotKey.get(data, "a.b.0") #=> 1
20
+ # DotKey.get(data, "c.0.d") #=> 3
21
+ #
22
+ # If any values along the path are <tt>nil</tt>, <tt>nil</tt> is returned. However, if
23
+ # a key along the path refers to a structure that is neither a Hash nor an Array, an
24
+ # error is raised.
25
+ #
26
+ # # `b.c` is nil so the result is nil
27
+ # DotKey.get({b: {}}, "b.c.d") #=> nil
28
+ #
29
+ # # `c` is not a valid key for an Array, so an error is raised
30
+ # DotKey.get({b: []}, "b.c.d") #=> raises DotKey::InvalidTypeError
31
+ #
32
+ # # `0` is a valid key but is nil so the result is nil
33
+ # DotKey.get({b: []}, "b.0.d") #=> nil
34
+ #
35
+ # # Strings cannot be traversed so an error is raised
36
+ # DotKey.get({a: "a string"}, "a.b") #=> raises DotKey::InvalidTypeError
37
+ #
38
+ # This behaviour can be disabled by specifying the `raise_on_invalid` parameter:
39
+ #
40
+ # DotKey.get({b: []}, "b.c.d", raise_on_invalid: false) #=> nil
41
+ # DotKey.get({a: "a string"}, "a.b", raise_on_invalid: false) #=> nil
42
+ #
43
+ # @param data [Hash,Array] The root data structure
44
+ # @param key [String,Symbol] A dot-delimited string or symbol representing the path to be resolved.
45
+ # @param raise_on_invalid [Boolean] A boolean indicating whether to raise an <tt>InvalidTypeError</tt> if a key path cannot be resolved due to an incompatible type. Defaults to <tt>true</tt>.
46
+ # @return The value at the specified key path. Returns <tt>nil</tt> if any part of the path is missing or nil.
47
+ # @raise [InvalidTypeError] If the key cannot be resolved due to an invalid type along the path, and <tt>raise_on_invalid</tt> is <tt>true</tt>.
48
+ def get(data, key, raise_on_invalid: true)
49
+ key_parts = key.to_s.split(delimiter)
50
+ current = data
51
+
52
+ key_parts.each do |key_part|
53
+ if current.is_a?(Hash)
54
+ current = current[key_part] || current[key_part.to_sym]
55
+ elsif current.is_a?(Array)
56
+ current = begin
57
+ current[Integer(key_part)]
58
+ rescue ArgumentError
59
+ if raise_on_invalid
60
+ raise InvalidTypeError.new "Unable to consume key #{key_part} on #{current.class}"
61
+ end
62
+ end
63
+ elsif current.nil?
64
+ return nil
65
+ elsif raise_on_invalid
66
+ raise InvalidTypeError.new "Unable to consume key #{key_part} on #{current.class}"
67
+ else
68
+ return nil
69
+ end
70
+ end
71
+
72
+ current
73
+ end
74
+
75
+ # = \get_all
76
+ # Retrieves all matching values from a data structure (Hash, Array, or a nested
77
+ # combination) using a dot-delimited key.
78
+ #
79
+ # <tt>*</tt> and <tt>**</tt> can be used as wildcards for Array items and Hash keys
80
+ # respectively:
81
+ #
82
+ # data = {a: [{b: 1}, {b: 2, c: 3}], d: [4, 5]}
83
+ #
84
+ # DotKey.get_all(data, "a.0.b") #=> {"a.0.b" => 1}
85
+ #
86
+ # # Use `*` as a wildcard for Array indexes
87
+ # DotKey.get_all(data, "a.*.b") #=> {"a.0.b" => 1, "a.1.b" => 2}
88
+ #
89
+ # # Use `**` as a wildcard for Hash keys
90
+ # DotKey.get_all(data, "a.1.**") #=> {"a.1.b" => 2, "a.1.c" => 3}
91
+ #
92
+ # DotKey.get_all(data, "**.*") #=> {"a.0" => {b: 1}, "a.1" => {b: 2, c: 3}, "d.0" => 4, "d.1" => 5}
93
+ #
94
+ # If any values along the path are <tt>nil</tt>, <tt>nil</tt> is returned. However,
95
+ # if a key along the path refers to a structure that is neither a Hash nor an Array,
96
+ # an error is raised.
97
+ #
98
+ # # `b.c` is nil so the result is nil
99
+ # DotKey.get({b: {}}, "b.c.d") #=> nil
100
+ #
101
+ # # `c` is not a valid key for an Array, so an error is raised
102
+ # DotKey.get({b: []}, "b.c.d") #=> raises DotKey::InvalidTypeError
103
+ #
104
+ # # `0` is a valid key for an array, but is nil so the result is nil
105
+ # DotKey.get({b: []}, "b.0.d") #=> nil
106
+ #
107
+ # # Strings cannot be traversed so an error is raised
108
+ # DotKey.get({a: "a string"}, "a.b") #=> raises DotKey::InvalidTypeError
109
+ #
110
+ # This behaviour can be disabled by specifying the <tt>raise_on_invalid</tt> parameter.
111
+ #
112
+ # DotKey.get({b: []}, "b.c.d", raise_on_invalid: false) #=> nil
113
+ # DotKey.get({a: "a string"}, "a.b", raise_on_invalid: false) #=> nil
114
+ #
115
+ # Missing values are included in the result as <tt>nil</tt> values, but these can be
116
+ # omitted by specifying the <tt>include_missing</tt> parameter.
117
+ #
118
+ # data = {a: [{b: 1}, {b: 2, c: 3}], d: 4}
119
+ #
120
+ # DotKey.get_all(data, "a.*.c") #=> {"a.0.c" => nil, "a.1.c" => 3}
121
+ # DotKey.get_all(data, "a.*.c", include_missing: false) #=> {"a.1.c" => 3}
122
+ #
123
+ # # This behaviour also affects `nil` values from invalid paths
124
+ # DotKey.get_all(data, "d.*", raise_on_invalid: false) #=> {"d.*" => nil}
125
+ # DotKey.get_all(data, "d.*", raise_on_invalid: false, include_missing: false) #=> {}
126
+ #
127
+ # # Note that existing `nil` values are still included even when `include_missing` is false
128
+ # DotKey.get_all({a: nil}, "**", include_missing: false) #=> {"a" => nil})
129
+ #
130
+ # @param data [Hash,Array] The root data structure
131
+ # @param key [String,Symbol] A dot-delimited string or symbol representing the path to be resolved. <tt>*</tt> and <tt>**</tt> can be used as wildcards for Array items and Hash keys respectively.
132
+ # @param include_missing [Boolean] A boolean indicating whether to include missing keys with <tt>nil</tt> values when a part of the key path does not exist. Defaults to <tt>true</tt>.
133
+ # @param raise_on_invalid [Boolean] A boolean indicating whether to raise an <tt>InvalidTypeError</tt> if a key path cannot be resolved due to an incompatible type. Defaults to <tt>true</tt>.
134
+ # @return [Hash] A Hash mapping the fully qualified resolved key paths to their corresponding values. If partial matches or missing keys are permitted, values may include <tt>nil</tt>.
135
+ # @raise [InvalidTypeError] If the key cannot be resolved due to an invalid type along the path, and <tt>raise_on_invalid</tt> is <tt>true</tt>.
136
+ def get_all(data, key, include_missing: true, raise_on_invalid: true)
137
+ key_parts = key.to_s.split(delimiter)
138
+ values = {"" => data}
139
+
140
+ key_parts.each do |key|
141
+ key_as_int = nil
142
+
143
+ values = if key == "*"
144
+ values.each_with_object({}) do |(parent_key, array), object|
145
+ key_prefix = (parent_key == "") ? "" : "#{parent_key}#{delimiter}"
146
+ if array.is_a? Array
147
+ array.each_with_index do |item, index|
148
+ object["#{key_prefix}#{index}"] = item
149
+ end
150
+ elsif raise_on_invalid
151
+ raise InvalidTypeError.new "Expected #{parent_key} to be an Array, but got #{array.class}"
152
+ elsif include_missing
153
+ object["#{key_prefix}*"] = nil
154
+ end
155
+ end
156
+ elsif key == "**"
157
+ values.each_with_object({}) do |(parent_key, hash), object|
158
+ key_prefix = (parent_key == "") ? "" : "#{parent_key}#{delimiter}"
159
+ if hash.is_a? Hash
160
+ hash.each do |hash_key, item|
161
+ object["#{key_prefix}#{hash_key}"] = item
162
+ end
163
+ elsif raise_on_invalid
164
+ raise InvalidTypeError.new "Expected #{parent_key} to be a Hash, but got #{hash.class}"
165
+ elsif include_missing
166
+ object["#{key_prefix}**"] = nil
167
+ end
168
+ end
169
+ else
170
+ values.each_with_object({}) do |(parent_key, val), object|
171
+ key_prefix = (parent_key == "") ? "" : "#{parent_key}#{delimiter}"
172
+
173
+ if val.is_a?(Hash)
174
+ if val.has_key?(key) || val.has_key?(key.to_sym)
175
+ object["#{key_prefix}#{key}"] = val[key] || val[key.to_sym]
176
+ elsif include_missing
177
+ object["#{key_prefix}#{key}"] = nil
178
+ end
179
+ elsif val.is_a?(Array) && key_as_int != :invalid
180
+ begin
181
+ key_as_int ||= Integer(key)
182
+
183
+ if val.length > key_as_int
184
+ object["#{key_prefix}#{key}"] = val[key_as_int]
185
+ elsif include_missing
186
+ object["#{key_prefix}#{key}"] = nil
187
+ end
188
+ rescue ArgumentError
189
+ key_as_int = :invalid
190
+
191
+ if raise_on_invalid
192
+ raise InvalidTypeError.new "Expected #{key} to be an array key for Array #{parent_key}"
193
+ elsif include_missing
194
+ object["#{key_prefix}#{key}"] = nil
195
+ end
196
+ end
197
+ elsif raise_on_invalid
198
+ if val.is_a?(Array) && key_as_int == :invalid
199
+ raise InvalidTypeError.new "Expected #{key} to be an array key for Array #{parent_key}"
200
+ else
201
+ raise InvalidTypeError.new "Expected #{parent_key} to be a Hash or Array, but got #{val.class}"
202
+ end
203
+ elsif include_missing
204
+ object["#{key_prefix}#{key}"] = nil
205
+ end
206
+ end
207
+ end
208
+ end
209
+
210
+ values
211
+ end
212
+
213
+ # = \flatten
214
+ # Converts a nested structure into a flat Hash, with the dot-delimited path to the
215
+ # value as the key.
216
+ #
217
+ # DotKey.flatten({a: {b: [1, 2]}, "c" => [{d: 3}, {e: 4}]})
218
+ # #=> {
219
+ # # "a.b.0" => 1,
220
+ # # "a.b.1" => 2,
221
+ # # "c.0.d" => 3,
222
+ # # "c.1.e" => 4,
223
+ # # }
224
+ #
225
+ # @param data [Hash, Array] The nested structure of Hashes and Arrays to be flattened.
226
+ # @return [Hash] A flattened Hash representation of the structure where keys are the dot-delimited paths to the values.
227
+ def flatten(data)
228
+ _flatten(data, "")
229
+ end
230
+
231
+ private def _flatten(data, prefix)
232
+ result = {}
233
+
234
+ if data.is_a?(Hash)
235
+ data.each do |key, value|
236
+ path = (prefix == "") ? key.to_s : "#{prefix}#{delimiter}#{key}"
237
+ result.merge! _flatten(value, path)
238
+ end
239
+ elsif data.is_a?(Array)
240
+ data.each_with_index do |item, index|
241
+ path = (prefix == "") ? index.to_s : "#{prefix}#{delimiter}#{index}"
242
+ result.merge! _flatten(item, path)
243
+ end
244
+ else
245
+ return {prefix => data}
246
+ end
247
+
248
+ result
249
+ end
250
+
251
+ # = \set!
252
+ # Sets a value in a data structure (Hash, Array, or a nested combination) using a
253
+ # dot-delimited key.
254
+ #
255
+ # data = {a: {b: [1]}}
256
+ # DotKey.set!(data, "a.b.0", "a")
257
+ # DotKey.set!(data, "a.b.1", "b")
258
+ # DotKey.set!(data, "c", "d")
259
+ # data #=> {a: {b: ["a", "b"]}, :c => "d"}
260
+ #
261
+ # Intermediate structures are created as needed when traversing a path that includes
262
+ # missing elements.
263
+ #
264
+ # data = {}
265
+ # DotKey.set!(data, "a.b.c.0", 42)
266
+ # data #=> {a: {b: {c: [42]}}}
267
+ #
268
+ # DotKey.set!(data, "a.b.c.2", 44)
269
+ # data #=> {a: {b: {c: [42, nil, 44]}}}
270
+ #
271
+ # By default, keys are created as symbols, but string keys can by specified using the
272
+ # <tt>string_keys</tt> parameter.
273
+ #
274
+ # data = {}
275
+ # DotKey.set!(data, "a", :symbol)
276
+ # DotKey.set!(data, "b", "string", string_keys: true)
277
+ # data #=> {a: :symbol, "b" => "string"}
278
+ #
279
+ # If a key along the path refers to a structure that is neither a Hash nor an Array,
280
+ # an error is raised.
281
+ #
282
+ # data = {a: "string"}
283
+ # DotKey.set!(data, "a.b", 42) #=> raises `DotKey::InvalidTypeError`
284
+ #
285
+ # @param data [Hash, Array] The root data structure.
286
+ # @param key [String, Symbol] A dot-delimited string or symbol representing the path to be set.
287
+ # @param value [Object] The value to set at the specified key path.
288
+ # @param string_keys [Boolean] If true, keys will be created as strings
289
+ # @raise [DotKey::InvalidTypeError] If the key cannot be resolved due to an invalid type along the path.
290
+ def set!(data, key, value, string_keys: false)
291
+ key_parts = key.to_s.split(delimiter)
292
+ current = data
293
+
294
+ key_parts.each_with_index do |next_key, i|
295
+ break if i == key_parts.length - 1
296
+
297
+ if current.is_a?(Hash)
298
+ next_value = current[next_key] || current[next_key.to_sym]
299
+ elsif current.is_a?(Array) && next_key.match?(/\A\d+\z/)
300
+ next_key = begin
301
+ Integer(next_key)
302
+ rescue ArgumentError
303
+ raise InvalidTypeError.new "Unable to consume key #{next_key}, expecting an integer"
304
+ end
305
+
306
+ next_value = current[next_key]
307
+ else
308
+ raise InvalidTypeError.new "Unable to consume key #{next_key}, expecting a Hash or Array, but got #{current.class}"
309
+ end
310
+
311
+ current = if next_value.is_a?(Hash) || next_value.is_a?(Array)
312
+ next_value
313
+ elsif next_value.nil?
314
+ if key_parts[i + 1].match?(/\A\d+\z/)
315
+ (current[(next_key.is_a?(Integer) || string_keys) ? next_key : next_key.to_sym] = [])
316
+ else
317
+ (current[(next_key.is_a?(Integer) || string_keys) ? next_key : next_key.to_sym] = {})
318
+ end
319
+ else
320
+ raise InvalidTypeError.new "Invalid type for key #{next_key}, expecting a Hash, Array or nil, but got #{next_value.class}"
321
+ end
322
+ end
323
+
324
+ last_key = key_parts.last
325
+ if current.is_a?(Hash)
326
+ last_key = if string_keys
327
+ current.has_key?(last_key.to_sym) ? last_key.to_sym : last_key
328
+ else
329
+ current.has_key?(last_key) ? last_key : last_key.to_sym
330
+ end
331
+
332
+ current[last_key] = value
333
+ elsif current.is_a?(Array)
334
+ next_key = begin
335
+ Integer(last_key)
336
+ rescue ArgumentError
337
+ raise InvalidTypeError.new "Unable to consume key #{last_key}, expecting an integer for Array index"
338
+ end
339
+
340
+ current[next_key] = value
341
+ else
342
+ raise InvalidTypeError.new "Unable to consume key #{last_key}, expecting a Hash or Array, but got #{current.class}"
343
+ end
344
+ end
345
+
346
+ # = \delete!
347
+ # Removes a value from a data structure (Hash, Array, or a nested combination) using
348
+ # a dot-delimited key and returns the deleted value.
349
+ #
350
+ # data = {a: {b: [1, 2]}, "c" => [{d: 3}, {e: 4}]}
351
+ #
352
+ # DotKey.delete!(data, "a.b.0") #=> 1
353
+ # data #=> {a: {b: [2]}, "c" => [{d: 3}, {e: 4}]}
354
+ #
355
+ # DotKey.delete!(data, "c.0.d") #=> 3
356
+ # data #=> {a: {b: [2]}, "c" => [{}, {e: 4}]}
357
+ #
358
+ # If any values along the path are <tt>nil</tt>, nothing happens and <tt>nil</tt> is
359
+ # returned. However, if a key along the path refers to a structure that is neither a
360
+ # Hash nor an Array, an error is raised.
361
+ #
362
+ # # `b.c` is nil so the result is nil
363
+ # DotKey.delete!({b: {}}, "b.c.d") #=> nil
364
+ #
365
+ # # `c` is not a valid key for an Array, so an error is raised
366
+ # DotKey.delete!({b: []}, "b.c.d") #=> raises DotKey::InvalidTypeError
367
+ #
368
+ # # `0` is a valid key but is nil so the result is nil
369
+ # DotKey.delete!({b: []}, "b.0.d") #=> nil
370
+ #
371
+ # # Strings cannot be traversed so an error is raised
372
+ # DotKey.delete!({a: "a string"}, "a.b") #=> raises DotKey::InvalidTypeError
373
+ #
374
+ # This behaviour can be disabled by specifying the `raise_on_invalid` parameter:
375
+ #
376
+ # DotKey.delete!({b: []}, "b.c.d", raise_on_invalid: false) #=> nil
377
+ # DotKey.delete!({a: "a string"}, "a.b", raise_on_invalid: false) #=> nil
378
+ #
379
+ # @param data [Hash,Array] The root data structure
380
+ # @param key [String,Symbol] A dot-delimited string or symbol representing the path to the value to be deleted.
381
+ # @param raise_on_invalid [Boolean] A boolean indicating whether to raise an <tt>InvalidTypeError</tt> if a key path cannot be resolved due to an incompatible type. Defaults to <tt>true</tt>.
382
+ # @return The deleted value at the specified key path. Returns <tt>nil</tt> if any part of the path is missing or nil.
383
+ # @raise [InvalidTypeError] If the key cannot be resolved due to an invalid type along the path, and <tt>raise_on_invalid</tt> is <tt>true</tt>.
384
+ def delete!(data, key, raise_on_invalid: true)
385
+ key_parts = key.to_s.split(delimiter)
386
+ current = data
387
+
388
+ key_parts.each_with_index do |key_part, i|
389
+ break if i == key_parts.length - 1
390
+
391
+ if current.is_a?(Hash)
392
+ current = current[key_part] || current[key_part.to_sym]
393
+ elsif current.is_a?(Array)
394
+ current = begin
395
+ current[Integer(key_part)]
396
+ rescue ArgumentError
397
+ if raise_on_invalid
398
+ raise InvalidTypeError.new "Expected #{key_part} to be an array key"
399
+ end
400
+ end
401
+ elsif current.nil?
402
+ return nil
403
+ elsif raise_on_invalid
404
+ raise InvalidTypeError.new "Unable to consume key #{key_part} on #{current.class}"
405
+ else
406
+ return nil
407
+ end
408
+ end
409
+
410
+ last_key = key_parts.last
411
+ if current.is_a?(Hash)
412
+ current.delete(current.has_key?(last_key) ? last_key : last_key.to_sym)
413
+ elsif current.is_a?(Array)
414
+ begin
415
+ current.delete_at Integer(last_key)
416
+ rescue ArgumentError
417
+ if raise_on_invalid
418
+ raise InvalidTypeError.new "Expected #{last_key} to be an array key"
419
+ end
420
+ end
421
+ elsif current.nil?
422
+ nil
423
+ elsif raise_on_invalid
424
+ raise InvalidTypeError.new "Unable to consume key #{last_key} on #{current.class}"
425
+ end
426
+ end
427
+ end
428
+ end
data/lib/dotkey.rb ADDED
@@ -0,0 +1 @@
1
+ require "dotkey/dot_key"
metadata ADDED
@@ -0,0 +1,122 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: dotkey
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Simon J
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2025-05-10 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: minitest
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '5.0'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '5.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: minitest-reporters
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '1.1'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '1.1'
41
+ - !ruby/object:Gem::Dependency
42
+ name: standard
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '1.49'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '1.49'
55
+ - !ruby/object:Gem::Dependency
56
+ name: rubocop
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '1.75'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '1.75'
69
+ - !ruby/object:Gem::Dependency
70
+ name: benchmark-ips
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '2.14'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '2.14'
83
+ description: A series of utility methods allowing you to easily interact with Ruby
84
+ objects using dot notation
85
+ email: 2857218+mwnciau@users.noreply.github.com
86
+ executables: []
87
+ extensions: []
88
+ extra_rdoc_files: []
89
+ files:
90
+ - CHANGELOG.md
91
+ - LICENSE.md
92
+ - README.md
93
+ - lib/dotkey.rb
94
+ - lib/dotkey/dot_key.rb
95
+ homepage: https://rubygems.org/gems/dotkey
96
+ licenses:
97
+ - MIT
98
+ metadata:
99
+ source_code_uri: https://github.com/mwnciau/dotkey
100
+ changelog_uri: https://github.com/mwnciau/dotkey/blob/main/CHANGELOG.md
101
+ documentation_uri: https://github.com/mwnciau/dotkey
102
+ bug_tracker_uri: https://github.com/mwnciau/dotkey/issues
103
+ post_install_message:
104
+ rdoc_options: []
105
+ require_paths:
106
+ - lib
107
+ required_ruby_version: !ruby/object:Gem::Requirement
108
+ requirements:
109
+ - - ">="
110
+ - !ruby/object:Gem::Version
111
+ version: 2.0.0
112
+ required_rubygems_version: !ruby/object:Gem::Requirement
113
+ requirements:
114
+ - - ">="
115
+ - !ruby/object:Gem::Version
116
+ version: '0'
117
+ requirements: []
118
+ rubygems_version: 3.4.20
119
+ signing_key:
120
+ specification_version: 4
121
+ summary: Interact with object using dot notation
122
+ test_files: []