dentaku 3.3.3 → 3.4.2

Sign up to get free protection for your applications and to get access to all the features.
Files changed (56) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +2 -7
  3. data/.travis.yml +4 -4
  4. data/CHANGELOG.md +34 -2
  5. data/README.md +4 -2
  6. data/dentaku.gemspec +1 -1
  7. data/lib/dentaku.rb +16 -5
  8. data/lib/dentaku/ast.rb +4 -0
  9. data/lib/dentaku/ast/access.rb +3 -1
  10. data/lib/dentaku/ast/arithmetic.rb +7 -2
  11. data/lib/dentaku/ast/array.rb +3 -1
  12. data/lib/dentaku/ast/case/case_else.rb +12 -4
  13. data/lib/dentaku/ast/case/case_switch_variable.rb +8 -0
  14. data/lib/dentaku/ast/case/case_then.rb +12 -4
  15. data/lib/dentaku/ast/case/case_when.rb +12 -4
  16. data/lib/dentaku/ast/function.rb +10 -1
  17. data/lib/dentaku/ast/functions/all.rb +36 -0
  18. data/lib/dentaku/ast/functions/any.rb +36 -0
  19. data/lib/dentaku/ast/functions/avg.rb +2 -2
  20. data/lib/dentaku/ast/functions/filter.rb +36 -0
  21. data/lib/dentaku/ast/functions/map.rb +36 -0
  22. data/lib/dentaku/ast/functions/pluck.rb +29 -0
  23. data/lib/dentaku/ast/functions/round.rb +1 -1
  24. data/lib/dentaku/ast/functions/rounddown.rb +1 -1
  25. data/lib/dentaku/ast/functions/roundup.rb +1 -1
  26. data/lib/dentaku/ast/functions/ruby_math.rb +49 -3
  27. data/lib/dentaku/ast/functions/string_functions.rb +52 -4
  28. data/lib/dentaku/ast/grouping.rb +3 -1
  29. data/lib/dentaku/ast/identifier.rb +6 -4
  30. data/lib/dentaku/bulk_expression_solver.rb +36 -25
  31. data/lib/dentaku/calculator.rb +14 -6
  32. data/lib/dentaku/date_arithmetic.rb +1 -1
  33. data/lib/dentaku/exceptions.rb +3 -3
  34. data/lib/dentaku/flat_hash.rb +7 -0
  35. data/lib/dentaku/parser.rb +2 -1
  36. data/lib/dentaku/tokenizer.rb +1 -1
  37. data/lib/dentaku/version.rb +1 -1
  38. data/spec/ast/arithmetic_spec.rb +19 -5
  39. data/spec/ast/avg_spec.rb +4 -0
  40. data/spec/ast/filter_spec.rb +18 -0
  41. data/spec/ast/map_spec.rb +15 -0
  42. data/spec/ast/max_spec.rb +13 -0
  43. data/spec/ast/min_spec.rb +13 -0
  44. data/spec/ast/mul_spec.rb +5 -0
  45. data/spec/ast/negation_spec.rb +18 -2
  46. data/spec/ast/round_spec.rb +10 -0
  47. data/spec/ast/rounddown_spec.rb +10 -0
  48. data/spec/ast/roundup_spec.rb +10 -0
  49. data/spec/ast/string_functions_spec.rb +35 -0
  50. data/spec/ast/sum_spec.rb +5 -0
  51. data/spec/bulk_expression_solver_spec.rb +27 -0
  52. data/spec/calculator_spec.rb +130 -0
  53. data/spec/dentaku_spec.rb +14 -8
  54. data/spec/parser_spec.rb +13 -0
  55. data/spec/tokenizer_spec.rb +24 -5
  56. metadata +26 -3
@@ -59,10 +59,10 @@ module Dentaku
59
59
  store(data) do
60
60
  node = expression
61
61
  node = ast(node) unless node.is_a?(AST::Node)
62
- unbound = node.dependencies - memory.keys
62
+ unbound = node.dependencies(memory)
63
63
  unless unbound.empty?
64
64
  raise UnboundVariableError.new(unbound),
65
- "no value provided for variables: #{unbound.join(', ')}"
65
+ "no value provided for variables: #{unbound.uniq.join(', ')}"
66
66
  end
67
67
  node.value(memory)
68
68
  end
