dentaku 3.2.0 → 3.5.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (100) hide show
  1. checksums.yaml +5 -5
  2. data/.rubocop.yml +5 -10
  3. data/.travis.yml +4 -6
  4. data/CHANGELOG.md +86 -2
  5. data/README.md +7 -6
  6. data/dentaku.gemspec +1 -1
  7. data/lib/dentaku/ast/access.rb +21 -1
  8. data/lib/dentaku/ast/arithmetic.rb +51 -15
  9. data/lib/dentaku/ast/array.rb +41 -0
  10. data/lib/dentaku/ast/bitwise.rb +30 -5
  11. data/lib/dentaku/ast/case/case_conditional.rb +17 -2
  12. data/lib/dentaku/ast/case/case_else.rb +17 -3
  13. data/lib/dentaku/ast/case/case_switch_variable.rb +14 -0
  14. data/lib/dentaku/ast/case/case_then.rb +17 -3
  15. data/lib/dentaku/ast/case/case_when.rb +21 -3
  16. data/lib/dentaku/ast/case.rb +19 -3
  17. data/lib/dentaku/ast/comparators.rb +38 -28
  18. data/lib/dentaku/ast/function.rb +11 -3
  19. data/lib/dentaku/ast/function_registry.rb +21 -0
  20. data/lib/dentaku/ast/functions/all.rb +23 -0
  21. data/lib/dentaku/ast/functions/and.rb +2 -2
  22. data/lib/dentaku/ast/functions/any.rb +23 -0
  23. data/lib/dentaku/ast/functions/avg.rb +2 -2
  24. data/lib/dentaku/ast/functions/count.rb +8 -0
  25. data/lib/dentaku/ast/functions/duration.rb +51 -0
  26. data/lib/dentaku/ast/functions/enum.rb +37 -0
  27. data/lib/dentaku/ast/functions/filter.rb +23 -0
  28. data/lib/dentaku/ast/functions/if.rb +19 -2
  29. data/lib/dentaku/ast/functions/map.rb +23 -0
  30. data/lib/dentaku/ast/functions/or.rb +4 -4
  31. data/lib/dentaku/ast/functions/pluck.rb +30 -0
  32. data/lib/dentaku/ast/functions/round.rb +1 -1
  33. data/lib/dentaku/ast/functions/rounddown.rb +1 -1
  34. data/lib/dentaku/ast/functions/roundup.rb +1 -1
  35. data/lib/dentaku/ast/functions/ruby_math.rb +50 -3
  36. data/lib/dentaku/ast/functions/string_functions.rb +105 -12
  37. data/lib/dentaku/ast/functions/xor.rb +44 -0
  38. data/lib/dentaku/ast/grouping.rb +3 -1
  39. data/lib/dentaku/ast/identifier.rb +16 -4
  40. data/lib/dentaku/ast/literal.rb +10 -0
  41. data/lib/dentaku/ast/negation.rb +7 -1
  42. data/lib/dentaku/ast/nil.rb +4 -0
  43. data/lib/dentaku/ast/node.rb +8 -0
  44. data/lib/dentaku/ast/operation.rb +17 -0
  45. data/lib/dentaku/ast/string.rb +7 -0
  46. data/lib/dentaku/ast.rb +8 -0
  47. data/lib/dentaku/bulk_expression_solver.rb +38 -27
  48. data/lib/dentaku/calculator.rb +21 -8
  49. data/lib/dentaku/date_arithmetic.rb +45 -0
  50. data/lib/dentaku/exceptions.rb +11 -8
  51. data/lib/dentaku/flat_hash.rb +9 -2
  52. data/lib/dentaku/parser.rb +57 -16
  53. data/lib/dentaku/print_visitor.rb +101 -0
  54. data/lib/dentaku/token_matcher.rb +1 -1
  55. data/lib/dentaku/token_scanner.rb +9 -3
  56. data/lib/dentaku/tokenizer.rb +7 -2
  57. data/lib/dentaku/version.rb +1 -1
  58. data/lib/dentaku/visitor/infix.rb +82 -0
  59. data/lib/dentaku.rb +20 -7
  60. data/spec/ast/addition_spec.rb +7 -1
  61. data/spec/ast/all_spec.rb +25 -0
  62. data/spec/ast/and_function_spec.rb +6 -6
  63. data/spec/ast/and_spec.rb +1 -1
  64. data/spec/ast/any_spec.rb +23 -0
  65. data/spec/ast/arithmetic_spec.rb +64 -29
  66. data/spec/ast/avg_spec.rb +9 -5
  67. data/spec/ast/comparator_spec.rb +31 -1
  68. data/spec/ast/count_spec.rb +7 -7
  69. data/spec/ast/division_spec.rb +7 -1
  70. data/spec/ast/filter_spec.rb +25 -0
  71. data/spec/ast/function_spec.rb +20 -15
  72. data/spec/ast/map_spec.rb +27 -0
  73. data/spec/ast/max_spec.rb +16 -3
  74. data/spec/ast/min_spec.rb +16 -3
  75. data/spec/ast/mul_spec.rb +11 -6
  76. data/spec/ast/negation_spec.rb +48 -0
  77. data/spec/ast/node_spec.rb +11 -8
  78. data/spec/ast/numeric_spec.rb +1 -1
  79. data/spec/ast/or_spec.rb +7 -7
  80. data/spec/ast/pluck_spec.rb +32 -0
  81. data/spec/ast/round_spec.rb +14 -4
  82. data/spec/ast/rounddown_spec.rb +14 -4
  83. data/spec/ast/roundup_spec.rb +14 -4
  84. data/spec/ast/string_functions_spec.rb +73 -0
  85. data/spec/ast/sum_spec.rb +11 -6
  86. data/spec/ast/switch_spec.rb +5 -5
  87. data/spec/ast/xor_spec.rb +35 -0
  88. data/spec/bulk_expression_solver_spec.rb +37 -1
  89. data/spec/calculator_spec.rb +341 -32
  90. data/spec/dentaku_spec.rb +19 -6
  91. data/spec/external_function_spec.rb +32 -6
  92. data/spec/parser_spec.rb +100 -123
  93. data/spec/print_visitor_spec.rb +66 -0
  94. data/spec/spec_helper.rb +6 -4
  95. data/spec/token_matcher_spec.rb +8 -8
  96. data/spec/token_scanner_spec.rb +4 -4
  97. data/spec/tokenizer_spec.rb +56 -13
  98. data/spec/visitor/infix_spec.rb +31 -0
  99. data/spec/visitor_spec.rb +138 -0
  100. metadata +52 -7
