dentaku 3.3.1 → 3.4.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.rubocop.yml +3 -8
- data/.travis.yml +3 -4
- data/CHANGELOG.md +37 -1
- data/README.md +2 -2
- data/dentaku.gemspec +0 -2
- data/lib/dentaku.rb +14 -6
- data/lib/dentaku/ast.rb +5 -0
- data/lib/dentaku/ast/access.rb +15 -1
- data/lib/dentaku/ast/arithmetic.rb +28 -5
- data/lib/dentaku/ast/array.rb +15 -1
- data/lib/dentaku/ast/case.rb +8 -0
- data/lib/dentaku/ast/case/case_conditional.rb +8 -0
- data/lib/dentaku/ast/case/case_else.rb +12 -4
- data/lib/dentaku/ast/case/case_switch_variable.rb +8 -0
- data/lib/dentaku/ast/case/case_then.rb +12 -4
- data/lib/dentaku/ast/case/case_when.rb +12 -4
- data/lib/dentaku/ast/function.rb +10 -1
- data/lib/dentaku/ast/function_registry.rb +21 -0
- data/lib/dentaku/ast/functions/all.rb +36 -0
- data/lib/dentaku/ast/functions/any.rb +36 -0
- data/lib/dentaku/ast/functions/avg.rb +2 -2
- data/lib/dentaku/ast/functions/count.rb +8 -0
- data/lib/dentaku/ast/functions/duration.rb +51 -0
- data/lib/dentaku/ast/functions/if.rb +15 -2
- data/lib/dentaku/ast/functions/map.rb +36 -0
- data/lib/dentaku/ast/functions/mul.rb +3 -2
- data/lib/dentaku/ast/functions/pluck.rb +29 -0
- data/lib/dentaku/ast/functions/round.rb +1 -1
- data/lib/dentaku/ast/functions/rounddown.rb +1 -1
- data/lib/dentaku/ast/functions/roundup.rb +1 -1
- data/lib/dentaku/ast/functions/ruby_math.rb +47 -3
- data/lib/dentaku/ast/functions/string_functions.rb +68 -4
- data/lib/dentaku/ast/functions/sum.rb +3 -2
- data/lib/dentaku/ast/grouping.rb +3 -1
- data/lib/dentaku/ast/identifier.rb +5 -1
- data/lib/dentaku/ast/negation.rb +3 -1
- data/lib/dentaku/ast/node.rb +4 -0
- data/lib/dentaku/ast/operation.rb +8 -0
- data/lib/dentaku/bulk_expression_solver.rb +36 -25
- data/lib/dentaku/calculator.rb +19 -6
- data/lib/dentaku/date_arithmetic.rb +45 -0
- data/lib/dentaku/exceptions.rb +4 -4
- data/lib/dentaku/flat_hash.rb +7 -0
- data/lib/dentaku/parser.rb +14 -3
- data/lib/dentaku/tokenizer.rb +1 -1
- data/lib/dentaku/version.rb +1 -1
- data/spec/ast/addition_spec.rb +6 -0
- data/spec/ast/arithmetic_spec.rb +41 -13
- data/spec/ast/avg_spec.rb +4 -0
- data/spec/ast/division_spec.rb +6 -0
- data/spec/ast/function_spec.rb +1 -1
- data/spec/ast/mul_spec.rb +4 -0
- data/spec/ast/negation_spec.rb +48 -0
- data/spec/ast/node_spec.rb +4 -1
- data/spec/ast/round_spec.rb +10 -0
- data/spec/ast/rounddown_spec.rb +10 -0
- data/spec/ast/roundup_spec.rb +10 -0
- data/spec/ast/string_functions_spec.rb +35 -0
- data/spec/ast/sum_spec.rb +4 -0
- data/spec/bulk_expression_solver_spec.rb +27 -0
- data/spec/calculator_spec.rb +144 -3
- data/spec/dentaku_spec.rb +18 -5
- data/spec/external_function_spec.rb +29 -5
- data/spec/parser_spec.rb +13 -0
- data/spec/tokenizer_spec.rb +24 -5
- metadata +11 -4
data/lib/dentaku/flat_hash.rb
CHANGED
@@ -6,6 +6,13 @@ module Dentaku
|
|
6
6
|
flatten_keys(acc)
|
7
7
|
end
|
8
8
|
|
9
|
+
def self.from_hash_with_intermediates(h, key = [], acc = {})
|
10
|
+
acc.update(key => h) unless key.empty?
|
11
|
+
return unless h.is_a? Hash
|
12
|
+
h.each { |k, v| from_hash_with_intermediates(v, key + [k], acc) }
|
13
|
+
flatten_keys(acc)
|
14
|
+
end
|
15
|
+
|
9
16
|
def self.flatten_keys(hash)
|
10
17
|
hash.each_with_object({}) do |(k, v), h|
|
11
18
|
h[flatten_key(k)] = v
|
data/lib/dentaku/parser.rb
CHANGED
@@ -40,9 +40,18 @@ module Dentaku
|
|
40
40
|
operator.peek(output)
|
41
41
|
|
42
42
|
args_size = operator.arity || count
|
43
|
-
|
44
|
-
|
43
|
+
min_size = operator.arity || operator.min_param_count || count
|
44
|
+
max_size = operator.arity || operator.max_param_count || count
|
45
|
+
|
46
|
+
if output.length < min_size
|
47
|
+
fail! :too_few_operands, operator: operator, expect: min_size, actual: output.length
|
48
|
+
end
|
49
|
+
|
50
|
+
if output.length > max_size && operations.empty?
|
51
|
+
fail! :too_many_operands, operator: operator, expect: max_size, actual: output.length
|
45
52
|
end
|
53
|
+
|
54
|
+
fail! :invalid_statement if output.size < args_size
|
46
55
|
args = Array.new(args_size) { output.pop }.reverse
|
47
56
|
|
48
57
|
output.push operator.new(*args)
|
@@ -305,6 +314,8 @@ module Dentaku
|
|
305
314
|
"#{meta.fetch(:operator)} requires #{meta.fetch(:expect).join(', ')} operands, but got #{meta.fetch(:actual)}"
|
306
315
|
when :too_few_operands
|
307
316
|
"#{meta.fetch(:operator)} has too few operands"
|
317
|
+
when :too_many_operands
|
318
|
+
"#{meta.fetch(:operator)} has too many operands"
|
308
319
|
when :undefined_function
|
309
320
|
"Undefined function #{meta.fetch(:function_name)}"
|
310
321
|
when :unprocessed_token
|
@@ -325,7 +336,7 @@ module Dentaku
|
|
325
336
|
raise ::ArgumentError, "Unhandled #{reason}"
|
326
337
|
end
|
327
338
|
|
328
|
-
raise ParseError.for(reason, meta), message
|
339
|
+
raise ParseError.for(reason, **meta), message
|
329
340
|
end
|
330
341
|
end
|
331
342
|
end
|
data/lib/dentaku/tokenizer.rb
CHANGED
data/lib/dentaku/version.rb
CHANGED
data/spec/ast/addition_spec.rb
CHANGED
@@ -9,6 +9,12 @@ describe Dentaku::AST::Addition do
|
|
9
9
|
|
10
10
|
let(:t) { Dentaku::AST::Numeric.new Dentaku::Token.new(:logical, true) }
|
11
11
|
|
12
|
+
it 'allows access to its sub-trees' do
|
13
|
+
node = described_class.new(five, six)
|
14
|
+
expect(node.left).to eq(five)
|
15
|
+
expect(node.right).to eq(six)
|
16
|
+
end
|
17
|
+
|
12
18
|
it 'performs addition' do
|
13
19
|
node = described_class.new(five, six)
|
14
20
|
expect(node.value).to eq(11)
|
data/spec/ast/arithmetic_spec.rb
CHANGED
@@ -4,17 +4,19 @@ require 'dentaku/ast/arithmetic'
|
|
4
4
|
require 'dentaku/token'
|
5
5
|
|
6
6
|
describe Dentaku::AST::Arithmetic do
|
7
|
-
let(:one)
|
8
|
-
let(:two)
|
9
|
-
let(:x)
|
10
|
-
let(:y)
|
11
|
-
let(:ctx)
|
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
|
+
let(:ctx) { {'x' => 1, 'y' => 2} }
|
12
|
+
let(:date) { Dentaku::AST::DateTime.new Dentaku::Token.new(:datetime, DateTime.new(2020, 4, 16)) }
|
12
13
|
|
13
14
|
it 'performs an arithmetic operation with numeric operands' do
|
14
15
|
expect(add(one, two)).to eq(3)
|
15
16
|
expect(sub(one, two)).to eq(-1)
|
16
17
|
expect(mul(one, two)).to eq(2)
|
17
18
|
expect(div(one, two)).to eq(0.5)
|
19
|
+
expect(neg(one)).to eq(-1)
|
18
20
|
end
|
19
21
|
|
20
22
|
it 'performs an arithmetic operation with one numeric operand and one string operand' do
|
@@ -34,23 +36,49 @@ describe Dentaku::AST::Arithmetic do
|
|
34
36
|
expect(sub(x, y)).to eq(-1)
|
35
37
|
expect(mul(x, y)).to eq(2)
|
36
38
|
expect(div(x, y)).to eq(0.5)
|
39
|
+
expect(neg(x)).to eq(-1)
|
40
|
+
end
|
41
|
+
|
42
|
+
it 'correctly parses string operands to numeric values' do
|
43
|
+
expect(add(x, one, 'x' => '1')).to eq(2)
|
44
|
+
expect(add(x, one, 'x' => '1.1')).to eq(2.1)
|
45
|
+
expect(add(x, one, 'x' => '.1')).to eq(1.1)
|
46
|
+
expect { add(x, one, 'x' => 'invalid') }.to raise_error(Dentaku::ArgumentError)
|
47
|
+
expect { add(x, one, 'x' => '') }.to raise_error(Dentaku::ArgumentError)
|
48
|
+
end
|
49
|
+
|
50
|
+
it 'performs arithmetic on arrays' do
|
51
|
+
expect(add(x, y, 'x' => [1], 'y' => [2])).to eq([1, 2])
|
52
|
+
end
|
53
|
+
|
54
|
+
it 'performs date arithmetic' do
|
55
|
+
expect(add(date, one)).to eq(DateTime.new(2020, 4, 17))
|
56
|
+
end
|
57
|
+
|
58
|
+
it 'raises ArgumentError if given individually valid but incompatible arguments' do
|
59
|
+
expect { add(one, date) }.to raise_error(Dentaku::ArgumentError)
|
60
|
+
expect { add(x, one, 'x' => [1]) }.to raise_error(Dentaku::ArgumentError)
|
37
61
|
end
|
38
62
|
|
39
63
|
private
|
40
64
|
|
41
|
-
def add(left, right)
|
42
|
-
Dentaku::AST::Addition.new(left, right).value(
|
65
|
+
def add(left, right, context = ctx)
|
66
|
+
Dentaku::AST::Addition.new(left, right).value(context)
|
67
|
+
end
|
68
|
+
|
69
|
+
def sub(left, right, context = ctx)
|
70
|
+
Dentaku::AST::Subtraction.new(left, right).value(context)
|
43
71
|
end
|
44
72
|
|
45
|
-
def
|
46
|
-
Dentaku::AST::
|
73
|
+
def mul(left, right, context = ctx)
|
74
|
+
Dentaku::AST::Multiplication.new(left, right).value(context)
|
47
75
|
end
|
48
76
|
|
49
|
-
def
|
50
|
-
Dentaku::AST::
|
77
|
+
def div(left, right, context = ctx)
|
78
|
+
Dentaku::AST::Division.new(left, right).value(context)
|
51
79
|
end
|
52
80
|
|
53
|
-
def
|
54
|
-
Dentaku::AST::
|
81
|
+
def neg(node, context = ctx)
|
82
|
+
Dentaku::AST::Negation.new(node).value(context)
|
55
83
|
end
|
56
84
|
end
|
data/spec/ast/avg_spec.rb
CHANGED
@@ -29,5 +29,9 @@ describe 'Dentaku::AST::Function::Avg' do
|
|
29
29
|
it 'raises an error if no arguments are passed' do
|
30
30
|
expect { calculator.evaluate!('AVG()') }.to raise_error(Dentaku::ArgumentError)
|
31
31
|
end
|
32
|
+
|
33
|
+
it 'raises an error if an empty array is passed' do
|
34
|
+
expect { calculator.evaluate!('AVG(x)', x: []) }.to raise_error(Dentaku::ArgumentError)
|
35
|
+
end
|
32
36
|
end
|
33
37
|
end
|
data/spec/ast/division_spec.rb
CHANGED
@@ -9,6 +9,12 @@ describe Dentaku::AST::Division do
|
|
9
9
|
|
10
10
|
let(:t) { Dentaku::AST::Numeric.new Dentaku::Token.new(:logical, true) }
|
11
11
|
|
12
|
+
it 'allows access to its sub-trees' do
|
13
|
+
node = described_class.new(five, six)
|
14
|
+
expect(node.left).to eq(five)
|
15
|
+
expect(node.right).to eq(six)
|
16
|
+
end
|
17
|
+
|
12
18
|
it 'performs division' do
|
13
19
|
node = described_class.new(five, six)
|
14
20
|
expect(node.value.round(4)).to eq(0.8333)
|
data/spec/ast/function_spec.rb
CHANGED
@@ -22,7 +22,7 @@ describe Dentaku::AST::Function do
|
|
22
22
|
end
|
23
23
|
|
24
24
|
describe "#arity" do
|
25
|
-
it "
|
25
|
+
it "returns the correct arity for custom functions" do
|
26
26
|
zero = described_class.register("zero", :numeric, ->() { 0 })
|
27
27
|
expect(zero.arity).to eq(0)
|
28
28
|
|
data/spec/ast/mul_spec.rb
CHANGED
@@ -34,5 +34,9 @@ describe 'Dentaku::AST::Function::Mul' do
|
|
34
34
|
it 'raises an error if no arguments are passed' do
|
35
35
|
expect { calculator.evaluate!('MUL()') }.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!('MUL(x)', x: []) }.to raise_error(Dentaku::ArgumentError)
|
40
|
+
end
|
37
41
|
end
|
38
42
|
end
|
@@ -0,0 +1,48 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
require 'dentaku/ast/arithmetic'
|
3
|
+
|
4
|
+
require 'dentaku/token'
|
5
|
+
|
6
|
+
describe Dentaku::AST::Negation do
|
7
|
+
let(:five) { Dentaku::AST::Numeric.new Dentaku::Token.new(:numeric, 5) }
|
8
|
+
let(:t) { Dentaku::AST::Logical.new Dentaku::Token.new(:logical, true) }
|
9
|
+
let(:x) { Dentaku::AST::Identifier.new Dentaku::Token.new(:identifier, 'x') }
|
10
|
+
|
11
|
+
it 'allows access to its sub-node' do
|
12
|
+
node = described_class.new(five)
|
13
|
+
expect(node.node).to eq(five)
|
14
|
+
end
|
15
|
+
|
16
|
+
it 'performs negation' do
|
17
|
+
node = described_class.new(five)
|
18
|
+
expect(node.value).to eq(-5)
|
19
|
+
end
|
20
|
+
|
21
|
+
it 'requires numeric operands' do
|
22
|
+
expect {
|
23
|
+
described_class.new(t)
|
24
|
+
}.to raise_error(Dentaku::NodeError, /requires numeric operands/)
|
25
|
+
|
26
|
+
expression = Dentaku::AST::Negation.new(five)
|
27
|
+
group = Dentaku::AST::Grouping.new(expression)
|
28
|
+
|
29
|
+
expect {
|
30
|
+
described_class.new(group)
|
31
|
+
}.not_to raise_error
|
32
|
+
end
|
33
|
+
|
34
|
+
it 'correctly parses string operands to numeric values' do
|
35
|
+
node = described_class.new(x)
|
36
|
+
expect(node.value('x' => '5')).to eq(-5)
|
37
|
+
end
|
38
|
+
|
39
|
+
it 'raises error if input string is not coercible to numeric' do
|
40
|
+
node = described_class.new(x)
|
41
|
+
expect { node.value('x' => 'invalid') }.to raise_error(Dentaku::ArgumentError)
|
42
|
+
end
|
43
|
+
|
44
|
+
it 'raises error if given a non-numeric argument' do
|
45
|
+
node = described_class.new(x)
|
46
|
+
expect { node.value('x' => true) }.to raise_error(Dentaku::ArgumentError)
|
47
|
+
end
|
48
|
+
end
|
data/spec/ast/node_spec.rb
CHANGED
@@ -21,7 +21,10 @@ describe Dentaku::AST::Node do
|
|
21
21
|
expect(node.dependencies).to eq(['x', 'y', 'z'])
|
22
22
|
|
23
23
|
node = make_node('if(x > 5, y, z)')
|
24
|
-
expect(node.dependencies('x' => 7)).to eq(['y'
|
24
|
+
expect(node.dependencies('x' => 7)).to eq(['y'])
|
25
|
+
|
26
|
+
node = make_node('if(x > 5, y, z)')
|
27
|
+
expect(node.dependencies('x' => 2)).to eq(['z'])
|
25
28
|
|
26
29
|
node = make_node('')
|
27
30
|
expect(node.dependencies).to eq([])
|
data/spec/ast/round_spec.rb
CHANGED
@@ -22,4 +22,14 @@ describe 'Dentaku::AST::Function::Round' do
|
|
22
22
|
result = Dentaku('ROUND(x, y)', x: '1.8453', y: nil)
|
23
23
|
expect(result).to eq(2)
|
24
24
|
end
|
25
|
+
|
26
|
+
context 'checking errors' do
|
27
|
+
it 'raises an error if first argument is not numeric' do
|
28
|
+
expect { Dentaku!("ROUND(2020-1-1, 0)") }.to raise_error(Dentaku::ArgumentError)
|
29
|
+
end
|
30
|
+
|
31
|
+
it 'raises an error if places is not numeric' do
|
32
|
+
expect { Dentaku!("ROUND(1.8, 2020-1-1)") }.to raise_error(Dentaku::ArgumentError)
|
33
|
+
end
|
34
|
+
end
|
25
35
|
end
|
data/spec/ast/rounddown_spec.rb
CHANGED
@@ -22,4 +22,14 @@ describe 'Dentaku::AST::Function::Round' do
|
|
22
22
|
result = Dentaku('ROUNDDOWN(x, y)', x: '1.8453', y: nil)
|
23
23
|
expect(result).to eq(1)
|
24
24
|
end
|
25
|
+
|
26
|
+
context 'checking errors' do
|
27
|
+
it 'raises an error if first argument is not numeric' do
|
28
|
+
expect { Dentaku!("ROUND(2020-1-1, 0)") }.to raise_error(Dentaku::ArgumentError)
|
29
|
+
end
|
30
|
+
|
31
|
+
it 'raises an error if places is not numeric' do
|
32
|
+
expect { Dentaku!("ROUND(1.8, 2020-1-1)") }.to raise_error(Dentaku::ArgumentError)
|
33
|
+
end
|
34
|
+
end
|
25
35
|
end
|
data/spec/ast/roundup_spec.rb
CHANGED
@@ -22,4 +22,14 @@ describe 'Dentaku::AST::Function::Round' do
|
|
22
22
|
result = Dentaku('ROUNDUP(x, y)', x: '1.8453', y: nil)
|
23
23
|
expect(result).to eq(2)
|
24
24
|
end
|
25
|
+
|
26
|
+
context 'checking errors' do
|
27
|
+
it 'raises an error if first argument is not numeric' do
|
28
|
+
expect { Dentaku!("ROUND(2020-1-1, 0)") }.to raise_error(Dentaku::ArgumentError)
|
29
|
+
end
|
30
|
+
|
31
|
+
it 'raises an error if places is not numeric' do
|
32
|
+
expect { Dentaku!("ROUND(1.8, 2020-1-1)") }.to raise_error(Dentaku::ArgumentError)
|
33
|
+
end
|
34
|
+
end
|
25
35
|
end
|
@@ -26,6 +26,10 @@ describe Dentaku::AST::StringFunctions::Left do
|
|
26
26
|
expect(subject.value('string' => 'abcdefg', 'length' => 40)).to eq 'abcdefg'
|
27
27
|
end
|
28
28
|
|
29
|
+
it 'accepts strings as length if they can be parsed to a number' do
|
30
|
+
expect(subject.value('string' => 'ABCDEFG', 'length' => '4')).to eq 'ABCD'
|
31
|
+
end
|
32
|
+
|
29
33
|
it 'has the proper type' do
|
30
34
|
expect(subject.type).to eq(:string)
|
31
35
|
end
|
@@ -35,6 +39,12 @@ describe Dentaku::AST::StringFunctions::Left do
|
|
35
39
|
subject.value('string' => 'abcdefg', 'length' => -2)
|
36
40
|
}.to raise_error(Dentaku::ArgumentError, /LEFT\(\) requires length to be positive/)
|
37
41
|
end
|
42
|
+
|
43
|
+
it 'raises an error when given a junk length' do
|
44
|
+
expect {
|
45
|
+
subject.value('string' => 'abcdefg', 'length' => 'junk')
|
46
|
+
}.to raise_error(Dentaku::ArgumentError, "'junk' is not coercible to numeric")
|
47
|
+
end
|
38
48
|
end
|
39
49
|
|
40
50
|
describe Dentaku::AST::StringFunctions::Right do
|
@@ -53,9 +63,19 @@ describe Dentaku::AST::StringFunctions::Right do
|
|
53
63
|
expect(subject.value).to eq 'abcdefg'
|
54
64
|
end
|
55
65
|
|
66
|
+
it 'accepts strings as length if they can be parsed to a number' do
|
67
|
+
subject = described_class.new(literal('ABCDEFG'), literal('4'))
|
68
|
+
expect(subject.value).to eq 'DEFG'
|
69
|
+
end
|
70
|
+
|
56
71
|
it 'has the proper type' do
|
57
72
|
expect(subject.type).to eq(:string)
|
58
73
|
end
|
74
|
+
|
75
|
+
it 'raises an error when given a junk length' do
|
76
|
+
subject = described_class.new(literal('abcdefg'), literal('junk'))
|
77
|
+
expect { subject.value }.to raise_error(Dentaku::ArgumentError, "'junk' is not coercible to numeric")
|
78
|
+
end
|
59
79
|
end
|
60
80
|
|
61
81
|
describe Dentaku::AST::StringFunctions::Mid do
|
@@ -79,9 +99,24 @@ describe Dentaku::AST::StringFunctions::Mid do
|
|
79
99
|
expect(subject.value).to eq 'defg'
|
80
100
|
end
|
81
101
|
|
102
|
+
it 'accepts strings as offset and length if they can be parsed to a number' do
|
103
|
+
subject = described_class.new(literal('ABCDEFG'), literal('4'), literal('2'))
|
104
|
+
expect(subject.value).to eq 'DE'
|
105
|
+
end
|
106
|
+
|
82
107
|
it 'has the proper type' do
|
83
108
|
expect(subject.type).to eq(:string)
|
84
109
|
end
|
110
|
+
|
111
|
+
it 'raises an error when given a junk offset' do
|
112
|
+
subject = described_class.new(literal('abcdefg'), literal('junk offset'), literal(2))
|
113
|
+
expect { subject.value }.to raise_error(Dentaku::ArgumentError, "'junk offset' is not coercible to numeric")
|
114
|
+
end
|
115
|
+
|
116
|
+
it 'raises an error when given a junk length' do
|
117
|
+
subject = described_class.new(literal('abcdefg'), literal(4), literal('junk'))
|
118
|
+
expect { subject.value }.to raise_error(Dentaku::ArgumentError, "'junk' is not coercible to numeric")
|
119
|
+
end
|
85
120
|
end
|
86
121
|
|
87
122
|
describe Dentaku::AST::StringFunctions::Len do
|
data/spec/ast/sum_spec.rb
CHANGED
@@ -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,26 @@ 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
|
+
|
59
|
+
it "does not execute functions unnecessarily" do
|
60
|
+
calls = 0
|
61
|
+
external = ->() { calls += 1 }
|
62
|
+
hash = {test: 'EXTERNAL()'}
|
63
|
+
calculator = Dentaku::Calculator.new
|
64
|
+
calculator.add_function(:external, :numeric, external)
|
65
|
+
calculator.solve(hash)
|
66
|
+
expect(calls).to eq(1)
|
67
|
+
end
|
68
|
+
|
42
69
|
it "evaluates expressions in hashes and arrays, and expands the results" do
|
43
70
|
calculator.store(
|
44
71
|
fruit_quantities: {
|
data/spec/calculator_spec.rb
CHANGED
@@ -74,6 +74,11 @@ describe Dentaku::Calculator do
|
|
74
74
|
expect(calculator.evaluate('NOT(a)', a: nil, b: nil)).to be_truthy
|
75
75
|
expect(calculator.evaluate('OR(a,b)', a: nil, b: nil)).to be_falsy
|
76
76
|
end
|
77
|
+
|
78
|
+
it 'supports lazy evaluation of variables' do
|
79
|
+
expect(calculator.evaluate('x + 1', x: -> { 1 })).to eq(2)
|
80
|
+
expect { calculator.evaluate('2', x: -> { raise 'boom' }) }.not_to raise_error
|
81
|
+
end
|
77
82
|
end
|
78
83
|
|
79
84
|
describe 'evaluate!' do
|
@@ -105,7 +110,7 @@ describe Dentaku::Calculator do
|
|
105
110
|
end
|
106
111
|
|
107
112
|
it 'raises argument error if a function is called with incorrect arity' do
|
108
|
-
expect { calculator.evaluate!('IF(a,b)', a: 1, b: 1) }.to raise_error(Dentaku::
|
113
|
+
expect { calculator.evaluate!('IF(a,b)', a: 1, b: 1) }.to raise_error(Dentaku::ParseError)
|
109
114
|
end
|
110
115
|
end
|
111
116
|
|
@@ -142,6 +147,7 @@ describe Dentaku::Calculator do
|
|
142
147
|
it 'stores nested hashes' do
|
143
148
|
calculator.store(a: {basket: {of: 'apples'}}, b: 2)
|
144
149
|
expect(calculator.evaluate!('a.basket.of')).to eq('apples')
|
150
|
+
expect(calculator.evaluate!('a.basket')).to eq(of: 'apples')
|
145
151
|
expect(calculator.evaluate!('b')).to eq(2)
|
146
152
|
end
|
147
153
|
|
@@ -191,6 +197,15 @@ describe Dentaku::Calculator do
|
|
191
197
|
)).to eq(pear: 1, weekly_apple_budget: 21, weekly_fruit_budget: 25)
|
192
198
|
end
|
193
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
|
+
|
194
209
|
it "preserves hash keys" do
|
195
210
|
expect(calculator.solve!(
|
196
211
|
'meaning_of_life' => 'age + kids',
|
@@ -314,6 +329,10 @@ describe Dentaku::Calculator do
|
|
314
329
|
expect(error.unbound_variables).to eq(['a', 'b'])
|
315
330
|
end
|
316
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'
|
317
336
|
expect(calculator.evaluate(unbound) { :bar }).to eq(:bar)
|
318
337
|
expect(calculator.evaluate(unbound) { |e| e }).to eq(unbound)
|
319
338
|
end
|
@@ -369,20 +388,35 @@ describe Dentaku::Calculator do
|
|
369
388
|
expect(calculator.evaluate('some_boolean OR 7 < 5', some_boolean: false)).to be_falsey
|
370
389
|
end
|
371
390
|
|
372
|
-
it 'compares
|
391
|
+
it 'compares time variables' do
|
373
392
|
expect(calculator.evaluate('t1 < t2', t1: Time.local(2017, 1, 1).to_datetime, t2: Time.local(2017, 1, 2).to_datetime)).to be_truthy
|
374
393
|
expect(calculator.evaluate('t1 < t2', t1: Time.local(2017, 1, 2).to_datetime, t2: Time.local(2017, 1, 1).to_datetime)).to be_falsy
|
375
394
|
expect(calculator.evaluate('t1 > t2', t1: Time.local(2017, 1, 1).to_datetime, t2: Time.local(2017, 1, 2).to_datetime)).to be_falsy
|
376
395
|
expect(calculator.evaluate('t1 > t2', t1: Time.local(2017, 1, 2).to_datetime, t2: Time.local(2017, 1, 1).to_datetime)).to be_truthy
|
377
396
|
end
|
378
397
|
|
379
|
-
it 'compares
|
398
|
+
it 'compares time literals with time variables' do
|
380
399
|
expect(calculator.evaluate('t1 < 2017-01-02', t1: Time.local(2017, 1, 1).to_datetime)).to be_truthy
|
381
400
|
expect(calculator.evaluate('t1 < 2017-01-02', t1: Time.local(2017, 1, 3).to_datetime)).to be_falsy
|
382
401
|
expect(calculator.evaluate('t1 > 2017-01-02', t1: Time.local(2017, 1, 1).to_datetime)).to be_falsy
|
383
402
|
expect(calculator.evaluate('t1 > 2017-01-02', t1: Time.local(2017, 1, 3).to_datetime)).to be_truthy
|
384
403
|
end
|
385
404
|
|
405
|
+
it 'supports date arithmetic' do
|
406
|
+
expect(calculator.evaluate!('2020-01-01 + 30').to_date).to eq(Time.local(2020, 1, 31).to_date)
|
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)
|
409
|
+
expect(calculator.evaluate!('2020-01-01 + duration(1, day)').to_date).to eq(Time.local(2020, 1, 2).to_date)
|
410
|
+
expect(calculator.evaluate!('2020-01-01 - duration(1, day)').to_date).to eq(Time.local(2019, 12, 31).to_date)
|
411
|
+
expect(calculator.evaluate!('2020-01-01 + duration(30, days)').to_date).to eq(Time.local(2020, 1, 31).to_date)
|
412
|
+
expect(calculator.evaluate!('2020-01-01 + duration(1, month)').to_date).to eq(Time.local(2020, 2, 1).to_date)
|
413
|
+
expect(calculator.evaluate!('2020-01-01 - duration(1, month)').to_date).to eq(Time.local(2019, 12, 1).to_date)
|
414
|
+
expect(calculator.evaluate!('2020-01-01 + duration(30, months)').to_date).to eq(Time.local(2022, 7, 1).to_date)
|
415
|
+
expect(calculator.evaluate!('2020-01-01 + duration(1, year)').to_date).to eq(Time.local(2021, 1, 1).to_date)
|
416
|
+
expect(calculator.evaluate!('2020-01-01 - duration(1, year)').to_date).to eq(Time.local(2019, 1, 1).to_date)
|
417
|
+
expect(calculator.evaluate!('2020-01-01 + duration(30, years)').to_date).to eq(Time.local(2050, 1, 1).to_date)
|
418
|
+
end
|
419
|
+
|
386
420
|
describe 'functions' do
|
387
421
|
it 'include IF' do
|
388
422
|
expect(calculator.evaluate('if(foo < 8, 10, 20)', foo: 2)).to eq(10)
|
@@ -414,6 +448,84 @@ describe Dentaku::Calculator do
|
|
414
448
|
expect(calculator.evaluate('NOT(some_boolean) AND -1 > 3', some_boolean: true)).to be_falsey
|
415
449
|
end
|
416
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
|
+
|
417
529
|
it 'evaluates functions with stored variables' do
|
418
530
|
calculator.store("multi_color" => true, "number_of_sheets" => 5000, "sheets_per_minute_black" => 2000, "sheets_per_minute_color" => 1000)
|
419
531
|
result = calculator.evaluate('number_of_sheets / if(multi_color, sheets_per_minute_color, sheets_per_minute_black)')
|
@@ -584,6 +696,7 @@ describe Dentaku::Calculator do
|
|
584
696
|
it method do
|
585
697
|
if Math.method(method).arity == 2
|
586
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)
|
587
700
|
else
|
588
701
|
expect(calculator.evaluate("#{method}(1)")).to eq(Math.send(method, 1))
|
589
702
|
end
|
@@ -644,6 +757,16 @@ describe Dentaku::Calculator do
|
|
644
757
|
calculator.evaluate('CONCAT(s1, s2, s3)', 's1' => 'ab', 's2' => 'cd', 's3' => 'ef')
|
645
758
|
).to eq('abcdef')
|
646
759
|
end
|
760
|
+
|
761
|
+
it 'manipulates string arguments' do
|
762
|
+
expect(calculator.evaluate("left('ABCD', 2)")).to eq('AB')
|
763
|
+
expect(calculator.evaluate("right('ABCD', 2)")).to eq('CD')
|
764
|
+
expect(calculator.evaluate("mid('ABCD', 2, 2)")).to eq('BC')
|
765
|
+
expect(calculator.evaluate("len('ABCD')")).to eq(4)
|
766
|
+
expect(calculator.evaluate("find('BC', 'ABCD')")).to eq(2)
|
767
|
+
expect(calculator.evaluate("substitute('ABCD', 'BC', 'XY')")).to eq('AXYD')
|
768
|
+
expect(calculator.evaluate("contains('BC', 'ABCD')")).to be_truthy
|
769
|
+
end
|
647
770
|
end
|
648
771
|
|
649
772
|
describe 'zero-arity functions' do
|
@@ -675,4 +798,22 @@ describe Dentaku::Calculator do
|
|
675
798
|
end.to raise_error(Dentaku::UnboundVariableError)
|
676
799
|
end
|
677
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
|
678
819
|
end
|