dentaku 3.3.0 → 3.4.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (80) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +5 -10
  3. data/.travis.yml +3 -6
  4. data/CHANGELOG.md +38 -1
  5. data/README.md +2 -2
  6. data/dentaku.gemspec +0 -2
  7. data/lib/dentaku.rb +14 -6
  8. data/lib/dentaku/ast.rb +5 -0
  9. data/lib/dentaku/ast/access.rb +15 -1
  10. data/lib/dentaku/ast/arithmetic.rb +29 -6
  11. data/lib/dentaku/ast/array.rb +15 -1
  12. data/lib/dentaku/ast/case.rb +13 -3
  13. data/lib/dentaku/ast/case/case_conditional.rb +13 -2
  14. data/lib/dentaku/ast/case/case_else.rb +12 -4
  15. data/lib/dentaku/ast/case/case_switch_variable.rb +8 -0
  16. data/lib/dentaku/ast/case/case_then.rb +12 -4
  17. data/lib/dentaku/ast/case/case_when.rb +12 -4
  18. data/lib/dentaku/ast/function.rb +11 -2
  19. data/lib/dentaku/ast/function_registry.rb +21 -0
  20. data/lib/dentaku/ast/functions/all.rb +36 -0
  21. data/lib/dentaku/ast/functions/any.rb +36 -0
  22. data/lib/dentaku/ast/functions/avg.rb +2 -2
  23. data/lib/dentaku/ast/functions/count.rb +8 -0
  24. data/lib/dentaku/ast/functions/duration.rb +51 -0
  25. data/lib/dentaku/ast/functions/if.rb +15 -2
  26. data/lib/dentaku/ast/functions/map.rb +36 -0
  27. data/lib/dentaku/ast/functions/mul.rb +3 -2
  28. data/lib/dentaku/ast/functions/pluck.rb +29 -0
  29. data/lib/dentaku/ast/functions/round.rb +1 -1
  30. data/lib/dentaku/ast/functions/rounddown.rb +1 -1
  31. data/lib/dentaku/ast/functions/roundup.rb +1 -1
  32. data/lib/dentaku/ast/functions/ruby_math.rb +47 -3
  33. data/lib/dentaku/ast/functions/string_functions.rb +68 -4
  34. data/lib/dentaku/ast/functions/sum.rb +3 -2
  35. data/lib/dentaku/ast/grouping.rb +3 -1
  36. data/lib/dentaku/ast/identifier.rb +5 -1
  37. data/lib/dentaku/ast/negation.rb +3 -1
  38. data/lib/dentaku/ast/node.rb +4 -0
  39. data/lib/dentaku/ast/operation.rb +8 -0
  40. data/lib/dentaku/bulk_expression_solver.rb +34 -25
  41. data/lib/dentaku/calculator.rb +19 -6
  42. data/lib/dentaku/date_arithmetic.rb +45 -0
  43. data/lib/dentaku/exceptions.rb +4 -4
  44. data/lib/dentaku/flat_hash.rb +9 -2
  45. data/lib/dentaku/parser.rb +31 -14
  46. data/lib/dentaku/token_matcher.rb +1 -1
  47. data/lib/dentaku/token_scanner.rb +1 -1
  48. data/lib/dentaku/tokenizer.rb +7 -2
  49. data/lib/dentaku/version.rb +1 -1
  50. data/spec/ast/addition_spec.rb +7 -1
  51. data/spec/ast/and_function_spec.rb +6 -6
  52. data/spec/ast/and_spec.rb +1 -1
  53. data/spec/ast/arithmetic_spec.rb +57 -29
  54. data/spec/ast/avg_spec.rb +9 -5
  55. data/spec/ast/count_spec.rb +7 -7
  56. data/spec/ast/division_spec.rb +7 -1
  57. data/spec/ast/function_spec.rb +9 -9
  58. data/spec/ast/max_spec.rb +3 -3
  59. data/spec/ast/min_spec.rb +3 -3
  60. data/spec/ast/mul_spec.rb +10 -6
  61. data/spec/ast/negation_spec.rb +48 -0
  62. data/spec/ast/node_spec.rb +11 -8
  63. data/spec/ast/numeric_spec.rb +1 -1
  64. data/spec/ast/or_spec.rb +6 -6
  65. data/spec/ast/round_spec.rb +14 -4
  66. data/spec/ast/rounddown_spec.rb +14 -4
  67. data/spec/ast/roundup_spec.rb +14 -4
  68. data/spec/ast/string_functions_spec.rb +35 -0
  69. data/spec/ast/sum_spec.rb +10 -6
  70. data/spec/ast/switch_spec.rb +5 -5
  71. data/spec/bulk_expression_solver_spec.rb +18 -1
  72. data/spec/calculator_spec.rb +173 -28
  73. data/spec/dentaku_spec.rb +18 -5
  74. data/spec/external_function_spec.rb +29 -5
  75. data/spec/parser_spec.rb +85 -123
  76. data/spec/spec_helper.rb +6 -4
  77. data/spec/token_matcher_spec.rb +8 -8
  78. data/spec/token_scanner_spec.rb +4 -4
  79. data/spec/tokenizer_spec.rb +32 -13
  80. metadata +11 -4
