dentaku 3.5.1 → 3.5.3

Sign up to get free protection for your applications and to get access to all the features.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +24 -1
  3. data/README.md +1 -1
  4. data/lib/dentaku/ast/arithmetic.rb +34 -12
  5. data/lib/dentaku/ast/comparators.rb +1 -2
  6. data/lib/dentaku/ast/function_registry.rb +10 -1
  7. data/lib/dentaku/ast/functions/abs.rb +5 -0
  8. data/lib/dentaku/ast/functions/avg.rb +1 -1
  9. data/lib/dentaku/ast/functions/enum.rb +5 -4
  10. data/lib/dentaku/ast/functions/if.rb +3 -7
  11. data/lib/dentaku/ast/functions/intercept.rb +33 -0
  12. data/lib/dentaku/ast/functions/reduce.rb +61 -0
  13. data/lib/dentaku/ast/functions/ruby_math.rb +2 -0
  14. data/lib/dentaku/ast.rb +4 -1
  15. data/lib/dentaku/calculator.rb +17 -10
  16. data/lib/dentaku/date_arithmetic.rb +8 -2
  17. data/lib/dentaku/exceptions.rb +17 -3
  18. data/lib/dentaku/parser.rb +20 -9
  19. data/lib/dentaku/print_visitor.rb +16 -5
  20. data/lib/dentaku/token_scanner.rb +12 -3
  21. data/lib/dentaku/tokenizer.rb +7 -3
  22. data/lib/dentaku/version.rb +1 -1
  23. data/lib/dentaku/visitor/infix.rb +4 -0
  24. data/spec/ast/abs_spec.rb +26 -0
  25. data/spec/ast/addition_spec.rb +4 -4
  26. data/spec/ast/all_spec.rb +1 -1
  27. data/spec/ast/any_spec.rb +1 -1
  28. data/spec/ast/arithmetic_spec.rb +61 -12
  29. data/spec/ast/avg_spec.rb +5 -0
  30. data/spec/ast/division_spec.rb +25 -0
  31. data/spec/ast/filter_spec.rb +1 -1
  32. data/spec/ast/intercept_spec.rb +30 -0
  33. data/spec/ast/map_spec.rb +1 -1
  34. data/spec/ast/pluck_spec.rb +1 -1
  35. data/spec/ast/reduce_spec.rb +22 -0
  36. data/spec/bulk_expression_solver_spec.rb +17 -0
  37. data/spec/calculator_spec.rb +99 -17
  38. data/spec/external_function_spec.rb +89 -18
  39. data/spec/parser_spec.rb +3 -0
  40. data/spec/print_visitor_spec.rb +6 -0
  41. data/spec/tokenizer_spec.rb +6 -4
  42. data/spec/visitor/infix_spec.rb +22 -1
  43. data/spec/visitor_spec.rb +2 -1
  44. metadata +12 -3
@@ -7,6 +7,8 @@ module Dentaku
7
7
  class TokenScanner
8
8
  extend StringCasing
9
9
 
10
+ DATE_TIME_REGEXP = /\d{2}\d{2}?-\d{1,2}-\d{1,2}([ |T]\d{1,2}:\d{1,2}:\d{1,2}(\.\d*)?)? ?(Z|((\+|\-)\d{2}\:?\d{2}))?(?!\d)/.freeze
11
+
10
12
  def initialize(category, regexp, converter = nil, condition = nil)
11
13
  @category = category
