dentaku 3.3.3 → 3.4.2

Sign up to get free protection for your applications and to get access to all the features.
Files changed (56) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +2 -7
  3. data/.travis.yml +4 -4
  4. data/CHANGELOG.md +34 -2
  5. data/README.md +4 -2
  6. data/dentaku.gemspec +1 -1
  7. data/lib/dentaku.rb +16 -5
  8. data/lib/dentaku/ast.rb +4 -0
  9. data/lib/dentaku/ast/access.rb +3 -1
  10. data/lib/dentaku/ast/arithmetic.rb +7 -2
  11. data/lib/dentaku/ast/array.rb +3 -1
  12. data/lib/dentaku/ast/case/case_else.rb +12 -4
  13. data/lib/dentaku/ast/case/case_switch_variable.rb +8 -0
  14. data/lib/dentaku/ast/case/case_then.rb +12 -4
  15. data/lib/dentaku/ast/case/case_when.rb +12 -4
  16. data/lib/dentaku/ast/function.rb +10 -1
  17. data/lib/dentaku/ast/functions/all.rb +36 -0
  18. data/lib/dentaku/ast/functions/any.rb +36 -0
  19. data/lib/dentaku/ast/functions/avg.rb +2 -2
  20. data/lib/dentaku/ast/functions/filter.rb +36 -0
  21. data/lib/dentaku/ast/functions/map.rb +36 -0
  22. data/lib/dentaku/ast/functions/pluck.rb +29 -0
  23. data/lib/dentaku/ast/functions/round.rb +1 -1
  24. data/lib/dentaku/ast/functions/rounddown.rb +1 -1
  25. data/lib/dentaku/ast/functions/roundup.rb +1 -1
  26. data/lib/dentaku/ast/functions/ruby_math.rb +49 -3
  27. data/lib/dentaku/ast/functions/string_functions.rb +52 -4
  28. data/lib/dentaku/ast/grouping.rb +3 -1
  29. data/lib/dentaku/ast/identifier.rb +6 -4
  30. data/lib/dentaku/bulk_expression_solver.rb +36 -25
  31. data/lib/dentaku/calculator.rb +14 -6
  32. data/lib/dentaku/date_arithmetic.rb +1 -1
  33. data/lib/dentaku/exceptions.rb +3 -3
  34. data/lib/dentaku/flat_hash.rb +7 -0
  35. data/lib/dentaku/parser.rb +2 -1
  36. data/lib/dentaku/tokenizer.rb +1 -1
  37. data/lib/dentaku/version.rb +1 -1
  38. data/spec/ast/arithmetic_spec.rb +19 -5
  39. data/spec/ast/avg_spec.rb +4 -0
  40. data/spec/ast/filter_spec.rb +18 -0
  41. data/spec/ast/map_spec.rb +15 -0
  42. data/spec/ast/max_spec.rb +13 -0
  43. data/spec/ast/min_spec.rb +13 -0
  44. data/spec/ast/mul_spec.rb +5 -0
  45. data/spec/ast/negation_spec.rb +18 -2
  46. data/spec/ast/round_spec.rb +10 -0
  47. data/spec/ast/rounddown_spec.rb +10 -0
  48. data/spec/ast/roundup_spec.rb +10 -0
  49. data/spec/ast/string_functions_spec.rb +35 -0
  50. data/spec/ast/sum_spec.rb +5 -0
  51. data/spec/bulk_expression_solver_spec.rb +27 -0
  52. data/spec/calculator_spec.rb +130 -0
  53. data/spec/dentaku_spec.rb +14 -8
  54. data/spec/parser_spec.rb +13 -0
  55. data/spec/tokenizer_spec.rb +24 -5
  56. metadata +26 -3
@@ -147,6 +147,7 @@ describe Dentaku::Calculator do
147
147
  it 'stores nested hashes' do
148
148
  calculator.store(a: {basket: {of: 'apples'}}, b: 2)
149
149
  expect(calculator.evaluate!('a.basket.of')).to eq('apples')
150
+ expect(calculator.evaluate!('a.basket')).to eq(of: 'apples')
150
151
  expect(calculator.evaluate!('b')).to eq(2)
151
152
  end
152
153
 
@@ -196,6 +197,15 @@ describe Dentaku::Calculator do
196
197
  )).to eq(pear: 1, weekly_apple_budget: 21, weekly_fruit_budget: 25)
197
198
  end
198
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
+
199
209
  it "preserves hash keys" do