@@ -1,8 +1,8 @@
1
1
  require 'spec_helper'
2
2
  require 'dentaku'
3
-
4
3
  describe Dentaku::Calculator do
5
4
  let(:calculator) { described_class.new }
5
+ let(:with_case_sensitivity) { described_class.new(case_sensitive: true) }
6
6
  let(:with_memory) { described_class.new.store(apples: 3) }
7
7
  let(:with_aliases) { described_class.new(aliases: { round: ['rrround'] }) }
8
8
  let(:without_nested_data) { described_class.new(nested_data_support: false) }
@@ -39,6 +39,93 @@ describe Dentaku::Calculator do
39
39
  expect(calculator.evaluate('t + 1*24*60*60', t: Time.local(2017, 1, 1))).to eq(Time.local(2017, 1, 2))
40
40
  expect(calculator.evaluate("2 | 3 * 9")).to eq (27)
41
41
  expect(calculator.evaluate("2 & 3 * 9")).to eq (2)
42
+ expect(calculator.evaluate("5%")).to eq (0.05)
43
+ expect(calculator.evaluate('1 << 3')).to eq (8)
44
+ expect(calculator.evaluate('0xFF >> 6')).to eq (3)
45
+ end
46
+
47
+ describe 'evaluate' do
48
+ it 'returns nil when formula has error' do
49
+ expect(calculator.evaluate('1 + + 1')).to be_nil
50
+ end
51
+
52
+ it 'suppresses unbound variable errors' do
53
+ expect(calculator.evaluate('AND(a,b)')).to be_nil
54
+ expect(calculator.evaluate('IF(a, 1, 0)')).to be_nil
55
+ expect(calculator.evaluate('MAX(a,b)')).to be_nil
56
+ expect(calculator.evaluate('MIN(a,b)')).to be_nil
57
+ expect(calculator.evaluate('NOT(a)')).to be_nil
58
+ expect(calculator.evaluate('OR(a,b)')).to be_nil
59
+ expect(calculator.evaluate('ROUND(a)')).to be_nil
60
+ expect(calculator.evaluate('ROUNDDOWN(a)')).to be_nil
61
+ expect(calculator.evaluate('ROUNDUP(a)')).to be_nil
62
+ expect(calculator.evaluate('SUM(a,b)')).to be_nil
63
+ end
64
+
65
+ it 'suppresses numeric coercion errors' do
66
+ expect(calculator.evaluate('MAX(a,b)', a: nil, b: nil)).to be_nil
67
+ expect(calculator.evaluate('MIN(a,b)', a: nil, b: nil)).to be_nil
68
+ expect(calculator.evaluate('ROUND(a)', a: nil)).to be_nil
69
+ expect(calculator.evaluate('ROUNDDOWN(a)', a: nil)).to be_nil
70
+ expect(calculator.evaluate('ROUNDUP(a)', a: nil)).to be_nil
71
+ expect(calculator.evaluate('SUM(a,b)', a: nil, b: nil)).to be_nil
72
+ expect(calculator.evaluate('1.0 & "bar"')).to be_nil
73
+ end
74
+
75
+ it 'treats explicit nil as logical false' do
76
+ expect(calculator.evaluate('AND(a,b)', a: nil, b: nil)).to be_falsy
77
+ expect(calculator.evaluate('IF(a,1,0)', a: nil, b: nil)).to eq(0)
78
+ expect(calculator.evaluate('NOT(a)', a: nil, b: nil)).to be_truthy
79
+ expect(calculator.evaluate('OR(a,b)', a: nil, b: nil)).to be_falsy
80
+ end
81
+
82
+ it 'supports lazy evaluation of variables' do
83
+ expect(calculator.evaluate('x + 1', x: -> { 1 })).to eq(2)
84
+ expect { calculator.evaluate('2', x: -> { raise 'boom' }) }.not_to raise_error
85
+ end
86
+ end
87
+
88
+ describe 'ast' do
89
+ it 'raises parsing errors' do
90
+ expect { calculator.ast('()') }.to raise_error(Dentaku::ParseError)
91
+ expect { calculator.ast('(}') }.to raise_error(Dentaku::TokenizerError)
92
+ end
93
+ end
94
+
95
+ describe 'evaluate!' do
96
+ it 'raises exception when formula has error' do
97
+ expect { calculator.evaluate!('1 + + 1') }.to raise_error(Dentaku::ParseError)
98
+ expect { calculator.evaluate!('(1 > 5) OR LEFT("abc", 1)') }.to raise_error(Dentaku::ParseError)
99
+ end
100
+
101
+ it 'raises unbound variable errors' do
102
+ expect { calculator.evaluate!('AND(a,b)') }.to raise_error(Dentaku::UnboundVariableError)
103
+ expect { calculator.evaluate!('IF(a, 1, 0)') }.to raise_error(Dentaku::UnboundVariableError)
104
+ expect { calculator.evaluate!('MAX(a,b)') }.to raise_error(Dentaku::UnboundVariableError)
105
+ expect { calculator.evaluate!('MIN(a,b)') }.to raise_error(Dentaku::UnboundVariableError)
106
+ expect { calculator.evaluate!('NOT(a)') }.to raise_error(Dentaku::UnboundVariableError)
107
+ expect { calculator.evaluate!('OR(a,b)') }.to raise_error(Dentaku::UnboundVariableError)
108
+ expect { calculator.evaluate!('ROUND(a)') }.to raise_error(Dentaku::UnboundVariableError)
109
+ expect { calculator.evaluate!('ROUNDDOWN(a)') }.to raise_error(Dentaku::UnboundVariableError)
110
+ expect { calculator.evaluate!('ROUNDUP(a)') }.to raise_error(Dentaku::UnboundVariableError)
111
+ expect { calculator.evaluate!('SUM(a,b)') }.to raise_error(Dentaku::UnboundVariableError)
112
+ end
113
+
114
+ it 'raises numeric coersion errors' do
115
+ expect { calculator.evaluate!('MAX(a,b)', a: nil, b: nil) }.to raise_error(Dentaku::ArgumentError)
116
+ expect { calculator.evaluate!('MIN(a,b)', a: nil, b: nil) }.to raise_error(Dentaku::ArgumentError)
117
+ expect { calculator.evaluate!('ROUND(a)', a: nil) }.to raise_error(Dentaku::ArgumentError)
118
+ expect { calculator.evaluate!('ROUNDDOWN(a)', a: nil) }.to raise_error(Dentaku::ArgumentError)
119
+ expect { calculator.evaluate!('ROUNDUP(a)', a: nil) }.to raise_error(Dentaku::ArgumentError)
120
+ expect { calculator.evaluate!('SUM(a,b)', a: nil, b: nil) }.to raise_error(Dentaku::ArgumentError)
121
+ expect { calculator.evaluate!('"foo" & "bar"') }.to raise_error(Dentaku::ArgumentError)
122
+ expect { calculator.evaluate!('1.0 & "bar"') }.to raise_error(Dentaku::ArgumentError)
123
+ expect { calculator.evaluate!('1 & "bar"') }.to raise_error(Dentaku::ArgumentError)
124
+ end
125
+
126
+ it 'raises argument error if a function is called with incorrect arity' do
127
+ expect { calculator.evaluate!('IF(a,b)', a: 1, b: 1) }.to raise_error(Dentaku::ParseError)
128
+ end
42
129
  end
