dentaku 3.5.0 → 3.5.2

Sign up to get free protection for your applications and to get access to all the features.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +22 -0
  3. data/README.md +2 -2
  4. data/lib/dentaku/ast/arithmetic.rb +46 -25
  5. data/lib/dentaku/ast/bitwise.rb +23 -6
  6. data/lib/dentaku/ast/comparators.rb +11 -2
  7. data/lib/dentaku/ast/function_registry.rb +10 -1
  8. data/lib/dentaku/ast/functions/abs.rb +5 -0
  9. data/lib/dentaku/ast/functions/avg.rb +1 -1
  10. data/lib/dentaku/ast/functions/enum.rb +5 -4
  11. data/lib/dentaku/ast/functions/ruby_math.rb +2 -0
  12. data/lib/dentaku/ast.rb +1 -0
  13. data/lib/dentaku/bulk_expression_solver.rb +1 -5
  14. data/lib/dentaku/calculator.rb +15 -8
  15. data/lib/dentaku/date_arithmetic.rb +5 -1
  16. data/lib/dentaku/exceptions.rb +11 -2
  17. data/lib/dentaku/parser.rb +19 -7
  18. data/lib/dentaku/print_visitor.rb +16 -5
  19. data/lib/dentaku/token_scanner.rb +8 -4
  20. data/lib/dentaku/tokenizer.rb +7 -3
  21. data/lib/dentaku/version.rb +1 -1
  22. data/lib/dentaku/visitor/infix.rb +4 -0
  23. data/spec/ast/abs_spec.rb +26 -0
  24. data/spec/ast/all_spec.rb +1 -1
  25. data/spec/ast/any_spec.rb +1 -1
  26. data/spec/ast/arithmetic_spec.rb +20 -5
  27. data/spec/ast/avg_spec.rb +5 -0
  28. data/spec/ast/comparator_spec.rb +8 -0
  29. data/spec/ast/filter_spec.rb +1 -1
  30. data/spec/ast/map_spec.rb +1 -1
  31. data/spec/ast/or_spec.rb +1 -1
  32. data/spec/ast/pluck_spec.rb +1 -1
  33. data/spec/bulk_expression_solver_spec.rb +9 -0
  34. data/spec/calculator_spec.rb +70 -16
  35. data/spec/external_function_spec.rb +89 -18
  36. data/spec/print_visitor_spec.rb +6 -0
  37. data/spec/tokenizer_spec.rb +12 -0
  38. data/spec/visitor/infix_spec.rb +22 -1
  39. data/spec/visitor_spec.rb +3 -2
  40. metadata +6 -3
@@ -0,0 +1,26 @@
1
+ require 'spec_helper'
2
+ require 'dentaku/ast/functions/abs'
3
+ require 'dentaku'
4
+
5
+ describe 'Dentaku::AST::Function::Abs' do
6
+ it 'returns the absolute value of number' do
7
+ result = Dentaku('ABS(-4.2)')
8
+ expect(result).to eq(4.2)
9
+ end
10
+
11
+ it 'returns the correct value for positive number' do
12
+ result = Dentaku('ABS(1.3)')
13
+ expect(result).to eq(1.3)
14
+ end
15
+
16
+ it 'returns the correct value for zero' do
17
+ result = Dentaku('ABS(0)')
18
+ expect(result).to eq(0)
19
+ end
20
+
21
+ context 'checking errors' do
22
+ it 'raises an error if argument is not numeric' do
23
+ expect { Dentaku!("ABS(2020-1-1)") }.to raise_error(Dentaku::ArgumentError)
24
+ end
25
+ end
26
+ end
data/spec/ast/all_spec.rb CHANGED
@@ -19,7 +19,7 @@ describe Dentaku::AST::All do
19
19
 
20
20
  it 'raises argument error if a string is passed as identifier' do
21
21
  expect { calculator.evaluate!('ALL({1, 2, 3}, "val", val % 2 == 0)') }.to raise_error(
22
- Dentaku::ArgumentError, 'ALL() requires second argument to be an identifier'
22
+ Dentaku::ParseError, 'ALL() requires second argument to be an identifier'
23
23
  )
24
24
  end
25
25
  end
data/spec/ast/any_spec.rb CHANGED
@@ -18,6 +18,6 @@ describe Dentaku::AST::Any do
18
18
  end
19
19
 
20
20
  it 'raises argument error if a string is passed as identifier' do
21
- expect { calculator.evaluate!('ANY({1, 2, 3}, "val", val % 2 == 0)') }.to raise_error(Dentaku::ArgumentError)
21
+ expect { calculator.evaluate!('ANY({1, 2, 3}, "val", val % 2 == 0)') }.to raise_error(Dentaku::ParseError)
22
22
  end
23
23
  end
@@ -4,12 +4,12 @@ require 'dentaku/ast/arithmetic'
4
4
  require 'dentaku/token'
