decentworks-hexdigest-support 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.
Files changed (54) hide show
  1. checksums.yaml +7 -0
  2. data/.claude/CLAUDE.md +24 -0
  3. data/.claude/hooks/rubocop-fix.sh +27 -0
  4. data/.claude/rules/general.md +5 -0
  5. data/.claude/rules/git.md +6 -0
  6. data/.claude/rules/rspec.md +24 -0
  7. data/.claude/rules/ruby.md +11 -0
  8. data/.claude/settings.json +67 -0
  9. data/.rspec +3 -0
  10. data/.rubocop.yml +300 -0
  11. data/CHANGELOG.md +3 -0
  12. data/CODE_OF_CONDUCT.md +132 -0
  13. data/LICENSE +21 -0
  14. data/LICENSE.txt +21 -0
  15. data/README.md +532 -0
  16. data/Rakefile +12 -0
  17. data/lib/decentworks/hexdigest_support/array.rb +24 -0
  18. data/lib/decentworks/hexdigest_support/big_decimal.rb +14 -0
  19. data/lib/decentworks/hexdigest_support/configuration.rb +40 -0
  20. data/lib/decentworks/hexdigest_support/data.rb +13 -0
  21. data/lib/decentworks/hexdigest_support/date.rb +24 -0
  22. data/lib/decentworks/hexdigest_support/hash.rb +28 -0
  23. data/lib/decentworks/hexdigest_support/input.rb +97 -0
  24. data/lib/decentworks/hexdigest_support/nil_class.rb +13 -0
  25. data/lib/decentworks/hexdigest_support/numeric.rb +31 -0
  26. data/lib/decentworks/hexdigest_support/numeric_like.rb +93 -0
  27. data/lib/decentworks/hexdigest_support/object.rb +91 -0
  28. data/lib/decentworks/hexdigest_support/range.rb +24 -0
  29. data/lib/decentworks/hexdigest_support/set.rb +14 -0
  30. data/lib/decentworks/hexdigest_support/struct.rb +16 -0
  31. data/lib/decentworks/hexdigest_support/time.rb +9 -0
  32. data/lib/decentworks/hexdigest_support/time_like.rb +42 -0
  33. data/lib/decentworks/hexdigest_support/time_with_zone.rb +19 -0
  34. data/lib/decentworks/hexdigest_support/version.rb +7 -0
  35. data/lib/decentworks/hexdigest_support.rb +20 -0
  36. data/lib/decentworks-hexdigest-support.rb +3 -0
  37. data/lib/generators/decentworks/hexdigest_support/install/install_generator.rb +38 -0
  38. data/lib/generators/decentworks/hexdigest_support/install/templates/decentworks_hexdigest_support.rb.tt +16 -0
  39. data/sig/decentworks/hexdigest_support/array.rbs +3 -0
  40. data/sig/decentworks/hexdigest_support/configuration.rbs +14 -0
  41. data/sig/decentworks/hexdigest_support/data.rbs +3 -0
  42. data/sig/decentworks/hexdigest_support/date.rbs +7 -0
  43. data/sig/decentworks/hexdigest_support/hash.rbs +3 -0
  44. data/sig/decentworks/hexdigest_support/input.rbs +11 -0
  45. data/sig/decentworks/hexdigest_support/nil_class.rbs +3 -0
  46. data/sig/decentworks/hexdigest_support/object.rbs +19 -0
  47. data/sig/decentworks/hexdigest_support/range.rbs +3 -0
  48. data/sig/decentworks/hexdigest_support/set.rbs +3 -0
  49. data/sig/decentworks/hexdigest_support/struct.rbs +3 -0
  50. data/sig/decentworks/hexdigest_support/time.rbs +3 -0
  51. data/sig/decentworks/hexdigest_support/time_like.rbs +8 -0
  52. data/sig/decentworks/hexdigest_support/time_with_zone.rbs +5 -0
  53. data/sig/decentworks/hexdigest_support/version.rbs +5 -0
  54. metadata +123 -0