200
210
  expect(calculator.solve!(
201
211
  'meaning_of_life' => 'age + kids',
@@ -319,6 +329,10 @@ describe Dentaku::Calculator do
319
329
  expect(error.unbound_variables).to eq(['a', 'b'])
320
330
  end
321
331
  expect(calculator.evaluate(unbound)).to be_nil
332
+ end
333
+
334
+ it 'accepts a block for custom handling of unbound variables' do
335
+ unbound = 'foo * 1.5'
322
336
  expect(calculator.evaluate(unbound) { :bar }).to eq(:bar)
323
337
  expect(calculator.evaluate(unbound) { |e| e }).to eq(unbound)
324
338
  end
@@ -391,6 +405,7 @@ describe Dentaku::Calculator do
391
405
  it 'supports date arithmetic' do
392
406
  expect(calculator.evaluate!('2020-01-01 + 30').to_date).to eq(Time.local(2020, 1, 31).to_date)
393
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)
394
409
  expect(calculator.evaluate!('2020-01-01 + duration(1, day)').to_date).to eq(Time.local(2020, 1, 2).to_date)
395
410
  expect(calculator.evaluate!('2020-01-01 - duration(1, day)').to_date).to eq(Time.local(2019, 12, 31).to_date)
396
411
  expect(calculator.evaluate!('2020-01-01 + duration(30, days)').to_date).to eq(Time.local(2020, 1, 31).to_date)
@@ -433,6 +448,88 @@ describe Dentaku::Calculator do
433
448
  expect(calculator.evaluate('NOT(some_boolean) AND -1 > 3', some_boolean: true)).to be_falsey
434
449
  end
435
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
+ expect(calculator.evaluate!('map(users, u, IF(u.age < 30, u, null))', users: [
506
+ {"name" => "Bob", "age" => 44},
507
+ {"name" => "Jane", "age" => 27}
508
+ ])).to eq([nil, { "name" => "Jane", "age" => 27 }])
509
+ end
510
+ end
511
+
512
+ describe "pluck" do
513
+ it "plucks values from array of hashes" do
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, age)', users: [
519
+ {"name" => "Bob", "age" => 44},
520
+ {"name" => "Jane", "age" => 27}
521
+ ])).to eq([44, 27])
522
+ expect(calculator.evaluate!('pluck(users, name)', users: [
523
+ {name: "Bob", age: 44},
524
+ {name: "Jane", age: 27}
525
+ ])).to eq(["Bob", "Jane"])
526
+ expect(calculator.evaluate!('pluck(users, name)', users: [
527
+ {"name" => "Bob", "age" => 44},
528
+ {"name" => "Jane", "age" => 27}
529
+ ])).to eq(["Bob", "Jane"])
530
+ end
531
+ end
532
+
436
533
  it 'evaluates functions with stored variables' do
437
534
  calculator.store("multi_color" => true, "number_of_sheets" => 5000, "sheets_per_minute_black" => 2000, "sheets_per_minute_color" => 1000)
438
535
  result = calculator.evaluate('number_of_sheets / if(multi_color, sheets_per_minute_color, sheets_per_minute_black)')
@@ -603,8 +700,13 @@ describe Dentaku::Calculator do
603
700
  it method do
604
701
  if Math.method(method).arity == 2
605
702
  expect(calculator.evaluate("#{method}(x,y)", x: 1, y: '2')).to eq(Math.send(method, 1, 2))
703
+ expect(calculator.evaluate("#{method}(x,y) + 1", x: 1, y: '2')).to be_within(0.00001).of(Math.send(method, 1, 2) + 1)
704
+ expect { calculator.evaluate!("#{method}(x)", x: 1) }.to raise_error(Dentaku::ParseError)
606
705
  else
607
706
  expect(calculator.evaluate("#{method}(1)")).to eq(Math.send(method, 1))
707
+ unless [:atanh, :frexp, :lgamma].include?(method)
708
+ expect(calculator.evaluate("#{method}(1) + 1")).to be_within(0.00001).of(Math.send(method, 1) + 1)
709
+ end
608
710
  end
609
711
  end
610
712
  end
@@ -663,6 +765,16 @@ describe Dentaku::Calculator do
663
765
  calculator.evaluate('CONCAT(s1, s2, s3)', 's1' => 'ab', 's2' => 'cd', 's3' => 'ef')
664
766
  ).to eq('abcdef')
665
767
  end
768
+
769
+ it 'manipulates string arguments' do
770
+ expect(calculator.evaluate("left('ABCD', 2)")).to eq('AB')
771
+ expect(calculator.evaluate("right('ABCD', 2)")).to eq('CD')
772
+ expect(calculator.evaluate("mid('ABCD', 2, 2)")).to eq('BC')
773
+ expect(calculator.evaluate("len('ABCD')")).to eq(4)
774
+ expect(calculator.evaluate("find('BC', 'ABCD')")).to eq(2)
775
+ expect(calculator.evaluate("substitute('ABCD', 'BC', 'XY')")).to eq('AXYD')
776
+ expect(calculator.evaluate("contains('BC', 'ABCD')")).to be_truthy
777
+ end
666
778
  end