5
5
 
6
6
  describe Dentaku::AST::Arithmetic do
7
- let(:one) { Dentaku::AST::Numeric.new Dentaku::Token.new(:numeric, 1) }
8
- let(:two) { Dentaku::AST::Numeric.new Dentaku::Token.new(:numeric, 2) }
9
- let(:x) { Dentaku::AST::Identifier.new Dentaku::Token.new(:identifier, 'x') }
10
- let(:y) { Dentaku::AST::Identifier.new Dentaku::Token.new(:identifier, 'y') }
7
+ let(:one) { Dentaku::AST::Numeric.new(Dentaku::Token.new(:numeric, 1)) }
8
+ let(:two) { Dentaku::AST::Numeric.new(Dentaku::Token.new(:numeric, 2)) }
9
+ let(:x) { Dentaku::AST::Identifier.new(Dentaku::Token.new(:identifier, 'x')) }
10
+ let(:y) { Dentaku::AST::Identifier.new(Dentaku::Token.new(:identifier, 'y')) }
11
11
  let(:ctx) { {'x' => 1, 'y' => 2} }
12
- let(:date) { Dentaku::AST::DateTime.new Dentaku::Token.new(:datetime, DateTime.new(2020, 4, 16)) }
12
+ let(:date) { Dentaku::AST::DateTime.new(Dentaku::Token.new(:datetime, DateTime.new(2020, 4, 16))) }
13
13
 
14
14
  it 'performs an arithmetic operation with numeric operands' do
15
15
  expect(add(one, two)).to eq(3)
@@ -45,6 +45,21 @@ describe Dentaku::AST::Arithmetic do
45
45
  expect(add(x, one, 'x' => '.1')).to eq(1.1)
46
46
  expect { add(x, one, 'x' => 'invalid') }.to raise_error(Dentaku::ArgumentError)
47
47
  expect { add(x, one, 'x' => '') }.to raise_error(Dentaku::ArgumentError)
48
+
49
+ int_one = Dentaku::AST::Numeric.new(Dentaku::Token.new(:numeric, "1"))
50
+ int_neg_one = Dentaku::AST::Numeric.new(Dentaku::Token.new(:numeric, "-1"))
51
+ decimal_one = Dentaku::AST::Numeric.new(Dentaku::Token.new(:numeric, "1.0"))
52
+ decimal_neg_one = Dentaku::AST::Numeric.new(Dentaku::Token.new(:numeric, "-1.0"))
53
+
54
+ [int_one, int_neg_one].permutation(2).each do |(left, right)|
55
+ expect(add(left, right).class).to eq(Integer)
56
+ end
57
+
58
+ [decimal_one, decimal_neg_one].each do |left|
59
+ [int_one, int_neg_one, decimal_one, decimal_neg_one].each do |right|
60
+ expect(add(left, right).class).to eq(BigDecimal)
61
+ end
62
+ end
48
63
  end
49
64
 
50
65
  it 'performs arithmetic on arrays' do
data/spec/ast/avg_spec.rb CHANGED
@@ -3,6 +3,11 @@ require 'dentaku/ast/functions/avg'
3
3
  require 'dentaku'
4
4
 
5
5
  describe 'Dentaku::AST::Function::Avg' do
6
+ it 'returns the average of an array of Numeric values as BigDecimal' do
7
+ result = Dentaku('AVG(1, 2)')
8
+ expect(result).to eq(1.5)
9
+ end
10
+
6
11
  it 'returns the average of an array of Numeric values' do
7
12
  result = Dentaku('AVG(1, x, 1.8)', x: 2.3)
8
13
  expect(result).to eq(1.7)
@@ -5,7 +5,9 @@ require 'dentaku/token'
5
5
 
6
6
  describe Dentaku::AST::Comparator do
7
7
  let(:one) { Dentaku::AST::Numeric.new Dentaku::Token.new(:numeric, 1) }
8
+ let(:one_str) { Dentaku::AST::String.new Dentaku::Token.new(:string, '1') }
8
9
  let(:two) { Dentaku::AST::Numeric.new Dentaku::Token.new(:numeric, 2) }
10
+ let(:two_str) { Dentaku::AST::String.new Dentaku::Token.new(:string, '2') }
9
11
  let(:x) { Dentaku::AST::Identifier.new Dentaku::Token.new(:identifier, 'x') }
10
12
  let(:y) { Dentaku::AST::Identifier.new Dentaku::Token.new(:identifier, 'y') }
11
13
  let(:nilly) do
@@ -21,6 +23,12 @@ describe Dentaku::AST::Comparator do
21
23
  expect(equal(x, y).value(ctx)).to be_falsey
22
24
  end
23
25
 