12
14
  @regexp = %r{\A(#{ regexp })}i
@@ -49,7 +51,8 @@ module Dentaku
49
51
  :comparator,
50
52
  :boolean,
51
53
  :function,
52
- :identifier
54
+ :identifier,
55
+ :quoted_identifier
53
56
  ]
54
57
  end
55
58
 
@@ -73,7 +76,9 @@ module Dentaku
73
76
 
74
77
  def scanners(options = {})
75
78
  @case_sensitive = options.fetch(:case_sensitive, false)
76
- @scanners.values
79
+ raw_date_literals = options.fetch(:raw_date_literals, true)
80
+
81
+ @scanners.select { |k, _| raw_date_literals || k != :datetime }.values
77
82
  end
78
83
 
79
84
  def whitespace
@@ -86,7 +91,7 @@ module Dentaku
86
91
 
87
92
  # NOTE: Convert to DateTime as Array(Time) returns the parts of the time for some reason
88
93
  def datetime
89
- new(:datetime, /\d{2}\d{2}?-\d{1,2}-\d{1,2}( \d{1,2}:\d{1,2}:\d{1,2})? ?(Z|((\+|\-)\d{2}\:?\d{2}))?/, lambda { |raw| Time.parse(raw).to_datetime })
94
+ new(:datetime, DATE_TIME_REGEXP, lambda { |raw| Time.parse(raw).to_datetime })
90
95
  end
91
96
 
92
97
  def numeric
@@ -176,6 +181,10 @@ module Dentaku
176
181
  def identifier
177
182
  new(:identifier, '[[[:word:]]\.]+\b', lambda { |raw| standardize_case(raw.strip) })
178
183
  end
184
+
185
+ def quoted_identifier
186
+ new(:identifier, '`[^`]*`', lambda { |raw| raw.gsub(/^`|`$/, '') })
187
+ end
179
188
  end
180
189
 
181
190
  register_default_scanners
@@ -4,7 +4,7 @@ require 'dentaku/token_scanner'
4
4
 
5
5
  module Dentaku
6
6
  class Tokenizer
7
- attr_reader :case_sensitive, :aliases
7
+ attr_reader :aliases
8
8
 
9
9
  LPAREN = TokenMatcher.new(:grouping, :open)
10
10
  RPAREN = TokenMatcher.new(:grouping, :close)
@@ -15,10 +15,14 @@ module Dentaku
15
15
  @aliases = options.fetch(:aliases, global_aliases)
16
16
  input = strip_comments(string.to_s.dup)
17
17
  input = replace_aliases(input)
18
- @case_sensitive = options.fetch(:case_sensitive, false)
18
+
19
+ scanner_options = {
20
+ case_sensitive: options.fetch(:case_sensitive, false),
21
+ raw_date_literals: options.fetch(:raw_date_literals, true)
22
+ }
19
23
 
20
24
  until input.empty?
21
- scanned = TokenScanner.scanners(case_sensitive: case_sensitive).any? do |scanner|
25
+ scanned = TokenScanner.scanners(scanner_options).any? do |scanner|
22
26
  scanned, input = scan(input, scanner)
23
27
  scanned
24
28
  end
@@ -1,3 +1,3 @@
1
1
  module Dentaku
2
- VERSION = "3.5.1"
2
+ VERSION = "3.5.3"
3
3
  end
@@ -77,6 +77,10 @@ module Dentaku
77
77
  def visit_nil(node)
78
78
  process(node)
79
79
  end
80
+
81
+ def visit_array(node)
82
+ process(node)
83
+ end
80
84
  end
81
85
  end
82
86
  end
@@ -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
@@ -36,10 +36,10 @@ describe Dentaku::AST::Addition do
36
36
  it 'allows operands that respond to addition' do
37
37
  # Sample struct that has a custom definition for addition
38
38
 
39
- Operand = Struct.new(:value) do
39
+ Addable = Struct.new(:value) do
40
40
  def +(other)
41
41
  case other
42
- when Operand
42
+ when Addable
43
43
  value + other.value
44
44
  when Numeric
45
45
  value + other
@@ -47,8 +47,8 @@ describe Dentaku::AST::Addition do
47
47
  end
48
48
  end
49
49
 
50
- operand_five = Dentaku::AST::Numeric.new Dentaku::Token.new(:numeric, Operand.new(5))
51
- operand_six = Dentaku::AST::Numeric.new Dentaku::Token.new(:numeric, Operand.new(6))
50
+ operand_five = Dentaku::AST::Numeric.new Dentaku::Token.new(:numeric, Addable.new(5))
51
+ operand_six = Dentaku::AST::Numeric.new Dentaku::Token.new(:numeric, Addable.new(6))
52
52
 
53
53
  expect {
54
54
  described_class.new(operand_five, operand_six)
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
@@ -1,15 +1,14 @@
1
1
  require 'spec_helper'
2
2
  require 'dentaku/ast/arithmetic'
3
-
4
- require 'dentaku/token'
3
+ require 'dentaku'
5
4
 
6
5
  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') }
6
+ let(:one) { Dentaku::AST::Numeric.new(Dentaku::Token.new(:numeric, 1)) }
7
+ let(:two) { Dentaku::AST::Numeric.new(Dentaku::Token.new(:numeric, 2)) }
8
+ let(:x) { Dentaku::AST::Identifier.new(Dentaku::Token.new(:identifier, 'x')) }
9
+ let(:y) { Dentaku::AST::Identifier.new(Dentaku::Token.new(:identifier, 'y')) }
11
10
  let(:ctx) { {'x' => 1, 'y' => 2} }
12
- let(:date) { Dentaku::AST::DateTime.new Dentaku::Token.new(:datetime, DateTime.new(2020, 4, 16)) }
11
+ let(:date) { Dentaku::AST::DateTime.new(Dentaku::Token.new(:datetime, DateTime.new(2020, 4, 16))) }
13
12
 
14
13
  it 'performs an arithmetic operation with numeric operands' do
15
14
  expect(add(one, two)).to eq(3)
@@ -46,20 +45,70 @@ describe Dentaku::AST::Arithmetic do
46
45
  expect { add(x, one, 'x' => 'invalid') }.to raise_error(Dentaku::ArgumentError)
47
46
  expect { add(x, one, 'x' => '') }.to raise_error(Dentaku::ArgumentError)
48
47
 
49
- int_one = Dentaku::AST::Numeric.new Dentaku::Token.new(:numeric, "1")
50
- decimal_one = Dentaku::AST::Numeric.new Dentaku::Token.new(:numeric, "1.0")
48
+ int_one = Dentaku::AST::Numeric.new(Dentaku::Token.new(:numeric, "1"))
49
+ int_neg_one = Dentaku::AST::Numeric.new(Dentaku::Token.new(:numeric, "-1"))
50
+ decimal_one = Dentaku::AST::Numeric.new(Dentaku::Token.new(:numeric, "1.0"))
51
+ decimal_neg_one = Dentaku::AST::Numeric.new(Dentaku::Token.new(:numeric, "-1.0"))
52
+
53
+ [int_one, int_neg_one].permutation(2).each do |(left, right)|
54
+ expect(add(left, right).class).to eq(Integer)
55
+ end
51
56
 
52
- expect(add(int_one, int_one).class).to eq(Integer)
53
- expect(add(int_one, decimal_one).class).to eq(BigDecimal)
54
- expect(add(decimal_one, decimal_one).class).to eq(BigDecimal)
57
+ [decimal_one, decimal_neg_one].each do |left|
58
+ [int_one, int_neg_one, decimal_one, decimal_neg_one].each do |right|
59
+ expect(add(left, right).class).to eq(BigDecimal)
60
+ end
61
+ end
55
62
  end
56
63
 
57
64
  it 'performs arithmetic on arrays' do
58
65
  expect(add(x, y, 'x' => [1], 'y' => [2])).to eq([1, 2])
66
+ expect(sub(x, y, 'x' => [1], 'y' => [2])).to eq([1])
59
67
  end
60
68
 
61
69
  it 'performs date arithmetic' do
62
70
  expect(add(date, one)).to eq(DateTime.new(2020, 4, 17))
71
+ expect(sub(date, one)).to eq(DateTime.new(2020, 4, 15))
72
+ end
73
+
74
+ it 'performs arithmetic on object which implements arithmetic' do
75
+ CanHazMath = Struct.new(:value) do
76
+ extend Forwardable
77
+
78
+ def_delegators :value, :zero?
79
+
80
+ def coerce(other)
81
+ case other
82
+ when Numeric
83
+ [other, value]
84
+ else
85
+ super
86
+ end
87
+ end
88
+
89
+ [:+, :-, :/, :*].each do |operand|
90
+ define_method(operand) do |other|
91
+ case other
92
+ when CanHazMath
93
+ value.public_send(operand, other.value)
94
+ when Numeric
95
+ value.public_send(operand, other)
96
+ end
97
+ end
98
+ end
99
+ end
100
+
101
+ op_one = CanHazMath.new(1)
102
+ op_two = CanHazMath.new(2)
103
+
104
+ [op_two, two].each do |left|
105
+ [op_one, one].each do |right|
106
+ expect(add(x, y, 'x' => left, 'y' => right)).to eq(3)
107
+ expect(sub(x, y, 'x' => left, 'y' => right)).to eq(1)
108
+ expect(mul(x, y, 'x' => left, 'y' => right)).to eq(2)
109
+ expect(div(x, y, 'x' => left, 'y' => right)).to eq(2)
110
+ end
111
+ end
63
112
  end
64
113
 
65
114
  it 'raises ArgumentError if given individually valid but incompatible arguments' 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)
@@ -32,4 +32,29 @@ describe Dentaku::AST::Division do
32
32
  described_class.new(group, five)
33
33
  }.not_to raise_error
