dentaku 4.0.0 → 4.0.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a911d2d58c12144c0692b27a9abe5ad08716e1800deb9e9bda08bee04735b9ac
4
- data.tar.gz: 8e55f39625ebb6a6a5f4d38b820137566c9bd2d529d411cb4c40c89770dbf048
3
+ metadata.gz: 8c52e5ed43e8cc6d9ac6f3cf81ae522e408860ab24bf59109bdd97301e0d6e12
4
+ data.tar.gz: fa376e35d073d5c0ebce858f96c4c1322ed1bba2a202dc6e41d265c1e5fa24f3
5
5
  SHA512:
6
- metadata.gz: ac372c840c68d33bb1a6577640ea17812e0aab7b1f82c1979d1caac5ef670148743ecc77f87c8cf32a33e5e90d6f9f35f5d6da85da3e345beb64cf0a0d7d2b78
7
- data.tar.gz: 4d9d05f5b7a79751cfc4f82576b6bddd5d621e02df4ee98282052d55cbe87c250d5f0906960726278ee078d9162f369a9efd42f166d7446d189aeb80de7c166d
6
+ metadata.gz: 040e836d96fad9833095285a2d3a9b6b7e7a3c4bffcd84cac9603bc16128308acb719103eb96693cc04910c028265231f72f982658dd34523d3685ac4dea8e7d
7
+ data.tar.gz: 66aa9ee21ce77b2d9dd25d44b3a7f1db7212e7776614839d4c92e403ed0d4e5561fee07e28a50eb81c1a886bf889c1ae285c4a89aa78a59910f5636de66159d4
data/CHANGELOG.md CHANGED
@@ -1,5 +1,39 @@
1
1
  # Change Log
2
2
 