26
+ it 'performs conversion from string to numeric operands' do
27
+ expect(less_than(one, two_str).value(ctx)).to be_truthy
28
+ expect(less_than(one_str, two_str).value(ctx)).to be_truthy
29
+ expect(less_than(one_str, two).value(ctx)).to be_truthy
30
+ end
31
+
24
32
  it 'raises a dentaku argument error when incorrect arguments are passed in' do
25
33
  expect { less_than(one, nilly).value(ctx) }.to raise_error Dentaku::ArgumentError
26
34
  expect { less_than_or_equal(one, nilly).value(ctx) }.to raise_error Dentaku::ArgumentError
@@ -19,7 +19,7 @@ describe Dentaku::AST::Filter do
19
19
 
20
20
  it 'raises argument error if a string is passed as identifier' do
21
21
  expect { calculator.evaluate!('FILTER({1, 2, 3}, "val", val % 2 == 0)') }.to raise_error(
22
- Dentaku::ArgumentError, 'FILTER() requires second argument to be an identifier'
22
+ Dentaku::ParseError, 'FILTER() requires second argument to be an identifier'
23
23
  )
24
24
  end
25
25
  end
data/spec/ast/map_spec.rb CHANGED
@@ -21,7 +21,7 @@ describe Dentaku::AST::Map do
21
21
 
22
22
  it 'raises argument error if a string is passed as identifier' do
23
23
  expect { calculator.evaluate!('MAP({1, 2, 3}, "val", val + 1)') }.to raise_error(
24
- Dentaku::ArgumentError, 'MAP() requires second argument to be an identifier'
24
+ Dentaku::ParseError, 'MAP() requires second argument to be an identifier'
25
25
  )
26
26
  end
27
27
  end
data/spec/ast/or_spec.rb CHANGED
@@ -6,7 +6,7 @@ describe 'Dentaku::AST::Or' do
6
6
  let(:calculator) { Dentaku::Calculator.new }
7
7
 
8
8
  it 'returns false if all of the arguments are false' do
9
- result = Dentaku('OR(1 = "1", 0 = 1)')
9
+ result = Dentaku('OR(1 = "2", 0 = 1)')
10
10
  expect(result).to eq(false)
11
11
  end
12
12
 
@@ -21,7 +21,7 @@ describe Dentaku::AST::Pluck do
21
21
  expect do Dentaku.evaluate!('PLUCK(users, "age")', users: [
22
22
  {name: "Bob", age: 44},
23
23
  {name: "Jane", age: 27}
24
- ]) end.to raise_error(Dentaku::ArgumentError, 'PLUCK() requires second argument to be an identifier')
24
+ ]) end.to raise_error(Dentaku::ParseError, 'PLUCK() requires second argument to be an identifier')
25
25
  end
26
26
 
27
27
  it 'raises argument error if a non array of hashes is passed as collection' do
@@ -143,6 +143,15 @@ RSpec.describe Dentaku::BulkExpressionSolver do
143
143
  expect(exception.recipient_variable).to eq('more_apples')
144
144
  end
145
145
 
146
+ it 'stores the recipient variable on the exception when there is an ArgumentError' do
147
+ expressions = {apples: "NULL", more_apples: "1 + apples"}
148
+ exception = nil
149
+ described_class.new(expressions, calculator).solve do |ex|
150
+ exception = ex
151
+ end
152
+ expect(exception.recipient_variable).to eq('more_apples')
153
+ end
154
+
146
155
  it 'safely handles argument errors' do
147
156
  expressions = {i: "a / 5 + d", a: "m * 12", d: "a + b"}
148
157
  result = described_class.new(expressions, calculator.store(m: 3)).solve
@@ -22,6 +22,7 @@ describe Dentaku::Calculator do
22
22
  expect(calculator.evaluate('(2 + 3) - 1')).to eq(4)
23
23
  expect(calculator.evaluate('(-2 + 3) - 1')).to eq(0)
24
24
  expect(calculator.evaluate('(-2 - 3) - 1')).to eq(-6)
25
+ expect(calculator.evaluate('1353+91-1-3322-22')).to eq(-1901)
25
26
  expect(calculator.evaluate('1 + -(2 ^ 2)')).to eq(-3)
26
27
  expect(calculator.evaluate('3 + -num', num: 2)).to eq(1)
27
28
  expect(calculator.evaluate('-num + 3', num: 2)).to eq(1)
@@ -40,6 +41,8 @@ describe Dentaku::Calculator do
40
41
  expect(calculator.evaluate("2 | 3 * 9")).to eq (27)
41
42
  expect(calculator.evaluate("2 & 3 * 9")).to eq (2)
42
43
  expect(calculator.evaluate("5%")).to eq (0.05)
44
+ expect(calculator.evaluate('1 << 3')).to eq (8)
45
+ expect(calculator.evaluate('0xFF >> 6')).to eq (3)
43
46
  end