@@ -77,13 +77,21 @@ module Dentaku
77
77
  end
78
78
 
79
79
  def dependencies(expression, context = {})
80
- if expression.is_a? Array
81
- return expression.flat_map { |e| dependencies(e, context) }
80
+ test_context = context.nil? ? {} : store(context) { memory }
81
+
82
+ case expression
83
+ when Dentaku::AST::Node
84
+ expression.dependencies(test_context)
85
+ when Array
86
+ expression.flat_map { |e| dependencies(e, context) }
87
+ else
88
+ ast(expression).dependencies(test_context)
82
89
  end
83
- store(context) { ast(expression).dependencies(memory) }
84
90
  end
85
91
 
86
92
  def ast(expression)
93
+ return expression.map { |e| ast(e) } if expression.is_a? Array
94
+
87
95
  @ast_cache.fetch(expression) {
88
96
  options = {
89
97
  case_sensitive: case_sensitive,
@@ -119,7 +127,7 @@ module Dentaku
119
127
  restore = Hash[memory]
120
128
 
121
129
  if value.nil?
122
- key_or_hash = FlatHash.from_hash(key_or_hash) if nested_data_support
130
+ key_or_hash = FlatHash.from_hash_with_intermediates(key_or_hash) if nested_data_support
123
131
  key_or_hash.each do |key, val|
124
132
  memory[standardize_case(key.to_s)] = val
125
133
  end
@@ -25,7 +25,7 @@ module Dentaku
25
25
 
26
26
  def sub(duration)
27
27
  case duration
28
- when Numeric
28
+ when DateTime, Numeric
29
29
  @base - duration
30
30
  when Dentaku::AST::Duration::Value
31
31
  case duration.unit
@@ -44,7 +44,7 @@ module Dentaku
44
44
  raise ::ArgumentError, "Unhandled #{reason}"
45
45
  end
46
46
 
47
- new reason, meta
47
+ new(reason, **meta)
48
48
  end
49
49
  end
50
50
 
@@ -68,7 +68,7 @@ module Dentaku
68
68
  raise ::ArgumentError, "Unhandled #{reason}"
69
69
  end
70
70
 
71
- new reason, meta
71
+ new(reason, **meta)
72
72
  end
73
73
  end
74
74
 
@@ -92,7 +92,7 @@ module Dentaku
92
92
  raise ::ArgumentError, "Unhandled #{reason}"
93
93
  end
94
94
 
95
- new reason, meta
95
+ new(reason, **meta)
96
96
  end
97
97
  end
98
98
 
@@ -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
@@ -51,6 +51,7 @@ module Dentaku
51
51
  fail! :too_many_operands, operator: operator, expect: max_size, actual: output.length
52
52
  end
53
53
 
54
+ fail! :invalid_statement if output.size < args_size
54
55
  args = Array.new(args_size) { output.pop }.reverse
55
56
 
56
57
  output.push operator.new(*args)
@@ -335,7 +336,7 @@ module Dentaku
335
336
  raise ::ArgumentError, "Unhandled #{reason}"
336
337
  end
337
338
 
338
- raise ParseError.for(reason, meta), message
339
+ raise ParseError.for(reason, **meta), message
339
340
  end
340
341
  end
341
342
  end
@@ -104,7 +104,7 @@ module Dentaku
104
104
  raise ::ArgumentError, "Unhandled #{reason}"
105
105
  end
106
106
 
107
- raise TokenizerError.for(reason, meta), message
107
+ raise TokenizerError.for(reason, **meta), message
108
108
  end
109
109
  end
110
110
  end
@@ -1,3 +1,3 @@
1
1
  module Dentaku
2
- VERSION = "3.3.3"
2
+ VERSION = "3.4.2"
3
3
  end
@@ -4,11 +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') }
11
- let(:ctx) { {'x' => 1, 'y' => 2} }
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)
@@ -46,6 +47,19 @@ describe Dentaku::AST::Arithmetic do
46
47
  expect { add(x, one, 'x' => '') }.to raise_error(Dentaku::ArgumentError)
47
48
  end
48
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)
61
+ end
62
+
49
63
  private
50
64
 