43
130
 
44
131
  it 'supports unicode characters in identifiers' do
@@ -57,35 +144,37 @@ describe Dentaku::Calculator do
57
144
 
58
145
  it 'can store the value `false`' do
59
146
  calculator.store('i_am_false', false)
60
- expect(calculator.evaluate!('i_am_false')).to eq false
147
+ expect(calculator.evaluate!('i_am_false')).to eq(false)
61
148
  end
62
149
 
63
150
  it 'can store multiple values' do
64
151
  calculator.store(first: 1, second: 2)
65
- expect(calculator.evaluate!('first')).to eq 1
66
- expect(calculator.evaluate!('second')).to eq 2
152
+ expect(calculator.evaluate!('first')).to eq(1)
153
+ expect(calculator.evaluate!('second')).to eq(2)
67
154
  end
68
155
 
69
156
  it 'stores formulas' do
70
157
  calculator.store_formula('area', 'length * width')
71
- expect(calculator.evaluate!('area', length: 5, width: 5)).to eq 25
158
+ expect(calculator.evaluate!('area', length: 5, width: 5)).to eq(25)
72
159
  end
73
160
 
74
161
  it 'stores nested hashes' do
75
162
  calculator.store(a: {basket: {of: 'apples'}}, b: 2)
76
- expect(calculator.evaluate!('a.basket.of')).to eq 'apples'
77
- expect(calculator.evaluate!('b')).to eq 2
163
+ expect(calculator.evaluate!('a.basket.of')).to eq('apples')
164
+ expect(calculator.evaluate!('a.basket')).to eq(of: 'apples')
165
+ expect(calculator.evaluate!('b')).to eq(2)
78
166
  end
79
167
 
80
168
  it 'stores arrays' do
81
169
  calculator.store(a: [1, 2, 3])
82
- expect(calculator.evaluate!('a[0]')).to eq 1
83
- expect(calculator.evaluate!('a[x]', x: 1)).to eq 2
84
- expect(calculator.evaluate!('a[x+1]', x: 1)).to eq 3
170
+ expect(calculator.evaluate!('a[0]')).to eq(1)
171
+ expect(calculator.evaluate!('a[x]', x: 1)).to eq(2)
172
+ expect(calculator.evaluate!('a[x+1]', x: 1)).to eq(3)
85
173
  end
86
174
 
87
- it 'evalutates arrays' do
175
+ it 'evaluates arrays' do
88
176
  expect(calculator.evaluate([1, 2, 3])).to eq([1, 2, 3])
177
+ expect(calculator.evaluate!('{1,2,3}')).to eq([1, 2, 3])
89
178
  end
90
179
  end
91
180
 
@@ -111,6 +200,11 @@ describe Dentaku::Calculator do
111
200
  it "finds no dependencies in array literals" do
112
201
  expect(calculator.dependencies([1, 2, 3])).to eq([])
113
202
  end
