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
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "object"
4
+
5
+ class Hash
6
+ # ハッシュ値を求めるためのオリジナルの値
7
+ #
8
+ # MEMO: キーも値と同じく#to_hexdigest_inputで正規化する。キーをそのまま文字列へ
9
+ # 埋めると、#to_sを実装していないオブジェクトがキーの場合にオブジェクトIDが
10
+ # 混入して非決定的になる。#to_hexdigest_input経由なら検出して例外になる
11
+ #
12
+ # MEMO: キー・値ともに#to_hexdigest_sourceではなく#to_hexdigest_inputを使う。
13
+ # 値だけでは型が落ちるため、{ a: 1 } と { "a" => 1 }、
14
+ # { a: 1, "a" => 2 } と { "a" => 1, a: 2 } が同じ値になってしまう
15
+ #
16
+ # MEMO: Hash#to_sには頼らず自前で文字列を組み立てる。ネイティブの#inspectの出力形式は
17
+ # Rubyのバージョンによって変わりうる(例: Ruby 3.4以降で"=>"の前後にスペースが
18
+ # 入るようになった)ため、バージョンが変わるとダイジェストの値も変わってしまう
19
+ def to_hexdigest_source
20
+ return "{}" if empty?
21
+
22
+ map { |key, value| "#{key.to_hexdigest_input}=>#{value.to_hexdigest_input}" }
23
+ .sort
24
+ .join(",")
25
+ .prepend("{")
26
+ .concat("}")
27
+ end
28
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Decentworks
4
+ module HexdigestSupport
5
+ # 値がオブジェクトIDを含んでおり、ダイジェストが決定的にならない
6
+ class NonDeterministicSourceError < ::StandardError; end
7
+
8
+ # 値が自身を含んでおり、入力の組み立てが終わらない
9
+ class CircularReferenceError < ::StandardError; end
10
+
11
+ # ダイジェストの入力の組み立て
12
+ class << self
13
+ # 既定の#to_sが返す文字列(例: "#<Object:0x00007f9e0c0d1234>")
14
+ #
15
+ # MEMO: メソッドの定義元(#to_sのowner)ではなく結果の文字列で判定する。
16
+ # Proc#to_sや無名クラスのModule#to_sのように、独自の#to_sを持ちながら
17
+ # オブジェクトIDを含む型も拾いたいため
18
+ DEFAULT_TO_S_PATTERN = /\A#<.*:0x\h+/
19
+
20
+ # 組み立て中のオブジェクトを記録するキー
21
+ #
22
+ # MEMO: Thread.current[]はスレッドではなくFiber単位で値を持つ。組み立ての
23
+ # 途中でFiberをまたぐことはないため、これで取り違えは起きない
24
+ VISITING_KEY = :decentworks_hexdigest_support_visiting
25
+
26
+ # 値を引用・エスケープする
27
+ #
28
+ # MEMO: #inspectを使わない。#inspectは非ASCII文字をEncoding.default_externalが
29
+ # 印字可能かどうかでエスケープするか決めるため、同じ値でも実行環境の
30
+ # ロケール次第で異なる入力になってしまう
31
+ # (UTF-8環境では"あ"、US-ASCII環境では"あ")
32
+ #
33
+ # MEMO: エスケープ対象は引用符とバックスラッシュのみ。UTF-8環境の#inspectと
34
+ # 同じ出力になるため、制御文字を含まない値のダイジェストは変わらない
35
+ #
36
+ # MEMO: 引用しないと、値に区切り文字(:)が含まれる場合に型名との境界が
37
+ # 曖昧になる(例: Foo::Barの"x" と Fooの":Bar:x" が衝突する)
38
+ def quote(value) = %("#{value.to_s.gsub(/[\\"]/) { |char| "\\#{char}" }}")
39
+
40
+ # 値が決定的かを検査する
41
+ #
42
+ # MEMO: 既定のObject#to_sはオブジェクトIDを含むため、#to_sも
43
+ # #to_hexdigest_sourceも実装していないオブジェクトのダイジェストは
44
+ # プロセスごとに変わる。永続化した後で気付くと復旧できないため、
45
+ # 黙って通さず例外にする
46
+ #
47
+ # MEMO: StringとSymbolは検査しない。検査は「既定のObject#to_sへ落ちていないか」を
48
+ # 見るためのものだが、この2つは値そのものが文字列であり、オブジェクトIDが
49
+ # 混入する経路がない。除外しないと "#<User:0x00007f9e0c0d1234>" のような
50
+ # 正当な文字列(ログの1行や#inspectの結果を保持した値)が例外になってしまう
51
+ #
52
+ # MEMO: 裏を返すと、利用側が自分でオブジェクトを文字列化して渡した場合は検出
53
+ # できない。gemから見ればただの文字列であり、他の文字列と区別できないため
54
+ def validate_source!(source, object)
55
+ return if object.is_a?(::String) || object.is_a?(::Symbol)
56
+ return unless DEFAULT_TO_S_PATTERN.match?(source.to_s)
57
+
58
+ raise ::Decentworks::HexdigestSupport::NonDeterministicSourceError,
59
+ "#{object.class}の値がオブジェクトIDを含むため、ダイジェストが決定的になりません" \
60
+ "(#{source})。#to_hexdigest_sourceか#to_sを実装してください"
61
+ end
62
+
63
+ # 組み立て中のオブジェクトを記録しながらブロックを実行する
64
+ #
65
+ # MEMO: 自身を含む値をそのまま辿ると再帰が終わらず、StandardErrorを継承しない
66
+ # SystemStackErrorになる。呼び出し側のrescueをすり抜けてプロセスを
67
+ # 落としてしまうため、自前で検出して例外にする
68
+ #
69
+ # MEMO: 記録するのは現在辿っている経路だけで、組み立てが終わった時点で取り除く。
70
+ # 同じオブジェクトが兄弟として複数回現れるのは循環ではないため、経路に
71
+ # 残っている場合だけを循環とみなす
72
+ #
73
+ # MEMO: 自身を含まない深いネスト(1万段など)はSystemStackErrorのまま。循環と
74
+ # 違って有限であり、深さの上限を決め打ちすると正当な構造まで弾いてしまう
75
+ def detect_circular_reference(object)
76
+ visiting = (::Thread.current[VISITING_KEY] ||= [])
77
+ raise_circular_reference(object) if visiting.include?(object.object_id)
78
+
79
+ visiting.push(object.object_id)
80
+
81
+ begin
82
+ yield
83
+ ensure
84
+ visiting.pop
85
+ end
86
+ end
87
+
88
+ private
89
+
90
+ def raise_circular_reference(object)
91
+ raise ::Decentworks::HexdigestSupport::CircularReferenceError,
92
+ "#{object.class}の値が自身を含んでいるため、ダイジェストを求められません。" \
93
+ "循環参照を取り除いてください"
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ class NilClass
4
+ # ハッシュ値を求めるためのオリジナルの値
5
+ #
6
+ # MEMO: NilClass#to_sは空文字を返すため、既定のままでは入力がNilClass:""となり、
7
+ # 空文字を持つ他の型(String:"")とは型でしか区別できない。#inspectと同じ
8
+ # "nil"を明示することで、入力を目視したときに値の不在だと分かるようにする
9
+ #
10
+ # MEMO: true / falseはTrueClass#to_s / FalseClass#to_sが"true" / "false"を返すため、
11
+ # 既定のObject#to_hexdigest_sourceのままで意図した入力になる
12
+ def to_hexdigest_source = "nil"
13
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "numeric_like"
4
+
5
+ # MEMO: Numericへ直接includeせず、具体的なクラスごとにincludeする。Numericのサブクラスには
6
+ # 有理数へ変換できないものがあり(Complexは実部と虚部の組なので、虚部があると
7
+ # #to_rがRangeErrorになる)、一律に寄せると壊れるため
8
+ #
9
+ # MEMO: Complexは対象外とする。Complex(1, 0) == 1 が真なので厳密には型で割れるが、
10
+ # アプリケーションで虚数を扱う場面はまれであり、対応のコストに見合わない
11
+ class Integer
12
+ include ::Decentworks::HexdigestSupport::NumericLike
13
+ end
14
+
15
+ class Rational
16
+ include ::Decentworks::HexdigestSupport::NumericLike
17
+ end
18
+
19
+ class Float
20
+ include ::Decentworks::HexdigestSupport::NumericLike
21
+
22
+ # 正規化に用いる有理数
23
+ #
24
+ # MEMO: #to_rではなく#to_sを経由して十進として読む。#to_rは2進の厳密値を返すため、
25
+ # 0.1が1/10ではなく3602879701896397/36028797018963968になり、
26
+ # BigDecimal("0.1")やRational(1, 10)と別のダイジェストになってしまう
27
+ #
28
+ # MEMO: Float#to_sは元の値へ復元できる最短の十進表記を返す。よって#==が真になる
29
+ # Float同士は必ず同じ有理数になり、異なるFloatが同じ有理数になることもない
30
+ def to_hexdigest_rational = ::Kernel.Rational(to_s)
31
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Decentworks
4
+ module HexdigestSupport
5
+ # 数を表す型に共通の入力生成
6
+ #
7
+ # MEMO: Integer / Float / Rational / BigDecimalへincludeする。Rubyのクラス階層上は
8
+ # それぞれ別の型だが、いずれも「数」を表す点は同じなのでダイジェストの入力は
9
+ # 共通化する
10
+ module NumericLike
11
+ # ハッシュ値化に用いる型の識別子
12
+ #
13
+ # MEMO: 実装クラス名ではなく"Numeric"へ正規化する。Railsでは同じ数が経路によって
14
+ # 別のクラスで現れる(decimalカラムはBigDecimal、integerカラムやJSONの整数は
15
+ # Integer、JSONの小数はFloat)ため、クラス名を型にすると入力経路の違いだけで
16
+ # ダイジェストが割れてしまう
17
+ #
18
+ # MEMO: 「型で区別する」という本gemの原則に対する意図的な例外。TimeLikeで
19
+ # Time / DateTime / TimeWithZoneを"Time"へ寄せているのと同じ割り切り
20
+ def to_hexdigest_type = "Numeric"
21
+
22
+ # ハッシュ値を求めるためのオリジナルの値
23
+ #
24
+ # MEMO: 有理数へ寄せてから文字列化する。#to_sの表記はクラスごとに異なり
25
+ # (1.5は"1.5"、BigDecimal("1.5")は"0.15e1")、同じ数でも別の値になってしまう
26
+ #
27
+ # MEMO: NaNと±Infinityは有理数にできないため#to_sをそのまま使う。FloatとBigDecimalの
28
+ # どちらも"NaN" / "Infinity" / "-Infinity"を返すので、型を寄せても表記は揃う
29
+ #
30
+ # MEMO: NaN同士は#==がfalseになるがダイジェストは一致する。値として区別する術が
31
+ # ない以上、インスタンスごとに異なる入力を作るよりは同一として扱う
32
+ def to_hexdigest_source
33
+ return to_s unless finite?
34
+
35
+ ::Decentworks::HexdigestSupport.format_rational(to_hexdigest_rational)
36
+ end
37
+
38
+ # 正規化に用いる有理数
39
+ #
40
+ # MEMO: 十進として解釈すべき型(Float)はこのメソッドを上書きする
41
+ def to_hexdigest_rational = to_r
42
+ end
43
+
44
+ # 数の正規化
45
+ class << self
46
+ # 有理数を一意な文字列へ整形する
47
+ #
48
+ # MEMO: 有限小数で表せる場合は十進表記、表せない場合は分数表記にする。Rationalは
49
+ # 既約かつ分母が正へ正規化済みのため、どちらの表記も数に対して一意になる
50
+ #
51
+ # MEMO: 分数表記が十進表記と衝突することはない。十進表記に区切り文字(/)は
52
+ # 現れないため
53
+ def format_rational(rational)
54
+ return rational.numerator.to_s if rational.denominator == 1
55
+
56
+ scale = decimal_scale(rational.denominator)
57
+ return "#{rational.numerator}/#{rational.denominator}" unless scale
58
+
59
+ sign = rational.negative? ? "-" : ""
60
+ digits = (rational.numerator.abs * (10**scale / rational.denominator)).to_s.rjust(scale + 1, "0")
61
+
62
+ "#{sign}#{digits[0...-scale]}.#{digits[-scale..]}"
63
+ end
64
+
65
+ private
66
+
67
+ # 有限小数で表すのに必要な小数点以下の桁数(表せない場合はnil)
68
+ #
69
+ # MEMO: 分母が2と5のべき乗の積のときだけ有限小数になる。既約分数なので、
70
+ # 2と5で割り切った残りが1かどうかだけで判定できる
71
+ #
72
+ # MEMO: 桁数は2と5の指数の大きい方。これが必要最小の桁数であり、末尾に0が
73
+ # 並ばないため表記が一意になる
74
+ def decimal_scale(denominator)
75
+ rest = denominator
76
+ twos = 0
77
+ fives = 0
78
+
79
+ while (rest % 2).zero?
80
+ rest /= 2
81
+ twos += 1
82
+ end
83
+
84
+ while (rest % 5).zero?
85
+ rest /= 5
86
+ fives += 1
87
+ end
88
+
89
+ (rest == 1) ? [twos, fives].max : nil
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ require_relative "configuration"
6
+ require_relative "input"
7
+
8
+ class Object
9
+ # MD5でハッシュ値化(16進数)
10
+ def to_md5_hexdigest = ::Digest::MD5.hexdigest(to_salted_hexdigest_input)
11
+
12
+ # RMD160でハッシュ値化(16進数)
13
+ def to_rmd160_hexdigest = ::Digest::RMD160.hexdigest(to_salted_hexdigest_input)
14
+
15
+ # SHA1でハッシュ値化(16進数)
16
+ def to_sha1_hexdigest = ::Digest::SHA1.hexdigest(to_salted_hexdigest_input)
17
+
18
+ # SHA256でハッシュ値化(16進数)
19
+ def to_sha256_hexdigest = ::Digest::SHA256.hexdigest(to_salted_hexdigest_input)
20
+
21
+ # SHA384でハッシュ値化(16進数)
22
+ def to_sha384_hexdigest = ::Digest::SHA384.hexdigest(to_salted_hexdigest_input)
23
+
24
+ # SHA512でハッシュ値化(16進数)
25
+ def to_sha512_hexdigest = ::Digest::SHA512.hexdigest(to_salted_hexdigest_input)
26
+
27
+ # MD系のデフォルトアルゴリズム
28
+ alias_method :to_md_hexdigest, :to_md5_hexdigest
29
+
30
+ # RMD系のデフォルトアルゴリズム
31
+ alias_method :to_rmd_hexdigest, :to_rmd160_hexdigest
32
+
33
+ # SHA系のデフォルトアルゴリズム
34
+ alias_method :to_sha_hexdigest, :to_sha256_hexdigest
35
+
36
+ # ハッシュ値化のデフォルトアルゴリズム
37
+ alias_method :to_hexdigest, :to_sha_hexdigest
38
+
39
+ # ソルトを前置したハッシュ値化の入力
40
+ #
41
+ # MEMO: ソルトはダイジェストを求める直前に一度だけ前置する。#to_hexdigest_inputに
42
+ # 含めてしまうと、配列やハッシュの要素ごとに再帰的にソルトが混入し、
43
+ # 構造によってソルトの出現回数が変わってしまう
44
+ def to_salted_hexdigest_input = "#{::Decentworks::HexdigestSupport.salt}#{to_hexdigest_input}"
45
+
46
+ # ハッシュ値化の入力(型 + 値)
47
+ #
48
+ # MEMO: #to_hexdigest_sourceは値を文字列へ射影するだけなので単射にならない。
49
+ # 型を添えないと :a と "a"、1 と "1" が同じ入力になり、内容の異なる
50
+ # オブジェクト同士が同じダイジェストになってしまう
51
+ #
52
+ # MEMO: 引用・エスケープと決定性の検査はHexdigestSupport側へ切り出している。
53
+ # #inspectに任せると実行環境のロケールで出力が変わるため
54
+ # (詳細はinput.rbのMEMOを参照)
55
+ #
56
+ # MEMO: 検査を#to_hexdigest_sourceではなく本メソッドで行うのは、ここが
57
+ # すべての値が通る唯一の経路であるため。オーバーライドされた
58
+ # #to_hexdigest_sourceが返した値も同じように検査される
59
+ #
60
+ # MEMO: 循環参照の検出も同じ理由でここに置く。要素を辿るのは各型の
61
+ # #to_hexdigest_sourceだが、その入り口は必ず本メソッドを通るため、
62
+ # 独自クラス同士が参照しあう循環も検出できる
63
+ def to_hexdigest_input
64
+ source = ::Decentworks::HexdigestSupport.detect_circular_reference(self) { to_hexdigest_source }
65
+ ::Decentworks::HexdigestSupport.validate_source!(source, self)
66
+
67
+ "#{to_hexdigest_type}:#{::Decentworks::HexdigestSupport.quote(source)}"
68
+ end
69
+
70
+ # ハッシュ値化に用いる型の識別子
71
+ #
72
+ # MEMO: 無名クラスは#nameがnilのため、名前を持つ祖先クラスまで遡る。
73
+ # #ancestorsではなく#superclassを辿るのは、無名クラスがincludeしている
74
+ # モジュール名を型として拾ってしまうのを避けるため
75
+ def to_hexdigest_type
76
+ klass = self.class
77
+ klass = klass.superclass until klass.name
78
+ klass.name
79
+ end
80
+
81
+ # ハッシュ値を求めるためのオリジナルの値
82
+ #
83
+ # MEMO: #to_s以外で値を指定する場合は、本メソッドをオーバーライドすること。
84
+ # 型の識別は#to_hexdigest_inputが担うため、オーバーライド側で
85
+ # 型を意識する必要はない
86
+ #
87
+ # MEMO: #to_sも本メソッドも実装していないオブジェクトは、既定のObject#to_sが
88
+ # 返すオブジェクトIDが値になってしまう。#to_hexdigest_inputで検査して
89
+ # 例外にしているため、そのままダイジェストになることはない
90
+ def to_hexdigest_source = to_s
91
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "hash"
4
+
5
+ class Range
6
+ # ハッシュ値を求めるためのオリジナルの値
7
+ #
8
+ # MEMO: 端点は文字列化せずそのままHashに渡す。先に#to_hexdigest_sourceで
9
+ # 文字列にしてしまうと型が落ちて、(1..3) と ("1".."3") が同じ値になってしまう
10
+ #
11
+ # MEMO: 端点の取得に#first/#lastではなく#begin/#endを使う。#firstは始端のない範囲で、
12
+ # #lastは終端のない範囲でRangeErrorになるため、(1..) や (..3) を扱えない。
13
+ # #begin/#endは端点がなければnilを返すので、端点なしをNilClassとして表現できる
14
+ #
15
+ # MEMO: 有界な範囲では#first/#lastと#begin/#endの戻り値は一致し、キー名もfirst/lastの
16
+ # ままとしているため、この実装で既存のダイジェストは変わらない
17
+ def to_hexdigest_source
18
+ {
19
+ first: self.begin,
20
+ last: self.end,
21
+ exclude_end: exclude_end?
22
+ }.to_hexdigest_source
23
+ end
24
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "array"
4
+
5
+ class Set
6
+ # ハッシュ値を求めるためのオリジナルの値
7
+ #
8
+ # MEMO: 要素の文字列化はArrayへ委譲する。Array#to_hexdigest_sourceが要素を
9
+ # ソートするため、挿入順が違っても同じ値になる
10
+ #
11
+ # MEMO: 型は#to_hexdigest_inputが付与するため、同じ要素を持つArrayとは
12
+ # 別のダイジェストになる
13
+ def to_hexdigest_source = to_a.to_hexdigest_source
14
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "hash"
4
+
5
+ class Struct
6
+ # ハッシュ値を求めるためのオリジナルの値
7
+ #
8
+ # MEMO: 値の配列(#to_a)ではなく#to_hに委譲する。メンバー名が落ちると、
9
+ # メンバー構成の異なるStruct同士が同じ値になってしまう
10
+ # (例: Struct.new(:a, :b)の(1, 2) と Struct.new(:x, :y)の(1, 2) が衝突する)
11
+ #
12
+ # MEMO: 定数へ代入していない無名のStructは#to_hexdigest_typeが"Struct"へ丸まるため、
13
+ # メンバー名と値が同じなら別々に生成したStruct同士でも同じダイジェストになる。
14
+ # 型で区別したい場合は定数へ代入して名前を与えること
15
+ def to_hexdigest_source = to_h.to_hexdigest_source
16
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "time_like"
4
+
5
+ class Time
6
+ # MEMO: 直接メソッドを定義せずincludeするのは、DateTime / TimeWithZoneと
7
+ # 実装を1箇所に集めるため
8
+ include ::Decentworks::HexdigestSupport::TimeLike
9
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Decentworks
4
+ module HexdigestSupport
5
+ # 時刻(ある一瞬)を表す型に共通の入力生成
6
+ #
7
+ # MEMO: Time / DateTime / ActiveSupport::TimeWithZoneへincludeする。Rubyの
8
+ # クラス階層上は無関係な3つだが、いずれも「ある一瞬」を表す点は同じなので
9
+ # ダイジェストの入力は共通化する
10
+ module TimeLike
11
+ # ハッシュ値化に用いる型の識別子
12
+ #
13
+ # MEMO: 実装クラス名ではなく"Time"へ正規化する。Railsでは同じ瞬間が経路によって
14
+ # 別のクラスで現れる(Time.zone.nowやActiveRecordのdatetimeカラムは
15
+ # ActiveSupport::TimeWithZone、Time.nowやFile.mtimeはTime)ため、
16
+ # クラス名を型にすると入力経路の違いだけでダイジェストが割れてしまう
17
+ #
18
+ # MEMO: 「型で区別する」という本gemの原則に対する意図的な例外。Rangeの端点を
19
+ # first / lastというキー名に固定しているのと同じく、実装ではなく意味論に
20
+ # 合わせた割り切り
21
+ def to_hexdigest_type = "Time"
22
+
23
+ # ハッシュ値を求めるためのオリジナルの値
24
+ #
25
+ # MEMO: UTCへ変換してから文字列化する。#to_sはタイムゾーンのオフセットを含むため、
26
+ # 同じ瞬間を指す時刻でも実行環境やTime.zoneの設定次第で異なる値になってしまう
27
+ #
28
+ # MEMO: 精度はナノ秒で固定する。秒へ丸めると同一秒内の異なる時刻が衝突し、
29
+ # 小数部を可変長にすると1.0秒と1秒が異なる値になってしまう
30
+ #
31
+ # MEMO: time(stdlib)の#iso8601ではなく#strftimeで組み立てる。ネイティブの
32
+ # 文字列表現に依存させないという方針はHash#to_hexdigest_sourceと同じ
33
+ #
34
+ # MEMO: Timeはナノ秒より細かい精度を持ちうるが、その桁は切り捨てる。
35
+ # DBのtimestamp(多くはマイクロ秒)と往復させても値が変わらない粒度に揃えるため
36
+ #
37
+ # MEMO: 先に#to_timeを挟むのは、DateTimeとTimeWithZoneをTimeへ寄せるため。
38
+ # Time#to_timeは自身を返すので、Timeにとっては実質的に無害
39
+ def to_hexdigest_source = to_time.getutc.strftime("%Y-%m-%dT%H:%M:%S.%9NZ")
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ # MEMO: active_support/timeを読み込むのは、ActiveSupport::TimeWithZoneがActiveSupportの
4
+ # autoload経由でしか解決できないため。内部のファイルパスに依存しない公開の入口として
5
+ # この行を使う
6
+ require "active_support/time"
7
+
8
+ require_relative "time_like"
9
+
10
+ # MEMO: TimeWithZoneはTimeのサブクラスではない(#is_a?(Time)がtrueを返すのは
11
+ # TimeWithZone側の偽装)ため、Timeへの拡張は届かない。明示的にincludeする
12
+ #
13
+ # MEMO: これを入れないとObject#to_hexdigest_source(=#to_s)へフォールバックし、
14
+ # オフセットを含む文字列が入力になる。Rails上ではTime.zone.nowやActiveRecordの
15
+ # datetimeカラムがこのクラスなので、実質ほぼ全ての時刻が該当する
16
+ #
17
+ # MEMO: class ... endで開き直さず.includeを呼ぶのは、万一TimeWithZoneが未解決だった場合に
18
+ # autoloadを潰して空のクラスを新規定義してしまうのを避けるため
19
+ ::ActiveSupport::TimeWithZone.include(::Decentworks::HexdigestSupport::TimeLike)
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Decentworks
4
+ module HexdigestSupport
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "hexdigest_support/version"
4
+ require_relative "hexdigest_support/configuration"
5
+ require_relative "hexdigest_support/input"
6
+ require_relative "hexdigest_support/object"
7
+ require_relative "hexdigest_support/nil_class"
8
+ require_relative "hexdigest_support/numeric_like"
9
+ require_relative "hexdigest_support/numeric"
10
+ require_relative "hexdigest_support/big_decimal"
11
+ require_relative "hexdigest_support/array"
12
+ require_relative "hexdigest_support/hash"
13
+ require_relative "hexdigest_support/range"
14
+ require_relative "hexdigest_support/struct"
15
+ require_relative "hexdigest_support/data"
16
+ require_relative "hexdigest_support/set"
17
+ require_relative "hexdigest_support/time_like"
18
+ require_relative "hexdigest_support/time"
19
+ require_relative "hexdigest_support/date"
20
+ require_relative "hexdigest_support/time_with_zone"
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "decentworks/hexdigest_support"
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators/base"
4
+
5
+ module Decentworks
6
+ module HexdigestSupport
7
+ module Generators
8
+ # 初期化ファイルを生成するジェネレータ
9
+ #
10
+ # bin/rails generate decentworks:hexdigest_support:install
11
+ #
12
+ # MEMO: 本ファイルはlib/decentworks/hexdigest_support.rbからはrequireしない。
13
+ # Railsのジェネレータ探索(lib/generators配下)から呼ばれた時にだけ
14
+ # 読み込まれるため、railtiesを導入していない環境でも利用できる
15
+ #
16
+ # MEMO: gem本体はactivesupportに依存する(ActiveSupport::TimeWithZone対応のため)が、
17
+ # railtiesには依存しない。この遅延読み込みが担保しているのは後者
18
+ class InstallGenerator < ::Rails::Generators::Base
19
+ source_root ::File.expand_path("templates", __dir__)
20
+
21
+ desc "config/initializers/decentworks_hexdigest_support.rb を生成する"
22
+
23
+ class_option :salt_key,
24
+ type: :string,
25
+ default: "hexdigest_support_salt",
26
+ desc: "credentialsから読み出すキー名(decentworks配下)"
27
+
28
+ def create_initializer_file
29
+ template "decentworks_hexdigest_support.rb", "config/initializers/decentworks_hexdigest_support.rb"
30
+ end
31
+
32
+ private
33
+
34
+ def salt_key = options[:salt_key]
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ # ハッシュ値化の設定
4
+ #
5
+ # MEMO: ソルトはダイジェストの入力へ前置される。ソルトを変更すると同じ値でも
6
+ # 異なるダイジェストになるため、永続化済みのダイジェストがある場合は
7
+ # 変更前に移行方針を検討すること
8
+ #
9
+ # MEMO: ソルトはリポジトリに平文で置かず、credentialsで管理する。
10
+ # EDITOR="vi" bundle exec rails credentials:edit --environment [development | test | production]
11
+ #
12
+ # decentworks:
13
+ # <%= salt_key %>: <ランダムな文字列(例: `bin/rails secret` の出力)>
14
+ ::Decentworks::HexdigestSupport.configure do |config|
15
+ config.salt = ::Rails.application.credentials.dig(:decentworks, :<%= salt_key %>)
16
+ end
@@ -0,0 +1,3 @@
1
+ class ::Array[unchecked out Elem]
2
+ def to_hexdigest_source: -> ::String
3
+ end
@@ -0,0 +1,14 @@
1
+ module Decentworks
2
+ module HexdigestSupport
3
+ class Configuration
4
+ attr_accessor salt: ::String?
5
+
6
+ def initialize: -> void
7
+ end
8
+
9
+ def self.configuration: -> ::Decentworks::HexdigestSupport::Configuration
10
+ def self.configure: { (::Decentworks::HexdigestSupport::Configuration) -> void } -> void
11
+ def self.reset_configuration!: -> nil
12
+ def self.salt: -> ::String
13
+ end
14
+ end
@@ -0,0 +1,3 @@
1
+ class ::Data
2
+ def to_hexdigest_source: -> ::String
3
+ end
@@ -0,0 +1,7 @@
1
+ class ::Date
2
+ def to_hexdigest_source: -> ::String
3
+ end
4
+
5
+ class ::DateTime
6
+ include ::Decentworks::HexdigestSupport::TimeLike
7
+ end
@@ -0,0 +1,3 @@
1
+ class ::Hash[unchecked out K, unchecked out V]
2
+ def to_hexdigest_source: -> ::String
3
+ end
@@ -0,0 +1,11 @@
1
+ module Decentworks
2
+ module HexdigestSupport
3
+ class NonDeterministicSourceError < ::StandardError
4
+ end
5
+
6
+ DEFAULT_TO_S_PATTERN: ::Regexp
7
+
8
+ def self.quote: (untyped value) -> ::String
9
+ def self.validate_source!: (untyped source, untyped object) -> void
10
+ end
11
+ end
@@ -0,0 +1,3 @@
1
+ class ::NilClass
2
+ def to_hexdigest_source: -> ::String
3
+ end