51
65
  def add(left, right, context = ctx)
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
@@ -0,0 +1,18 @@
1
+ require 'spec_helper'
2
+ require 'dentaku/ast/functions/filter'
3
+ require 'dentaku'
4
+
5
+ describe Dentaku::AST::Filter do
6
+ it 'excludes unmatched values' do
7
+ result = Dentaku('SUM(FILTER(vals, val, val > 1))', vals: [1, 2, 3])
8
+ expect(result).to eq(5)
9
+ end
10
+
11
+ it 'works with a single value if needed for some reason' do
12
+ result = Dentaku('FILTER(vals, val, val > 1)', vals: 1)
13
+ expect(result).to eq([])
14
+
15
+ result = Dentaku('FILTER(vals, val, val > 1)', vals: 2)
16
+ expect(result).to eq([2])
17
+ end
18
+ end
@@ -0,0 +1,15 @@
1
+ require 'spec_helper'
2
+ require 'dentaku/ast/functions/map'
3
+ require 'dentaku'
4
+
5
+ describe Dentaku::AST::Map do
6
+ it 'operates on each value in an array' do
7
+ result = Dentaku('SUM(MAP(vals, val, val + 1))', vals: [1, 2, 3])
8
+ expect(result).to eq(9)
9
+ end
10
+
11
+ it 'works with an empty array' do
12
+ result = Dentaku('MAP(vals, val, val + 1)', vals: [])
13
+ expect(result).to eq([])
14
+ end
15
+ end
data/spec/ast/max_spec.rb CHANGED
@@ -17,4 +17,17 @@ describe 'Dentaku::AST::Function::Max' do
17
17
  result = Dentaku('MAX(1, x, 1.8)', x: [1.5, 2.3, 1.7])
18
18
  expect(result).to eq(2.3)
19
19
  end
20
+
21
+ it 'returns the largest value if only an Array is passed' do
22
+ result = Dentaku('MAX(x)', x: [1.5, 2.3, 1.7])
23
+ expect(result).to eq(2.3)
24
+ end
25
+
26
+ context 'checking errors' do
27
+ let(:calculator) { Dentaku::Calculator.new }
28
+
29
+ it 'does not raise an error if an empty array is passed' do
30
+ expect(calculator.evaluate!('MAX(x)', x: [])).to eq(nil)
31
+ end
32
+ end
20
33
  end
data/spec/ast/min_spec.rb CHANGED
@@ -17,4 +17,17 @@ describe 'Dentaku::AST::Function::Min' do
17
17
  result = Dentaku('MIN(1, x, 1.8)', x: [1.5, 0.3, 1.7])
18
18
  expect(result).to eq(0.3)
19
19
  end
20
+
21
+ it 'returns the smallest value if only an Array is passed' do
22
+ result = Dentaku('MIN(x)', x: [1.5, 2.3, 1.7])
23
+ expect(result).to eq(1.5)
24
+ end
25
+
26
+ context 'checking errors' do
27
+ let(:calculator) { Dentaku::Calculator.new }
28
+
29
+ it 'does not raise an error if an empty array is passed' do
30
+ expect(calculator.evaluate!('MIN(x)', x: [])).to eq(nil)
31
+ end
32
+ end
20
33
  end
data/spec/ast/mul_spec.rb CHANGED
@@ -34,5 +34,10 @@ 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 'does not raise an error if an empty array is passed' do
39
+ result = calculator.evaluate!('MUL(x)', x: [])
40
+ expect(result).to eq(1)
41
+ end
37
42
  end
38
43
  end
@@ -4,8 +4,9 @@ require 'dentaku/ast/arithmetic'
4
4
  require 'dentaku/token'
5
5
 
6
6
  describe Dentaku::AST::Negation do
7
- let(:five) { Dentaku::AST::Logical.new Dentaku::Token.new(:numeric, 5) }
8
- let(:t) { Dentaku::AST::Numeric.new Dentaku::Token.new(:logical, true) }
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') }
9
10
 
10
11
  it 'allows access to its sub-node' do
11
12
  node = described_class.new(five)
@@ -29,4 +30,19 @@ describe Dentaku::AST::Negation do
29
30
  described_class.new(group)
30
31
  }.not_to raise_error
31
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
32
48
  end
@@ -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
@@ -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
@@ -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,10 @@ 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 'does not raise an error if an empty array is passed' do
39
+ result = calculator.evaluate!('SUM(x)', x: [])
40
+ expect(result).to eq(0)
41
+ end
37
42
  end
38
43
  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: {