203
+
204
+ it "finds dependencies in item expressions" do
205
+ expect(calculator.dependencies('MAP(vals, val, val + step)')).to eq(['vals', 'step'])
206
+ expect(calculator.dependencies('ALL(people, person, person.age < adult)')).to eq(['people', 'adult'])
207
+ end
114
208
  end
115
209
 
116
210
  describe 'solve!' do
@@ -122,6 +216,15 @@ describe Dentaku::Calculator do
122
216
  )).to eq(pear: 1, weekly_apple_budget: 21, weekly_fruit_budget: 25)
123
217
  end
124
218
 
219
+ it "prefers variables over values in memory if they have no dependencies" do
220
+ expect(with_memory.solve!(
221
+ weekly_fruit_budget: "weekly_apple_budget + pear * 4",
222
+ weekly_apple_budget: "apples * 7",
223
+ pear: "1",
224
+ apples: "4"
225
+ )).to eq(apples: 4, pear: 1, weekly_apple_budget: 28, weekly_fruit_budget: 32)
226
+ end
227
+
125
228
  it "preserves hash keys" do
126
229
  expect(calculator.solve!(
127
230
  'meaning_of_life' => 'age + kids',
@@ -138,7 +241,7 @@ describe Dentaku::Calculator do
138
241
 
139
242
  it 'is case-insensitive' do
140
243
  result = with_memory.solve!(total_fruit: "Apples + pears", pears: 10)
141
- expect(result[:total_fruit]).to eq 13
244
+ expect(result[:total_fruit]).to eq(13)
142
245
  end
143
246
 
144
247
  it "lets you know if a variable is unbound" do
@@ -158,14 +261,28 @@ describe Dentaku::Calculator do
158
261
  width: "length * 2",
159
262
  )
160
263
 
161
- expect(result[:weight]).to eq 130.368
264
+ expect(result[:weight]).to eq(130.368)
265
+ end
266
+
267
+ it 'raises an exception if there are cyclic dependencies' do
268
+ expect {
269
+ calculator.solve!(
270
+ make_money: "have_money",
271
+ have_money: "make_money"
272
+ )
273
+ }.to raise_error(TSort::Cyclic)
162
274
  end
163
275
  end
164
276
 
165
277
  describe 'solve' do
166
278
  it "returns :undefined when variables are unbound" do
167
- expressions = {more_apples: "apples + 1"}
168
- expect(calculator.solve(expressions)).to eq(more_apples: :undefined)
279
+ expressions = {more_apples: "apples + 1", compare_apples: "apples > 1"}
280
+ expect(calculator.solve(expressions)).to eq(more_apples: :undefined, compare_apples: :undefined)
281
+ end
282
+
283
+ it "returns :undefined when variables are nil" do
284
+ expressions = {more_apples: "apples + 1", compare_apples: "apples > 1"}
285
+ expect(calculator.store(apples: nil).solve(expressions)).to eq(more_apples: :undefined, compare_apples: :undefined)
169
286
  end
170
287
 
171
288
  it "allows passing in a custom value to an error handler" do
@@ -198,6 +315,33 @@ describe Dentaku::Calculator do
198
315
  d: 0,
199
316
  )
200
317
  end
318
+
319
+ it 'returns undefined if there are cyclic dependencies' do
320
+ expect {
321
+ result = calculator.solve(
322
+ make_money: "have_money",
323
+ have_money: "make_money"
324
+ )
325
+ expect(result).to eq(
326
+ make_money: :undefined,
327
+ have_money: :undefined
328
+ )
329
+ }.not_to raise_error
330
+ end
331
+
332
+ it "integrates with custom functions" do
333
+ calculator.add_function(:custom, :integer, -> { 1 })
334
+
335
+ result = calculator.solve(
336
+ a: "1",
337
+ b: "CUSTOM() - a"
338
+ )
339
+
340
+ expect(result).to eq(
341
+ a: 1,
342
+ b: 0
343
+ )
344
+ end
201
345
  end
202
346
 
203
347
  it 'evaluates a statement with no variables' do
@@ -217,14 +361,18 @@ describe Dentaku::Calculator do
217
361
  unbound = 'foo * 1.5'
218
362
  expect { calculator.evaluate!(unbound) }.to raise_error(Dentaku::UnboundVariableError)
219
363
  expect { calculator.evaluate!(unbound) }.to raise_error do |error|
220
- expect(error.unbound_variables).to eq ['foo']
364
+ expect(error.unbound_variables).to eq(['foo'])
221
365
  end
222
366
  expect { calculator.evaluate!('a + b') }.to raise_error do |error|
223
- expect(error.unbound_variables).to eq ['a', 'b']
367
+ expect(error.unbound_variables).to eq(['a', 'b'])
224
368
  end
225
369
  expect(calculator.evaluate(unbound)).to be_nil
226
- expect(calculator.evaluate(unbound) { :bar }).to eq :bar
227
- expect(calculator.evaluate(unbound) { |e| e }).to eq unbound
370
+ end
371
+
372
+ it 'accepts a block for custom handling of unbound variables' do
373
+ unbound = 'foo * 1.5'
374
+ expect(calculator.evaluate(unbound) { :bar }).to eq(:bar)
375
+ expect(calculator.evaluate(unbound) { |e| e }).to eq(unbound)
228
376
  end
229
377
 
230
378
  it 'fails to evaluate incomplete statements' do
@@ -278,20 +426,35 @@ describe Dentaku::Calculator do
278
426
  expect(calculator.evaluate('some_boolean OR 7 < 5', some_boolean: false)).to be_falsey
279
427
  end
280
428
 
281
- it 'compares Time variables' do
429
+ it 'compares time variables' do
282
430
  expect(calculator.evaluate('t1 < t2', t1: Time.local(2017, 1, 1).to_datetime, t2: Time.local(2017, 1, 2).to_datetime)).to be_truthy