44
47
 
45
48
  describe 'evaluate' do
@@ -67,6 +70,7 @@ describe Dentaku::Calculator do
67
70
  expect(calculator.evaluate('ROUNDDOWN(a)', a: nil)).to be_nil
68
71
  expect(calculator.evaluate('ROUNDUP(a)', a: nil)).to be_nil
69
72
  expect(calculator.evaluate('SUM(a,b)', a: nil, b: nil)).to be_nil
73
+ expect(calculator.evaluate('1.0 & "bar"')).to be_nil
70
74
  end
71
75
 
72
76
  it 'treats explicit nil as logical false' do
@@ -82,6 +86,13 @@ describe Dentaku::Calculator do
82
86
  end
83
87
  end
84
88
 
89
+ describe 'ast' do
90
+ it 'raises parsing errors' do
91
+ expect { calculator.ast('()') }.to raise_error(Dentaku::ParseError)
92
+ expect { calculator.ast('(}') }.to raise_error(Dentaku::TokenizerError)
93
+ end
94
+ end
95
+
85
96
  describe 'evaluate!' do
86
97
  it 'raises exception when formula has error' do
87
98
  expect { calculator.evaluate!('1 + + 1') }.to raise_error(Dentaku::ParseError)
@@ -108,6 +119,9 @@ describe Dentaku::Calculator do
108
119
  expect { calculator.evaluate!('ROUNDDOWN(a)', a: nil) }.to raise_error(Dentaku::ArgumentError)
109
120
  expect { calculator.evaluate!('ROUNDUP(a)', a: nil) }.to raise_error(Dentaku::ArgumentError)
110
121
  expect { calculator.evaluate!('SUM(a,b)', a: nil, b: nil) }.to raise_error(Dentaku::ArgumentError)
122
+ expect { calculator.evaluate!('"foo" & "bar"') }.to raise_error(Dentaku::ArgumentError)
123
+ expect { calculator.evaluate!('1.0 & "bar"') }.to raise_error(Dentaku::ArgumentError)
124
+ expect { calculator.evaluate!('1 & "bar"') }.to raise_error(Dentaku::ArgumentError)
111
125
  end
112
126
 
113
127
  it 'raises argument error if a function is called with incorrect arity' do
@@ -316,6 +330,11 @@ describe Dentaku::Calculator do
316
330
  }.not_to raise_error
317
331
  end
318
332
 
333
+ it 'allows to compare "-" or "-."' do
334
+ expect { calculator.solve("IF('-' = '-', 0, 1)") }.not_to raise_error
335
+ expect { calculator.solve("IF('-.'= '-.', 0, 1)") }.not_to raise_error
336
+ end
337
+
319
338
  it "integrates with custom functions" do
320
339
  calculator.add_function(:custom, :integer, -> { 1 })
321
340
 
@@ -427,19 +446,42 @@ describe Dentaku::Calculator do
427
446
  expect(calculator.evaluate('t1 > 2017-01-02', t1: Time.local(2017, 1, 3).to_datetime)).to be_truthy
428
447
  end
429
448
 