34
34
  end
35
+
36
+ it 'allows operands that respond to division' do
37
+ # Sample struct that has a custom definition for division
38
+ Divisible = Struct.new(:value) do
39
+ def /(other)
40
+ case other
41
+ when Divisible
42
+ value + other.value
43
+ when Numeric
44
+ value + other
45
+ end
46
+ end
47
+ end
48
+
49
+ operand_five = Dentaku::AST::Numeric.new Dentaku::Token.new(:numeric, Divisible.new(5))
50
+ operand_six = Dentaku::AST::Numeric.new Dentaku::Token.new(:numeric, Divisible.new(6))
51
+
52
+ expect {
53
+ described_class.new(operand_five, operand_six)
54
+ }.not_to raise_error
55
+
56
+ expect {
57
+ described_class.new(operand_five, six)
58
+ }.not_to raise_error
59
+ end
35
60
  end
@@ -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
@@ -0,0 +1,30 @@
1
+ require 'spec_helper'
2
+ require 'dentaku/ast/functions/intercept'
3
+ require 'dentaku'
4
+
5
+ describe 'Dentaku::AST::Function::Intercept' do
6
+ it 'returns the correct intercept for given x and y arrays' do
7
+ x_values = [6, 13, 15, 10, 11, 10]
8
+ y_values = [-1, 8, 8, 13, 3, 15]
9
+ result = Dentaku('INTERCEPT(ys, xs)', xs: x_values, ys: y_values)
10
+ expect(result).to be_within(0.001).of(9.437)
11
+ end
12
+
13
+ context 'checking errors' do
14
+ it 'raises an error if arguments are not arrays' do
15
+ expect { Dentaku!("INTERCEPT(1, 2)") }.to raise_error(Dentaku::ArgumentError)
16
+ end
17
+
18
+ it 'raises an error if the arrays are not of equal length' do
19
+ x_values = [1, 2, 3]
20
+ y_values = [2, 3, 5, 4]
21
+ expect { Dentaku!("INTERCEPT(y, x)", x: x_values, y: y_values) }.to raise_error(Dentaku::ArgumentError)
22
+ end
23
+
24
+ it 'raises an error if any of the arrays is empty' do
25
+ x_values = []
26
+ y_values = [2, 3, 5, 4]
27
+ expect { Dentaku!("INTERCEPT(y, x)", x: x_values, y: y_values) }.to raise_error(Dentaku::ArgumentError)
28
+ end
29
+ end
30
+ 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
@@ -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
@@ -0,0 +1,22 @@
1
+ require 'spec_helper'
2
+ require 'dentaku/ast/functions/reduce'
3
+ require 'dentaku'
4
+
5
+ describe Dentaku::AST::Reduce do
6
+ let(:calculator) { Dentaku::Calculator.new }
7
+
8
+ it 'performs REDUCE operation with initial value' do
9
+ result = Dentaku('REDUCE(vals, memo, val, CONCAT(memo, val), "hello")', vals: ["wo", "rl", "d"])
10
+ expect(result).to eq("helloworld")
11
+ end
12
+
13
+ it 'performs REDUCE operation without initial value' do
14
+ result = Dentaku('REDUCE(vals, memo, val, CONCAT(memo, val))', vals: ["wo", "rl", "d"])
15
+ expect(result).to eq("world")
16
+ end
17
+
18
+ it 'raises argument error if a string is passed as identifier' do
19
+ expect { calculator.evaluate!('REDUCE({1, 2, 3}, memo, "val", memo + val)') }.to raise_error(Dentaku::ParseError)
20
+ expect { calculator.evaluate!('REDUCE({1, 2, 3}, "memo", val, memo + val)') }.to raise_error(Dentaku::ParseError)
21
+ end
22
+ end
@@ -125,6 +125,12 @@ RSpec.describe Dentaku::BulkExpressionSolver do
125
125
  .to eq(more_apples: :foo)
