dentaku 3.5.8 → 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.
Files changed (61) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/rspec.yml +2 -4
  3. data/.github/workflows/rubocop.yml +1 -1
  4. data/.rubocop.yml +1 -1
  5. data/CHANGELOG.md +108 -1
  6. data/Gemfile +1 -1
  7. data/README.md +123 -0
  8. data/dentaku.gemspec +15 -8
  9. data/lib/dentaku/ast/access.rb +18 -1
  10. data/lib/dentaku/ast/arithmetic.rb +22 -9
  11. data/lib/dentaku/ast/array.rb +6 -0
  12. data/lib/dentaku/ast/bitwise.rb +6 -4
  13. data/lib/dentaku/ast/case/case_conditional.rb +6 -0
  14. data/lib/dentaku/ast/case/case_else.rb +6 -0
  15. data/lib/dentaku/ast/case/case_switch_variable.rb +6 -0
  16. data/lib/dentaku/ast/case/case_then.rb +6 -0
  17. data/lib/dentaku/ast/case/case_when.rb +7 -0
  18. data/lib/dentaku/ast/case.rb +30 -3
  19. data/lib/dentaku/ast/combinators.rb +52 -5
  20. data/lib/dentaku/ast/comparators.rb +2 -3
  21. data/lib/dentaku/ast/function.rb +15 -2
  22. data/lib/dentaku/ast/function_registry.rb +28 -2
  23. data/lib/dentaku/ast/functions/and.rb +4 -4
  24. data/lib/dentaku/ast/functions/avg.rb +2 -2
  25. data/lib/dentaku/ast/functions/duration.rb +1 -1
  26. data/lib/dentaku/ast/functions/if.rb +9 -1
  27. data/lib/dentaku/ast/functions/intercept.rb +2 -2
  28. data/lib/dentaku/ast/functions/mul.rb +2 -2
  29. data/lib/dentaku/ast/functions/or.rb +4 -4
  30. data/lib/dentaku/ast/functions/pluck.rb +1 -1
  31. data/lib/dentaku/ast/functions/string_functions.rb +1 -1
  32. data/lib/dentaku/ast/functions/sum.rb +2 -2
  33. data/lib/dentaku/ast/functions/xor.rb +4 -4
  34. data/lib/dentaku/ast/grouping.rb +6 -0
  35. data/lib/dentaku/ast/negation.rb +8 -1
  36. data/lib/dentaku/ast/node.rb +25 -0
  37. data/lib/dentaku/ast/operation.rb +7 -0
  38. data/lib/dentaku/bulk_expression_solver.rb +9 -10
  39. data/lib/dentaku/calculator.rb +83 -34
  40. data/lib/dentaku/date_arithmetic.rb +2 -2
  41. data/lib/dentaku/exceptions.rb +105 -13
  42. data/lib/dentaku/numeric_parser.rb +1 -1
  43. data/lib/dentaku/parser.rb +24 -48
  44. data/lib/dentaku/token_matcher.rb +31 -37
  45. data/lib/dentaku/token_matchers.rb +3 -3
  46. data/lib/dentaku/tokenizer.rb +1 -15
  47. data/lib/dentaku/version.rb +1 -1
  48. data/spec/ast/arithmetic_spec.rb +93 -0
  49. data/spec/ast/case_spec.rb +7 -0
  50. data/spec/bulk_expression_solver_spec.rb +13 -3
  51. data/spec/calculator_spec.rb +146 -0
  52. data/spec/exceptions_spec.rb +87 -0
  53. data/spec/external_function_spec.rb +33 -0
  54. data/spec/gemspec_spec.rb +10 -0
  55. data/spec/identifiers_spec.rb +66 -0
  56. data/spec/parser_spec.rb +35 -0
  57. data/spec/print_visitor_spec.rb +5 -0
  58. data/spec/spec_helper.rb +9 -0
  59. data/spec/volatile_functions_spec.rb +121 -0
  60. metadata +21 -63
  61. data/.travis.yml +0 -10
@@ -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)
@@ -80,5 +80,12 @@ describe Dentaku::AST::Case do
80
80
  node = described_class.new(switch, conditional1, conditional2, else2)
81
81
  expect(node.dependencies).to eq([:fruit, :tax, :fallback])
82
82
  end
83
+
84
+ it 'short-circuits to the matched branch when the switch is resolvable' do
85
+ node = described_class.new(switch, conditional1, conditional2, else2)
86
+ expect(node.dependencies(fruit: 'apple')).to eq([])
87
+ expect(node.dependencies(fruit: 'banana')).to eq([:tax])
88
+ expect(node.dependencies(fruit: 'orange')).to eq([:fallback])
89
+ end
83
90
  end