283
431
  expect(calculator.evaluate('t1 < t2', t1: Time.local(2017, 1, 2).to_datetime, t2: Time.local(2017, 1, 1).to_datetime)).to be_falsy
284
432
  expect(calculator.evaluate('t1 > t2', t1: Time.local(2017, 1, 1).to_datetime, t2: Time.local(2017, 1, 2).to_datetime)).to be_falsy
285
433
  expect(calculator.evaluate('t1 > t2', t1: Time.local(2017, 1, 2).to_datetime, t2: Time.local(2017, 1, 1).to_datetime)).to be_truthy
286
434
  end
287
435
 
288
- it 'compares Time literals with Time variables' do
436
+ it 'compares time literals with time variables' do
289
437
  expect(calculator.evaluate('t1 < 2017-01-02', t1: Time.local(2017, 1, 1).to_datetime)).to be_truthy
290
438
  expect(calculator.evaluate('t1 < 2017-01-02', t1: Time.local(2017, 1, 3).to_datetime)).to be_falsy
291
439
  expect(calculator.evaluate('t1 > 2017-01-02', t1: Time.local(2017, 1, 1).to_datetime)).to be_falsy
292
440
  expect(calculator.evaluate('t1 > 2017-01-02', t1: Time.local(2017, 1, 3).to_datetime)).to be_truthy
293
441
  end
294
442
 
443
+ it 'supports date arithmetic' do
444
+ expect(calculator.evaluate!('2020-01-01 + 30').to_date).to eq(Time.local(2020, 1, 31).to_date)
445
+ expect(calculator.evaluate!('2020-01-01 - 1').to_date).to eq(Time.local(2019, 12, 31).to_date)
446
+ expect(calculator.evaluate!('2020-01-01 - 2019-12-31')).to eq(1)
447
+ expect(calculator.evaluate!('2020-01-01 + duration(1, day)').to_date).to eq(Time.local(2020, 1, 2).to_date)
448
+ expect(calculator.evaluate!('2020-01-01 - duration(1, day)').to_date).to eq(Time.local(2019, 12, 31).to_date)
449
+ expect(calculator.evaluate!('2020-01-01 + duration(30, days)').to_date).to eq(Time.local(2020, 1, 31).to_date)
450
+ expect(calculator.evaluate!('2020-01-01 + duration(1, month)').to_date).to eq(Time.local(2020, 2, 1).to_date)
451
+ expect(calculator.evaluate!('2020-01-01 - duration(1, month)').to_date).to eq(Time.local(2019, 12, 1).to_date)
452
+ expect(calculator.evaluate!('2020-01-01 + duration(30, months)').to_date).to eq(Time.local(2022, 7, 1).to_date)
453
+ expect(calculator.evaluate!('2020-01-01 + duration(1, year)').to_date).to eq(Time.local(2021, 1, 1).to_date)
454
+ expect(calculator.evaluate!('2020-01-01 - duration(1, year)').to_date).to eq(Time.local(2019, 1, 1).to_date)
455
+ expect(calculator.evaluate!('2020-01-01 + duration(30, years)').to_date).to eq(Time.local(2050, 1, 1).to_date)
456
+ end
457
+
295
458
  describe 'functions' do
296
459
  it 'include IF' do
297
460
  expect(calculator.evaluate('if(foo < 8, 10, 20)', foo: 2)).to eq(10)
@@ -303,7 +466,7 @@ describe Dentaku::Calculator do
303
466
  it 'include ROUND' do
304
467
  expect(calculator.evaluate('round(8.2)')).to eq(8)
305
468
  expect(calculator.evaluate('round(8.8)')).to eq(9)
306
- expect(calculator.evaluate('round(8.75, 1)')).to eq(BigDecimal.new('8.8'))
469
+ expect(calculator.evaluate('round(8.75, 1)')).to eq(BigDecimal('8.8'))
307
470
 
308
471
  expect(calculator.evaluate('ROUND(apples * 0.93)', apples: 10)).to eq(9)
309
472
  end
@@ -319,10 +482,92 @@ describe Dentaku::Calculator do
319
482
  it 'evaluates functions with negative numbers' do
320
483
  expect(calculator.evaluate('if (-1 < 5, -1, 5)')).to eq(-1)
321
484
  expect(calculator.evaluate('if (-1 = -1, -1, 5)')).to eq(-1)
322
- expect(calculator.evaluate('round(-1.23, 1)')).to eq(BigDecimal.new('-1.2'))
485
+ expect(calculator.evaluate('round(-1.23, 1)')).to eq(BigDecimal('-1.2'))
323
486
  expect(calculator.evaluate('NOT(some_boolean) AND -1 > 3', some_boolean: true)).to be_falsey
324
487
  end
325
488
 