126
126
  end
127
127
 
128
+ it "allows passing in ast as expression" do
129
+ expressions = {more_apples: calculator.ast("1/0")}
130
+ expect(described_class.new(expressions, calculator).solve { :foo })
131
+ .to eq(more_apples: :foo)
132
+ end
133
+
128
134
  it 'stores the recipient variable on the exception when there is a div/0 error' do
129
135
  expressions = {more_apples: "1/0"}
130
136
  exception = nil
@@ -197,5 +203,16 @@ RSpec.describe Dentaku::BulkExpressionSolver do
197
203
  expect(results).to eq('key' => [3, :undefined])
198
204
  expect { solver.solve! }.to raise_error(Dentaku::UnboundVariableError)
199
205
  end
206
+
207
+ it do
208
+ calculator.store(val: nil)
209
+ expressions = {
210
+ a: 'IF(5 / 0 > 0, 100, 1000)',
211
+ b: 'IF(val = 0, 0, IF(val > 0, 0, 0))'
212
+ }
213
+ solver = described_class.new(expressions, calculator)
214
+ results = solver.solve
215
+ expect(results).to eq(a: :undefined, b: :undefined)
216
+ end
200
217
  end
201
218
  end
@@ -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)
@@ -158,6 +159,13 @@ describe Dentaku::Calculator do
158
159
  expect(calculator.evaluate!('area', length: 5, width: 5)).to eq(25)