84
91
  end
@@ -195,7 +195,7 @@ RSpec.describe Dentaku::BulkExpressionSolver do
195
195
  described_class.new(expressions, calculator).solve do |ex|
196
196
  exception = ex
197
197
  end
198
- expect(exception.recipient_variable).to eq('more_apples')
198
+ expect(exception.assigned_to).to eq('more_apples')
199
199
  end
200
200
 
201
201
  it 'stores the recipient variable on the exception when there is an unbound variable' do
@@ -204,7 +204,17 @@ RSpec.describe Dentaku::BulkExpressionSolver do
204
204
  described_class.new(expressions, calculator).solve do |ex|
205
205
  exception = ex
206
206
  end
207
- expect(exception.recipient_variable).to eq('more_apples')
207
+ expect(exception.assigned_to).to eq('more_apples')
208
+ end
209
+
210
+ it 'sets the recipient variable before the solve block is invoked' do
211
+ expressions = {more_apples: "apples + 1"}
212
+ seen = :unset
213
+ described_class.new(expressions, calculator).solve do |ex|
214
+ seen = ex.assigned_to
215
+ :err
216
+ end
217
+ expect(seen).to eq('more_apples')
208
218
  end
209
219
 
210
220
  it 'stores the recipient variable on the exception when there is an ArgumentError' do
@@ -213,7 +223,7 @@ RSpec.describe Dentaku::BulkExpressionSolver do
213
223
  described_class.new(expressions, calculator).solve do |ex|
214
224
  exception = ex
215
225
  end
216
- expect(exception.recipient_variable).to eq('more_apples')
226
+ expect(exception.assigned_to).to eq('more_apples')
217
227
  end
218
228
 
219
229
  it 'safely handles argument errors' do
@@ -195,6 +195,21 @@ describe Dentaku::Calculator do
195
195
  expect(calculator.evaluate!('a[x+1]', x: 1)).to eq(3)
196
196
  end
197
197
 
198
+ it 'accesses into strings and nested arrays' do
199
+ expect(calculator.evaluate!('a[1]', a: 'abc')).to eq('b')
200
+ expect(calculator.evaluate!('a[1][0]', a: [[1], [2, 3]])).to eq(2)
201
+ end
202
+
203
+ it 'raises an argument error when accessing into a non-indexable value' do
204
+ expect { calculator.evaluate!('a[0]', a: 5) }.to raise_error(Dentaku::ArgumentError)
205
+ expect { calculator.evaluate!('a[0]', a: nil) }.to raise_error(Dentaku::ArgumentError)
206
+ expect { calculator.evaluate!('a[0]', a: true) }.to raise_error(Dentaku::ArgumentError)
207
+ end
208
+
209
+ it 'raises an argument error when the index type is incompatible' do
210
+ expect { calculator.evaluate!("a['one']", a: [1, 2, 3]) }.to raise_error(Dentaku::ArgumentError)
211
+ end
212
+
198
213
  it 'evaluates arrays' do
199
214
  expect(calculator.evaluate([1, 2, 3])).to eq([1, 2, 3])
200
215
  expect(calculator.evaluate!('{1,2,3}')).to eq([1, 2, 3])
@@ -967,6 +982,25 @@ describe Dentaku::Calculator do
967
982
  end
968
983
  end
969
984
 
985
+ describe 'logical operators with unbound operands' do
986
+ it 'short-circuits OR when the bound operand is true' do
987
+ expect(calculator.evaluate!('a OR b', a: true)).to eq(true)
988
+ expect(calculator.evaluate!('a OR b', b: true)).to eq(true)
989
+ end
990
+
991
+ it 'short-circuits AND when the bound operand is false' do
992
+ expect(calculator.evaluate!('a AND b', a: false)).to eq(false)
993
+ expect(calculator.evaluate!('a AND b', b: false)).to eq(false)
994
+ end
995
+
996
+ it 'still raises when the unbound operand is needed to decide' do
997
+ expect { calculator.evaluate!('a OR b', a: false) }
998
+ .to raise_error(Dentaku::UnboundVariableError)
999
+ expect { calculator.evaluate!('a AND b', a: true) }
1000
+ .to raise_error(Dentaku::UnboundVariableError)
1001
+ end
1002
+ end
1003
+
970
1004
  describe 'aliases' do
971
1005
  it 'accepts aliases as instance option' do
972
1006
  expect(with_aliases.evaluate('rrround(5.1)')).to eq(5)