430
- it 'supports date arithmetic' do
431
- expect(calculator.evaluate!('2020-01-01 + 30').to_date).to eq(Time.local(2020, 1, 31).to_date)
432
- expect(calculator.evaluate!('2020-01-01 - 1').to_date).to eq(Time.local(2019, 12, 31).to_date)
433
- expect(calculator.evaluate!('2020-01-01 - 2019-12-31')).to eq(1)
434
- expect(calculator.evaluate!('2020-01-01 + duration(1, day)').to_date).to eq(Time.local(2020, 1, 2).to_date)
435
- expect(calculator.evaluate!('2020-01-01 - duration(1, day)').to_date).to eq(Time.local(2019, 12, 31).to_date)
436
- expect(calculator.evaluate!('2020-01-01 + duration(30, days)').to_date).to eq(Time.local(2020, 1, 31).to_date)
437
- expect(calculator.evaluate!('2020-01-01 + duration(1, month)').to_date).to eq(Time.local(2020, 2, 1).to_date)
438
- expect(calculator.evaluate!('2020-01-01 - duration(1, month)').to_date).to eq(Time.local(2019, 12, 1).to_date)
439
- expect(calculator.evaluate!('2020-01-01 + duration(30, months)').to_date).to eq(Time.local(2022, 7, 1).to_date)
440
- expect(calculator.evaluate!('2020-01-01 + duration(1, year)').to_date).to eq(Time.local(2021, 1, 1).to_date)
441
- expect(calculator.evaluate!('2020-01-01 - duration(1, year)').to_date).to eq(Time.local(2019, 1, 1).to_date)
442
- expect(calculator.evaluate!('2020-01-01 + duration(30, years)').to_date).to eq(Time.local(2050, 1, 1).to_date)
449
+ describe 'disabling date literals' do
450
+ it 'does not parse formulas with minus signs as dates' do
451
+ calculator = described_class.new(raw_date_literals: false)
452
+ expect(calculator.evaluate!('2020-01-01')).to eq(2018)
453
+ end
454
+ end
455
+
456
+ describe 'supports date arithmetic' do
457
+ it 'from hardcoded string' do
458
+ expect(calculator.evaluate!('2020-01-01 + 30').to_date).to eq(Time.local(2020, 1, 31).to_date)
459
+ expect(calculator.evaluate!('2020-01-01 - 1').to_date).to eq(Time.local(2019, 12, 31).to_date)
460
+ expect(calculator.evaluate!('2020-01-01 - 2019-12-31')).to eq(1)
461
+ expect(calculator.evaluate!('2020-01-01 + duration(1, day)').to_date).to eq(Time.local(2020, 1, 2).to_date)
462
+ expect(calculator.evaluate!('2020-01-01 - duration(1, day)').to_date).to eq(Time.local(2019, 12, 31).to_date)
463
+ expect(calculator.evaluate!('2020-01-01 + duration(30, days)').to_date).to eq(Time.local(2020, 1, 31).to_date)
464
+ expect(calculator.evaluate!('2020-01-01 + duration(1, month)').to_date).to eq(Time.local(2020, 2, 1).to_date)
465
+ expect(calculator.evaluate!('2020-01-01 - duration(1, month)').to_date).to eq(Time.local(2019, 12, 1).to_date)
466
+ expect(calculator.evaluate!('2020-01-01 + duration(30, months)').to_date).to eq(Time.local(2022, 7, 1).to_date)
467
+ expect(calculator.evaluate!('2020-01-01 + duration(1, year)').to_date).to eq(Time.local(2021, 1, 1).to_date)
468
+ expect(calculator.evaluate!('2020-01-01 - duration(1, year)').to_date).to eq(Time.local(2019, 1, 1).to_date)
469
+ expect(calculator.evaluate!('2020-01-01 + duration(30, years)').to_date).to eq(Time.local(2050, 1, 1).to_date)
470
+ end
471
+
472
+ it 'from string variable' do
473
+ value = '2023-01-01'
474
+
475
+ expect(calculator.evaluate!('value + duration(1, month)', { value: value }).to_date).to eql(Date.parse('2023-02-01'))
476
+ expect(calculator.evaluate!('value - duration(1, month)', { value: value }).to_date).to eql(Date.parse('2022-12-01'))
477
+ end
478
+
479
+ it 'from date object' do
480
+ value = Date.parse('2023-01-01').to_date
481
+
482
+ expect(calculator.evaluate!('value + duration(1, month)', { value: value }).to_date).to eql(Date.parse('2023-02-01'))
483
+ expect(calculator.evaluate!('value - duration(1, month)', { value: value }).to_date).to eql(Date.parse('2022-12-01'))
484
+ end
443
485
  end
444
486
 
445
487
  describe 'functions' do
@@ -458,6 +500,13 @@ describe Dentaku::Calculator do
458
500
  expect(calculator.evaluate('ROUND(apples * 0.93)', apples: 10)).to eq(9)
459
501
  end
460
502
 
503
+ it 'include ABS' do
504
+ expect(calculator.evaluate('abs(-2.2)')).to eq(2.2)
505
+ expect(calculator.evaluate('abs(5)')).to eq(5)
506
+
507
+ expect(calculator.evaluate('ABS(x * -1)', x: 10)).to eq(10)
508
+ end
509
+
461
510
  it 'include NOT' do
462
511
  expect(calculator.evaluate('NOT(some_boolean)', some_boolean: true)).to be_falsey
463
512
  expect(calculator.evaluate('NOT(some_boolean)', some_boolean: false)).to be_truthy
@@ -745,9 +794,9 @@ describe Dentaku::Calculator do
745
794
  end
746
795
  end
747
796
 
748
- describe 'math functions' do
797
+ describe 'math support' do
749
798
  Math.methods(false).each do |method|
750
- it method do
799
+ it "includes `#{method}`" do
751
800
  if Math.method(method).arity == 2
752
801
  expect(calculator.evaluate("#{method}(x,y)", x: 1, y: '2')).to eq(Math.send(method, 1, 2))
753
802
  expect(calculator.evaluate("#{method}(x,y) + 1", x: 1, y: '2')).to be_within(0.00001).of(Math.send(method, 1, 2) + 1)
@@ -761,11 +810,16 @@ describe Dentaku::Calculator do
761
810
  end
762
811
  end
763
812
 
764
- it 'are defined with a properly named class that represents it to support AST marshaling' do
813
+ it 'defines a properly named class to support AST marshaling' do
765
814
  expect {
766
815
  Marshal.dump(calculator.ast('SQRT(20)'))
767
816
  }.not_to raise_error