159
160
  end
160
161
 
162
+ it 'stores dates' do
163
+ calculator.store("d1", Date.parse("2024/01/02"))
164
+ calculator.store("d2", Date.parse("2024/01/06"))
165
+ expect(calculator.solve(diff: "d1 - d2")).to eq(diff: -4)
166
+ end
167
+
168
+
161
169
  it 'stores nested hashes' do
162
170
  calculator.store(a: {basket: {of: 'apples'}}, b: 2)
163
171
  expect(calculator.evaluate!('a.basket.of')).to eq('apples')
@@ -165,6 +173,13 @@ describe Dentaku::Calculator do
165
173
  expect(calculator.evaluate!('b')).to eq(2)
166
174
  end
167
175
 
176
+ it 'stores nested hashes with quotes' do
177
+ calculator.store(a: {basket: {of: 'apples'}}, b: 2)
178
+ expect(calculator.evaluate!('`a.basket.of`')).to eq('apples')
179
+ expect(calculator.evaluate!('`a.basket`')).to eq(of: 'apples')
180
+ expect(calculator.evaluate!('`b`')).to eq(2)
181
+ end
182
+
168
183
  it 'stores arrays' do
169
184
  calculator.store(a: [1, 2, 3])
170
185
  expect(calculator.evaluate!('a[0]')).to eq(1)