489
+ describe "any" do
490
+ it "enumerates values and returns true if any evaluation is truthy" do
491
+ expect(calculator.evaluate!('any(xs, x, x > 3)', xs: [1, 2, 3, 4])).to be_truthy
492
+ expect(calculator.evaluate!('any(xs, x, x > 3)', xs: 3)).to be_falsy
493
+ expect(calculator.evaluate!('any({1,2,3,4}, x, x > 3)')).to be_truthy
494
+ expect(calculator.evaluate!('any({1,2,3,4}, x, x > 10)')).to be_falsy
495
+ expect(calculator.evaluate!('any(users, u, u.age > 33)', users: [
496
+ {name: "Bob", age: 44},
497
+ {name: "Jane", age: 27}
498
+ ])).to be_truthy
499
+ expect(calculator.evaluate!('any(users, u, u.age < 18)', users: [
500
+ {name: "Bob", age: 44},
501
+ {name: "Jane", age: 27}
502
+ ])).to be_falsy
503
+ end
504
+ end
505
+
506
+ describe "all" do
507
+ it "enumerates values and returns true if all evaluations are truthy" do
508
+ expect(calculator.evaluate!('all(xs, x, x > 3)', xs: [1, 2, 3, 4])).to be_falsy
509
+ expect(calculator.evaluate!('any(xs, x, x > 2)', xs: 3)).to be_truthy
510
+ expect(calculator.evaluate!('all({1,2,3,4}, x, x > 0)')).to be_truthy
511
+ expect(calculator.evaluate!('all({1,2,3,4}, x, x > 10)')).to be_falsy
512
+ expect(calculator.evaluate!('all(users, u, u.age > 33)', users: [
513
+ {name: "Bob", age: 44},
514
+ {name: "Jane", age: 27}
515
+ ])).to be_falsy
516
+ expect(calculator.evaluate!('all(users, u, u.age < 50)', users: [
517
+ {name: "Bob", age: 44},
518
+ {name: "Jane", age: 27}
519
+ ])).to be_truthy
520
+ end
521
+ end
522
+
523
+ describe "map" do
524
+ it "maps values" do
525
+ expect(calculator.evaluate!('map(xs, x, x * 2)', xs: [1, 2, 3, 4])).to eq([2, 4, 6, 8])
526
+ expect(calculator.evaluate!('map({1,2,3,4}, x, x * 2)')).to eq([2, 4, 6, 8])
527
+ expect(calculator.evaluate!('map(users, u, u.age)', users: [
528
+ {name: "Bob", age: 44},
529
+ {name: "Jane", age: 27}
530
+ ])).to eq([44, 27])
531
+ expect(calculator.evaluate!('map(users, u, u.age)', users: [
532
+ {"name" => "Bob", "age" => 44},
533
+ {"name" => "Jane", "age" => 27}
534
+ ])).to eq([44, 27])
535
+ expect(calculator.evaluate!('map(users, u, u.name)', users: [
536
+ {name: "Bob", age: 44},
537
+ {name: "Jane", age: 27}
538
+ ])).to eq(["Bob", "Jane"])
539
+ expect(calculator.evaluate!('map(users, u, u.name)', users: [
540
+ {"name" => "Bob", "age" => 44},
541
+ {"name" => "Jane", "age" => 27}
542
+ ])).to eq(["Bob", "Jane"])
543
+ expect(calculator.evaluate!('map(users, u, IF(u.age < 30, u, null))', users: [
544
+ {"name" => "Bob", "age" => 44},
545
+ {"name" => "Jane", "age" => 27}
546
+ ])).to eq([nil, { "name" => "Jane", "age" => 27 }])
547
+ end
548
+ end
549
+
550
+ describe "pluck" do
551
+ it "plucks values from array of hashes" do
552
+ expect(calculator.evaluate!('pluck(users, age)', users: [
553
+ {name: "Bob", age: 44},
554
+ {name: "Jane", age: 27}
555
+ ])).to eq([44, 27])
556
+ expect(calculator.evaluate!('pluck(users, age)', users: [
557
+ {"name" => "Bob", "age" => 44},
558
+ {"name" => "Jane", "age" => 27}
559
+ ])).to eq([44, 27])
560
+ expect(calculator.evaluate!('pluck(users, name)', users: [
561
+ {name: "Bob", age: 44},
562
+ {name: "Jane", age: 27}
563
+ ])).to eq(["Bob", "Jane"])
564
+ expect(calculator.evaluate!('pluck(users, name)', users: [
565
+ {"name" => "Bob", "age" => 44},
566
+ {"name" => "Jane", "age" => 27}
567
+ ])).to eq(["Bob", "Jane"])
568
+ end
569
+ end
570
+
326
571
  it 'evaluates functions with stored variables' do
327
572
  calculator.store("multi_color" => true, "number_of_sheets" => 5000, "sheets_per_minute_black" => 2000, "sheets_per_minute_color" => 1000)
328
573
  result = calculator.evaluate('number_of_sheets / if(multi_color, sheets_per_minute_color, sheets_per_minute_black)')
@@ -419,14 +664,14 @@ describe Dentaku::Calculator do
419
664
  expect(calculator.evaluate(formula, number: 6)).to eq(2)
420
665
  end
421
666
 
422
- it 'throws an exception when no match and there is no default value' do
667
+ it 'raises an exception when no match and there is no default value' do
423
668
  formula = <<-FORMULA
424
669
  CASE number
425
670
  WHEN 42
426
671
  THEN 1
427
672
  END
428
673
  FORMULA
429
- expect { calculator.evaluate(formula, number: 2) }
674
+ expect { calculator.evaluate!(formula, number: 2) }
430
675
  .to raise_error("No block matched the switch value '2'")
431
676
  end
432
677
 
@@ -459,6 +704,31 @@ describe Dentaku::Calculator do
459
704
  expect(value).to eq(5)
460
705
  end
461
706
 
707
+ it 'handles nested case statements with case-sensitivity' do
708
+ formula = <<-FORMULA
709
+ CASE fruit
710
+ WHEN 'apple'
711
+ THEN 1 * quantity
712
+ WHEN 'banana'
713
+ THEN
714
+ CASE QUANTITY
715
+ WHEN 1 THEN 2
716
+ WHEN 10 THEN
717
+ CASE type
718
+ WHEN 'organic' THEN 5
719
+ END
720
+ END
721
+ END
722
+ FORMULA
723
+ value = with_case_sensitivity.evaluate(
724
+ formula,
725
+ type: 'organic',
726
+ quantity: 1,
727
+ QUANTITY: 10,
728
+ fruit: 'banana')
729
+ expect(value).to eq(5)
730
+ end
731
+
462
732
  it 'handles multiple nested case statements' do
