dentaku 3.3.4 → 3.4.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (48) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +2 -7
  3. data/.travis.yml +3 -4
  4. data/CHANGELOG.md +13 -0
  5. data/dentaku.gemspec +0 -2
  6. data/lib/dentaku.rb +14 -4
  7. data/lib/dentaku/ast.rb +4 -0
  8. data/lib/dentaku/ast/access.rb +3 -1
  9. data/lib/dentaku/ast/arithmetic.rb +7 -2
  10. data/lib/dentaku/ast/array.rb +3 -1
  11. data/lib/dentaku/ast/function.rb +10 -1
  12. data/lib/dentaku/ast/functions/all.rb +36 -0
  13. data/lib/dentaku/ast/functions/any.rb +36 -0
  14. data/lib/dentaku/ast/functions/avg.rb +2 -2
  15. data/lib/dentaku/ast/functions/map.rb +36 -0
  16. data/lib/dentaku/ast/functions/mul.rb +3 -2
  17. data/lib/dentaku/ast/functions/pluck.rb +29 -0
  18. data/lib/dentaku/ast/functions/round.rb +1 -1
  19. data/lib/dentaku/ast/functions/rounddown.rb +1 -1
  20. data/lib/dentaku/ast/functions/roundup.rb +1 -1
  21. data/lib/dentaku/ast/functions/ruby_math.rb +47 -3
  22. data/lib/dentaku/ast/functions/string_functions.rb +4 -4
  23. data/lib/dentaku/ast/functions/sum.rb +3 -2
  24. data/lib/dentaku/ast/grouping.rb +3 -1
  25. data/lib/dentaku/ast/identifier.rb +3 -1
  26. data/lib/dentaku/bulk_expression_solver.rb +34 -25
  27. data/lib/dentaku/calculator.rb +13 -5
  28. data/lib/dentaku/date_arithmetic.rb +1 -1
  29. data/lib/dentaku/exceptions.rb +3 -3
  30. data/lib/dentaku/flat_hash.rb +7 -0
  31. data/lib/dentaku/parser.rb +2 -1
  32. data/lib/dentaku/tokenizer.rb +1 -1
  33. data/lib/dentaku/version.rb +1 -1
  34. data/spec/ast/arithmetic_spec.rb +19 -5
  35. data/spec/ast/avg_spec.rb +4 -0
  36. data/spec/ast/mul_spec.rb +4 -0
  37. data/spec/ast/negation_spec.rb +18 -2
  38. data/spec/ast/round_spec.rb +10 -0
  39. data/spec/ast/rounddown_spec.rb +10 -0
  40. data/spec/ast/roundup_spec.rb +10 -0
  41. data/spec/ast/string_functions_spec.rb +35 -0
  42. data/spec/ast/sum_spec.rb +4 -0
  43. data/spec/bulk_expression_solver_spec.rb +17 -0
  44. data/spec/calculator_spec.rb +112 -0
  45. data/spec/dentaku_spec.rb +14 -8
  46. data/spec/parser_spec.rb +13 -0
  47. data/spec/tokenizer_spec.rb +24 -5
  48. metadata +7 -3
@@ -34,5 +34,9 @@ describe 'Dentaku::AST::Function::Sum' do
34
34
  it 'raises an error if no arguments are passed' do
35
35
  expect { calculator.evaluate!('SUM()') }.to raise_error(Dentaku::ArgumentError)
36
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)
40
+ end
37
41
  end
38
42
  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: {
@@ -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,84 @@ 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
+ 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
+
436
529
  it 'evaluates functions with stored variables' do
437
530
  calculator.store("multi_color" => true, "number_of_sheets" => 5000, "sheets_per_minute_black" => 2000, "sheets_per_minute_color" => 1000)
438
531
  result = calculator.evaluate('number_of_sheets / if(multi_color, sheets_per_minute_color, sheets_per_minute_black)')
@@ -603,6 +696,7 @@ describe Dentaku::Calculator do
603
696
  it method do
604
697
  if Math.method(method).arity == 2
605
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)
606
700
  else
607
701
  expect(calculator.evaluate("#{method}(1)")).to eq(Math.send(method, 1))
608
702
  end
@@ -704,4 +798,22 @@ describe Dentaku::Calculator do
704
798
  end.to raise_error(Dentaku::UnboundVariableError)
705
799
  end
706
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
707
819
  end
@@ -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
@@ -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,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: dentaku
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.3.4
4
+ version: 3.4.0
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-21 00:00:00.000000000 Z
11
+ date: 2020-12-07 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: codecov
@@ -156,16 +156,20 @@ files:
156
156
  - lib/dentaku/ast/datetime.rb
157
157
  - lib/dentaku/ast/function.rb
158
158
  - lib/dentaku/ast/function_registry.rb
159
+ - lib/dentaku/ast/functions/all.rb
159
160
  - lib/dentaku/ast/functions/and.rb
161
+ - lib/dentaku/ast/functions/any.rb
160
162
  - lib/dentaku/ast/functions/avg.rb
161
163
  - lib/dentaku/ast/functions/count.rb
162
164
  - lib/dentaku/ast/functions/duration.rb
163
165
  - lib/dentaku/ast/functions/if.rb
166
+ - lib/dentaku/ast/functions/map.rb
164
167
  - lib/dentaku/ast/functions/max.rb
165
168
  - lib/dentaku/ast/functions/min.rb
166
169
  - lib/dentaku/ast/functions/mul.rb
167
170
  - lib/dentaku/ast/functions/not.rb
168
171
  - lib/dentaku/ast/functions/or.rb
172
+ - lib/dentaku/ast/functions/pluck.rb
169
173
  - lib/dentaku/ast/functions/round.rb
170
174
  - lib/dentaku/ast/functions/rounddown.rb
171
175
  - lib/dentaku/ast/functions/roundup.rb
@@ -251,7 +255,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
251
255
  - !ruby/object:Gem::Version
252
256
  version: '0'
253
257
  requirements: []
254
- rubygems_version: 3.0.3
258
+ rubygems_version: 3.1.4
255
259
  signing_key:
256
260
  specification_version: 4
257
261
  summary: A formula language parser and evaluator