768
817
  end
818
+
819
+ it 'properly handles a Math::DomainError' do
820
+ expect(calculator.evaluate('asin(2)')).to be_nil
821
+ expect { calculator.evaluate!('asin(2)') }.to raise_error(Dentaku::MathDomainError)
822
+ end
769
823
  end
770
824
 
771
825
  describe 'disable_cache' do
@@ -5,8 +5,7 @@ require 'dentaku/calculator'
5
5
  describe Dentaku::Calculator do
6
6
  describe 'functions' do
7
7
  describe 'external functions' do
8
-
9
- let(:with_external_funcs) do
8
+ let(:custom_calculator) do
10
9
  c = described_class.new
11
10
 
12
11
  c.add_function(:now, :string, -> { Time.now.to_s })
@@ -22,30 +21,30 @@ describe Dentaku::Calculator do
22
21
  end
23
22
 
24
23
  it 'includes NOW' do
25
- now = with_external_funcs.evaluate('NOW()')
24
+ now = custom_calculator.evaluate('NOW()')
26
25
  expect(now).not_to be_nil
27
26
  expect(now).not_to be_empty
28
27
  end
29
28
 
30
29
  it 'includes POW' do
31
- expect(with_external_funcs.evaluate('POW(2,3)')).to eq(8)
32
- expect(with_external_funcs.evaluate('POW(3,2)')).to eq(9)
33
- expect(with_external_funcs.evaluate('POW(mantissa,exponent)', mantissa: 2, exponent: 4)).to eq(16)
30
+ expect(custom_calculator.evaluate('POW(2,3)')).to eq(8)
31
+ expect(custom_calculator.evaluate('POW(3,2)')).to eq(9)
32
+ expect(custom_calculator.evaluate('POW(mantissa,exponent)', mantissa: 2, exponent: 4)).to eq(16)
34
33
  end
35
34
 
36
35
  it 'includes BIGGEST' do
37
- expect(with_external_funcs.evaluate('BIGGEST(8,6,7,5,3,0,9)')).to eq(9)
36
+ expect(custom_calculator.evaluate('BIGGEST(8,6,7,5,3,0,9)')).to eq(9)
38
37
  end
39
38
 
40
39
  it 'includes SMALLEST' do
41
- expect(with_external_funcs.evaluate('SMALLEST(8,6,7,5,3,0,9)')).to eq(0)
40
+ expect(custom_calculator.evaluate('SMALLEST(8,6,7,5,3,0,9)')).to eq(0)
42
41
  end
43
42
 
44
43
  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)
44
+ expect(custom_calculator.evaluate('OPTIONAL(1,2)')).to eq(3)
45
+ expect(custom_calculator.evaluate('OPTIONAL(1,2,3)')).to eq(6)
46
+ expect { custom_calculator.dependencies('OPTIONAL()') }.to raise_error(Dentaku::ParseError)
47
+ expect { custom_calculator.dependencies('OPTIONAL(1,2,3,4)') }.to raise_error(Dentaku::ParseError)
49
48
  end
50
49
 
51
50
  it 'supports array parameters' do
@@ -62,6 +61,66 @@ describe Dentaku::Calculator do
62
61
  end
63
62
  end
64
63
 
64
+ describe 'with callbacks' do
65
+ let(:custom_calculator) do
66
+ c = described_class.new
67
+
68
+ @counts = Hash.new(0)
69
+
70
+ @initial_time = "2023-02-03"
71
+ @last_time = @initial_time
72
+
73
+ c.add_function(
74
+ :reverse,
75
+ :stringl,
76
+ ->(a) { a.reverse },
77
+ lambda do |args|
78
+ args.each do |arg|
79
+ @counts[arg.value] += 1 if arg.type == :string
80
+ end
81
+ end
82
+ )
83
+
84
+ fns = [
85
+ [:biggest_callback, :numeric, ->(*args) { args.max }, ->(args) { args.each { |arg| raise Dentaku::ArgumentError unless arg.type == :numeric } }],
86
+ [:pythagoras, :numeric, ->(l1, l2) { Math.sqrt(l1**2 + l2**2) }, ->(e) { @last_time = Time.now.to_s }],
87
+ [:callback_lambda, :string, ->() { " " }, ->() { "lambda executed" }],
88
+ [:no_lambda_function, :numeric, ->(a) { a**a }],
89
+ ]
90
+
91
+ c.add_functions(fns)
92
+ end
93
+
94
+ it 'includes BIGGEST_CALLBACK' do
95
+ expect(custom_calculator.evaluate('BIGGEST_CALLBACK(1, 2, 5, 4)')).to eq(5)
96
+ expect { custom_calculator.dependencies('BIGGEST_CALLBACK(1, 3, 6, "hi", 10)') }.to raise_error(Dentaku::ArgumentError)
97
+ end
98
+
99
+ it 'includes REVERSE' do
100
+ expect(custom_calculator.evaluate('REVERSE(\'Dentaku\')')).to eq('ukatneD')
101
+ expect { custom_calculator.evaluate('REVERSE(22)') }.to raise_error(NoMethodError)
102
+ expect(@counts["Dentaku"]).to eq(1)
103
+ end
104
+
105
+ it 'includes PYTHAGORAS' do
106
+ expect(custom_calculator.evaluate('PYTHAGORAS(8, 7)')).to eq(10.63014581273465)
107
+ expect(custom_calculator.evaluate('PYTHAGORAS(3, 4)')).to eq(5)
108
+ expect(@last_time).not_to eq(@initial_time)
109
+ end
110
+
111
+ it 'exposes the `callback` method of a function' do
112
+ expect(Dentaku::AST::Function::Callback_lambda.callback.call()).to eq("lambda executed")
113
+ end
114
+
115
+ it 'does not add a `callback` method to built-in functions' do
116
+ expect { Dentaku::AST::If.callback.call }.to raise_error(NoMethodError)
117
+ end
118
+
119
+ it 'defaults `callback` method to nil if not specified' do
120
+ expect(Dentaku::AST::Function::No_lambda_function.callback).to eq(nil)
121
+ end
122
+ end
123
+
65
124
  it 'allows registering "bang" functions' do