667
779
 
668
780
  describe 'zero-arity functions' do
@@ -694,4 +806,22 @@ describe Dentaku::Calculator do
694
806
  end.to raise_error(Dentaku::UnboundVariableError)
695
807
  end
696
808
  end
809
+
810
+ describe 'identifier cache' do
811
+ it 'reduces call count by caching results of resolved identifiers' do
812
+ called = 0
813
+ calculator.store_formula("A1", "B1+B1+B1")
814
+ calculator.store_formula("B1", "C1+C1+C1+C1")
815
+ calculator.store_formula("C1", "D1")
816
+ calculator.store("D1", proc { called += 1; 1 })
817
+
818
+ expect {
819
+ Dentaku.enable_identifier_cache!
820
+ }.to change {
821
+ called = 0
822
+ calculator.evaluate("A1")
823
+ called
824
+ }.from(12).to(1)
825
+ end
826
+ end
697
827
  end
data/spec/dentaku_spec.rb CHANGED
@@ -27,20 +27,26 @@ describe Dentaku do
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)
38
44
  end
39
45
 
40
46
  it 'sets caching opt-in flags' do
41
47
  expect {
42
- Dentaku.enable_caching!
43
- }.to change { Dentaku.cache_ast? }.from(false).to(true)
44
- .and change { Dentaku.cache_dependency_order? }.from(false).to(true)
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)
45
51
  end
46
52
  end
data/spec/parser_spec.rb CHANGED
@@ -71,6 +71,11 @@ describe Dentaku::Parser do
71
71
  expect(node.value("x" => 3)).to eq(4)
72
72
  end
73
73
 
74
+ it 'evaluates arrays' do
75
+ node = parse('{1, 2, 3}')
76
+ expect(node.value).to eq([1, 2, 3])
77
+ end
78
+
74
79
  context 'invalid expression' do
75
80
  it 'raises a parse error for bad math' do
76
81
  expect {
@@ -97,6 +102,14 @@ describe Dentaku::Parser do
97
102
  expect {
98
103
  parse("5 + 5, x")
99
104
  }.to raise_error(Dentaku::ParseError)
105
+
106
+ expect {
107
+ parse("{1, 2, }")
108
+ }.to raise_error(Dentaku::ParseError)
109
+
110
+ expect {
111
+ parse("CONCAT('1', '2', )")
112
+ }.to raise_error(Dentaku::ParseError)
100
113
  end
101
114
 
102
115
  it 'raises parse errors for malformed case statements' do
@@ -231,15 +231,15 @@ describe Dentaku::Tokenizer do
231
231
  ])
232
232
  end
233
233
 
234
- describe 'functions' do
235
- it 'include IF' do
234
+ describe 'tokenizing function calls' do
235
+ it 'handles IF' do
236
236
  tokens = tokenizer.tokenize('if(x < 10, y, z)')
237
237
  expect(tokens.length).to eq(10)
238
238
  expect(tokens.map(&:category)).to eq([:function, :grouping, :identifier, :comparator, :numeric, :grouping, :identifier, :grouping, :identifier, :grouping])
239
239
  expect(tokens.map(&:value)).to eq([:if, :open, 'x', :lt, 10, :comma, 'y', :comma, 'z', :close])
240
240
  end
241
241
 
242
- it 'include ROUND/UP/DOWN' do
242
+ it 'handles ROUND/UP/DOWN' do
243
243
  tokens = tokenizer.tokenize('round(8.2)')
244
244
  expect(tokens.length).to eq(4)
245
245
  expect(tokens.map(&:category)).to eq([:function, :grouping, :numeric, :grouping])
@@ -261,13 +261,32 @@ describe Dentaku::Tokenizer do
261
261
  expect(tokens.map(&:value)).to eq([:rounddown, :open, BigDecimal('8.2'), :close])
262
262
  end
263
263
 
264
- it 'include NOT' do
264
+ it 'handles NOT' do
265
265
  tokens = tokenizer.tokenize('not(8 < 5)')
266
266
  expect(tokens.length).to eq(6)
267
267
  expect(tokens.map(&:category)).to eq([:function, :grouping, :numeric, :comparator, :numeric, :grouping])
268
268
  expect(tokens.map(&:value)).to eq([:not, :open, 8, :lt, 5, :close])
269
269
  end
270
270
 