@@ -26,6 +26,10 @@ describe Dentaku::AST::StringFunctions::Left do
26
26
  expect(subject.value('string' => 'abcdefg', 'length' => 40)).to eq 'abcdefg'
27
27
  end
28
28
 
29
+ it 'accepts strings as length if they can be parsed to a number' do
30
+ expect(subject.value('string' => 'ABCDEFG', 'length' => '4')).to eq 'ABCD'
31
+ end
32
+
29
33
  it 'has the proper type' do
30
34
  expect(subject.type).to eq(:string)
31
35
  end
@@ -35,6 +39,12 @@ describe Dentaku::AST::StringFunctions::Left do
35
39
  subject.value('string' => 'abcdefg', 'length' => -2)
36
40
  }.to raise_error(Dentaku::ArgumentError, /LEFT\(\) requires length to be positive/)
37
41
  end
42
+
43
+ it 'raises an error when given a junk length' do
44
+ expect {
45
+ subject.value('string' => 'abcdefg', 'length' => 'junk')
46
+ }.to raise_error(Dentaku::ArgumentError, "'junk' is not coercible to numeric")
47
+ end
38
48
  end
39
49
 
40
50
  describe Dentaku::AST::StringFunctions::Right do
@@ -53,9 +63,19 @@ describe Dentaku::AST::StringFunctions::Right do
53
63
  expect(subject.value).to eq 'abcdefg'
54
64
  end
55
65
 
66
+ it 'accepts strings as length if they can be parsed to a number' do
67
+ subject = described_class.new(literal('ABCDEFG'), literal('4'))
68
+ expect(subject.value).to eq 'DEFG'
69
+ end
70
+
56
71
  it 'has the proper type' do
57
72
  expect(subject.type).to eq(:string)
58
73
  end
74
+
75
+ it 'raises an error when given a junk length' do
76
+ subject = described_class.new(literal('abcdefg'), literal('junk'))
77
+ expect { subject.value }.to raise_error(Dentaku::ArgumentError, "'junk' is not coercible to numeric")
78
+ end
59
79
  end
60
80
 
61
81
  describe Dentaku::AST::StringFunctions::Mid do
@@ -79,9 +99,24 @@ describe Dentaku::AST::StringFunctions::Mid do
79
99
  expect(subject.value).to eq 'defg'
80
100
  end
81
101
 
102
+ it 'accepts strings as offset and length if they can be parsed to a number' do
103
+ subject = described_class.new(literal('ABCDEFG'), literal('4'), literal('2'))
104
+ expect(subject.value).to eq 'DE'
105
+ end
106
+
82
107
  it 'has the proper type' do
83
108
  expect(subject.type).to eq(:string)
84
109
  end
110
+
111
+ it 'raises an error when given a junk offset' do
112
+ subject = described_class.new(literal('abcdefg'), literal('junk offset'), literal(2))
113
+ expect { subject.value }.to raise_error(Dentaku::ArgumentError, "'junk offset' is not coercible to numeric")
114
+ end
115
+
116
+ it 'raises an error when given a junk length' do
117
+ subject = described_class.new(literal('abcdefg'), literal(4), literal('junk'))
118
+ expect { subject.value }.to raise_error(Dentaku::ArgumentError, "'junk' is not coercible to numeric")
119
+ end
85
120
  end
86
121
 
87
122
  describe Dentaku::AST::StringFunctions::Len do
@@ -5,34 +5,38 @@ require 'dentaku'
5
5
  describe 'Dentaku::AST::Function::Sum' do
6
6
  it 'returns the sum of an array of Numeric values' do
7
7
  result = Dentaku('SUM(1, x, 1.8)', x: 2.3)