463
733
  formula = <<-FORMULA
464
734
  CASE fruit
@@ -492,12 +762,23 @@ describe Dentaku::Calculator do
492
762
  Math.methods(false).each do |method|
493
763
  it method do
494
764
  if Math.method(method).arity == 2
495
- expect(calculator.evaluate("#{method}(x,y)", x: 1, y: '2')).to eq Math.send(method, 1, 2)
765
+ expect(calculator.evaluate("#{method}(x,y)", x: 1, y: '2')).to eq(Math.send(method, 1, 2))
766
+ expect(calculator.evaluate("#{method}(x,y) + 1", x: 1, y: '2')).to be_within(0.00001).of(Math.send(method, 1, 2) + 1)
767
+ expect { calculator.evaluate!("#{method}(x)", x: 1) }.to raise_error(Dentaku::ParseError)
496
768
  else
497
- expect(calculator.evaluate("#{method}(1)")).to eq Math.send(method, 1)
769
+ expect(calculator.evaluate("#{method}(1)")).to eq(Math.send(method, 1))
770
+ unless [:atanh, :frexp, :lgamma].include?(method)
771
+ expect(calculator.evaluate("#{method}(1) + 1")).to be_within(0.00001).of(Math.send(method, 1) + 1)
772
+ end
498
773
  end
499
774
  end
500
775
  end
776
+
777
+ it 'are defined with a properly named class that represents it to support AST marshaling' do
778
+ expect {
779
+ Marshal.dump(calculator.ast('SQRT(20)'))
780
+ }.not_to raise_error
781
+ end
501
782
  end
502
783
 
503
784
  describe 'disable_cache' do
@@ -528,7 +809,7 @@ describe Dentaku::Calculator do
528
809
  end
529
810
 
530
811
  it 'clears all items from cache' do
531
- expect(calculator.ast_cache.length).to eq 3
812
+ expect(calculator.ast_cache.length).to eq(3)
532
813
  calculator.clear_cache
533
814
  expect(calculator.ast_cache.keys).to be_empty
534
815
  end
@@ -551,21 +832,31 @@ describe Dentaku::Calculator do
551
832
  it 'concatenates strings' do
552
833
  expect(
553
834
  calculator.evaluate('CONCAT(s1, s2, s3)', 's1' => 'ab', 's2' => 'cd', 's3' => 'ef')
554
- ).to eq 'abcdef'
835
+ ).to eq('abcdef')
836
+ end
837
+
838
+ it 'manipulates string arguments' do
839
+ expect(calculator.evaluate("left('ABCD', 2)")).to eq('AB')
840
+ expect(calculator.evaluate("right('ABCD', 2)")).to eq('CD')
841
+ expect(calculator.evaluate("mid('ABCD', 2, 2)")).to eq('BC')
842
+ expect(calculator.evaluate("len('ABCD')")).to eq(4)
843
+ expect(calculator.evaluate("find('BC', 'ABCD')")).to eq(2)
844
+ expect(calculator.evaluate("substitute('ABCD', 'BC', 'XY')")).to eq('AXYD')
845
+ expect(calculator.evaluate("contains('BC', 'ABCD')")).to be_truthy
555
846
  end
556
847
  end
557
848
 
558
849
  describe 'zero-arity functions' do
559
850
  it 'can be used in formulas' do
560
851
  calculator.add_function(:two, :numeric, -> { 2 })
561
- expect(calculator.evaluate("max(two(), 1)")).to eq 2
562
- expect(calculator.evaluate("max(1, two())")).to eq 2
852
+ expect(calculator.evaluate("max(two(), 1)")).to eq(2)
853
+ expect(calculator.evaluate("max(1, two())")).to eq(2)
563
854
  end
564
855
  end
565
856
 
566
857
  describe 'aliases' do
567
858
  it 'accepts aliases as instance option' do
568
- expect(with_aliases.evaluate('rrround(5.1)')).to eq 5
859
+ expect(with_aliases.evaluate('rrround(5.1)')).to eq(5)
569
860
  end
570
861
  end
571
862
 
@@ -584,4 +875,22 @@ describe Dentaku::Calculator do
584
875
  end.to raise_error(Dentaku::UnboundVariableError)
585
876
  end
586
877
  end
878
+
879
+ describe 'identifier cache' do
880
+ it 'reduces call count by caching results of resolved identifiers' do
881
+ called = 0
882
+ calculator.store_formula("A1", "B1+B1+B1")
883
+ calculator.store_formula("B1", "C1+C1+C1+C1")
884
+ calculator.store_formula("C1", "D1")
885
+ calculator.store("D1", proc { called += 1; 1 })
886
+
887
+ expect {
888
+ Dentaku.enable_identifier_cache!
889
+ }.to change {
890
+ called = 0
891
+ calculator.evaluate("A1")
892
+ called
893
+ }.from(12).to(1)
894
+ end
895
+ end
587
896
  end
data/spec/dentaku_spec.rb CHANGED
@@ -22,18 +22,31 @@ describe Dentaku do
22
22
 
23
23
  it 'raises a parse error for bad logic expressions' do
24
24
  expect {
25
- Dentaku('true AND')
25
+ Dentaku!('true AND')
26
26
  }.to raise_error(Dentaku::ParseError)
27
27
  end
28
28
 
29
29
  it 'evaluates with class-level shortcut functions' do