@@ -179,6 +194,10 @@ describe Dentaku::Calculator do
179
194
  end
180
195
 
181
196
  describe 'dependencies' do
197
+ it 'respects quoted identifiers in dependencies' do
198
+ expect(calculator.dependencies("`bob the builder` + `dole the digger` / 3")).to eq(['bob the builder', 'dole the digger'])
199
+ end
200
+
182
201
  it "finds dependencies in a generic statement" do
183
202
  expect(calculator.dependencies("bob + dole / 3")).to eq(['bob', 'dole'])
184
203
  end
@@ -187,6 +206,10 @@ describe Dentaku::Calculator do
187
206
  expect(calculator.dependencies("a + b", a: 1)).to eq(['b'])
188
207
  end
189
208
 
209
+ it "ignores dependencies passed in context for quoted identifiers" do
210
+ expect(calculator.dependencies("`a-c` + b", "a-c": 1)).to eq(['b'])
211
+ end
212
+
190
213
  it "finds dependencies in formula arguments" do
191
214
  allow(Dentaku).to receive(:cache_ast?) { true }
192
215
 
@@ -210,10 +233,11 @@ describe Dentaku::Calculator do
210
233
  describe 'solve!' do
211
234
  it "evaluates properly with variables, even if some in memory" do
212
235
  expect(with_memory.solve!(
236
+ "monthly fruit budget": "weekly_fruit_budget * 4",
213
237
  weekly_fruit_budget: "weekly_apple_budget + pear * 4",
214
238
  weekly_apple_budget: "apples * 7",
215
239
  pear: "1"
216
- )).to eq(pear: 1, weekly_apple_budget: 21, weekly_fruit_budget: 25)
240
+ )).to eq(pear: 1, weekly_apple_budget: 21, weekly_fruit_budget: 25, "monthly fruit budget": 100)
217
241
  end
218
242
 
219
243
  it "prefers variables over values in memory if they have no dependencies" do
@@ -329,6 +353,11 @@ describe Dentaku::Calculator do
329
353
  }.not_to raise_error
330
354
  end
331
355
 
356
+ it 'allows to compare "-" or "-."' do
357
+ expect { calculator.solve("IF('-' = '-', 0, 1)") }.not_to raise_error
358
+ expect { calculator.solve("IF('-.'= '-.', 0, 1)") }.not_to raise_error
359
+ end
360
+
332
361
  it "integrates with custom functions" do
333
362
  calculator.add_function(:custom, :integer, -> { 1 })
334
363
 
@@ -406,6 +435,13 @@ describe Dentaku::Calculator do
406
435
  expect(calculator.evaluate('fo1o * 2', fo1o: 4)).to eq(8)
407
436
  end
408
437
 
438
+ it 'accepts special characters in quoted identifiers' do
439
+ expect(calculator.evaluate('`foo1 bar` * 2', "foo1 bar": 2)).to eq(4)
440
+ expect(calculator.evaluate('`foo1-bar` * 2', 'foo1-bar' => 4)).to eq(8)
441
+ expect(calculator.evaluate('`1foo (bar)` * 2', '1foo (bar)' => 2)).to eq(4)
442
+ expect(calculator.evaluate('`fo1o *bar*` * 2', 'fo1o *bar*': 4)).to eq(8)
443
+ end
444
+
409
445
  it 'compares string literals with string variables' do
410
446
  expect(calculator.evaluate('fruit = "apple"', fruit: 'apple')).to be_truthy
411
447
  expect(calculator.evaluate('fruit = "apple"', fruit: 'pear')).to be_falsey
@@ -440,19 +476,46 @@ describe Dentaku::Calculator do
440
476
  expect(calculator.evaluate('t1 > 2017-01-02', t1: Time.local(2017, 1, 3).to_datetime)).to be_truthy
441
477
  end
442
478
 