8
- expect(result).to eq 5.1
8
+ expect(result).to eq(5.1)
9
9
  end
10
10
 
11
11
  it 'returns the sum of a single entry array of a Numeric value' do
12
12
  result = Dentaku('SUM(x)', x: 2.3)
13
- expect(result).to eq 2.3
13
+ expect(result).to eq(2.3)
14
14
  end
15
15
 
16
16
  it 'returns the sum even if a String is passed' do
17
17
  result = Dentaku('SUM(1, x, 1.8)', x: '2.3')
18
- expect(result).to eq 5.1
18
+ expect(result).to eq(5.1)
19
19
  end
20
20
 
21
21
  it 'returns the sum even if an array is passed' do
22
22
  result = Dentaku('SUM(1, x, 2.3)', x: [4, 5])
23
- expect(result).to eq 12.3
23
+ expect(result).to eq(12.3)
24
24
  end
25
25
 
26
26
  it 'returns the sum of nested sums' do
27
27
  result = Dentaku('SUM(1, x, SUM(4, 5))', x: '2.3')
28
- expect(result).to eq 12.3
28
+ expect(result).to eq(12.3)
29
29
  end
30
30
 
31
31
  context 'checking errors' do
32
32
  let(:calculator) { Dentaku::Calculator.new }
33
33
 
34
34
  it 'raises an error if no arguments are passed' do
35
- expect { calculator.evaluate!('SUM()') }.to raise_error(ArgumentError)
35
+ expect { calculator.evaluate!('SUM()') }.to raise_error(Dentaku::ArgumentError)
36
+ end
37
+
38
+ it 'raises an error if an empty array is passed' do
39
+ expect { calculator.evaluate!('SUM(x)', x: []) }.to raise_error(Dentaku::ArgumentError)
36
40
  end
37
41
  end
38
42
  end
@@ -5,26 +5,26 @@ require 'dentaku'
5
5
  describe 'Dentaku::AST::Function::Switch' do
6
6
  it 'returns the match if present in argumtents' do
7
7
  result = Dentaku('SWITCH(1, 1, "one", 2, "two")')
8
- expect(result).to eq 'one'
8
+ expect(result).to eq('one')
9
9
  end
10
10
 
11
11
  it 'returns nil if no match was found' do
12
12
  result = Dentaku('SWITCH(3, 1, "one", 2, "two")')
13
- expect(result).to eq nil
13
+ expect(result).to eq(nil)
14
14
  end
15
15
 
16
16
  it 'returns the default value if present and no match was found' do
17
17
  result = Dentaku('SWITCH(3, 1, "one", 2, "two", "no match")')
18
- expect(result).to eq 'no match'
18
+ expect(result).to eq('no match')
19
19
  end
20
20
 
21
21
  it 'returns the first match if multiple matches exist' do
22
22
  result = Dentaku('SWITCH(1, 1, "one", 2, "two", 1, "three")')
23
- expect(result).to eq 'one'
23
+ expect(result).to eq('one')
24
24
  end
25
25
 
26
26
  it 'does not return a match where a value matches the search value' do
27
27
  result = Dentaku('SWITCH(1, "one", 1, 2, "two", 3)')
28
- expect(result).to eq 3
28
+ expect(result).to eq(3)
29
29
  end
30
30
  end
@@ -26,6 +26,13 @@ RSpec.describe Dentaku::BulkExpressionSolver do
26
26
  }.to raise_error(Dentaku::UnboundVariableError)
27
27
  end
28
28
 
29
+ it "properly handles access on an unbound variable" do
30
+ expressions = {more_apples: "apples[0]"}
31
+ expect {
32
+ described_class.new(expressions, calculator).solve!
33
+ }.to raise_error(Dentaku::UnboundVariableError)
34
+ end
35
+
29
36
  it "lets you know if the result is a div/0 error" do
30
37
  expressions = {more_apples: "1/0"}
