dentaku 3.3.4 → 3.4.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (48) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +2 -7
  3. data/.travis.yml +3 -4
  4. data/CHANGELOG.md +13 -0
  5. data/dentaku.gemspec +0 -2
  6. data/lib/dentaku.rb +14 -4
  7. data/lib/dentaku/ast.rb +4 -0
  8. data/lib/dentaku/ast/access.rb +3 -1
  9. data/lib/dentaku/ast/arithmetic.rb +7 -2
  10. data/lib/dentaku/ast/array.rb +3 -1
  11. data/lib/dentaku/ast/function.rb +10 -1
  12. data/lib/dentaku/ast/functions/all.rb +36 -0
  13. data/lib/dentaku/ast/functions/any.rb +36 -0
  14. data/lib/dentaku/ast/functions/avg.rb +2 -2
  15. data/lib/dentaku/ast/functions/map.rb +36 -0
  16. data/lib/dentaku/ast/functions/mul.rb +3 -2
  17. data/lib/dentaku/ast/functions/pluck.rb +29 -0
  18. data/lib/dentaku/ast/functions/round.rb +1 -1
  19. data/lib/dentaku/ast/functions/rounddown.rb +1 -1
  20. data/lib/dentaku/ast/functions/roundup.rb +1 -1
  21. data/lib/dentaku/ast/functions/ruby_math.rb +47 -3
  22. data/lib/dentaku/ast/functions/string_functions.rb +4 -4
  23. data/lib/dentaku/ast/functions/sum.rb +3 -2
  24. data/lib/dentaku/ast/grouping.rb +3 -1
  25. data/lib/dentaku/ast/identifier.rb +3 -1
  26. data/lib/dentaku/bulk_expression_solver.rb +34 -25
  27. data/lib/dentaku/calculator.rb +13 -5
  28. data/lib/dentaku/date_arithmetic.rb +1 -1
  29. data/lib/dentaku/exceptions.rb +3 -3
  30. data/lib/dentaku/flat_hash.rb +7 -0
  31. data/lib/dentaku/parser.rb +2 -1
  32. data/lib/dentaku/tokenizer.rb +1 -1
  33. data/lib/dentaku/version.rb +1 -1
  34. data/spec/ast/arithmetic_spec.rb +19 -5
  35. data/spec/ast/avg_spec.rb +4 -0
  36. data/spec/ast/mul_spec.rb +4 -0
  37. data/spec/ast/negation_spec.rb +18 -2
  38. data/spec/ast/round_spec.rb +10 -0
  39. data/spec/ast/rounddown_spec.rb +10 -0
  40. data/spec/ast/roundup_spec.rb +10 -0
  41. data/spec/ast/string_functions_spec.rb +35 -0
  42. data/spec/ast/sum_spec.rb +4 -0
  43. data/spec/bulk_expression_solver_spec.rb +17 -0
  44. data/spec/calculator_spec.rb +112 -0
  45. data/spec/dentaku_spec.rb +14 -8
  46. data/spec/parser_spec.rb +13 -0
  47. data/spec/tokenizer_spec.rb +24 -5
  48. metadata +7 -3
@@ -32,7 +32,7 @@ module Dentaku
32
32
 
33
33
  def value(context = {})
34
34
  string = @string.value(context).to_s
35
- length = @length.value(context)
35
+ length = Dentaku::AST::Function.numeric(@length.value(context)).to_i
36
36
  negative_argument_failure('LEFT') if length < 0
37
37
  string[0, length]
38
38
  end
@@ -54,7 +54,7 @@ module Dentaku
54
54
 
55
55
  def value(context = {})
56
56
  string = @string.value(context).to_s
57
- length = @length.value(context)
57
+ length = Dentaku::AST::Function.numeric(@length.value(context)).to_i
58
58
  negative_argument_failure('RIGHT') if length < 0
59
59
  string[length * -1, length] || string
60
60
  end
@@ -76,9 +76,9 @@ module Dentaku
76
76
 
77
77
  def value(context = {})
78
78
  string = @string.value(context).to_s
79
- offset = @offset.value(context)
79
+ offset = Dentaku::AST::Function.numeric(@offset.value(context)).to_i
80
80
  negative_argument_failure('MID', 'offset') if offset < 0
81
- length = @length.value(context)
81
+ length = Dentaku::AST::Function.numeric(@length.value(context)).to_i
82
82
  negative_argument_failure('MID') if length < 0
83
83
  string[offset - 1, length].to_s
84
84
  end
@@ -1,12 +1,13 @@
1
1
  require_relative '../function'
2
2
 