@@ -975,6 +1009,39 @@ describe Dentaku::Calculator do
975
1009
  it 'accepts aliases with whitespace before parentheses' do
976
1010
  expect(with_aliases.evaluate('rrround (5.1)')).to eq(5)
977
1011
  end
1012
+
1013
+ it 'applies module-level aliases set after calculator creation' do
1014
+ Dentaku.aliases = { round: ['redondear'] }
1015
+ expect(calculator.evaluate('redondear(5.1)')).to eq(5)
1016
+ ensure
1017
+ Dentaku.aliases = {}
1018
+ end
1019
+ end
1020
+
1021
+ describe 'constructor options' do
1022
+ it 'raises on unknown options' do
1023
+ expect { described_class.new(case_sensitve: true) }
1024
+ .to raise_error(::ArgumentError, /unknown keyword/)
1025
+ end
1026
+
1027
+ it 'accepts a pre-warmed AST cache' do
1028
+ node = calculator.ast('1+1')
1029
+ warmed = described_class.new(ast_cache: { '1+1' => node })
1030
+ expect(warmed.ast_cache).to eq('1+1' => node)
1031
+ end
1032
+
1033
+ it 'caches ASTs when caching is enabled on the instance' do
1034
+ caching = described_class.new(cache_ast: true)
1035
+ caching.ast('1+1')
1036
+ expect(caching.ast_cache.keys).to eq(['1+1'])
1037
+ end
1038
+
1039
+ it 'does not cache ASTs when disabled on the instance, even if enabled globally' do
1040
+ allow(Dentaku).to receive(:cache_ast?) { true }
1041
+ non_caching = described_class.new(cache_ast: false)
1042
+ non_caching.ast('1+1')
1043
+ expect(non_caching.ast_cache).to be_empty
1044
+ end
978
1045
  end
979
1046
 
980
1047
  describe 'nested_data' do
@@ -1009,5 +1076,84 @@ describe Dentaku::Calculator do
1009
1076
  called
1010
1077
  }.from(12).to(1)
1011
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
1012
1158
  end
1013
1159
  end
@@ -7,3 +7,90 @@ describe Dentaku::UnboundVariableError do
7
7
  expect(exception.unbound_variables).to include('length')
8
8
  end
9
9
  end