31
38
  expect {
@@ -39,6 +46,16 @@ RSpec.describe Dentaku::BulkExpressionSolver do
39
46
  expect(solver.solve!).to eq("the value of x, incremented" => 4)
40
47
  end
41
48
 
49
+ it "allows self-referential formulas" do
50
+ expressions = { x: "x + 1" }
51
+ solver = described_class.new(expressions, calculator.store(x: 1))
52
+ expect(solver.solve!).to eq(x: 2)
53
+
54
+ expressions = { x: "y + 3", y: "x * 2" }
55
+ solver = described_class.new(expressions, calculator.store(x: 5, y: 3))
56
+ expect(solver.solve!).to eq(x: 6, y: 12) # x = 6 by the time y is calculated
57
+ end
58
+
42
59
  it "evaluates expressions in hashes and arrays, and expands the results" do
43
60
  calculator.store(
44
61
  fruit_quantities: {
@@ -137,7 +154,7 @@ RSpec.describe Dentaku::BulkExpressionSolver do
137
154
  f: "e[0] + 1"
138
155
  }
139
156
  results = described_class.new(expressions, calculator).solve
140
- expect(results[:f]).to eq 10
157
+ expect(results[:f]).to eq(10)
141
158
  end
142
159
 
143
160
  it 'uses stored values for expressions when they are known' do
@@ -74,6 +74,11 @@ describe Dentaku::Calculator do
74
74
  expect(calculator.evaluate('NOT(a)', a: nil, b: nil)).to be_truthy
75
75
  expect(calculator.evaluate('OR(a,b)', a: nil, b: nil)).to be_falsy
76
76
  end
77
+
78
+ it 'supports lazy evaluation of variables' do
79
+ expect(calculator.evaluate('x + 1', x: -> { 1 })).to eq(2)
80
+ expect { calculator.evaluate('2', x: -> { raise 'boom' }) }.not_to raise_error
81
+ end
77
82
  end
78
83
 
79
84
  describe 'evaluate!' do
@@ -103,6 +108,10 @@ describe Dentaku::Calculator do
103
108
  expect { calculator.evaluate!('ROUNDUP(a)', a: nil) }.to raise_error(Dentaku::ArgumentError)
104
109
  expect { calculator.evaluate!('SUM(a,b)', a: nil, b: nil) }.to raise_error(Dentaku::ArgumentError)
105
110
  end
111
+
112
+ it 'raises argument error if a function is called with incorrect arity' do
113
+ expect { calculator.evaluate!('IF(a,b)', a: 1, b: 1) }.to raise_error(Dentaku::ParseError)
114
+ end
106
115
  end
107
116
 
108
117
  it 'supports unicode characters in identifiers' do
@@ -121,31 +130,32 @@ describe Dentaku::Calculator do
121
130
 
122
131
  it 'can store the value `false`' do
123
132
  calculator.store('i_am_false', false)
124
- expect(calculator.evaluate!('i_am_false')).to eq false
133
+ expect(calculator.evaluate!('i_am_false')).to eq(false)
125
134
  end
126
135
 
127
136
  it 'can store multiple values' do
128
137
  calculator.store(first: 1, second: 2)
129
- expect(calculator.evaluate!('first')).to eq 1
130
- expect(calculator.evaluate!('second')).to eq 2
138
+ expect(calculator.evaluate!('first')).to eq(1)
139
+ expect(calculator.evaluate!('second')).to eq(2)
131
140
  end
132
141
 
133
142
  it 'stores formulas' do
134
143
  calculator.store_formula('area', 'length * width')
135
- expect(calculator.evaluate!('area', length: 5, width: 5)).to eq 25
144
+ expect(calculator.evaluate!('area', length: 5, width: 5)).to eq(25)
136
145
  end
137
146
 
138
147
  it 'stores nested hashes' do
139
148
  calculator.store(a: {basket: {of: 'apples'}}, b: 2)
140
- expect(calculator.evaluate!('a.basket.of')).to eq 'apples'
141
- expect(calculator.evaluate!('b')).to eq 2
149
+ expect(calculator.evaluate!('a.basket.of')).to eq('apples')
150
+ expect(calculator.evaluate!('a.basket')).to eq(of: 'apples')
151
+ expect(calculator.evaluate!('b')).to eq(2)
142
152
  end
143
153
 
144
154
  it 'stores arrays' do
145
155
  calculator.store(a: [1, 2, 3])
146
- expect(calculator.evaluate!('a[0]')).to eq 1
147
- expect(calculator.evaluate!('a[x]', x: 1)).to eq 2
148
- expect(calculator.evaluate!('a[x+1]', x: 1)).to eq 3
156
+ expect(calculator.evaluate!('a[0]')).to eq(1)
157
+ expect(calculator.evaluate!('a[x]', x: 1)).to eq(2)
158
+ expect(calculator.evaluate!('a[x+1]', x: 1)).to eq(3)
149
159
  end
150
160
 
151
161
  it 'evaluates arrays' do
@@ -187,6 +197,15 @@ describe Dentaku::Calculator do
187
197
  )).to eq(pear: 1, weekly_apple_budget: 21, weekly_fruit_budget: 25)