3
3
  Dentaku::AST::Function.register(:sum, :numeric, ->(*args) {
4
- if args.empty?
4
+ flatten_args = args.flatten
5
+ if flatten_args.empty?
5
6
  raise Dentaku::ArgumentError.for(
6
7
  :too_few_arguments,
7
8
  function_name: 'SUM()', at_least: 1, given: 0
8
9
  ), 'SUM() requires at least one argument'
9
10
  end
10
11
 
11
- args.flatten.map { |arg| Dentaku::AST::Function.numeric(arg) }.reduce(0, :+)
12
+ flatten_args.map { |arg| Dentaku::AST::Function.numeric(arg) }.reduce(0, :+)
12
13
  })
@@ -1,6 +1,8 @@
1
+ require_relative "./node"
2
+
1
3
  module Dentaku
2
4
  module AST
3
- class Grouping
5
+ class Grouping < Node
4
6
  def initialize(node)
5
7
  @node = node
6
8
  end
@@ -20,7 +20,9 @@ module Dentaku
20
20
 
21
21
  case v
22
22
  when Node
23
- v.value(context)
23
+ value = v.value(context)
24
+ context[identifier] = value if Dentaku.cache_identifier?
25
+ value
24
26
  when Proc
25
27
  v.call
26
28
  else
@@ -53,40 +53,53 @@ module Dentaku
53
53
  end
54
54
 
55
55
  def expression_with_exception_handler(&block)
56
- ->(expr, ex) { block.call(ex) }
56
+ ->(_expr, ex) { block.call(ex) }
57
57
  end
58
58
 
59
59
  def load_results(&block)
60
- variables_in_resolve_order.each_with_object({}) do |var_name, r|
61
- begin
62
- solved = calculator.memory
63
- value_from_memory = solved[var_name.downcase]
64
-
65
- if value_from_memory.nil? &&
66
- expressions[var_name].nil? &&
67
- !solved.has_key?(var_name)
68
- next
69
- end
70
-
71
- value = value_from_memory || evaluate!(
60
+ facts, _formulas = expressions.transform_keys(&:downcase)
61
+ .transform_values { |v| calculator.ast(v) }
62
+ .partition { |_, v| calculator.dependencies(v, nil).empty? }
63
+
64
+ context = calculator.memory.merge(facts.to_h.each_with_object({}) do |(var_name, ast), h|
65
+ with_rescues(var_name, h, block) do
66
+ h[var_name] = ast.is_a?(Array) ? ast.map(&:value) : ast.value
67
+ end
68
+ end)
69
+
70
+ variables_in_resolve_order.each_with_object({}) do |var_name, results|
71
+ next if expressions[var_name].nil?
72
+
73
+ with_rescues(var_name, results, block) do
74
+ results[var_name] = calculator.evaluate!(
72
75
  expressions[var_name],
73
- expressions.merge(r).merge(solved),
76
+ context.merge(results),
74
77
  &expression_with_exception_handler(&block)
75
78
  )
76
-
77
- r[var_name] = value
78
- rescue UnboundVariableError, Dentaku::ZeroDivisionError => ex
79
- ex.recipient_variable = var_name
80
- r[var_name] = block.call(ex)
81
- rescue Dentaku::ArgumentError => ex
82
- r[var_name] = block.call(ex)
83
79
  end
84
80
  end
81
+
85
82
  rescue TSort::Cyclic => ex
86
83
  block.call(ex)
87
84
  {}
88
85
  end
89
86
 
87
+ def with_rescues(var_name, results, block)
88
+ yield
89
+
90
+ rescue UnboundVariableError, Dentaku::ZeroDivisionError => ex
91
+ ex.recipient_variable = var_name
92
+ results[var_name] = block.call(ex)
93
+
94
+ rescue Dentaku::ArgumentError => ex
95
+ results[var_name] = block.call(ex)
96
+
97
+ ensure
98
+ if results[var_name] == :undefined && calculator.memory.has_key?(var_name.downcase)
99
+ results[var_name] = calculator.memory[var_name.downcase]
100
+ end
101
+ end
102
+
90
103
  def expressions
91
104
  @expressions ||= Hash[expression_hash.map { |k, v| [k.to_s, v] }]
92
105
  end
@@ -113,9 +126,5 @@ module Dentaku
113
126
  end
114
127
  }
115
128
  end
116
-
117
- def evaluate!(expression, results, &block)
118
- calculator.evaluate!(expression, results, &block)
119
- end
120
129
  end
121
130
  end
@@ -62,7 +62,7 @@ module Dentaku
62
62
  unbound = node.dependencies - memory.keys
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.4"
2
+ VERSION = "3.4.0"
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)
@@ -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
@@ -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
@@ -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