flexor 0.1.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 +7 -0
- data/.rspec +3 -0
- data/.rubocop.yml +394 -0
- data/.ruby-version +1 -0
- data/LICENSE.txt +21 -0
- data/README.md +218 -0
- data/Rakefile +8 -0
- data/benchmark/compare.rb +173 -0
- data/benchmark/results-with-caching.txt +175 -0
- data/docs/benchmark-results.md +64 -0
- data/docs/original_specification.yaml +426 -0
- data/docs/specification.yaml +453 -0
- data/lib/flexor/hash_delegation.rb +30 -0
- data/lib/flexor/serialization.rb +32 -0
- data/lib/flexor/version.rb +3 -0
- data/lib/flexor/vivification.rb +47 -0
- data/lib/flexor.rb +187 -0
- data/rakelib/benchmark.rake +4 -0
- data/rakelib/rdoc.rake +8 -0
- data/rakelib/version.rake +72 -0
- metadata +64 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 823be7501c328a913f46121e7aa40b37fde22d73505f92928087f0d68b9355e2
|
|
4
|
+
data.tar.gz: 717311911cdb6e059859390ef9b238c1f524ed96d52071563cbc9f56568d119a
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 9abb50d29119d0cb6deb40e8a14dc1bed3cf860fc65eb599adb1fc8f740374fc664440dc55cc5b3868b1cbe93e44d262ea338ab544f0fc590d587d1d738ab047
|
|
7
|
+
data.tar.gz: 531b16d783d2f49bf68edd73a27b47a7565f17f6a465b12b64f9d9542e4d781b9b4dfdacf345b941bad847e36afdee523b8666f42d09cb6ef0dc9f4de393a9c4
|
data/.rspec
ADDED
data/.rubocop.yml
ADDED
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
# ===========================================================================
|
|
2
|
+
# RuboCop Configuration
|
|
3
|
+
#
|
|
4
|
+
# Base: Stock RuboCop defaults
|
|
5
|
+
# AI guardrails: rubocop-claude plugin (all Claude/ cops + stricter metrics)
|
|
6
|
+
# Performance: rubocop-performance (with chain-hostile cops disabled)
|
|
7
|
+
#
|
|
8
|
+
# Philosophy: idiomatic Ruby, pipeline-style chaining, strict for AI agents,
|
|
9
|
+
# readable for humans.
|
|
10
|
+
# ===========================================================================
|
|
11
|
+
|
|
12
|
+
plugins:
|
|
13
|
+
- rubocop-claude
|
|
14
|
+
- rubocop-performance
|
|
15
|
+
- rubocop-rspec
|
|
16
|
+
- rubocop-md
|
|
17
|
+
- rubocop-rake
|
|
18
|
+
|
|
19
|
+
AllCops:
|
|
20
|
+
TargetRubyVersion: 3.4
|
|
21
|
+
|
|
22
|
+
# ===========================================================================
|
|
23
|
+
# Overrides from stock — personal style preferences
|
|
24
|
+
# ===========================================================================
|
|
25
|
+
|
|
26
|
+
# Double quotes everywhere. One less decision to make.
|
|
27
|
+
#
|
|
28
|
+
# # bad
|
|
29
|
+
# name = 'Alice'
|
|
30
|
+
#
|
|
31
|
+
# # good
|
|
32
|
+
# name = "Alice"
|
|
33
|
+
# greeting = "Hello, #{name}"
|
|
34
|
+
Style/StringLiterals:
|
|
35
|
+
EnforcedStyle: double_quotes
|
|
36
|
+
|
|
37
|
+
Style/StringLiteralsInInterpolation:
|
|
38
|
+
EnforcedStyle: double_quotes
|
|
39
|
+
|
|
40
|
+
# Frozen string literal is transitional cruft. Ruby 3.4 has chilled strings,
|
|
41
|
+
# full default freeze is coming in a future Ruby.
|
|
42
|
+
# EnforcedStyle: never actively removes existing magic comments via autocorrect.
|
|
43
|
+
#
|
|
44
|
+
# # bad — autocorrect will strip this
|
|
45
|
+
# # frozen_string_literal: true
|
|
46
|
+
#
|
|
47
|
+
# class Foo
|
|
48
|
+
# end
|
|
49
|
+
#
|
|
50
|
+
# # good
|
|
51
|
+
# class Foo
|
|
52
|
+
# end
|
|
53
|
+
Style/FrozenStringLiteralComment:
|
|
54
|
+
EnforcedStyle: never
|
|
55
|
+
|
|
56
|
+
# Pipeline style. Chaining multi-line blocks is the whole point.
|
|
57
|
+
#
|
|
58
|
+
# # good — this is how we write Ruby
|
|
59
|
+
# users
|
|
60
|
+
# .select { it.active? }
|
|
61
|
+
# .map(&:name)
|
|
62
|
+
# .sort
|
|
63
|
+
#
|
|
64
|
+
# # bad — stock rubocop wants you to break this into temp variables
|
|
65
|
+
# active = users.select { it.active? }
|
|
66
|
+
# names = active.map(&:name)
|
|
67
|
+
# names.sort
|
|
68
|
+
Style/MultilineBlockChain:
|
|
69
|
+
Enabled: false
|
|
70
|
+
|
|
71
|
+
# Block delimiters are a taste call. Pipeline code uses braces for chaining,
|
|
72
|
+
# do/end for side effects. No cop captures this nuance.
|
|
73
|
+
#
|
|
74
|
+
# # good — braces for functional transforms
|
|
75
|
+
# users.map { it.name.downcase }
|
|
76
|
+
#
|
|
77
|
+
# # good — do/end for side effects
|
|
78
|
+
# users.each do |user|
|
|
79
|
+
# send_notification(user)
|
|
80
|
+
# log_activity(user)
|
|
81
|
+
# end
|
|
82
|
+
Style/BlockDelimiters:
|
|
83
|
+
Enabled: false
|
|
84
|
+
|
|
85
|
+
# Write arrays like arrays.
|
|
86
|
+
#
|
|
87
|
+
# # bad
|
|
88
|
+
# colors = %w[red green blue]
|
|
89
|
+
# statuses = %i[active inactive pending]
|
|
90
|
+
#
|
|
91
|
+
# # good
|
|
92
|
+
# colors = ["red", "green", "blue"]
|
|
93
|
+
# statuses = [:active, :inactive, :pending]
|
|
94
|
+
Style/WordArray:
|
|
95
|
+
Enabled: false
|
|
96
|
+
|
|
97
|
+
Style/SymbolArray:
|
|
98
|
+
Enabled: false
|
|
99
|
+
|
|
100
|
+
# Argument indentation: consistent 2-space indent, not aligned to first arg.
|
|
101
|
+
#
|
|
102
|
+
# # bad — renaming the method cascades whitespace changes
|
|
103
|
+
# some_method(arg1,
|
|
104
|
+
# arg2,
|
|
105
|
+
# arg3)
|
|
106
|
+
#
|
|
107
|
+
# # good
|
|
108
|
+
# some_method(
|
|
109
|
+
# arg1,
|
|
110
|
+
# arg2,
|
|
111
|
+
# arg3
|
|
112
|
+
# )
|
|
113
|
+
Layout/FirstArgumentIndentation:
|
|
114
|
+
EnforcedStyle: consistent
|
|
115
|
+
|
|
116
|
+
# Dot-aligned chaining. Dots form a visual column.
|
|
117
|
+
# rubocop-claude sets indented — we override back to aligned.
|
|
118
|
+
#
|
|
119
|
+
# # bad (indented)
|
|
120
|
+
# users.where(active: true)
|
|
121
|
+
# .order(:name)
|
|
122
|
+
# .limit(10)
|
|
123
|
+
#
|
|
124
|
+
# # good (aligned)
|
|
125
|
+
# users.where(active: true)
|
|
126
|
+
# .order(:name)
|
|
127
|
+
# .limit(10)
|
|
128
|
+
Layout/MultilineMethodCallIndentation:
|
|
129
|
+
EnforcedStyle: aligned
|
|
130
|
+
|
|
131
|
+
# ===========================================================================
|
|
132
|
+
# Overrides from rubocop-claude — loosen where pipeline style conflicts
|
|
133
|
+
# ===========================================================================
|
|
134
|
+
|
|
135
|
+
# rubocop-claude sets MaxSafeNavigationChain: 1. That's too tight for
|
|
136
|
+
# chaining code at boundaries (controllers, API responses).
|
|
137
|
+
#
|
|
138
|
+
# # bad — 3+ deep, hiding a nil propagation problem
|
|
139
|
+
# user&.profile&.settings&.theme
|
|
140
|
+
#
|
|
141
|
+
# # good — two-step is normal for optional associations
|
|
142
|
+
# user&.profile&.avatar_url
|
|
143
|
+
#
|
|
144
|
+
# # good — trust internal code, no &. needed
|
|
145
|
+
# user.profile.settings.theme
|
|
146
|
+
Claude/NoOverlyDefensiveCode:
|
|
147
|
+
MaxSafeNavigationChain: 2
|
|
148
|
+
|
|
149
|
+
Style/SafeNavigation:
|
|
150
|
+
MaxChainLength: 2
|
|
151
|
+
|
|
152
|
+
# Allow `return a, b` for tuple-style returns.
|
|
153
|
+
# rubocop-claude sets this; declared here to survive load-order surprises.
|
|
154
|
+
#
|
|
155
|
+
# # bad (stock rubocop flags this)
|
|
156
|
+
# def swap(a, b)
|
|
157
|
+
# return b, a
|
|
158
|
+
# end
|
|
159
|
+
#
|
|
160
|
+
# # good (we allow it)
|
|
161
|
+
# def swap(a, b)
|
|
162
|
+
# return b, a
|
|
163
|
+
# end
|
|
164
|
+
#
|
|
165
|
+
# # still flagged — redundant single return
|
|
166
|
+
# def name
|
|
167
|
+
# return @name
|
|
168
|
+
# end
|
|
169
|
+
Style/RedundantReturn:
|
|
170
|
+
AllowMultipleReturnValues: true
|
|
171
|
+
|
|
172
|
+
# ===========================================================================
|
|
173
|
+
# Overrides from rubocop-performance — disable chain-hostile cops
|
|
174
|
+
# ===========================================================================
|
|
175
|
+
Performance:
|
|
176
|
+
Exclude:
|
|
177
|
+
- "spec/**/*"
|
|
178
|
+
|
|
179
|
+
# ChainArrayAllocation flags idiomatic pipelines for microsecond gains.
|
|
180
|
+
# If we need real throughput we parallelize, not uglify.
|
|
181
|
+
#
|
|
182
|
+
# # "bad" according to this cop — but we write this deliberately
|
|
183
|
+
# users.select(&:active?).map(&:name).sort
|
|
184
|
+
#
|
|
185
|
+
# # "good" according to this cop — mutating, unreadable, not our style
|
|
186
|
+
# users.select!(&:active?)
|
|
187
|
+
# users.map!(&:name)
|
|
188
|
+
# users.sort!
|
|
189
|
+
Performance/ChainArrayAllocation:
|
|
190
|
+
Enabled: false
|
|
191
|
+
|
|
192
|
+
# MapMethodChain wants to collapse chained maps into one block.
|
|
193
|
+
# That's the opposite of pipeline decomposition.
|
|
194
|
+
#
|
|
195
|
+
# # "bad" according to this cop — but each step is atomic and named
|
|
196
|
+
# users
|
|
197
|
+
# .map(&:name)
|
|
198
|
+
# .map(&:downcase)
|
|
199
|
+
#
|
|
200
|
+
# # "good" according to this cop — combines concerns into one block
|
|
201
|
+
# users.map { it.name.downcase }
|
|
202
|
+
Performance/MapMethodChain:
|
|
203
|
+
Enabled: false
|
|
204
|
+
|
|
205
|
+
# ===========================================================================
|
|
206
|
+
# Additional tightening — not set by stock or rubocop-claude
|
|
207
|
+
# ===========================================================================
|
|
208
|
+
|
|
209
|
+
# Short blocks push toward small chained steps instead of fat lambdas.
|
|
210
|
+
# Stock default is 25. rubocop-claude doesn't touch it. We want 8.
|
|
211
|
+
#
|
|
212
|
+
# # bad — too much in one block, decompose into a pipeline
|
|
213
|
+
# users.map { |user|
|
|
214
|
+
# name = user.full_name
|
|
215
|
+
# parts = name.split(" ")
|
|
216
|
+
# first = parts.first
|
|
217
|
+
# last = parts.last
|
|
218
|
+
# domain = user.email.split("@").last
|
|
219
|
+
# "#{first}.#{last}@#{domain}".downcase
|
|
220
|
+
# }
|
|
221
|
+
#
|
|
222
|
+
# # good — each step is clear
|
|
223
|
+
# users
|
|
224
|
+
# .map(&:full_name)
|
|
225
|
+
# .map { it.split(" ") }
|
|
226
|
+
# .map { "#{it.first}.#{it.last}" }
|
|
227
|
+
# .map(&:downcase)
|
|
228
|
+
Metrics/BlockLength:
|
|
229
|
+
Max: 8
|
|
230
|
+
CountAsOne:
|
|
231
|
+
- array
|
|
232
|
+
- hash
|
|
233
|
+
- heredoc
|
|
234
|
+
- method_call
|
|
235
|
+
AllowedMethods:
|
|
236
|
+
- describe
|
|
237
|
+
- context
|
|
238
|
+
- shared_examples
|
|
239
|
+
- shared_examples_for
|
|
240
|
+
- shared_context
|
|
241
|
+
|
|
242
|
+
# Anonymous forwarding (*, **, &) breaks TruffleRuby, JRuby, and
|
|
243
|
+
# Ruby < 3.2. Named args are explicit and portable.
|
|
244
|
+
#
|
|
245
|
+
# # bad — anonymous forwarding, not portable
|
|
246
|
+
# def process(*, **, &)
|
|
247
|
+
# other_method(*, **, &)
|
|
248
|
+
# end
|
|
249
|
+
#
|
|
250
|
+
# # good — named, works everywhere, readable
|
|
251
|
+
# def process(*args, **kwargs, &block)
|
|
252
|
+
# other_method(*args, **kwargs, &block)
|
|
253
|
+
# end
|
|
254
|
+
Style/ArgumentsForwarding:
|
|
255
|
+
Enabled: false
|
|
256
|
+
|
|
257
|
+
# Explicit begin/rescue/end is clearer than implicit method-body rescue.
|
|
258
|
+
# The begin block scopes what's being rescued. Without it, the rescue
|
|
259
|
+
# looks like it belongs to the method signature.
|
|
260
|
+
#
|
|
261
|
+
# # bad — what exactly is being rescued here?
|
|
262
|
+
# def process
|
|
263
|
+
# logger.info("starting")
|
|
264
|
+
# result = dangerous_call
|
|
265
|
+
# logger.info("done")
|
|
266
|
+
# rescue NetworkError => e
|
|
267
|
+
# retry_later(e)
|
|
268
|
+
# end
|
|
269
|
+
#
|
|
270
|
+
# # good — begin scopes the danger
|
|
271
|
+
# def process
|
|
272
|
+
# logger.info("starting")
|
|
273
|
+
# begin
|
|
274
|
+
# result = dangerous_call
|
|
275
|
+
# rescue NetworkError => e
|
|
276
|
+
# retry_later(e)
|
|
277
|
+
# end
|
|
278
|
+
# logger.info("done")
|
|
279
|
+
# end
|
|
280
|
+
Style/RedundantBegin:
|
|
281
|
+
Enabled: false
|
|
282
|
+
|
|
283
|
+
# Classes get rdoc. Run `rake rdoc` and keep it honest.
|
|
284
|
+
#
|
|
285
|
+
# # bad
|
|
286
|
+
# class UserService
|
|
287
|
+
# def call(user) = process(user)
|
|
288
|
+
# end
|
|
289
|
+
#
|
|
290
|
+
# # good
|
|
291
|
+
# # Handles user lifecycle operations including activation,
|
|
292
|
+
# # deactivation, and profile updates.
|
|
293
|
+
# class UserService
|
|
294
|
+
# def call(user) = process(user)
|
|
295
|
+
# end
|
|
296
|
+
Style/Documentation:
|
|
297
|
+
Enabled: true
|
|
298
|
+
Exclude:
|
|
299
|
+
- "spec/**/*"
|
|
300
|
+
- "test/**/*"
|
|
301
|
+
|
|
302
|
+
# Trailing commas in multiline literals and arguments.
|
|
303
|
+
# Cleaner diffs — adding an element doesn't touch the previous line.
|
|
304
|
+
# Also saves keystrokes when appending.
|
|
305
|
+
#
|
|
306
|
+
# # bad
|
|
307
|
+
# method_call(
|
|
308
|
+
# arg1,
|
|
309
|
+
# arg2
|
|
310
|
+
# )
|
|
311
|
+
#
|
|
312
|
+
# # good
|
|
313
|
+
# method_call(
|
|
314
|
+
# arg1,
|
|
315
|
+
# arg2,
|
|
316
|
+
# )
|
|
317
|
+
#
|
|
318
|
+
# # bad
|
|
319
|
+
# hash = {
|
|
320
|
+
# name: "Alice",
|
|
321
|
+
# age: 30
|
|
322
|
+
# }
|
|
323
|
+
#
|
|
324
|
+
# # good
|
|
325
|
+
# hash = {
|
|
326
|
+
# name: "Alice",
|
|
327
|
+
# age: 30,
|
|
328
|
+
# }
|
|
329
|
+
Style/TrailingCommaInArrayLiteral:
|
|
330
|
+
EnforcedStyleForMultiline: comma
|
|
331
|
+
|
|
332
|
+
Style/TrailingCommaInHashLiteral:
|
|
333
|
+
EnforcedStyleForMultiline: comma
|
|
334
|
+
|
|
335
|
+
Style/TrailingCommaInArguments:
|
|
336
|
+
EnforcedStyleForMultiline: comma
|
|
337
|
+
|
|
338
|
+
# ===========================================================================
|
|
339
|
+
# RSpec — rubocop-rspec overrides
|
|
340
|
+
# ===========================================================================
|
|
341
|
+
|
|
342
|
+
# Not every describe block wraps a class. Feature specs, request specs,
|
|
343
|
+
# and integration tests describe behavior, not objects.
|
|
344
|
+
#
|
|
345
|
+
# # "bad" according to this cop — but perfectly valid
|
|
346
|
+
# RSpec.describe "User registration flow" do
|
|
347
|
+
# it "sends a welcome email" do
|
|
348
|
+
# ...
|
|
349
|
+
# end
|
|
350
|
+
# end
|
|
351
|
+
#
|
|
352
|
+
# # this cop only wants
|
|
353
|
+
# RSpec.describe User do
|
|
354
|
+
# ...
|
|
355
|
+
# end
|
|
356
|
+
RSpec/DescribeClass:
|
|
357
|
+
Enabled: false
|
|
358
|
+
|
|
359
|
+
# Subject placement is a readability call, not a rule. Sometimes
|
|
360
|
+
# let blocks need to come first to make subject comprehensible.
|
|
361
|
+
#
|
|
362
|
+
# # "bad" according to this cop
|
|
363
|
+
# let(:user) { create(:user) }
|
|
364
|
+
# subject { described_class.new(user) }
|
|
365
|
+
#
|
|
366
|
+
# # cop wants subject first — but then you're reading
|
|
367
|
+
# # `described_class.new(user)` before knowing what `user` is
|
|
368
|
+
RSpec/LeadingSubject:
|
|
369
|
+
Enabled: false
|
|
370
|
+
|
|
371
|
+
# Block style for expect { }.to change { } reads like a sentence
|
|
372
|
+
# and makes the mutation scope explicit.
|
|
373
|
+
#
|
|
374
|
+
# # bad (method style)
|
|
375
|
+
# expect { user.activate! }.to change(user, :active?).from(false).to(true)
|
|
376
|
+
#
|
|
377
|
+
# # good (block style)
|
|
378
|
+
# expect { user.activate! }.to change { user.active? }.from(false).to(true)
|
|
379
|
+
RSpec/ExpectChange:
|
|
380
|
+
EnforcedStyle: block
|
|
381
|
+
|
|
382
|
+
RSpec/NamedSubject:
|
|
383
|
+
Enabled: false
|
|
384
|
+
|
|
385
|
+
# PROJECT SPECIFIC:
|
|
386
|
+
#
|
|
387
|
+
# Behavior is very intricate here and warrants separation
|
|
388
|
+
RSpec/SpecFilePathFormat:
|
|
389
|
+
Enabled: false
|
|
390
|
+
|
|
391
|
+
RSpec/NestedGroups:
|
|
392
|
+
Enabled: false
|
|
393
|
+
RSpec/MultipleExpectations:
|
|
394
|
+
Max: 3
|
data/.ruby-version
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.4.8
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 David Gillis
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
# Flexor
|
|
2
|
+
|
|
3
|
+
A Hash-like data store that does what you tell it to do.
|
|
4
|
+
|
|
5
|
+
Flexor gives you autovivifying nested access, nil-safe chaining, and seamless conversion between hashes and method-style access. Built for spikes, prototyping, and anywhere you need a flexible data container without upfront schema design.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
Install the gem and add to the application's Gemfile by executing:
|
|
10
|
+
|
|
11
|
+
$ bundle add flexor
|
|
12
|
+
|
|
13
|
+
If bundler is not being used to manage dependencies, install the gem by executing:
|
|
14
|
+
|
|
15
|
+
$ gem install flexor
|
|
16
|
+
|
|
17
|
+
## Usage
|
|
18
|
+
|
|
19
|
+
### Construction
|
|
20
|
+
|
|
21
|
+
From a hash:
|
|
22
|
+
|
|
23
|
+
```ruby
|
|
24
|
+
store = Flexor.new({ user: { name: "Alice", address: { city: "NYC" } } })
|
|
25
|
+
store.user.name # => "Alice"
|
|
26
|
+
store.user.address.city # => "NYC"
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
From JSON:
|
|
30
|
+
|
|
31
|
+
```ruby
|
|
32
|
+
store = Flexor.from_json('{"api": {"version": 2}}')
|
|
33
|
+
store.api.version # => 2
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
### Accessing Properties
|
|
37
|
+
|
|
38
|
+
Method access and bracket access are interchangeable:
|
|
39
|
+
|
|
40
|
+
```ruby
|
|
41
|
+
store = Flexor.new({ name: "Alice" })
|
|
42
|
+
store.name # => "Alice"
|
|
43
|
+
store[:name] # => "Alice"
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Nested chaining works to any depth:
|
|
47
|
+
|
|
48
|
+
```ruby
|
|
49
|
+
store = Flexor.new
|
|
50
|
+
store.config.database.host = "localhost"
|
|
51
|
+
store.config.database.port = 5432
|
|
52
|
+
store.config.database.host # => "localhost"
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
### Safe Chaining
|
|
56
|
+
|
|
57
|
+
Accessing an unset property returns a nil-like Flexor instead of raising. You can chain as deep as you want without guard clauses:
|
|
58
|
+
|
|
59
|
+
```ruby
|
|
60
|
+
store = Flexor.new
|
|
61
|
+
store.anything.deeply.nested.nil? # => true
|
|
62
|
+
store.missing.nil? # => true
|
|
63
|
+
store.missing.to_s # => ""
|
|
64
|
+
"Hello #{store.ghost}" # => "Hello "
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### Assignment Vivifies
|
|
68
|
+
|
|
69
|
+
When you assign a hash or array of hashes, Flexor auto-converts them so chaining continues to work:
|
|
70
|
+
|
|
71
|
+
```ruby
|
|
72
|
+
store = Flexor.new
|
|
73
|
+
store.config = { db: { host: "localhost" } }
|
|
74
|
+
store.config.db.host # => "localhost"
|
|
75
|
+
store[:config].class # => Flexor
|
|
76
|
+
|
|
77
|
+
store.items = [{ id: 1 }, { id: 2 }]
|
|
78
|
+
store.items.first.id # => 1
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### Deep Merge
|
|
82
|
+
|
|
83
|
+
`merge` returns a new Flexor; `merge!` mutates in place. Both deep merge nested structures:
|
|
84
|
+
|
|
85
|
+
```ruby
|
|
86
|
+
defaults = Flexor.new({ db: { host: "localhost", port: 5432 }, log: "info" })
|
|
87
|
+
overrides = { db: { port: 3306, name: "mydb" }, log: "debug" }
|
|
88
|
+
|
|
89
|
+
config = defaults.merge(overrides)
|
|
90
|
+
config.db.host # => "localhost" (preserved from defaults)
|
|
91
|
+
config.db.port # => 3306 (overridden)
|
|
92
|
+
config.db.name # => "mydb" (added)
|
|
93
|
+
config.log # => "debug" (overridden)
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### Serialization
|
|
97
|
+
|
|
98
|
+
`to_json` converts to JSON via `to_h`:
|
|
99
|
+
|
|
100
|
+
```ruby
|
|
101
|
+
store = Flexor.new({ user: { name: "Alice" }, tags: ["admin"] })
|
|
102
|
+
store.to_json # => '{"user":{"name":"Alice"},"tags":["admin"]}'
|
|
103
|
+
|
|
104
|
+
# Round-trips with from_json
|
|
105
|
+
Flexor.from_json(store.to_json).user.name # => "Alice"
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
### Deleting Keys
|
|
109
|
+
|
|
110
|
+
```ruby
|
|
111
|
+
store = Flexor.new({ a: 1, b: 2, c: 3 })
|
|
112
|
+
store.delete(:b) # => 2
|
|
113
|
+
store.to_h # => { a: 1, c: 3 }
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
### Raw Storage
|
|
117
|
+
|
|
118
|
+
If you need to store a raw Hash without conversion, use `set_raw`:
|
|
119
|
+
|
|
120
|
+
```ruby
|
|
121
|
+
store = Flexor.new
|
|
122
|
+
store.set_raw(:headers, { "Content-Type" => "application/json" })
|
|
123
|
+
store[:headers].class # => Hash
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
### Converting Back
|
|
127
|
+
|
|
128
|
+
`to_h` recursively converts back to plain hashes. Round-trips are lossless:
|
|
129
|
+
|
|
130
|
+
```ruby
|
|
131
|
+
original = { users: [{ name: "Bob" }], meta: { version: 1 } }
|
|
132
|
+
Flexor.new(original).to_h == original # => true
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Autovivified-but-never-written paths don't appear in `to_h`:
|
|
136
|
+
|
|
137
|
+
```ruby
|
|
138
|
+
store = Flexor.new({ real: "data" })
|
|
139
|
+
store.phantom.deep.chain # read-only access
|
|
140
|
+
store.to_h # => { real: "data" }
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
### Pattern Matching
|
|
144
|
+
|
|
145
|
+
Hash patterns via `deconstruct_keys`:
|
|
146
|
+
|
|
147
|
+
```ruby
|
|
148
|
+
config = Flexor.new({ db: { host: "pg", port: 5432 }, cache: "redis" })
|
|
149
|
+
|
|
150
|
+
case config
|
|
151
|
+
in { db: { host: String => host }, cache: "redis" }
|
|
152
|
+
puts "db host=#{host}, cache=redis"
|
|
153
|
+
end
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
Array patterns via `deconstruct`:
|
|
157
|
+
|
|
158
|
+
```ruby
|
|
159
|
+
point = Flexor.new({ x: 3, y: 4 })
|
|
160
|
+
|
|
161
|
+
case point
|
|
162
|
+
in [Integer => x, Integer => y]
|
|
163
|
+
puts "Point(#{x}, #{y})"
|
|
164
|
+
end
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
### Hash-like Methods
|
|
168
|
+
|
|
169
|
+
```ruby
|
|
170
|
+
store = Flexor.new({ a: 1, b: 2, c: 3 })
|
|
171
|
+
store.keys # => [:a, :b, :c]
|
|
172
|
+
store.values # => [1, 2, 3]
|
|
173
|
+
store.size # => 3
|
|
174
|
+
store.empty? # => false
|
|
175
|
+
store.key?(:a) # => true
|
|
176
|
+
|
|
177
|
+
store.each { |k, v| puts "#{k}: #{v}" }
|
|
178
|
+
store.map { |k, v| [k, v * 10] }
|
|
179
|
+
store.select { |_k, v| v > 1 }
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
### Equality
|
|
183
|
+
|
|
184
|
+
```ruby
|
|
185
|
+
a = Flexor.new({ x: 1 })
|
|
186
|
+
b = Flexor.new({ x: 1 })
|
|
187
|
+
a == b # => true
|
|
188
|
+
a == { x: 1 } # => true
|
|
189
|
+
Flexor.new.nil? # => true
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
### Freezing
|
|
193
|
+
|
|
194
|
+
```ruby
|
|
195
|
+
store = Flexor.new({ locked: true })
|
|
196
|
+
store.freeze
|
|
197
|
+
store.locked # => true
|
|
198
|
+
store.new_key = 1 # => FrozenError
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
### Copying
|
|
202
|
+
|
|
203
|
+
`dup` and `clone` create shallow copies:
|
|
204
|
+
|
|
205
|
+
```ruby
|
|
206
|
+
original = Flexor.new({ a: 1 })
|
|
207
|
+
copy = original.dup
|
|
208
|
+
copy.b = 2
|
|
209
|
+
original.key?(:b) # => false
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
## Development
|
|
213
|
+
|
|
214
|
+
After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. Run `rake` to run both tests and rubocop.
|
|
215
|
+
|
|
216
|
+
## License
|
|
217
|
+
|
|
218
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|