10
+
11
+ describe Dentaku::Error do
12
+ it 'is included in every Dentaku exception, even those subclassing Ruby built-ins' do
13
+ [
14
+ Dentaku::UnboundVariableError.new(['x']),
15
+ Dentaku::MathDomainError.new(:asin, [2]),
16
+ Dentaku::NodeError.new(:numeric, :string, :left),
17
+ Dentaku::ParseError.for(:node_invalid),
18
+ Dentaku::TokenizerError.for(:parse_error),
19
+ Dentaku::ArgumentError.for(:invalid_value),
20
+ Dentaku::ZeroDivisionError.new,
21
+ ].each do |exception|
22
+ expect(exception).to be_a(described_class)
23
+ end
24
+ end
25
+
26
+ it 'preserves stdlib ancestry for exceptions subclassing Ruby built-ins' do
27
+ expect(Dentaku::ArgumentError.for(:invalid_value)).to be_a(::ArgumentError)
28
+ expect(Dentaku::ZeroDivisionError.new).to be_a(::ZeroDivisionError)
29
+ end
30
+ end
31
+
32
+ describe Dentaku::ParseError do
33
+ it 'builds a message from reason and metadata' do
34
+ exception = described_class.for(:too_few_operands, operation: Dentaku::AST::Addition, expected: 2, actual: 1)
35
+ expect(exception.message).to eq('Dentaku::AST::Addition has too few operands (given 1, expected 2)')
36
+ end
37
+
38
+ it 'constructs with sparse metadata' do
39
+ expect(described_class.for(:node_invalid).message).to eq('Node is invalid')
40
+ end
41
+
42
+ it 'describes an :incompatible expectation as requiring compatible operands' do
43
+ exception = described_class.for(:node_invalid, operation: Dentaku::AST::Addition, expected: :incompatible, actual: :string)
44
+ expect(exception.message).to eq('Dentaku::AST::Addition requires compatible operands, but got string')
45
+ end
46
+
47
+ it 'allows call sites to override the default message' do
48
+ expect {
49
+ raise described_class.for(:node_invalid), 'Case missing switch variable'
50
+ }.to raise_error(described_class, 'Case missing switch variable')
51
+ end
52
+ end
53
+
54
+ describe Dentaku::TokenizerError do
55
+ it 'builds a message from reason and metadata' do
56
+ exception = described_class.for(:parse_error, at: '$5')
57
+ expect(exception.message).to eq("parse error at: '$5'")
58
+ end
59
+ end
60
+
61
+ describe Dentaku::ArgumentError do
62
+ it 'builds an incompatible_type message from a function context' do
63
+ exception = described_class.for(:incompatible_type, function_name: 'AND', expected: :logical, actual: 5)
64
+ expect(exception.message).to eq('AND() requires logical arguments, but got Integer')
65
+ end
66
+
67
+ it 'builds an incompatible_type message from expected and actual' do
68
+ exception = described_class.for(:incompatible_type, expected: Integer, actual: 2.5)
69
+ expect(exception.message).to eq('Float is not compatible with Integer')
70
+ end
71
+
72
+ it 'builds an invalid_operator message' do
73
+ exception = described_class.for(:invalid_operator, operation: Dentaku::AST::Addition, operator: :+)
74
+ expect(exception.message).to eq('Dentaku::AST::Addition requires operands that respond to +')
75
+ end
76
+
77
+ it 'describes an at-least arity as a range' do
78
+ exception = described_class.for(:too_few_arguments, function_name: 'SUM', expected: 1.., actual: 0)
79
+ expect(exception.message).to eq('SUM() has too few arguments (given 0, expected at least 1)')
80
+ end
81
+
82
+ it 'describes an exact arity as a count' do
83
+ exception = described_class.for(:wrong_number_of_arguments, function_name: 'INTERCEPT', expected: 2, actual: 4)
84
+ expect(exception.message).to eq('INTERCEPT() has the wrong number of arguments (given 4, expected 2)')
85
+ end
86
+
87
+ it 'constructs with sparse metadata' do
88
+ expect(described_class.for(:invalid_value).message).to eq('Invalid value')
89
+ end
90
+
91
+ it 'allows call sites to override the default message' do
92
+ expect {
93
+ raise described_class.for(:invalid_value), 'INTERCEPT() requires arrays of equal length'
94
+ }.to raise_error(described_class, 'INTERCEPT() requires arrays of equal length')
95
+ end
96
+ 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 })
@@ -0,0 +1,10 @@
1
+ require 'spec_helper'
2
+
3
+ describe 'dentaku.gemspec' do
4
+ # tsort no longer ships as a default gem in Ruby 4.1 (PR #334)
5
+ it 'declares tsort as a runtime dependency' do
6
+ gemspec = Gem::Specification.load(File.expand_path('../dentaku.gemspec', __dir__))
7
+ runtime_deps = gemspec.dependencies.select { |d| d.type == :runtime }.map(&:name)
8
+ expect(runtime_deps).to include('tsort')
9
+ end
10
+ end
@@ -0,0 +1,66 @@
1
+ require 'spec_helper'
2
+ require 'dentaku'
3
+ require 'dentaku/calculator'
4
+
5
+ describe Dentaku::Calculator do
6
+ describe '#identifiers' do
7
+ let(:calculator) { described_class.new }
8
+
9
+ it 'does not prune literal guards' do
10
+ expect(calculator.identifiers('IF(1 > 0, a, b)')).to eq(%w(a b))
11
+ end
12
+
13
+ it 'includes the guard identifiers themselves' do
14
+ expect(calculator.identifiers('IF(x > 5, y, z)')).to eq(%w(x y z))
15
+ end
16
+
17
+ it 'never executes functions, pure or volatile' do
18
+ count = { pure: 0, volatile: 0 }
19
+ calculator.add_function(:purefn, :numeric, ->(x) { count[:pure] += 1; x })
20
+ calculator.add_function(:volfn, :numeric, ->(x) { count[:volatile] += 1; x }, volatile: true)
21
+
22
+ expect(calculator.identifiers('IF(purefn(1) > 0, a, IF(volfn(2) > 0, b, c))')).to eq(%w(a b c))
23
+ expect(count).to eq(pure: 0, volatile: 0)
24
+ end
25
+
26
+ it 'includes switch and every branch of CASE' do
27
+ formula = 'CASE fruit WHEN w1 THEN t1 WHEN 2 THEN t2 ELSE e1 END'
28
+ expect(calculator.identifiers(formula)).to eq(%w(fruit w1 t1 t2 e1))
29
+ end
30
+
31
+ it 'includes both operands of AND/OR even when one is decisive' do
32
+ expect(calculator.identifiers('1 > 2 AND x')).to eq(['x'])
33
+ expect(calculator.identifiers('a AND b OR c')).to eq(%w(a b c))
34
+ end
35
+
36
+ it 'excludes enum-bound variables' do
37
+ expect(calculator.identifiers('MAP(users, u, u.age + bonus)')).to eq(%w(users bonus))
38
+ end
39
+
40
+ it 'ignores stored memory' do
41
+ calculator.store(a: 1)
42
+ expect(calculator.identifiers('a + b')).to eq(%w(a b))
43
+ end
44
+
45
+ it 'accepts an AST node' do
46
+ node = calculator.ast('x + y')
47
+ expect(calculator.identifiers(node)).to eq(%w(x y))
48
+ end
49
+
50
+ it 'accepts an array of expressions and deduplicates' do
51
+ expect(calculator.identifiers(['a + b', 'b + c'])).to eq(%w(a b c))
52
+ end
53
+
54
+ it 'deduplicates repeated identifiers' do
55
+ expect(calculator.identifiers('a + a * a')).to eq(['a'])
56
+ end
57
+
58
+ it 'lists all inputs of a formula using external-state functions' do
59
+ user_attr = ->(name, default = 0) { default }
60
+ calculator.add_function(:user_attr, :numeric, user_attr)
61
+
62
+ identifiers = calculator.identifiers("IF(user_attr('level') > 50, high_level_value, low_level_value)")
63
+ expect(identifiers).to eq(%w(high_level_value low_level_value))
64
+ end
65
+ end
66
+ end
data/spec/parser_spec.rb CHANGED
@@ -84,6 +84,17 @@ describe Dentaku::Parser do
84
84
  expect(node.value("x" => 1, "y" => "A", "Y" => "B")).to eq(3)
