dentaku 3.5.1 → 3.5.3
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/CHANGELOG.md +24 -1
- data/README.md +1 -1
- data/lib/dentaku/ast/arithmetic.rb +34 -12
- data/lib/dentaku/ast/comparators.rb +1 -2
- data/lib/dentaku/ast/function_registry.rb +10 -1
- data/lib/dentaku/ast/functions/abs.rb +5 -0
- data/lib/dentaku/ast/functions/avg.rb +1 -1
- data/lib/dentaku/ast/functions/enum.rb +5 -4
- data/lib/dentaku/ast/functions/if.rb +3 -7
- data/lib/dentaku/ast/functions/intercept.rb +33 -0
- data/lib/dentaku/ast/functions/reduce.rb +61 -0
- data/lib/dentaku/ast/functions/ruby_math.rb +2 -0
- data/lib/dentaku/ast.rb +4 -1
- data/lib/dentaku/calculator.rb +17 -10
- data/lib/dentaku/date_arithmetic.rb +8 -2
- data/lib/dentaku/exceptions.rb +17 -3
- data/lib/dentaku/parser.rb +20 -9
- data/lib/dentaku/print_visitor.rb +16 -5
- data/lib/dentaku/token_scanner.rb +12 -3
- data/lib/dentaku/tokenizer.rb +7 -3
- data/lib/dentaku/version.rb +1 -1
- data/lib/dentaku/visitor/infix.rb +4 -0
- data/spec/ast/abs_spec.rb +26 -0
- data/spec/ast/addition_spec.rb +4 -4
- data/spec/ast/all_spec.rb +1 -1
- data/spec/ast/any_spec.rb +1 -1
- data/spec/ast/arithmetic_spec.rb +61 -12
- data/spec/ast/avg_spec.rb +5 -0
- data/spec/ast/division_spec.rb +25 -0
- data/spec/ast/filter_spec.rb +1 -1
- data/spec/ast/intercept_spec.rb +30 -0
- data/spec/ast/map_spec.rb +1 -1
- data/spec/ast/pluck_spec.rb +1 -1
- data/spec/ast/reduce_spec.rb +22 -0
- data/spec/bulk_expression_solver_spec.rb +17 -0
- data/spec/calculator_spec.rb +99 -17
- data/spec/external_function_spec.rb +89 -18
- data/spec/parser_spec.rb +3 -0
- data/spec/print_visitor_spec.rb +6 -0
- data/spec/tokenizer_spec.rb +6 -4
- data/spec/visitor/infix_spec.rb +22 -1
- data/spec/visitor_spec.rb +2 -1
- 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
|
-
|
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,
|
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
|
data/lib/dentaku/tokenizer.rb
CHANGED
@@ -4,7 +4,7 @@ require 'dentaku/token_scanner'
|
|
4
4
|
|
5
5
|
module Dentaku
|
6
6
|
class Tokenizer
|
7
|
-
attr_reader :
|
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
|
-
|
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(
|
25
|
+
scanned = TokenScanner.scanners(scanner_options).any? do |scanner|
|
22
26
|
scanned, input = scan(input, scanner)
|
23
27
|
scanned
|
24
28
|
end
|
data/lib/dentaku/version.rb
CHANGED
@@ -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/addition_spec.rb
CHANGED
@@ -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
|
-
|
39
|
+
Addable = Struct.new(:value) do
|
40
40
|
def +(other)
|
41
41
|
case other
|
42
|
-
when
|
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,
|
51
|
-
operand_six = Dentaku::AST::Numeric.new Dentaku::Token.new(:numeric,
|
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::
|
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::
|
21
|
+
expect { calculator.evaluate!('ANY({1, 2, 3}, "val", val % 2 == 0)') }.to raise_error(Dentaku::ParseError)
|
22
22
|
end
|
23
23
|
end
|
data/spec/ast/arithmetic_spec.rb
CHANGED
@@ -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
|
8
|
-
let(:two) { Dentaku::AST::Numeric.new
|
9
|
-
let(:x) { Dentaku::AST::Identifier.new
|
10
|
-
let(:y) { Dentaku::AST::Identifier.new
|
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
|
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
|
50
|
-
|
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
|
-
|
53
|
-
|
54
|
-
|
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)
|
data/spec/ast/division_spec.rb
CHANGED
@@ -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
|
data/spec/ast/filter_spec.rb
CHANGED
@@ -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::
|
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::
|
24
|
+
Dentaku::ParseError, 'MAP() requires second argument to be an identifier'
|
25
25
|
)
|
26
26
|
end
|
27
27
|
end
|
data/spec/ast/pluck_spec.rb
CHANGED
@@ -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::
|
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
|
data/spec/calculator_spec.rb
CHANGED
@@ -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
|
-
|
444
|
-
|
445
|
-
|
446
|
-
|
447
|
-
|
448
|
-
|
449
|
-
|
450
|
-
|
451
|
-
|
452
|
-
|
453
|
-
|
454
|
-
|
455
|
-
|
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
|
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 '
|
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
|