30
- expect(Dentaku.evaluate('2+2')).to eq(4)
31
- expect(Dentaku.evaluate!('2+2')).to eq(4)
32
- expect { Dentaku.evaluate!('a+1') }.to raise_error(Dentaku::UnboundVariableError)
30
+ expect(described_class.evaluate('2+2')).to eq(4)
31
+ expect(described_class.evaluate!('2+2')).to eq(4)
32
+ expect { described_class.evaluate!('a+1') }.to raise_error(Dentaku::UnboundVariableError)
33
+ end
34
+
35
+ it 'accepts a block for custom handling of unbound variables' do
36
+ unbound = 'apples * 1.5'
37
+ expect(described_class.evaluate(unbound) { :bar }).to eq(:bar)
38
+ expect(described_class.evaluate(unbound) { |e| e }).to eq(unbound)
33
39
  end
34
40
 
35
41
  it 'evaluates with class-level aliases' do
36
- Dentaku.aliases = { roundup: ['roundupup'] }
37
- expect(Dentaku.evaluate('roundupup(6.1)')).to eq(7)
42
+ described_class.aliases = { roundup: ['roundupup'] }
43
+ expect(described_class.evaluate('roundupup(6.1)')).to eq(7)
44
+ end
45
+
46
+ it 'sets caching opt-in flags' do
47
+ expect {
48
+ described_class.enable_caching!
49
+ }.to change { described_class.cache_ast? }.from(false).to(true)
50
+ .and change { described_class.cache_dependency_order? }.from(false).to(true)
38
51
  end
39
52
  end
@@ -1,4 +1,5 @@
1
1
  require 'spec_helper'
2
+ require 'dentaku'
2
3
  require 'dentaku/calculator'
3
4
 
4
5
  describe Dentaku::Calculator do
@@ -14,6 +15,7 @@ describe Dentaku::Calculator do
14
15
  [:pow, :numeric, ->(mantissa, exponent) { mantissa**exponent }],
15
16
  [:biggest, :numeric, ->(*args) { args.max }],
16
17
  [:smallest, :numeric, ->(*args) { args.min }],
18
+ [:optional, :numeric, ->(x, y, z = 0) { x + y + z }],
17
19
  ]
18
20
 
19
21
  c.add_functions(fns)
@@ -39,6 +41,13 @@ describe Dentaku::Calculator do
39
41
  expect(with_external_funcs.evaluate('SMALLEST(8,6,7,5,3,0,9)')).to eq(0)
40
42
  end
41
43
 
44
+ it 'includes OPTIONAL' do
45
+ expect(with_external_funcs.evaluate('OPTIONAL(1,2)')).to eq(3)
46
+ expect(with_external_funcs.evaluate('OPTIONAL(1,2,3)')).to eq(6)
47
+ expect { with_external_funcs.dependencies('OPTIONAL()') }.to raise_error(Dentaku::ParseError)
48
+ expect { with_external_funcs.dependencies('OPTIONAL(1,2,3,4)') }.to raise_error(Dentaku::ParseError)
49
+ end
50
+
42
51
  it 'supports array parameters' do
43
52
  calculator = described_class.new
44
53
  calculator.add_function(
@@ -59,6 +68,19 @@ describe Dentaku::Calculator do
59
68
  expect(calculator.evaluate("hey!()")).to eq("hey!")
60
69
  end
61
70
 
71
+ it 'defines for a given function a properly named class that represents it to support AST marshaling' do
72
+ calculator = described_class.new
73
+ expect {
74
+ calculator.add_function(:ho, :string, -> {})
75
+ }.to change {
76
+ Dentaku::AST::Function.const_defined?("Ho")
77
+ }.from(false).to(true)
78
+
79
+ expect {
80
+ Marshal.dump(calculator.ast('MAX(1, 2)'))
81
+ }.not_to raise_error
82
+ end
83
+
62
84
  it 'does not store functions across all calculators' do
63
85
  calculator1 = Dentaku::Calculator.new
64
86
  calculator1.add_function(:my_function, :numeric, ->(x) { 2 * x + 1 })
@@ -66,15 +88,19 @@ describe Dentaku::Calculator do
66
88
  calculator2 = Dentaku::Calculator.new
67
89
  calculator2.add_function(:my_function, :numeric, ->(x) { 4 * x + 3 })
68
90
 
69
- expect(calculator1.evaluate("1 + my_function(2)")). to eq (1 + 2 * 2 + 1)
70
- expect(calculator2.evaluate("1 + my_function(2)")). to eq (1 + 4 * 2 + 3)
91
+ expect(calculator1.evaluate!("1 + my_function(2)")). to eq(1 + 2 * 2 + 1)
92
+ expect(calculator2.evaluate!("1 + my_function(2)")). to eq(1 + 4 * 2 + 3)
71
93
 
72
- expect { Dentaku::Calculator.new.evaluate("1 + my_function(2)") }.to raise_error(Dentaku::ParseError)
94
+ expect {
95
+ Dentaku::Calculator.new.evaluate!("1 + my_function(2)")
96
+ }.to raise_error(Dentaku::ParseError)
73
97
  end
74
98
 
75
- it 'self.add_function adds to default/global function registry' do
76
- Dentaku::Calculator.add_function(:global_function, :numeric, ->(x) { 10 + x**2 })
77
- expect(Dentaku::Calculator.new.evaluate("global_function(3) + 5")).to eq (10 + 3**2 + 5)
99
+ describe 'Dentaku::Calculator.add_function' do
100
+ it 'adds to default/global function registry' do
101
+ Dentaku::Calculator.add_function(:global_function, :numeric, ->(x) { 10 + x**2 })
102
+ expect(Dentaku::Calculator.new.evaluate("global_function(3) + 5")).to eq(10 + 3**2 + 5)
103
+ end
78
104
  end
79
105
  end
80
106
  end