3
+ ## [v4.0.1] 2026-08-02
4
+ - parsing no longer executes user functions. The three parse-time operand
5
+ validators (`Arithmetic`, `Negation`, and the logical combinators) asked
6
+ each operand for its dependencies without static mode, which evaluates
7
+ short-circuit guards -- so an `IF` or `CASE` used as an operand ran its
8
+ predicate while the parser was still building the node. `Calculator#ast`
9
+ and `Calculator#identifiers` could therefore raise from inside a
10
+ user-registered function, contrary to the documented contract that
11
+ `identifiers` never evaluates guards or functions (#197). Regression
12
+ introduced in 3.5.6 (ecd46bf); evaluation and short-circuiting during
13
+ `evaluate`/`dependencies` are unchanged.
14
+ - functions registered under a name that another calculator already registered
15
+ no longer render as `#<Class:0x...>` in error messages; every generated
16
+ function class now has a stable `to_s` of
17
+ `Dentaku::AST::Function::<Name>` (#264)
18
+ - arithmetic operations that reject an otherwise well-typed operand (notably
19
+ `^` with an oversized exponent) now raise `Dentaku::ArgumentError` instead of
20
+ leaking a raw Ruby `ArgumentError`, so the non-bang `Calculator#evaluate`
21
+ returns `nil` as documented rather than raising (#332)
22
+ - `Calculator#store` restores only the keys it set instead of snapshotting the
23
+ whole memory hash, so evaluation cost no longer scales with the number of
24
+ stored variables (#336). Measured on 20k evaluations of a two-variable
25
+ formula: 34.5 -> 18.6 us/eval with 20,000 variables in memory, and flat
26
+ across memory sizes. Block-scoping semantics are unchanged; when the
27
+ identifier cache is enabled the previous whole-hash snapshot is still used,
28
+ because evaluation writes into memory in that mode.
29
+ - document what the `add_function` return type argument actually does: it is
30
+ consulted at parse time to decide whether a call is a valid arithmetic
31
+ operand, is never coerced or checked at runtime, and has no observable
32
+ effect outside arithmetic (#303)
33
+ - the test suite no longer depends on example ordering: the module-level
34
+ caching opt-ins are reset before each example, since they have no public
35
+ disable and leaked between examples
36
+
3
37
  ## [v4.0.0] 2026-08-01
4
38
  BREAKING CHANGES
5
39
  - require Ruby 3.2 or newer
data/README.md CHANGED
@@ -347,6 +347,42 @@ function)
347
347
  Functions can be added individually using Calculator#add_function, or en masse
348
348
  using Calculator#add_functions.
349
349
 
350
+ ### The return type argument
351
+
352
+ The second argument is the function's *declared* return type. It is used at
353
+ parse time to decide whether a call to the function is a valid operand of an
354
+ arithmetic operator -- only `:numeric`, `:integer`, `:array`, and `nil` are
355
+ accepted there. Declaring anything else means the function cannot be used in
356
+ arithmetic:
357
+
358
+ ```ruby
359
+ > c = Dentaku::Calculator.new
360
+ > c.add_function(:label, :string, ->(x) { "item#{x}" })
361
+ > c.evaluate!('label(1)')
362
+ #=> "item1"
363
+ > c.evaluate!("label(1) = 'item1'") # comparison: type is not consulted
364
+ #=> true
365
+ > c.evaluate!('label(1) + 1') # arithmetic: rejected at parse time
366
+ #=> Dentaku::ParseError: Dentaku::AST::Addition requires operands that are
367
+ # numeric or compatible types, not string
368
+ ```
369
+
370
+ The type is a declaration, not a guarantee: Dentaku does not coerce the return
371
+ value and does not check it at runtime. Declaring `:numeric` for a function
372
+ that actually returns a String defers the failure to whatever consumes it:
373
+
374
+ ```ruby
375
+ > c.add_function(:mislabeled, :numeric, ->(x) { "item#{x}" })
376
+ > c.evaluate!('mislabeled(1) + 1')
377
+ #=> Dentaku::ArgumentError: String input 'item1' is not coercible to numeric
378
+ ```
379
+
380
+ So the type only has an observable effect when the result feeds an arithmetic
381
+ operator. A function used standalone, in comparisons, or as a function
382
+ argument behaves the same whatever type is declared -- which is why an
383
+ incorrect type can go unnoticed for a long time. Declare the type your lambda
384
+ actually returns.
385
+
350
386
  Dentaku assumes registered functions are pure (see DEPENDENCY ANALYSIS AND
351
387
  SHORT-CIRCUITING above). A function that reads external state, performs
352
388
  I/O, or returns different values across calls must be declared volatile so
@@ -38,10 +38,21 @@ module Dentaku
38
38
  l = cast(left_value)
39
39
  r = cast(right_value)
40
40
 
41
- l.public_send(operator, r)
42
- rescue ::TypeError => e
43
- # Right cannot be converted to a suitable type for left. e.g. [] + 1
44
- raise Dentaku::ArgumentError.for(:incompatible_type, actual: r, expected: l.class), e.message
41
+ begin
42
+ l.public_send(operator, r)
43
+ rescue ::TypeError => e
44
+ # Right cannot be converted to a suitable type for left. e.g. [] + 1
45
+ raise Dentaku::ArgumentError.for(:incompatible_type, actual: r, expected: l.class), e.message
46
+ rescue Dentaku::Error
47
+ # already one of ours (e.g. from arithmetic on a custom class)
48
+ raise
49
+ rescue ::ArgumentError, ::RangeError, ::FloatDomainError => e
50
+ # the operation itself rejected an otherwise well-typed operand,
51
+ # e.g. BigDecimal#** with an oversized exponent. Without this, a raw
52
+ # Ruby exception escapes the non-bang Calculator#evaluate, which
53
+ # promises to return nil rather than raise.
54
+ raise Dentaku::ArgumentError.for(:invalid_value, actual: r), e.message
55
+ end
45
56
  end
46
57
 
47
58
  def cast(val)
@@ -66,8 +77,11 @@ module Dentaku
66
77
  def valid_node?(node)
67
78
  return false unless node
68
79
 
69
- # Allow nodes with dependencies (identifiers that will be resolved later)
70
- return true if node.dependencies.any?
80
+ # Allow nodes with dependencies (identifiers that will be resolved later).
81
+ # Ask statically: this runs while the parser is building the node, and
82
+ # a non-static dependency check evaluates short-circuit guards, which
83
+ # would execute user functions at parse time (#197).
84
+ return true if node.dependencies(Node::STATIC_CONTEXT).any?
71
85
 
72
86
  # Allow compatible types
73
87
  return true if [:numeric, :integer, :array].include?(node.type)
@@ -57,8 +57,10 @@ module Dentaku
57
57
 
58
58
  private
59
59
 
60
+ # static dependency check: this runs at parse time, and evaluating
61
+ # short-circuit guards here would execute user functions (#197)
60
62
  def valid_node?(node)
61
- node && (node.dependencies.any? || node.type == :logical)
63
+ node && (node.dependencies(Node::STATIC_CONTEXT).any? || node.type == :logical)
62
64
  end
63
65
 
64
66
  # whether a single operand with this value already determines the
@@ -18,6 +18,21 @@ module Dentaku
18
18
  @name
19
19
  end
20
20
 
21
+ # only the first registration of a given name claims the constant
22
+ # under Dentaku::AST::Function, so later ones stay anonymous and
23
+ # would otherwise render as "#<Class:0x...>" in error messages
24
+ def self.display_name=(display_name)
25
+ @display_name = display_name
26
+ end
27
+
28
+ def self.to_s
29
+ @display_name || super
30
+ end
31
+
32
+ def self.inspect
33
+ to_s
34
+ end
35
+
21
36
  def self.implementation=(impl)
22
37
  @implementation = impl
23
38
  end
@@ -72,9 +87,11 @@ module Dentaku
72
87
  end
73
88
  end
74
89
 
90
+ function.name = name
91
+ function.display_name = "#{Function}::#{normalize_name(name)}"
92
+
75
93
  define_class(name, function)
76
94
 
77
- function.name = name
78
95
  function.type = type
79
96
  function.implementation = implementation
80
97
  function.callback = callback
@@ -51,8 +51,10 @@ module Dentaku
51
51
  @node.pure?
52
52
  end
53
53
 
54
+ # static dependency check: this runs at parse time, and evaluating
55
+ # short-circuit guards here would execute user functions (#197)
54
56
  def valid_node?(node)
55
- node && (node.dependencies.any? || node.type == :numeric)
57
+ node && (node.dependencies(Node::STATIC_CONTEXT).any? || node.type == :numeric)
56
58
  end
57
59
  end
58
60
  end
@@ -102,7 +102,9 @@ module Dentaku
102
102
  end
103
103
 
104
104
  def dependencies(expression, context = {})
105
- test_context = context.nil? ? {} : store(context) { memory }
105
+ # dup inside the block: `store` now restores in place, so the caller
106
+ # needs its own copy of the merged context rather than the live memory
107
+ test_context = context.nil? ? {} : store(context) { memory.dup }
106
108
 
107
109
  case expression
108
110
  when Dentaku::AST::Node
@@ -169,32 +171,51 @@ module Dentaku
169
171
  end
170
172
 
171
173
  def store(key_or_hash, value = nil)
172
- restore = Hash[memory]
174
+ pairs = pairs_to_store(key_or_hash, value)
173
175
 
174
- if value.nil?
175
- key_or_hash = FlatHash.from_hash_with_intermediates(key_or_hash) if nested_data_support
176
- key_or_hash.each do |key, val|
177
- memory[standardize_case(key.to_s)] = val
178
- end
179
- else
180
- memory[standardize_case(key_or_hash.to_s)] = value
176
+ unless block_given?
177
+ pairs.each { |key, val| memory[key] = val }
178
+ return self
181
179
  end
182
180
 
183
- if block_given?
181
+ # `evaluate!` routes every call through here, so snapshotting the whole
182
+ # memory hash makes each evaluation scale with the number of stored
183
+ # variables (#336). Undoing just the keys this call touched is O(pairs)
184
+ # instead of O(memory) -- but it is only equivalent while nothing else
185
+ # writes into memory during the block. The identifier cache does exactly
186
+ # that, so when it is enabled we still need the wholesale snapshot to
187
+ # keep cached values scoped to a single evaluation.
188
+ if Dentaku.cache_identifier?
189
+ restore = Hash[memory]
190
+ pairs.each { |key, val| memory[key] = val }
191
+
184
192
  begin
185
- result = yield
193
+ yield
194
+ ensure
186
195
  @memory = restore
187
- return result
188
- rescue => e
189
- @memory = restore
190
- raise e
191
196
  end
192
- end
197
+ else
198
+ undo = pairs.map { |key, _| [key, memory.key?(key), memory[key]] }
199
+ pairs.each { |key, val| memory[key] = val }
193
200
 
194
- self
201
+ begin
202
+ yield
203
+ ensure
204
+ undo.each { |key, present, val| present ? memory[key] = val : memory.delete(key) }
205
+ end
206
+ end
195
207
  end
196
208
  alias_method :bind, :store
197
209
 
210
+ private def pairs_to_store(key_or_hash, value)
211
+ if value.nil?
212
+ key_or_hash = FlatHash.from_hash_with_intermediates(key_or_hash) if nested_data_support
213
+ key_or_hash.map { |key, val| [standardize_case(key.to_s), val] }
214
+ else
215
+ [[standardize_case(key_or_hash.to_s), value]]
216
+ end
217
+ end
218
+
198
219
  def store_formula(key, formula)
199
220
  store(key, ast(formula))
200
221
  end
@@ -1,3 +1,3 @@
1
1
  module Dentaku
2
- VERSION = "4.0.0"
2
+ VERSION = "4.0.1"
3
3
  end
@@ -123,6 +123,99 @@ describe Dentaku::AST::Arithmetic do
123
123
  expect { add(x, one, 'x' => [1]) }.to raise_error(Dentaku::ArgumentError)
124
124
  end
125
125
 
126
+ describe 'parse-time operand validation (#197)' do
127
+ let(:calculator) do
128
+ Dentaku::Calculator.new.tap do |c|
129
+ c.add_function(:explode, :numeric, ->(*) { raise "function executed at parse time" })
130
+ c.add_function(:explode_flag, :logical, ->(*) { raise "function executed at parse time" })
131
+ end
132
+ end
133
+
134
+ it 'does not execute functions when an IF is an arithmetic operand' do
135
+ expect {
136
+ calculator.ast("IF(explode(1) = 1, 1, 2) + category")
137
+ }.not_to raise_error
138
+ end
139
+
140
+ it 'does not execute functions when an IF is negated' do
141
+ expect {
142
+ calculator.ast("-IF(explode(1) = 1, 1, 2)")
143
+ }.not_to raise_error
144
+ end
145
+
146
+ it 'does not execute functions when an IF is a combinator operand' do
147
+ expect {
148
+ calculator.ast("IF(explode_flag(), true, false) AND x")
149
+ }.not_to raise_error
150
+ end
151
+
152
+ it 'does not execute functions when a CASE is an arithmetic operand' do
153
+ expect {
154
+ calculator.ast("(CASE explode(1) WHEN 1 THEN 1 ELSE 2 END) + category")
155
+ }.not_to raise_error
156
+ end
157
+
158
+ it 'reports identifiers across both branches without evaluating' do
159
+ expect(calculator.identifiers("IF(explode(1) = 1, 1, 2) + category")).to eq(["category"])
160
+ end
161
+
162
+ it 'still validates operand types' do
163
+ expect { Dentaku::Calculator.new.ast("'abc' + 1") }.to raise_error(Dentaku::ParseError)
164
+ end
165
+
166
+ it 'leaves evaluation and short-circuiting unchanged' do
167
+ plain = Dentaku::Calculator.new
168
+
169
+ expect(plain.evaluate!("IF(a = 1, 10, 20) + 5", a: 1)).to eq(15)
170
+ expect(plain.evaluate!("IF(a = 1, 10, 20) + 5", a: 2)).to eq(25)
171
+ expect(plain.evaluate!("-IF(a = 1, 10, 20)", a: 1)).to eq(-10)
172
+ expect(plain.dependencies("IF(a, b, c)", a: true)).to eq(["b"])
173
+ end
174
+ end
175
+
176
+ describe 'operations that reject well-typed operands (#332)' do
177
+ let(:oversized) { "999999999999999 ^ 99999999999999" }
178
+
179
+ it 'wraps the raw Ruby error so it is a Dentaku::Error' do
180
+ expect {
181
+ Dentaku::Calculator.new.evaluate!(oversized)
182
+ }.to raise_error(Dentaku::ArgumentError, /exponent is too large/)
183
+ end
184
+
185
+ it 'is rescuable as Dentaku::Error' do
186
+ error = begin
187
+ Dentaku::Calculator.new.evaluate!(oversized)
188
+ nil
189
+ rescue Dentaku::Error => e
190
+ e
191
+ end
192
+
193
+ expect(error).to be_a(Dentaku::ArgumentError)
194
+ end
195
+
196
+ it 'returns nil from the non-bang evaluate rather than raising' do
197
+ expect(Dentaku::Calculator.new.evaluate(oversized)).to be_nil
198
+ expect(Dentaku::Calculator.new.evaluate("2 ^ 99999999999")).to be_nil
199
+ end
200
+
201
+ it 'yields to the block form' do
202
+ handled = nil
203
+ Dentaku::Calculator.new.evaluate(oversized) { |_expr, ex| handled = ex }
204
+
205
+ expect(handled).to be_a(Dentaku::ArgumentError)
206
+ end
207
+
208
+ it 'still evaluates exponentiation that fits' do
209
+ expect(Dentaku::Calculator.new.evaluate!("2 ^ 10")).to eq(1024)
210
+ end
211
+
212
+ it 'does not swallow Dentaku errors raised by the operation itself' do
213
+ expect {
214
+ Dentaku::Calculator.new.evaluate!("1 / 0")
215
+ }.to raise_error(Dentaku::ZeroDivisionError)
216
+ end
217
+ end
218
+
126
219
  private
127
220
 
128
221
  def add(left, right, context = ctx)
@@ -1076,5 +1076,84 @@ describe Dentaku::Calculator do
1076
1076
  called
1077
1077
  }.from(12).to(1)
1078
1078
  end
1079
+
1080
+ it 'does not carry cached identifier values across evaluations' do
1081
+ # there is no public disable, so save/restore around the example
1082
+ was_enabled = Dentaku.cache_identifier?
1083
+ Dentaku.enable_identifier_cache!
1084
+ called = 0
1085
+ calculator.store_formula("A1", "B1+B1")
1086
+ calculator.store_formula("B1", "C1")
1087
+ calculator.store("C1", proc { called += 1; 1 })
1088
+
1089
+ calculator.evaluate("A1")
1090
+ after_first = called
1091
+ calculator.evaluate("A1")
1092
+
1093
+ expect(after_first).to be > 0
1094
+ expect(called).to eq(after_first * 2)
1095
+ ensure
1096
+ Dentaku.instance_variable_set(:@enable_identifier_caching, was_enabled)
1097
+ end
1098
+ end
1099
+
1100
+ describe 'block-scoped store (#336)' do
1101
+ it 'removes keys the block introduced' do
1102
+ calculator.store(a: 1)
1103
+ result = calculator.store(b: 2) { calculator.evaluate!("a + b") }
1104
+
1105
+ expect(result).to eq(3)
1106
+ expect(calculator.memory.keys).to eq(["a"])
1107
+ end
1108
+
1109
+ it 'restores keys the block shadowed' do
1110
+ calculator.store(a: 1)
1111
+ inner = calculator.store(a: 99) { calculator.evaluate!("a") }
1112
+
1113
+ expect(inner).to eq(99)
1114
+ expect(calculator.evaluate!("a")).to eq(1)
1115
+ end
1116
+
1117
+ it 'restores when the block raises' do
1118
+ calculator.store(a: 1)
1119
+
1120
+ expect {
1121
+ calculator.store(a: 99) { raise "boom" }
1122
+ }.to raise_error("boom")
1123
+
1124
+ expect(calculator.evaluate!("a")).to eq(1)
1125
+ expect(calculator.memory.keys).to eq(["a"])
1126
+ end
1127
+
1128
+ it 'restores correctly when nested' do
1129
+ calculator.store(a: 1)
1130
+ innermost = calculator.store(a: 2) { calculator.store(a: 3) { calculator.evaluate!("a") } }
1131
+
1132
+ expect(innermost).to eq(3)
1133
+ expect(calculator.evaluate!("a")).to eq(1)
1134
+ end
1135
+
1136
+ it 'does not leak the internal evaluation mode key into memory' do
1137
+ calculator.store(a: 1)
1138
+ calculator.evaluate("a + 1")
1139
+
1140
+ expect(calculator.memory.keys).to eq(["a"])
1141
+ end
1142
+
1143
+ it 'restores in place rather than copying the whole memory hash' do
1144
+ # the old implementation snapshotted memory up front and reassigned
1145
+ # @memory to the snapshot, so evaluation cost scaled with memory size.
1146
+ # Restoring in place keeps the same hash object throughout.
1147
+ was_enabled = Dentaku.cache_identifier?
1148
+ Dentaku.instance_variable_set(:@enable_identifier_caching, false)
1149
+
1150
+ calculator.store(a: 1, b: 2)
1151
+ before = calculator.memory.object_id
1152
+
1153
+ expect(calculator.evaluate!("a + b")).to eq(3)
1154
+ expect(calculator.memory.object_id).to eq(before)
1155
+ ensure
1156
+ Dentaku.instance_variable_set(:@enable_identifier_caching, was_enabled)
1157
+ end
1079
1158
  end
1080
1159
  end
@@ -109,6 +109,7 @@ describe Dentaku::Calculator do
109
109
  end
110
110
 
111
111
  it 'exposes the `callback` method of a function' do
112
+ custom_calculator # the `let` is lazy, and it is what registers the function
112
113
  expect(Dentaku::AST::Function::Callback_lambda.callback.call()).to eq("lambda executed")
113
114
  end
114
115
 
@@ -155,6 +156,38 @@ describe Dentaku::Calculator do
155
156
  }.to raise_error(Dentaku::ParseError)
156
157
  end
157
158
 
159
+ describe 'error messages (#264)' do
160
+ it 'names the function even when another calculator claimed the constant' do
161
+ messages = 2.times.map do
162
+ calculator = described_class.new
163
+ calculator.add_function(:custom, :integer, -> { 1 })
164
+
165
+ begin
166
+ calculator.ast("CUSTOM(1,2)")
167
+ nil
168
+ rescue Dentaku::ParseError => e
169
+ e.message
170
+ end
171
+ end
172
+
173
+ expect(messages).to all(include("Dentaku::AST::Function::Custom"))
174
+ messages.each { |message| expect(message).not_to include("#<Class:") }
175
+ end
176
+
177
+ it 'gives re-registered functions a stable string representation' do
178
+ first = described_class.new
179
+ first.add_function(:repeated, :numeric, ->(x) { x })
180
+ second = described_class.new
181
+ second.add_function(:repeated, :numeric, ->(x) { x * 2 })
182
+
183
+ registry = ->(c) { c.instance_variable_get(:@function_registry).get("repeated") }
184
+
185
+ expect(registry.call(second).to_s).to eq("Dentaku::AST::Function::Repeated")
186
+ expect(registry.call(second).to_s).to eq(registry.call(first).to_s)
187
+ expect(registry.call(second).name).to eq("repeated")
188
+ end
189
+ end
190
+
158
191
  describe 'Dentaku::Calculator.add_function' do
159
192
  it 'adds a function to default/global function registry' do
160
193
  described_class.add_function(:global_function, :numeric, ->(x) { 10 + x**2 })
data/spec/spec_helper.rb CHANGED
@@ -20,6 +20,15 @@ RSpec.configure do |c|
20
20
  Dentaku.aliases = { roundup: ['roundupup'] }
21
21
  end
22
22
  }
23
+
24
+ # the caching opt-ins are module-level and have no public "disable", so an
25
+ # example that turns one on leaks into every example that runs after it.
26
+ # Reset to the documented defaults so the suite does not depend on order.
27
+ c.before(:each) {
28
+ Dentaku.instance_variable_set(:@enable_ast_caching, false)
29
+ Dentaku.instance_variable_set(:@enable_dependency_order_caching, false)
30
+ Dentaku.instance_variable_set(:@enable_identifier_caching, false)
31
+ }
23
32
  end
24
33
 
25
34
  # automatically create a token stream from bare values
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: dentaku
3
3
  version: !ruby/object:Gem::Version
4
- version: 4.0.0
4
+ version: 4.0.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Solomon White