66
125
  calculator = described_class.new
67
126
  calculator.add_function(:hey!, :string, -> { "hey!" })
@@ -82,24 +141,36 @@ describe Dentaku::Calculator do
82
141
  end
83
142
 
84
143
  it 'does not store functions across all calculators' do
85
- calculator1 = Dentaku::Calculator.new
144
+ calculator1 = described_class.new
86
145
  calculator1.add_function(:my_function, :numeric, ->(x) { 2 * x + 1 })
87
146
 
88
- calculator2 = Dentaku::Calculator.new
147
+ calculator2 = described_class.new
89
148
  calculator2.add_function(:my_function, :numeric, ->(x) { 4 * x + 3 })
90
149
 
91
150
  expect(calculator1.evaluate!("1 + my_function(2)")). to eq(1 + 2 * 2 + 1)
92
151
  expect(calculator2.evaluate!("1 + my_function(2)")). to eq(1 + 4 * 2 + 3)
93
152
 
94
153
  expect {
95
- Dentaku::Calculator.new.evaluate!("1 + my_function(2)")
154
+ described_class.new.evaluate!("1 + my_function(2)")
96
155
  }.to raise_error(Dentaku::ParseError)
97
156
  end
98
157
 
99
158
  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)
159
+ it 'adds a function to default/global function registry' do
160
+ described_class.add_function(:global_function, :numeric, ->(x) { 10 + x**2 })
161
+ expect(described_class.new.evaluate("global_function(3) + 5")).to eq(10 + 3**2 + 5)
162
+ end
163
+ end
164
+
165
+ describe 'Dentaku::Calculator.add_functions' do
166
+ it 'adds multiple functions to default/global function registry' do
167
+ described_class.add_functions([
168
+ [:cube, :numeric, ->(x) { x**3 }],
169
+ [:spongebob, :string, ->(x) { x.split("").each_with_index().map { |c,i| i.even? ? c.upcase : c.downcase }.join() }],
170
+ ])
171
+
172
+ expect(described_class.new.evaluate("1 + cube(3)")).to eq(28)
173
+ expect(described_class.new.evaluate("spongebob('How are you today?')")).to eq("HoW ArE YoU ToDaY?")
103
174
  end
104
175
  end
105
176
  end
@@ -8,6 +8,12 @@ describe Dentaku::PrintVisitor do
8
8
  expect(repr).to eq('5 + 4')
9
9
  end
10
10
 
11
+ it 'handles grouping correctly' do
12
+ formula = '10 - (0 - 10)'
13
+ repr = roundtrip(formula)
14
+ expect(repr).to eq(formula)
15
+ end
16
+
11
17
  it 'quotes string literals' do
12
18
  repr = roundtrip('Concat(\'a\', "B")')
13
19
  expect(repr).to eq('CONCAT("a", "B")')
@@ -89,6 +89,18 @@ describe Dentaku::Tokenizer do
89
89
  expect(tokens.map(&:value)).to eq([2, :bitand, 3])
90
90
  end
91
91
 
92
+ it 'tokenizes bitwise SHIFT LEFT' do
93
+ tokens = tokenizer.tokenize('2 << 3')
94
+ expect(tokens.map(&:category)).to eq([:numeric, :operator, :numeric])
95
+ expect(tokens.map(&:value)).to eq([2, :bitshiftleft, 3])
96
+ end
97
+
98
+ it 'tokenizes bitwise SHIFT RIGHT' do
99
+ tokens = tokenizer.tokenize('2 >> 3')
100
+ expect(tokens.map(&:category)).to eq([:numeric, :operator, :numeric])
101
+ expect(tokens.map(&:value)).to eq([2, :bitshiftright, 3])
102
+ end
103
+
92
104
  it 'ignores whitespace' do