85
85
  end
86
86
 
87
+ it 'evaluates a case statement with an unparenthesized operation as switch' do
88
+ node = parse('CASE a % 5 WHEN 0 THEN a ELSE b END')
89
+ expect(node.value("a" => 10, "b" => 1)).to eq(10)
90
+ expect(node.value("a" => 7, "b" => 1)).to eq(1)
91
+ end
92
+
93
+ it 'evaluates a case statement with a comparator as switch' do
94
+ node = parse('CASE a > 5 WHEN true THEN a ELSE b END')
95
+ expect(node.value("a" => 10, "b" => 1)).to eq(10)
96
+ end
97
+
87
98
  it 'evaluates arrays' do
88
99
  node = parse('{}')
89
100
  expect(node.value).to eq([])
@@ -111,6 +122,21 @@ describe Dentaku::Parser do
111
122
  }.to raise_error(Dentaku::ParseError)
112
123
  end
113
124
 
125
+ it 'preserves the node-level message for incompatible operands' do
126
+ expect {
127
+ parse('"hello" + 1')
128
+ }.to raise_error(
129
+ Dentaku::ParseError,
130
+ /requires operands that are numeric or compatible types, not string/
131
+ )
132
+ end
133
+
134
+ it 'preserves the node-level message for non-logical combinator operands' do
135
+ expect {
136
+ parse('1 AND 2')
137
+ }.to raise_error(Dentaku::ParseError, /requires logical operands/)
138
+ end
139
+
114
140
  it 'raises a parse error for too many operands' do
115
141
  expect {
116
142
  parse("IF(1, 0, IF(1, 2, 3, 4))")
@@ -167,6 +193,15 @@ describe Dentaku::Parser do
167
193
  parse("undefined()")
168
194
  }.to raise_error(Dentaku::ParseError)
169
195
  end
196
+
197
+ it 'raises a parse error for an unbalanced closing parenthesis' do
198
+ # the tokenizer catches unbalanced parentheses in a string expression,
199
+ # so exercise the parser directly with a hand-built token list
200
+ rparen = Dentaku::Token.new(:grouping, :close)
201
+ expect {
202
+ described_class.new([rparen]).parse
203
+ }.to raise_error(Dentaku::ParseError)
204
+ end
170
205
  end
171
206
 
172
207
  it "evaluates explicit 'NULL' as nil" do
@@ -49,6 +49,11 @@ describe Dentaku::PrintVisitor do
49
49
  expect(repr).to eq('CASE a % 5 WHEN 0 THEN a ELSE b END')
50
50
  end
51
51
 
52
+ it 'prints a case statement that can be re-parsed' do
53
+ repr = roundtrip('case (a % 5) when 0 then a else b end')
54
+ expect(roundtrip(repr)).to eq(repr)
55
+ end
56
+
52
57
  it 'handles a bitwise operators' do
53
58
  repr = roundtrip('0xCAFE & 0xDECAF | 0xBEEF')
54
59
  expect(repr).to eq('0xCAFE & 0xDECAF | 0xBEEF')
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