data/README.md ADDED
@@ -0,0 +1,532 @@
1
+ # Decentworks::HexdigestSupport
2
+
3
+ > [!IMPORTANT]
4
+ > 本ライブラリは個人によって開発・保守されています。予告なく仕様変更または提供を終了する場合があります。ご利用にあたってはバージョンを固定のうえ、更新時は変更内容をご確認ください。
5
+
6
+ 任意のRubyオブジェクトから、決定的なハッシュ値(16進ダイジェスト)を求めるための拡張ライブラリです。
7
+
8
+ `Object` にダイジェスト生成用のメソッドを追加し、`Array` / `Hash` / `Range` / `Struct` / `Data` / `Set` には構造を考慮した入力生成を、`Integer` / `Float` / `Rational` / `BigDecimal` / `Time` / `Date` / `DateTime` / `ActiveSupport::TimeWithZone` には正規化した入力生成を実装しています。
9
+
10
+ - **型を保持する** — `:a` と `"a"`、`1` と `"1"` は異なるダイジェストになります
11
+ - **順序に依存しない** — 配列・ハッシュは要素をソートしてから連結するため、並び順が違っても同じダイジェストになります
12
+ - **実行環境に依存しない** — `Hash#inspect` や `String#inspect` などネイティブの文字列表現には依存せず、自前で入力を組み立てます(Rubyのバージョンやロケールが変わってもダイジェストは変わりません)
13
+ - **ソルトに対応** — 設定したソルトをダイジェストの入力へ前置します
14
+ - **Railsの日時に対応** — `ActiveSupport::TimeWithZone` も `Time` と同じダイジェストになります
15
+ - **Railsの数値に対応** — `decimal` カラムの `BigDecimal` も `Integer` / `Float` と同じダイジェストになります
16
+
17
+ 対応アルゴリズムはMD5 / RMD160 / SHA1 / SHA256 / SHA384 / SHA512です。
18
+
19
+ ## インストール
20
+
21
+ Gemfileに追加します。
22
+
23
+ ```ruby
24
+ gem "decentworks-hexdigest-support"
25
+ ```
26
+
27
+ ```console
28
+ $ bundle install
29
+ ```
30
+
31
+ Bundlerを使わない場合は次のコマンドでインストールします。
32
+
33
+ ```console
34
+ $ gem install decentworks-hexdigest-support
35
+ ```
36
+
37
+ 読み込みは `require` で行います。
38
+
39
+ ```ruby
40
+ require "decentworks/hexdigest_support"
41
+ ```
42
+
43
+ 必要なRubyのバージョンは `>= 4.0.0` です。`activesupport` (`>= 8.0`)と `bigdecimal` (`>= 3.1`)に依存します。
44
+
45
+ ## セットアップ
46
+
47
+ ソルトを設定すると、ダイジェストの入力へ前置されます(未設定時はソルトなし)。
48
+
49
+ ```ruby
50
+ ::Decentworks::HexdigestSupport.configure do |config|
51
+ config.salt = "..."
52
+ end
53
+ ```
54
+
55
+ ### Rails
56
+
57
+ 初期化ファイルはジェネレータで生成できます。
58
+
59
+ ```console
60
+ $ bin/rails generate decentworks:hexdigest_support:install
61
+ create config/initializers/decentworks_hexdigest_support.rb
62
+ ```
63
+
64
+ 生成される初期化ファイルは `credentials` からソルトを読み出します。
65
+
66
+ ```console
67
+ $ bin/rails credentials:edit
68
+ ```
69
+
70
+ ```yaml
71
+ decentworks:
72
+ hexdigest_support_salt: <ランダムな文字列(例: `bin/rails secret` の出力)>
73
+ ```
74
+
75
+ キー名は `--salt-key` で変更できます。
76
+
77
+ ```console
78
+ $ bin/rails generate decentworks:hexdigest_support:install --salt-key=custom_salt
79
+ ```
80
+
81
+ ## 使い方
82
+
83
+ ### ダイジェストを求める
84
+
85
+ ```ruby
86
+ "user@example.com".to_hexdigest
87
+ # => "8d3b74fd6c74d37b4abf8845a1b2e9c775510b529b0b224c87f5ee989bc2da0a"
88
+
89
+ "user@example.com".to_md5_hexdigest
90
+ # => "ccf86d256ba77a8e4a9c4b8dae6a3019"
91
+ ```
92
+
93
+ アルゴリズムごとのメソッドと、系統ごとのデフォルトのエイリアスが用意されています。
94
+
95
+ | メソッド | アルゴリズム | 長さ |
96
+ | --- | --- | --- |
97
+ | `#to_md5_hexdigest` | MD5 | 32 |
98
+ | `#to_rmd160_hexdigest` | RMD160 | 40 |
99
+ | `#to_sha1_hexdigest` | SHA1 | 40 |
100
+ | `#to_sha256_hexdigest` | SHA256 | 64 |
101
+ | `#to_sha384_hexdigest` | SHA384 | 96 |
102
+ | `#to_sha512_hexdigest` | SHA512 | 128 |
103
+ | `#to_md_hexdigest` | MD5のエイリアス | 32 |
104
+ | `#to_rmd_hexdigest` | RMD160のエイリアス | 40 |
105
+ | `#to_sha_hexdigest` | SHA256のエイリアス | 64 |
106
+ | `#to_hexdigest` | SHA256のエイリアス(既定) | 64 |
107
+
108
+ ### 型が保持される
109
+
110
+ ```ruby
111
+ 1.to_hexdigest_input # => "Numeric:\"1\""
112
+ "1".to_hexdigest_input # => "String:\"1\""
113
+
114
+ 1.to_hexdigest == "1".to_hexdigest # => false
115
+ :a.to_hexdigest == "a".to_hexdigest # => false
116
+ ```
117
+
118
+ ### 配列・ハッシュ・範囲
119
+
120
+ 要素はソートされてから連結されるため、順序が違っても同じダイジェストになります。
121
+
122
+ ```ruby
123
+ [1, "a", :b].to_hexdigest == [:b, 1, "a"].to_hexdigest # => true
124
+
125
+ { a: 1, b: 2 }.to_hexdigest == { b: 2, a: 1 }.to_hexdigest # => true
126
+
127
+ # キーの型も区別される
128
+ { a: 1 }.to_hexdigest == { "a" => 1 }.to_hexdigest # => false
129
+
130
+ # 範囲は始端・終端・終端を含むかで決まる
131
+ (1..3).to_hexdigest == (1...3).to_hexdigest # => false
132
+
133
+ # 端点のない範囲も扱える
134
+ (1..).to_hexdigest
135
+ (..3).to_hexdigest
136
+ ```
137
+
138
+ ネストした構造もそのまま扱えます。
139
+
140
+ ```ruby
141
+ { id: 1, tags: %w[a b], range: (1..3) }.to_hexdigest
142
+ ```
143
+
144
+ ### 構造体・集合
145
+
146
+ `Struct` / `Data` はメンバー名と値の組で決まります。`Set` は配列と同じく要素をソートしてから連結します。
147
+
148
+ ```ruby
149
+ Point = Struct.new(:x, :y)
150
+ Point.new(1, 2).to_hexdigest_source # => '{Symbol:"x"=>Numeric:"1",Symbol:"y"=>Numeric:"2"}'
151
+
152
+ Coord = Data.define(:x, :y)
153
+ Coord.new(x: 1, y: 2).to_hexdigest
154
+
155
+ # メンバー名が違えば値が同じでも異なるダイジェストになる
156
+ Struct.new(:a, :b).new(1, 2).to_hexdigest == Struct.new(:x, :y).new(1, 2).to_hexdigest # => false
157
+
158
+ Set[1, 2].to_hexdigest == Set[2, 1].to_hexdigest # => true
159
+
160
+ # 同じ要素の配列とは異なるダイジェストになる(型で区別される)
161
+ Set[1, 2].to_hexdigest == [1, 2].to_hexdigest # => false
162
+ ```
163
+
164
+ > [!NOTE]
165
+ > 定数へ代入していない無名の `Struct` / `Data` は型が `Struct` / `Data` へ丸まるため、メンバー名と値が同じであれば別々に生成したもの同士も同じダイジェストになります。型で区別したい場合は定数へ代入してください。
166
+
167
+ ### 数値
168
+
169
+ `Integer` / `Float` / `Rational` / `BigDecimal` は有理数として正規化され、**同じ型として扱われます**。同じ数であればクラスが違ってもダイジェストは一致します。
170
+
171
+ ```ruby
172
+ 1.to_hexdigest_type # => "Numeric"
173
+ BigDecimal("1.0").to_hexdigest_type # => "Numeric"
174
+
175
+ 1.to_hexdigest == 1.0.to_hexdigest # => true
176
+ 1.to_hexdigest == BigDecimal("1.00").to_hexdigest # => true
177
+ 1.to_hexdigest == Rational(2, 2).to_hexdigest # => true
178
+ ```
179
+
180
+ Railsでは同じ数が経路によって別のクラスで現れます(`decimal` カラムは `BigDecimal`、`integer` カラムやJSONの整数は `Integer`、JSONの小数は `Float`)。型をクラス名のままにすると入力経路の違いだけでダイジェストが割れてしまうため、時刻と同じく型を `"Numeric"` へ正規化しています。
181
+
182
+ 値は、有限小数で表せる場合は十進表記に、表せない場合は既約分数の表記になります。
183
+
184
+ ```ruby
185
+ 1.0.to_hexdigest_source # => "1"
186
+ 1.5.to_hexdigest_source # => "1.5"
187
+ BigDecimal("1.50").to_hexdigest_source # => "1.5"
188
+ 1e20.to_hexdigest_source # => "100000000000000000000"
189
+ (-0.0).to_hexdigest_source # => "0"
190
+
191
+ Rational(1, 3).to_hexdigest_source # => "1/3"
192
+ ```
193
+
194
+ `Float` は2進の厳密値ではなく、`#to_s` が返す十進表記として解釈されます。`0.1` の厳密値は `1/10` ではありませんが、見た目どおりの十進として読むため `BigDecimal("0.1")` と同じダイジェストになります。
195
+
196
+ ```ruby
197
+ 0.1.to_hexdigest == BigDecimal("0.1").to_hexdigest # => true
198
+ 0.1.to_hexdigest == Rational(1, 10).to_hexdigest # => true
199
+
200
+ # 計算誤差は丸められず、そのまま保たれる
201
+ (0.1 + 0.2).to_hexdigest == 0.3.to_hexdigest # => false
202
+ ```
203
+
204
+ `NaN` と `±Infinity` は有理数にできないため、`#to_s` の結果がそのまま値になります。
205
+
206
+ ```ruby
207
+ Float::NAN.to_hexdigest_source # => "NaN"
208
+ Float::INFINITY.to_hexdigest_source # => "Infinity"
209
+
210
+ # NaN同士は#==がfalseになるが、ダイジェストは一致する
211
+ Float::NAN.to_hexdigest == BigDecimal("NaN").to_hexdigest # => true
212
+ ```
213
+
214
+ > [!NOTE]
215
+ > `Complex` は正規化の対象外で、型は `"Complex"` のままです。`Complex(1, 0) == 1` は真ですが、ダイジェストは一致しません。
216
+
217
+ ### 日時
218
+
219
+ `Time` / `DateTime` / `ActiveSupport::TimeWithZone` はUTCへ変換し、ナノ秒までの精度で正規化されます。タイムゾーンの違いはダイジェストに影響しません。
220
+
221
+ ```ruby
222
+ Time.utc(2026, 8, 13, 4, 5, 6).to_hexdigest_source
223
+ # => "2026-08-13T04:05:06.000000000Z"
224
+
225
+ # 同じ瞬間を指す時刻は同じダイジェストになる
226
+ Time.new(2026, 8, 13, 13, 5, 6, "+09:00").to_hexdigest == Time.utc(2026, 8, 13, 4, 5, 6).to_hexdigest # => true
227
+ ```
228
+
229
+ さらに、この3つは**同じ型として扱われます**。同じ瞬間を指していればクラスが違ってもダイジェストは一致します。
230
+
231
+ ```ruby
232
+ Time.zone = "Asia/Tokyo"
233
+
234
+ Time.zone.local(2026, 8, 13, 13, 5, 6).to_hexdigest_type # => "Time"
235
+ DateTime.new(2026, 8, 13, 13, 5, 6, "+09:00").to_hexdigest_type # => "Time"
236
+
237
+ Time.zone.local(2026, 8, 13, 13, 5, 6).to_hexdigest == Time.utc(2026, 8, 13, 4, 5, 6).to_hexdigest # => true
238
+ ```
239
+
240
+ Railsでは同じ瞬間が経路によって別のクラスで現れます(`Time.zone.now` とActiveRecordの `datetime` カラムは `ActiveSupport::TimeWithZone`、`Time.now` や `File.mtime` は `Time`)。型をクラス名のままにすると、入力経路の違いだけでダイジェストが割れてしまうため、時刻に限っては型を `"Time"` へ正規化しています。
241
+
242
+ `Date` は「ある一瞬」ではなく1日を指すため、この正規化の対象外です。
243
+
244
+ ```ruby
245
+ Date.new(2026, 8, 13).to_hexdigest_source # => "2026-08-13"
246
+ Date.new(2026, 8, 13).to_hexdigest_type # => "Date"
247
+
248
+ # 同じ日付のDateTimeとは異なるダイジェストになる
249
+ Date.new(2026, 8, 13).to_hexdigest == DateTime.new(2026, 8, 13).to_hexdigest # => false
250
+ ```
251
+
252
+ > [!NOTE]
253
+ > ナノ秒より細かい精度は切り捨てられます。DBの `timestamp`(多くはマイクロ秒)と往復させても値が変わらない粒度に揃えるためです。
254
+
255
+ ### nil・真偽値
256
+
257
+ ```ruby
258
+ nil.to_hexdigest_input # => "NilClass:\"nil\""
259
+ true.to_hexdigest_input # => "TrueClass:\"true\""
260
+ false.to_hexdigest_input # => "FalseClass:\"false\""
261
+
262
+ # 空文字とは区別される
263
+ nil.to_hexdigest == "".to_hexdigest # => false
264
+
265
+ # ハッシュの値がnilの場合も、キーそのものがない場合と区別される
266
+ { a: nil }.to_hexdigest == {}.to_hexdigest # => false
267
+ ```
268
+
269
+ ### 独自クラスのダイジェスト
270
+
271
+ 既定では `#to_s` の結果が入力になります。値を明示したい場合は `#to_hexdigest_source` をオーバーライドします。型は `#to_hexdigest_input` が付与するため、オーバーライド側で型を意識する必要はありません。
272
+
273
+ ```ruby
274
+ class User
275
+ attr_reader :id, :email
276
+
277
+ def initialize(id, email)
278
+ @id = id
279
+ @email = email
280
+ end
281
+
282
+ def to_hexdigest_source = { id: id, email: email }.to_hexdigest_source
283
+ end
284
+
285
+ User.new(1, "user@example.com").to_hexdigest
286
+ # => "90a52738430094c7aee77ad317032d77dbc8ffa285bb0b5b9cf66d3d4b8727bf"
287
+
288
+ # 値が同じなら同じダイジェストになる
289
+ User.new(1, "user@example.com").to_hexdigest == User.new(1, "user@example.com").to_hexdigest
290
+ # => true
291
+ ```
292
+
293
+ `#to_s` も `#to_hexdigest_source` も実装していないオブジェクトは、既定の `Object#to_s` が返すオブジェクトIDが値になってしまいます。この場合は例外になります。
294
+
295
+ ```ruby
296
+ Object.new.to_hexdigest
297
+ # => Decentworks::HexdigestSupport::NonDeterministicSourceError
298
+
299
+ # Procや無名クラスのように、独自の#to_sがオブジェクトIDを含む型も同様
300
+ proc {}.to_hexdigest
301
+ # => Decentworks::HexdigestSupport::NonDeterministicSourceError
302
+ ```
303
+
304
+ 配列やハッシュの中に含まれている場合も検出されます。
305
+
306
+ ```ruby
307
+ { user: Object.new }.to_hexdigest
308
+ # => Decentworks::HexdigestSupport::NonDeterministicSourceError
309
+ ```
310
+
311
+ `String` と `Symbol` は検査の対象外です。値そのものが文字列であり、オブジェクトIDが混入する経路がないためです。オブジェクトIDの表記で始まる文字列(`#inspect` の結果や、それを含むログの1行など)もそのまま扱えます。
312
+
313
+ ```ruby
314
+ "#<User:0x00007f9e0c0d1234>".to_hexdigest # => 例外にならない
315
+ ```
316
+
317
+ > [!NOTE]
318
+ > 裏を返すと、利用側が自分でオブジェクトを文字列化して渡した場合(`"#{object}"` など)は検出できません。gemから見ればただの文字列であり、他の文字列と区別する手段がないためです。
319
+
320
+ ### 値オブジェクトのダイジェスト
321
+
322
+ 不変で、値そのものが同一性を決めるクラス(`Money` / `EmailAddress` / `Period` など)は、次の順で検討してください。
323
+
324
+ **1. `Data.define` で足りるなら、それで足りる**
325
+
326
+ 属性がそのまま値になるだけなら、`Data` のサポートがそのまま効くため独自の実装は不要です。
327
+
328
+ ```ruby
329
+ Money = ::Data.define(:amount, :currency)
330
+
331
+ Money.new(amount: 100, currency: "JPY").to_hexdigest_input
332
+ # => "Money:\"{Symbol:\\\"amount\\\"=>Numeric:\\\"100\\\",Symbol:\\\"currency\\\"=>String:\\\"JPY\\\"}\""
333
+ ```
334
+
335
+ 継承階層が既にある、`Data` の一部のメンバーはダイジェストへ含めたくない、といった場合に次へ進みます。
336
+
337
+ **2. 値が 1 つなら `#to_s` で足りる**
338
+
339
+ `#to_s` が値そのものを返すクラスは、既定の実装がそのまま使えます。型はクラス名から付与されるため、`#to_s` が同じでも別のクラス同士が衝突することはありません。
340
+
341
+ ```ruby
342
+ class Currency
343
+ def initialize(code) = @code = code
344
+
345
+ def to_s = @code
346
+ end
347
+
348
+ Currency.new("JPY").to_hexdigest_input # => "Currency:\"JPY\""
349
+
350
+ # 同じ文字列とは異なるダイジェストになる
351
+ Currency.new("JPY").to_hexdigest == "JPY".to_hexdigest # => false
352
+ ```
353
+
354
+ **3. 属性が複数あるなら、ハッシュへ委譲する**
355
+
356
+ ```ruby
357
+ class EmailAddress
358
+ attr_reader :local, :domain
359
+
360
+ def initialize(local, domain)
361
+ @local = local
362
+ @domain = domain
363
+ end
364
+
365
+ def to_hexdigest_source = { local:, domain: }.to_hexdigest_source
366
+ end
367
+
368
+ EmailAddress.new("user", "example.com").to_hexdigest_source
369
+ # => "{Symbol:\"domain\"=>String:\"example.com\",Symbol:\"local\"=>String:\"user\"}"
370
+ ```
371
+
372
+ `Hash#to_hexdigest_source` はキーでソートするため、ハッシュへ書く順序はダイジェストに影響しません。
373
+
374
+ > [!IMPORTANT]
375
+ > 委譲先は `#to_hexdigest_source` です。`#to_hexdigest_input` と書くと値に `Hash` という型が混ざり、ダイジェストは正常に求まるものの意図した値になりません。
376
+ >
377
+ > ```ruby
378
+ > # 誤り: to_hexdigest_input へ委譲した場合
379
+ > # => "EmailAddress:\"Hash:\\\"{...}\\\"\""
380
+ > ```
381
+
382
+ #### 属性を追加したときの挙動
383
+
384
+ 未設定の属性(`nil`)も値として含まれます。ハッシュの値が `nil` の場合とキーそのものがない場合を区別する扱いと同じです。
385
+
386
+ ```ruby
387
+ class Period
388
+ attr_reader :from, :to
389
+
390
+ def initialize(from, to = nil)
391
+ @from = from
392
+ @to = to
393
+ end
394
+
395
+ def to_hexdigest_source = { from:, to: }.to_hexdigest_source
396
+ end
397
+
398
+ Period.new(::Date.new(2026, 8, 13)).to_hexdigest_source
399
+ # => "{Symbol:\"from\"=>Date:\"2026-08-13\",Symbol:\"to\"=>NilClass:\"nil\"}"
400
+ ```
401
+
402
+ したがって、属性を追加して `#to_hexdigest_source` へ含めると、既存の値のダイジェストも変わります。永続化済みの値がある場合は移行方針が必要です([独自クラスのオーバーライド](#独自クラスのオーバーライド)を参照)。
403
+
404
+ `nil` の属性を除外すれば既存のダイジェストは保てますが、推奨しません。「値が `nil`」と「その属性を持たない」が同じダイジェストになるため、`Period` の例では終了日が未定であることと、`to` という属性が存在しなかった時点のデータが区別できなくなります。値オブジェクトでは `nil` 自体が意味を持つことが多く、失うものの方が大きくなります。
405
+
406
+ ### 循環参照
407
+
408
+ 自身を含む値も例外になります。そのまま辿ると再帰が終わらず、`StandardError` を継承しない `SystemStackError` になってしまうためです。`SystemStackError` は呼び出し側の `rescue` をすり抜けます。
409
+
410
+ ```ruby
411
+ values = [1]
412
+ values << values
413
+
414
+ values.to_hexdigest
415
+ # => Decentworks::HexdigestSupport::CircularReferenceError
416
+ ```
417
+
418
+ 配列・ハッシュ・`Struct` / `Data` / `Set` に加えて、独自クラス同士が参照しあう場合も検出されます。Railsで `belongs_to :parent` と `has_many :children` の両方をダイジェストへ含めた場合などが該当します。
419
+
420
+ ```ruby
421
+ class Node
422
+ attr_accessor :parent
423
+
424
+ def to_hexdigest_source = { parent: }.to_hexdigest_source
425
+ end
426
+
427
+ node = Node.new
428
+ node.parent = node
429
+
430
+ node.to_hexdigest
431
+ # => Decentworks::HexdigestSupport::CircularReferenceError
432
+ ```
433
+
434
+ 同じオブジェクトが兄弟として複数回現れるのは循環ではないため、例外にはなりません。判定に使うのは、その時点で辿っている経路だけです。
435
+
436
+ ```ruby
437
+ tags = %w[a b]
438
+
439
+ [tags, tags].to_hexdigest # => 例外にならない
440
+ ```
441
+
442
+ > [!NOTE]
443
+ > 自身を含まない深いネスト(1万段など)は `SystemStackError` のままです。循環と違って有限であり、深さの上限を決め打ちすると正当な構造まで弾いてしまうためです。
444
+
445
+ ### 入力の確認
446
+
447
+ デバッグ時は、ダイジェストの元になる文字列を確認できます。
448
+
449
+ ```ruby
450
+ "user@example.com".to_hexdigest_input
451
+ # => "String:\"user@example.com\""
452
+
453
+ # ソルトを前置した実際の入力
454
+ "user@example.com".to_salted_hexdigest_input
455
+ # => "pepperString:\"user@example.com\""
456
+ ```
457
+
458
+ ## 注意事項
459
+
460
+ ### ソルトの変更
461
+
462
+ > [!CAUTION]
463
+ > ソルトを変更すると、同じ値でも異なるダイジェストになります。永続化済みのダイジェストがある場合は、変更前に移行方針を検討してください。
464
+
465
+ ソルトはリポジトリに平文で置かず、Railsであれば `credentials` で管理してください。
466
+
467
+ ### 用途
468
+
469
+ ダイジェストは同一性の判定や値の秘匿を目的としたものです。パスワードの保存など、総当たり耐性が必要な用途には適していません(その用途にはbcryptなどのパスワードハッシュを使ってください)。
470
+
471
+ ### 値の引用とエスケープ
472
+
473
+ 値は引用符で囲まれ、引用符(`"`)とバックスラッシュ(`\`)だけがエスケープされます。`#inspect` は使いません。`#inspect` は非ASCII文字を `Encoding.default_external` が印字可能かどうかでエスケープするか決めるため、同じ値でもロケール次第でダイジェストが変わってしまうためです。
474
+
475
+ ```ruby
476
+ # UTF-8環境の#inspectと同じ出力になる
477
+ "あ".to_hexdigest_input # => "String:\"あ\""
478
+
479
+ # 制御文字はエスケープせず、そのまま入力に含まれる
480
+ "a\nb".to_hexdigest_input # => "String:\"a\nb\"" (#inspectなら "String:\"a\\nb\"")
481
+ ```
482
+
483
+ > [!CAUTION]
484
+ > 改行やタブなどの制御文字を含む値は、`#inspect` を使っていた頃とダイジェストが変わります。ASCIIのみで制御文字を含まない値、およびUTF-8環境で求めた非ASCIIの値のダイジェストは変わりません。
485
+
486
+ ### コアクラスの拡張
487
+
488
+ 本gemは `Object` / `NilClass` / `Array` / `Hash` / `Range` / `Struct` / `Data` / `Set` / `Integer` / `Float` / `Rational` / `BigDecimal` / `Time` / `Date` / `DateTime` / `ActiveSupport::TimeWithZone` にメソッドを追加するモンキーパッチです。`#to_hexdigest_source` などのメソッド名が他のライブラリと衝突しないか確認してください。
489
+
490
+ ### ActiveSupportのコア拡張の読み込み
491
+
492
+ `ActiveSupport::TimeWithZone` は ActiveSupport の autoload 経由でしか解決できないため、本gemは `require "active_support/time"` を無条件に実行します。これに伴い、`Time` / `Date` / `DateTime` / `Integer` / `Numeric` / `String` へのActiveSupportのコア拡張(`3.days` や `String#to_time` など)も読み込まれます。
493
+
494
+ Railsであればいずれも読み込まれているものなので影響はありませんが、Rails以外で使う場合はこの副作用を考慮してください。
495
+
496
+ なお、gem本体が依存するのは `activesupport` のみで、`railties` には依存しません(ジェネレータは `lib/generators` 配下に置かれ、Railsのジェネレータ探索から呼ばれた時にだけ読み込まれます)。
497
+
498
+ ### 数値の型の正規化
499
+
500
+ `Integer` / `Float` / `Rational` / `BigDecimal` の `#to_hexdigest_type` は `"Numeric"` を返します。「型で区別する」という本gemの原則に対する意図的な例外です。
501
+
502
+ 同じ数が経路によって別のクラスで現れるRailsでは、型を実装クラス名のままにするとダイジェストが割れます。詳細は[数値](#数値)を参照してください。
503
+
504
+ ### 時刻の型の正規化
505
+
506
+ `Time` / `DateTime` / `ActiveSupport::TimeWithZone` の `#to_hexdigest_type` は `"Time"` を返します。「型で区別する」という本gemの原則に対する意図的な例外です。
507
+
508
+ 同じ瞬間が経路によって別のクラスで現れるRailsでは、型を実装クラス名のままにするとダイジェストが割れます。詳細は[日時](#日時)を参照してください。
509
+
510
+ ### 独自クラスのオーバーライド
511
+
512
+ `#to_hexdigest_source` の実装を変更すると、そのクラスのダイジェストも変わります。永続化済みの値がある場合は、ソルト変更と同様に移行方針が必要です。属性の追加・削除も実装の変更にあたります([値オブジェクトのダイジェスト](#値オブジェクトのダイジェスト)を参照)。
513
+
514
+ クラス名を変更した場合も同様です。型はクラス名から付与されるため、リネームだけでダイジェストが変わります。
515
+
516
+ 無名クラスは名前を持つ祖先クラスまで遡って型として扱われます。
517
+
518
+ ## 開発
519
+
520
+ ```console
521
+ $ bin/setup # 依存関係のインストール
522
+ $ bundle exec rake # RSpec + RuboCop
523
+ $ bundle exec rspec # テストのみ
524
+ $ bundle exec rubocop # 静的解析のみ
525
+ $ bin/console # 対話コンソール
526
+ ```
527
+
528
+ テストのカバレッジはSimpleCovで計測され、`coverage/` に出力されます。
529
+
530
+ ## ライセンス
531
+
532
+ MIT License. 詳細は [LICENSE.txt](LICENSE.txt) を参照してください。
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ require "rubocop/rake_task"
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: %i[spec rubocop]
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "object"
4
+
5
+ class Array
6
+ # ハッシュ値を求めるためのオリジナルの値
7
+ #
8
+ # MEMO: 要素は#to_hexdigest_sourceではなく#to_hexdigest_inputで文字列化する。
9
+ # 値だけでは型が落ちるため、[:a] と ["a"]、[1] と ["1"] が同じ値になってしまう
10
+ #
11
+ # MEMO: #to_hexdigest_inputは値を#inspectで引用・エスケープ済みのため、ここでの
12
+ # 追加の引用は不要。引用がないと、要素の文字列表現に区切り文字(,)が含まれる
13
+ # 場合に内容が異なる配列同士が同じ文字列になってしまう
14
+ # (例: ["a,b","c"] と ["a","b,c"] が衝突する)
15
+ def to_hexdigest_source
16
+ return "[]" if empty?
17
+
18
+ map(&:to_hexdigest_input)
19
+ .sort
20
+ .join(",")
21
+ .prepend("[")
22
+ .concat("]")
23
+ end
24
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bigdecimal"
4
+
5
+ require_relative "numeric_like"
6
+
7
+ # MEMO: bigdecimalは条件付きではなく無条件に読み込む。Railsのdecimalカラムの値は常に
8
+ # BigDecimalであり、対応の有無が環境によって変わるとダイジェストが割れてしまうため
9
+ #
10
+ # MEMO: BigDecimal#to_rは内部表現どおりの厳密な有理数を返すため、Floatのような
11
+ # 十進への読み替えは不要
12
+ class BigDecimal
13
+ include ::Decentworks::HexdigestSupport::NumericLike
14
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Decentworks
4
+ module HexdigestSupport
5
+ # ハッシュ値化の設定
6
+ #
7
+ # MEMO: 初期化時(Railsならconfig/initializers配下)で以下のように設定する
8
+ #
9
+ # ::Decentworks::HexdigestSupport.configure do |config|
10
+ # config.salt = ::Rails.application.credentials.hexdigest_salt
11
+ # end
12
+ class Configuration
13
+ # ハッシュ値化の入力に前置するソルト
14
+ attr_accessor :salt
15
+
16
+ def initialize
17
+ @salt = ""
18
+ end
19
+ end
20
+
21
+ class << self
22
+ # 設定
23
+ def configuration = @configuration ||= ::Decentworks::HexdigestSupport::Configuration.new
24
+
25
+ # 設定の変更
26
+ #
27
+ # MEMO: ダイジェストの値はソルトに依存するため、永続化済みの値がある状態で
28
+ # ソルトを変更すると過去の値と一致しなくなる点に注意
29
+ def configure = yield(configuration)
30
+
31
+ # 設定のリセット(主にテスト用)
32
+ def reset_configuration! = @configuration = nil
33
+
34
+ # ハッシュ値化の入力に前置するソルト
35
+ #
36
+ # MEMO: 未設定(nil)はソルトなし(空文字)として扱う
37
+ def salt = configuration.salt.to_s
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "hash"
4
+
5
+ class Data
6
+ # ハッシュ値を求めるためのオリジナルの値
7
+ #
8
+ # MEMO: DataはStructのサブクラスではないため、Struct側の実装は継承されない。
9
+ # メンバー名を落とさない理由はStructと同じ
10
+ #
11
+ # MEMO: 無名のDataが"Data"へ丸まる点もStructと同じ
12
+ def to_hexdigest_source = to_h.to_hexdigest_source
13
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+
5
+ require_relative "time_like"
6
+
7
+ class Date
8
+ # ハッシュ値を求めるためのオリジナルの値
9
+ #
10
+ # MEMO: 日付は時刻もタイムゾーンも持たないため、Timeのような正規化は不要。
11
+ # ISO 8601の日付として組み立てる
12
+ #
13
+ # MEMO: 型は"Date"のまま(TimeLikeをincludeしない)。DateはTimeと違って
14
+ # ある一瞬ではなく1日を指すため、同一視すると意味が壊れる
15
+ def to_hexdigest_source = strftime("%Y-%m-%d")
16
+ end
17
+
18
+ class DateTime
19
+ # MEMO: DateTimeはDateのサブクラスだが、指すものはある一瞬なのでTimeLikeへ寄せる。
20
+ # includeしないとDate#to_hexdigest_sourceを継承して時刻が丸ごと落ちてしまう
21
+ #
22
+ # MEMO: includeはDateより手前に入るため、Date#to_hexdigest_sourceより優先される
23
+ include ::Decentworks::HexdigestSupport::TimeLike
24
+ end