93
105
  tokens = tokenizer.tokenize('1 / 1 ')
94
106
  expect(tokens.map(&:category)).to eq([:numeric, :operator, :numeric])
@@ -10,6 +10,21 @@ class ArrayProcessor
10
10
  @expression = []
11
11
  end
12
12
 
13
+ def visit_array(node)
14
+ @expression << "{"
15
+
16
+ head, *tail = node.value
17
+
18
+ process(head) if head
19
+
20
+ tail.each do |v|
21
+ @expression << ","
22
+ process(v)
23
+ end
24
+
25
+ @expression << "}"
26
+ end
27
+
13
28
  def process(node)
14
29
  @expression << node.to_s
15
30
  end
@@ -22,10 +37,16 @@ RSpec.describe Dentaku::Visitor::Infix do
22
37
  expect(processor.expression).to eq ['5', '+', '3']
23
38
  end
24
39
 
40
+ it 'supports array nodes' do
41
+ processor = ArrayProcessor.new
42
+ processor.visit(ast('{1, 2, 3}'))
43
+ expect(processor.expression).to eq ['{', '1', ',', '2', ',', '3', '}']
44
+ end
45
+
25
46
  private
26
47
 
27
48
  def ast(expression)
28
49
  tokens = Dentaku::Tokenizer.new.tokenize(expression)
29
50
  Dentaku::Parser.new(tokens).parse
30
51
  end
31
- end
52
+ end
data/spec/visitor_spec.rb CHANGED
@@ -90,6 +90,7 @@ describe TestVisitor do
90
90
  def generic_subclasses
91
91
  [
92
92
  :Arithmetic,
93
+ :Bitwise,
93
94
  :Combinator,
94
95
  :Comparator,
95
96
  :Function,
@@ -111,9 +112,9 @@ describe TestVisitor do
111
112
  visit_nodes('1 < 2 and 3 <= 4 or 5 > 6 AND 7 >= 8 OR 9 != 10 and true')
112
113
  visit_nodes('IF(a[0] = NULL, "five", \'seven\')')
113
114
  visit_nodes('case (a % 5) when 0 then a else b end')
114
- visit_nodes('0xCAFE & 0xDECAF | 0xBEEF')
115
+ visit_nodes('0xCAFE & (0xDECAF << 3) | (0xBEEF >> 5)')
115
116
  visit_nodes('2017-12-24 23:59:59')
116
- visit_nodes('ALL({1, 2, 3}, "val", val % 2 == 0)')
117
+ visit_nodes('ALL({1, 2, 3}, val, val % 2 == 0)')
117
118
  visit_nodes('ANY(vals, val, val > 1)')
118
119
  visit_nodes('COUNT({1, 2, 3})')
119
120
  visit_nodes('PLUCK(users, age)')
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.5.0
4
+ version: 3.5.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: 2022-03-17 00:00:00.000000000 Z
11
+ date: 2023-12-07 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: concurrent-ruby
@@ -170,6 +170,7 @@ files:
170
170
  - lib/dentaku/ast/datetime.rb
171
171
  - lib/dentaku/ast/function.rb
172
172
  - lib/dentaku/ast/function_registry.rb
173
+ - lib/dentaku/ast/functions/abs.rb
173
174
  - lib/dentaku/ast/functions/all.rb
174
175
  - lib/dentaku/ast/functions/and.rb
175
176
  - lib/dentaku/ast/functions/any.rb
@@ -220,6 +221,7 @@ files:
220
221
  - lib/dentaku/tokenizer.rb
221
222
  - lib/dentaku/version.rb
222
223
  - lib/dentaku/visitor/infix.rb
224
+ - spec/ast/abs_spec.rb
223
225
  - spec/ast/addition_spec.rb
224
226
  - spec/ast/all_spec.rb
225
227
  - spec/ast/and_function_spec.rb
@@ -283,11 +285,12 @@ required_rubygems_version: !ruby/object:Gem::Requirement
283
285
  - !ruby/object:Gem::Version
284
286
  version: '0'
285
287
  requirements: []
286
- rubygems_version: 3.3.9
288
+ rubygems_version: 3.3.7
287
289
  signing_key:
288
290
  specification_version: 4
289
291
  summary: A formula language parser and evaluator
290
292
  test_files:
293
+ - spec/ast/abs_spec.rb
291
294
  - spec/ast/addition_spec.rb
292
295
  - spec/ast/all_spec.rb
293
296
  - spec/ast/and_function_spec.rb