443
- it 'supports date arithmetic' do
444
- expect(calculator.evaluate!('2020-01-01 + 30').to_date).to eq(Time.local(2020, 1, 31).to_date)
445
- expect(calculator.evaluate!('2020-01-01 - 1').to_date).to eq(Time.local(2019, 12, 31).to_date)
446
- expect(calculator.evaluate!('2020-01-01 - 2019-12-31')).to eq(1)
447
- expect(calculator.evaluate!('2020-01-01 + duration(1, day)').to_date).to eq(Time.local(2020, 1, 2).to_date)
448
- expect(calculator.evaluate!('2020-01-01 - duration(1, day)').to_date).to eq(Time.local(2019, 12, 31).to_date)
449
- expect(calculator.evaluate!('2020-01-01 + duration(30, days)').to_date).to eq(Time.local(2020, 1, 31).to_date)
450
- expect(calculator.evaluate!('2020-01-01 + duration(1, month)').to_date).to eq(Time.local(2020, 2, 1).to_date)
451
- expect(calculator.evaluate!('2020-01-01 - duration(1, month)').to_date).to eq(Time.local(2019, 12, 1).to_date)
452
- expect(calculator.evaluate!('2020-01-01 + duration(30, months)').to_date).to eq(Time.local(2022, 7, 1).to_date)
453
- expect(calculator.evaluate!('2020-01-01 + duration(1, year)').to_date).to eq(Time.local(2021, 1, 1).to_date)
454
- expect(calculator.evaluate!('2020-01-01 - duration(1, year)').to_date).to eq(Time.local(2019, 1, 1).to_date)
455
- expect(calculator.evaluate!('2020-01-01 + duration(30, years)').to_date).to eq(Time.local(2050, 1, 1).to_date)
479
+ describe 'disabling date literals' do
480
+ it 'does not parse formulas with minus signs as dates' do
481
+ calculator = described_class.new(raw_date_literals: false)
482
+ expect(calculator.evaluate!('2020-01-01')).to eq(2018)
483
+ end
484
+ end
485
+
486
+ describe 'supports date arithmetic' do
487
+ it 'from hardcoded string' do
488
+ expect(calculator.evaluate!('2020-01-01 + 30').to_date).to eq(Time.local(2020, 1, 31).to_date)
489
+ expect(calculator.evaluate!('2020-01-01 - 1').to_date).to eq(Time.local(2019, 12, 31).to_date)
490
+ expect(calculator.evaluate!('2020-01-01 - 2019-12-31')).to eq(1)
491
+ expect(calculator.evaluate!('2020-01-01 + duration(1, day)').to_date).to eq(Time.local(2020, 1, 2).to_date)
492
+ expect(calculator.evaluate!('2020-01-01 - duration(1, day)').to_date).to eq(Time.local(2019, 12, 31).to_date)
493
+ expect(calculator.evaluate!('2020-01-01 + duration(30, days)').to_date).to eq(Time.local(2020, 1, 31).to_date)
494
+ expect(calculator.evaluate!('2020-01-01 + duration(1, month)').to_date).to eq(Time.local(2020, 2, 1).to_date)
495
+ expect(calculator.evaluate!('2020-01-01 - duration(1, month)').to_date).to eq(Time.local(2019, 12, 1).to_date)
496
+ expect(calculator.evaluate!('2020-01-01 + duration(30, months)').to_date).to eq(Time.local(2022, 7, 1).to_date)
497
+ expect(calculator.evaluate!('2020-01-01 + duration(1, year)').to_date).to eq(Time.local(2021, 1, 1).to_date)
498
+ expect(calculator.evaluate!('2020-01-01 - duration(1, year)').to_date).to eq(Time.local(2019, 1, 1).to_date)
499
+ expect(calculator.evaluate!('2020-01-01 + duration(30, years)').to_date).to eq(Time.local(2050, 1, 1).to_date)
500
+ end
501
+
502
+ it 'from string variable' do
503
+ value = '2023-01-01'
504
+ value2 = '2022-12-31'
505
+
506
+ expect(calculator.evaluate!('value + duration(1, month)', { value: value }).to_date).to eq(Date.parse('2023-02-01'))
507
+ expect(calculator.evaluate!('value - duration(1, month)', { value: value }).to_date).to eq(Date.parse('2022-12-01'))
508
+ expect(calculator.evaluate!('value - value2', { value: value, value2: value2 })).to eq(1)
509
+ end
510
+
511
+ it 'from date object' do
512
+ value = Date.parse('2023-01-01').to_date
513
+ value2 = Date.parse('2022-12-31').to_date
514
+
515
+ expect(calculator.evaluate!('value + duration(1, month)', { value: value }).to_date).to eq(Date.parse('2023-02-01'))
516
+ expect(calculator.evaluate!('value - duration(1, month)', { value: value }).to_date).to eq(Date.parse('2022-12-01'))
517
+ expect(calculator.evaluate!('value - value2', { value: value, value2: value2 })).to eq(1)
518
+ end
456
519
  end