188
198
  end
189
199
 
200
+ it "prefers variables over values in memory if they have no dependencies" do
201
+ expect(with_memory.solve!(
202
+ weekly_fruit_budget: "weekly_apple_budget + pear * 4",
203
+ weekly_apple_budget: "apples * 7",
204
+ pear: "1",
205
+ apples: "4"
206
+ )).to eq(apples: 4, pear: 1, weekly_apple_budget: 28, weekly_fruit_budget: 32)
207
+ end
208
+
190
209
  it "preserves hash keys" do
191
210
  expect(calculator.solve!(
192
211
  'meaning_of_life' => 'age + kids',
@@ -203,7 +222,7 @@ describe Dentaku::Calculator do
203
222
 
204
223
  it 'is case-insensitive' do
205
224
  result = with_memory.solve!(total_fruit: "Apples + pears", pears: 10)
206
- expect(result[:total_fruit]).to eq 13
225
+ expect(result[:total_fruit]).to eq(13)
207
226
  end
208
227
 
209
228
  it "lets you know if a variable is unbound" do
@@ -223,7 +242,7 @@ describe Dentaku::Calculator do
223
242
  width: "length * 2",
224
243
  )
225
244
 
226
- expect(result[:weight]).to eq 130.368
245
+ expect(result[:weight]).to eq(130.368)
227
246
  end
228
247
 
229
248
  it 'raises an exception if there are cyclic dependencies' do
@@ -304,14 +323,18 @@ describe Dentaku::Calculator do
304
323
  unbound = 'foo * 1.5'
305
324
  expect { calculator.evaluate!(unbound) }.to raise_error(Dentaku::UnboundVariableError)
306
325
  expect { calculator.evaluate!(unbound) }.to raise_error do |error|
307
- expect(error.unbound_variables).to eq ['foo']
326
+ expect(error.unbound_variables).to eq(['foo'])
308
327
  end
309
328
  expect { calculator.evaluate!('a + b') }.to raise_error do |error|
310
- expect(error.unbound_variables).to eq ['a', 'b']
329
+ expect(error.unbound_variables).to eq(['a', 'b'])
311
330
  end
312
331
  expect(calculator.evaluate(unbound)).to be_nil
313
- expect(calculator.evaluate(unbound) { :bar }).to eq :bar
314
- expect(calculator.evaluate(unbound) { |e| e }).to eq unbound
332
+ end
333
+
334
+ it 'accepts a block for custom handling of unbound variables' do
335
+ unbound = 'foo * 1.5'
336
+ expect(calculator.evaluate(unbound) { :bar }).to eq(:bar)
337
+ expect(calculator.evaluate(unbound) { |e| e }).to eq(unbound)
315
338
  end
316
339
 
317
340
  it 'fails to evaluate incomplete statements' do
@@ -365,20 +388,35 @@ describe Dentaku::Calculator do
365
388
  expect(calculator.evaluate('some_boolean OR 7 < 5', some_boolean: false)).to be_falsey
366
389
  end
367
390
 
368
- it 'compares Time variables' do
391
+ it 'compares time variables' do
369
392
  expect(calculator.evaluate('t1 < t2', t1: Time.local(2017, 1, 1).to_datetime, t2: Time.local(2017, 1, 2).to_datetime)).to be_truthy
370
393
  expect(calculator.evaluate('t1 < t2', t1: Time.local(2017, 1, 2).to_datetime, t2: Time.local(2017, 1, 1).to_datetime)).to be_falsy
371
394
  expect(calculator.evaluate('t1 > t2', t1: Time.local(2017, 1, 1).to_datetime, t2: Time.local(2017, 1, 2).to_datetime)).to be_falsy
372
395
  expect(calculator.evaluate('t1 > t2', t1: Time.local(2017, 1, 2).to_datetime, t2: Time.local(2017, 1, 1).to_datetime)).to be_truthy
373
396
  end
374
397
 
375
- it 'compares Time literals with Time variables' do
398
+ it 'compares time literals with time variables' do
376
399
  expect(calculator.evaluate('t1 < 2017-01-02', t1: Time.local(2017, 1, 1).to_datetime)).to be_truthy
377
400
  expect(calculator.evaluate('t1 < 2017-01-02', t1: Time.local(2017, 1, 3).to_datetime)).to be_falsy
378
401
  expect(calculator.evaluate('t1 > 2017-01-02', t1: Time.local(2017, 1, 1).to_datetime)).to be_falsy
379
402
  expect(calculator.evaluate('t1 > 2017-01-02', t1: Time.local(2017, 1, 3).to_datetime)).to be_truthy
380
403
  end
381
404
 
405
+ it 'supports date arithmetic' do
406
+ expect(calculator.evaluate!('2020-01-01 + 30').to_date).to eq(Time.local(2020, 1, 31).to_date)
407
+ expect(calculator.evaluate!('2020-01-01 - 1').to_date).to eq(Time.local(2019, 12, 31).to_date)
408
+ expect(calculator.evaluate!('2020-01-01 - 2019-12-31')).to eq(1)
409
+ expect(calculator.evaluate!('2020-01-01 + duration(1, day)').to_date).to eq(Time.local(2020, 1, 2).to_date)
410
+ expect(calculator.evaluate!('2020-01-01 - duration(1, day)').to_date).to eq(Time.local(2019, 12, 31).to_date)
411
+ expect(calculator.evaluate!('2020-01-01 + duration(30, days)').to_date).to eq(Time.local(2020, 1, 31).to_date)
412
+ expect(calculator.evaluate!('2020-01-01 + duration(1, month)').to_date).to eq(Time.local(2020, 2, 1).to_date)
413
+ expect(calculator.evaluate!('2020-01-01 - duration(1, month)').to_date).to eq(Time.local(2019, 12, 1).to_date)
414
+ expect(calculator.evaluate!('2020-01-01 + duration(30, months)').to_date).to eq(Time.local(2022, 7, 1).to_date)
415
+ expect(calculator.evaluate!('2020-01-01 + duration(1, year)').to_date).to eq(Time.local(2021, 1, 1).to_date)
416
+ expect(calculator.evaluate!('2020-01-01 - duration(1, year)').to_date).to eq(Time.local(2019, 1, 1).to_date)
417
+ expect(calculator.evaluate!('2020-01-01 + duration(30, years)').to_date).to eq(Time.local(2050, 1, 1).to_date)
418
+ end
419
+
382
420
  describe 'functions' do
383
421
  it 'include IF' do
384
422
  expect(calculator.evaluate('if(foo < 8, 10, 20)', foo: 2)).to eq(10)
@@ -390,7 +428,7 @@ describe Dentaku::Calculator do
390
428
  it 'include ROUND' do
391
429
  expect(calculator.evaluate('round(8.2)')).to eq(8)
392
430
  expect(calculator.evaluate('round(8.8)')).to eq(9)
393
- expect(calculator.evaluate('round(8.75, 1)')).to eq(BigDecimal.new('8.8'))
431
+ expect(calculator.evaluate('round(8.75, 1)')).to eq(BigDecimal('8.8'))
394
432
 
395
433
  expect(calculator.evaluate('ROUND(apples * 0.93)', apples: 10)).to eq(9)
396
434
  end
@@ -406,10 +444,88 @@ describe Dentaku::Calculator do
406
444
  it 'evaluates functions with negative numbers' do
407
445
  expect(calculator.evaluate('if (-1 < 5, -1, 5)')).to eq(-1)
408
446
  expect(calculator.evaluate('if (-1 = -1, -1, 5)')).to eq(-1)
409
- expect(calculator.evaluate('round(-1.23, 1)')).to eq(BigDecimal.new('-1.2'))
447
+ expect(calculator.evaluate('round(-1.23, 1)')).to eq(BigDecimal('-1.2'))
410
448
  expect(calculator.evaluate('NOT(some_boolean) AND -1 > 3', some_boolean: true)).to be_falsey
411
449
  end
412
450
 
451
+ describe "any" do
452
+ it "enumerates values and returns true if any evaluation is truthy" do
453
+ expect(calculator.evaluate!('any(xs, x, x > 3)', xs: [1, 2, 3, 4])).to be_truthy
454
+ expect(calculator.evaluate!('any(xs, x, x > 3)', xs: 3)).to be_falsy
455
+ expect(calculator.evaluate!('any({1,2,3,4}, x, x > 3)')).to be_truthy
456
+ expect(calculator.evaluate!('any({1,2,3,4}, x, x > 10)')).to be_falsy
457
+ expect(calculator.evaluate!('any(users, u, u.age > 33)', users: [
458
+ {name: "Bob", age: 44},
459
+ {name: "Jane", age: 27}
460
+ ])).to be_truthy
461
+ expect(calculator.evaluate!('any(users, u, u.age < 18)', users: [
462
+ {name: "Bob", age: 44},
463
+ {name: "Jane", age: 27}
464
+ ])).to be_falsy
465
+ end
466
+ end
467
+
468
+ describe "all" do
469
+ it "enumerates values and returns true if all evaluations are truthy" do
470
+ expect(calculator.evaluate!('all(xs, x, x > 3)', xs: [1, 2, 3, 4])).to be_falsy
471
+ expect(calculator.evaluate!('any(xs, x, x > 2)', xs: 3)).to be_truthy
472
+ expect(calculator.evaluate!('all({1,2,3,4}, x, x > 0)')).to be_truthy
473
+ expect(calculator.evaluate!('all({1,2,3,4}, x, x > 10)')).to be_falsy
474
+ expect(calculator.evaluate!('all(users, u, u.age > 33)', users: [
475
+ {name: "Bob", age: 44},
476
+ {name: "Jane", age: 27}
477
+ ])).to be_falsy
478
+ expect(calculator.evaluate!('all(users, u, u.age < 50)', users: [
479
+ {name: "Bob", age: 44},
480
+ {name: "Jane", age: 27}
481
+ ])).to be_truthy
482
+ end
483
+ end
484
+
485
+ describe "map" do
486
+ it "maps values" do
487
+ expect(calculator.evaluate!('map(xs, x, x * 2)', xs: [1, 2, 3, 4])).to eq([2, 4, 6, 8])
488
+ expect(calculator.evaluate!('map({1,2,3,4}, x, x * 2)')).to eq([2, 4, 6, 8])
489
+ expect(calculator.evaluate!('map(users, u, u.age)', users: [
490
+ {name: "Bob", age: 44},
491
+ {name: "Jane", age: 27}
492
+ ])).to eq([44, 27])
493
+ expect(calculator.evaluate!('map(users, u, u.age)', users: [
494
+ {"name" => "Bob", "age" => 44},
495
+ {"name" => "Jane", "age" => 27}
496
+ ])).to eq([44, 27])
497
+ expect(calculator.evaluate!('map(users, u, u.name)', users: [
498
+ {name: "Bob", age: 44},
499
+ {name: "Jane", age: 27}
500
+ ])).to eq(["Bob", "Jane"])
501
+ expect(calculator.evaluate!('map(users, u, u.name)', users: [
502
+ {"name" => "Bob", "age" => 44},
503
+ {"name" => "Jane", "age" => 27}
504
+ ])).to eq(["Bob", "Jane"])
505
+ end
506
+ end
507
+
508
+ describe "pluck" do
509
+ it "plucks values from array of hashes" do
510
+ expect(calculator.evaluate!('pluck(users, age)', users: [
511
+ {name: "Bob", age: 44},
512
+ {name: "Jane", age: 27}
513
+ ])).to eq([44, 27])
514
+ expect(calculator.evaluate!('pluck(users, age)', users: [
515
+ {"name" => "Bob", "age" => 44},
516
+ {"name" => "Jane", "age" => 27}
517
+ ])).to eq([44, 27])
518
+ expect(calculator.evaluate!('pluck(users, name)', users: [
519
+ {name: "Bob", age: 44},
520
+ {name: "Jane", age: 27}
521
+ ])).to eq(["Bob", "Jane"])
522
+ expect(calculator.evaluate!('pluck(users, name)', users: [
523
+ {"name" => "Bob", "age" => 44},
524
+ {"name" => "Jane", "age" => 27}
525
+ ])).to eq(["Bob", "Jane"])
526
+ end
527
+ end
528
+
413
529
  it 'evaluates functions with stored variables' do
414
530
  calculator.store("multi_color" => true, "number_of_sheets" => 5000, "sheets_per_minute_black" => 2000, "sheets_per_minute_color" => 1000)
415
531
  result = calculator.evaluate('number_of_sheets / if(multi_color, sheets_per_minute_color, sheets_per_minute_black)')
@@ -506,14 +622,14 @@ describe Dentaku::Calculator do
506
622
  expect(calculator.evaluate(formula, number: 6)).to eq(2)
507
623
  end
508
624
 
509
- it 'throws an exception when no match and there is no default value' do
625
+ it 'raises an exception when no match and there is no default value' do
510
626
  formula = <<-FORMULA
511
627
  CASE number
512
628
  WHEN 42
513
629
  THEN 1
514
630
  END
515
631
  FORMULA
516
- expect { calculator.evaluate(formula, number: 2) }
632
+ expect { calculator.evaluate!(formula, number: 2) }
517
633
  .to raise_error("No block matched the switch value '2'")
518
634
  end
519
635
 
@@ -579,9 +695,10 @@ describe Dentaku::Calculator do
579
695
  Math.methods(false).each do |method|
580
696
  it method do
581
697
  if Math.method(method).arity == 2
582
- expect(calculator.evaluate("#{method}(x,y)", x: 1, y: '2')).to eq Math.send(method, 1, 2)
698
+ expect(calculator.evaluate("#{method}(x,y)", x: 1, y: '2')).to eq(Math.send(method, 1, 2))
699
+ expect { calculator.evaluate!("#{method}(x)", x: 1) }.to raise_error(Dentaku::ParseError)
583
700
  else
584
- expect(calculator.evaluate("#{method}(1)")).to eq Math.send(method, 1)
701
+ expect(calculator.evaluate("#{method}(1)")).to eq(Math.send(method, 1))
585
702
  end
586
703
  end
587
704
  end
@@ -615,7 +732,7 @@ describe Dentaku::Calculator do
615
732
  end
616
733
 
617
734
  it 'clears all items from cache' do
618
- expect(calculator.ast_cache.length).to eq 3
735
+ expect(calculator.ast_cache.length).to eq(3)
619
736
  calculator.clear_cache
620
737
  expect(calculator.ast_cache.keys).to be_empty
621
738
  end
@@ -638,21 +755,31 @@ describe Dentaku::Calculator do
638
755
  it 'concatenates strings' do
639
756
  expect(
640
757
  calculator.evaluate('CONCAT(s1, s2, s3)', 's1' => 'ab', 's2' => 'cd', 's3' => 'ef')
641
- ).to eq 'abcdef'
758
+ ).to eq('abcdef')
759
+ end
760
+
761
+ it 'manipulates string arguments' do
762
+ expect(calculator.evaluate("left('ABCD', 2)")).to eq('AB')
763
+ expect(calculator.evaluate("right('ABCD', 2)")).to eq('CD')
764
+ expect(calculator.evaluate("mid('ABCD', 2, 2)")).to eq('BC')
765
+ expect(calculator.evaluate("len('ABCD')")).to eq(4)
766
+ expect(calculator.evaluate("find('BC', 'ABCD')")).to eq(2)
767
+ expect(calculator.evaluate("substitute('ABCD', 'BC', 'XY')")).to eq('AXYD')
768
+ expect(calculator.evaluate("contains('BC', 'ABCD')")).to be_truthy
642
769
  end