271
+ it 'handles ANY/ALL' do
272
+ %i( any all ).each do |fn|
273
+ tokens = tokenizer.tokenize("#{fn}(users, u, u.age > 18)")
274
+ expect(tokens.length).to eq(10)
275
+ expect(tokens.map { |t| [t.category, t.value] }).to eq([
276
+ [:function, fn ], # function call (any/all)
277
+ [:grouping, :open ], # (
278
+ [:identifier, "users"], # users
279
+ [:grouping, :comma ], # ,
280
+ [:identifier, "u" ], # u
281
+ [:grouping, :comma ], # ,
282
+ [:identifier, "u.age"], # u.age
283
+ [:comparator, :gt ], # >
284
+ [:numeric, 18 ], # 18
285
+ [:grouping, :close ] # )
286
+ ])
287
+ end
288
+ end
289
+
271
290
  it 'handles whitespace after function name' do
272
291
  tokens = tokenizer.tokenize('not (8 < 5)')
273
292
  expect(tokens.length).to eq(6)
@@ -275,7 +294,7 @@ describe Dentaku::Tokenizer do
275
294
  expect(tokens.map(&:value)).to eq([:not, :open, 8, :lt, 5, :close])
276
295
  end
277
296
 
278
- it 'can end with a bang' do
297
+ it 'handles when function ends with a bang' do
279
298
  tokens = tokenizer.tokenize('exp!(5 * 3)')
280
299
  expect(tokens.length).to eq(6)
281
300
  expect(tokens.map(&:category)).to eq([:function, :grouping, :numeric, :operator, :numeric, :grouping])
metadata CHANGED
@@ -1,15 +1,29 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: dentaku
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.3.3
4
+ version: 3.4.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Solomon White
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2019-11-20 00:00:00.000000000 Z
11
+ date: 2021-07-16 00:00:00.000000000 Z
12
12
  dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: concurrent-ruby
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '0'
13
27
  - !ruby/object:Gem::Dependency
14
28
  name: codecov
15
29
  requirement: !ruby/object:Gem::Requirement
@@ -156,16 +170,21 @@ files:
156
170
  - lib/dentaku/ast/datetime.rb
157
171
  - lib/dentaku/ast/function.rb
158
172
  - lib/dentaku/ast/function_registry.rb
173
+ - lib/dentaku/ast/functions/all.rb
159
174
  - lib/dentaku/ast/functions/and.rb
175
+ - lib/dentaku/ast/functions/any.rb
160
176
  - lib/dentaku/ast/functions/avg.rb
161
177
  - lib/dentaku/ast/functions/count.rb
162
178
  - lib/dentaku/ast/functions/duration.rb
179
+ - lib/dentaku/ast/functions/filter.rb
163
180
  - lib/dentaku/ast/functions/if.rb
181
+ - lib/dentaku/ast/functions/map.rb
164
182
  - lib/dentaku/ast/functions/max.rb
165
183
  - lib/dentaku/ast/functions/min.rb
166
184
  - lib/dentaku/ast/functions/mul.rb
167
185
  - lib/dentaku/ast/functions/not.rb
168
186
  - lib/dentaku/ast/functions/or.rb
187
+ - lib/dentaku/ast/functions/pluck.rb
169
188
  - lib/dentaku/ast/functions/round.rb
170
189
  - lib/dentaku/ast/functions/rounddown.rb
171
190
  - lib/dentaku/ast/functions/roundup.rb
@@ -206,7 +225,9 @@ files:
206
225
  - spec/ast/comparator_spec.rb
207
226
  - spec/ast/count_spec.rb
208
227
  - spec/ast/division_spec.rb
228
+ - spec/ast/filter_spec.rb
209
229
  - spec/ast/function_spec.rb
230
+ - spec/ast/map_spec.rb
210
231
  - spec/ast/max_spec.rb
211
232
  - spec/ast/min_spec.rb
212
233
  - spec/ast/mul_spec.rb
@@ -251,7 +272,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
251
272
  - !ruby/object:Gem::Version
252
273
  version: '0'
253
274
  requirements: []
254
- rubygems_version: 3.0.3
275
+ rubygems_version: 3.1.4
255
276
  signing_key:
256
277
  specification_version: 4
257
278
  summary: A formula language parser and evaluator
@@ -265,7 +286,9 @@ test_files:
265
286
  - spec/ast/comparator_spec.rb
266
287
  - spec/ast/count_spec.rb
267
288
  - spec/ast/division_spec.rb
289
+ - spec/ast/filter_spec.rb
268
290
  - spec/ast/function_spec.rb
291
+ - spec/ast/map_spec.rb
269
292
  - spec/ast/max_spec.rb
270
293
  - spec/ast/min_spec.rb
271
294
  - spec/ast/mul_spec.rb