457
520
 
458
521
  describe 'functions' do
@@ -471,6 +534,13 @@ describe Dentaku::Calculator do
471
534
  expect(calculator.evaluate('ROUND(apples * 0.93)', apples: 10)).to eq(9)
472
535
  end
473
536
 
537
+ it 'include ABS' do
538
+ expect(calculator.evaluate('abs(-2.2)')).to eq(2.2)
539
+ expect(calculator.evaluate('abs(5)')).to eq(5)
540
+
541
+ expect(calculator.evaluate('ABS(x * -1)', x: 10)).to eq(10)
542
+ end
543
+
474
544
  it 'include NOT' do
475
545
  expect(calculator.evaluate('NOT(some_boolean)', some_boolean: true)).to be_falsey
476
546
  expect(calculator.evaluate('NOT(some_boolean)', some_boolean: false)).to be_truthy
@@ -486,6 +556,13 @@ describe Dentaku::Calculator do
486
556
  expect(calculator.evaluate('NOT(some_boolean) AND -1 > 3', some_boolean: true)).to be_falsey
487
557
  end
488
558
 
559
+ it 'calculates intercept correctly' do
560
+ x_values = [1, 2, 3, 4, 5]
561
+ y_values = [2, 3, 5, 4, 6]
562
+ result = calculator.evaluate('INTERCEPT(x_values, y_values)', x_values: x_values, y_values: y_values)
563
+ expect(result).to be_within(0.001).of(1.3)
564
+ end
565
+
489
566
  describe "any" do
490
567
  it "enumerates values and returns true if any evaluation is truthy" do
491
568
  expect(calculator.evaluate!('any(xs, x, x > 3)', xs: [1, 2, 3, 4])).to be_truthy
@@ -758,9 +835,9 @@ describe Dentaku::Calculator do
758
835
  end
759
836
  end
760
837
 
761
- describe 'math functions' do
838
+ describe 'math support' do
762
839
  Math.methods(false).each do |method|
763
- it method do
840
+ it "includes `#{method}`" do
764
841
  if Math.method(method).arity == 2
765
842
  expect(calculator.evaluate("#{method}(x,y)", x: 1, y: '2')).to eq(Math.send(method, 1, 2))
766
843
  expect(calculator.evaluate("#{method}(x,y) + 1", x: 1, y: '2')).to be_within(0.00001).of(Math.send(method, 1, 2) + 1)
@@ -774,11 +851,16 @@ describe Dentaku::Calculator do
774
851
  end
775
852
  end
776
853
 
777
- it 'are defined with a properly named class that represents it to support AST marshaling' do
854
+ it 'defines a properly named class to support AST marshaling' do
778
855
  expect {
779
856
  Marshal.dump(calculator.ast('SQRT(20)'))
780
857
  }.not_to raise_error
781
858
  end
859
+
860
+ it 'properly handles a Math::DomainError' do
861
+ expect(calculator.evaluate('asin(2)')).to be_nil
862
+ expect { calculator.evaluate!('asin(2)') }.to raise_error(Dentaku::MathDomainError)
863
+ end
782
864
  end
783
865
 
784
866
  describe 'disable_cache' do