643
770
  end
644
771
 
645
772
  describe 'zero-arity functions' do
646
773
  it 'can be used in formulas' do
647
774
  calculator.add_function(:two, :numeric, -> { 2 })
648
- expect(calculator.evaluate("max(two(), 1)")).to eq 2
649
- expect(calculator.evaluate("max(1, two())")).to eq 2
775
+ expect(calculator.evaluate("max(two(), 1)")).to eq(2)
776
+ expect(calculator.evaluate("max(1, two())")).to eq(2)
650
777
  end
651
778
  end
652
779
 
653
780
  describe 'aliases' do
654
781
  it 'accepts aliases as instance option' do
655
- expect(with_aliases.evaluate('rrround(5.1)')).to eq 5
782
+ expect(with_aliases.evaluate('rrround(5.1)')).to eq(5)
656
783
  end
657
784
  end
658
785
 
@@ -671,4 +798,22 @@ describe Dentaku::Calculator do
671
798
  end.to raise_error(Dentaku::UnboundVariableError)
672
799
  end
673
800
  end
801
+
802
+ describe 'identifier cache' do
803
+ it 'reduces call count by caching results of resolved identifiers' do
804
+ called = 0
805
+ calculator.store_formula("A1", "B1+B1+B1")
806
+ calculator.store_formula("B1", "C1+C1+C1+C1")
807
+ calculator.store_formula("C1", "D1")
808
+ calculator.store("D1", proc { called += 1; 1 })
809
+
810
+ expect {
811
+ Dentaku.enable_identifier_cache!
812
+ }.to change {
813
+ called = 0
814
+ calculator.evaluate("A1")
815
+ called
816
+ }.from(12).to(1)
817
+ end
818
+ end
674
819
  end