dentaku 3.5.8 → 4.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/rspec.yml +2 -4
  3. data/.github/workflows/rubocop.yml +1 -1
  4. data/.rubocop.yml +1 -1
  5. data/CHANGELOG.md +108 -1
  6. data/Gemfile +1 -1
  7. data/README.md +123 -0
  8. data/dentaku.gemspec +15 -8
  9. data/lib/dentaku/ast/access.rb +18 -1
  10. data/lib/dentaku/ast/arithmetic.rb +22 -9
  11. data/lib/dentaku/ast/array.rb +6 -0
  12. data/lib/dentaku/ast/bitwise.rb +6 -4
  13. data/lib/dentaku/ast/case/case_conditional.rb +6 -0
  14. data/lib/dentaku/ast/case/case_else.rb +6 -0
  15. data/lib/dentaku/ast/case/case_switch_variable.rb +6 -0
  16. data/lib/dentaku/ast/case/case_then.rb +6 -0
  17. data/lib/dentaku/ast/case/case_when.rb +7 -0
  18. data/lib/dentaku/ast/case.rb +30 -3
  19. data/lib/dentaku/ast/combinators.rb +52 -5
  20. data/lib/dentaku/ast/comparators.rb +2 -3
  21. data/lib/dentaku/ast/function.rb +15 -2
  22. data/lib/dentaku/ast/function_registry.rb +28 -2
  23. data/lib/dentaku/ast/functions/and.rb +4 -4
  24. data/lib/dentaku/ast/functions/avg.rb +2 -2
  25. data/lib/dentaku/ast/functions/duration.rb +1 -1
  26. data/lib/dentaku/ast/functions/if.rb +9 -1
  27. data/lib/dentaku/ast/functions/intercept.rb +2 -2
  28. data/lib/dentaku/ast/functions/mul.rb +2 -2
  29. data/lib/dentaku/ast/functions/or.rb +4 -4
  30. data/lib/dentaku/ast/functions/pluck.rb +1 -1
  31. data/lib/dentaku/ast/functions/string_functions.rb +1 -1
  32. data/lib/dentaku/ast/functions/sum.rb +2 -2
  33. data/lib/dentaku/ast/functions/xor.rb +4 -4
  34. data/lib/dentaku/ast/grouping.rb +6 -0
  35. data/lib/dentaku/ast/negation.rb +8 -1
  36. data/lib/dentaku/ast/node.rb +25 -0
  37. data/lib/dentaku/ast/operation.rb +7 -0
  38. data/lib/dentaku/bulk_expression_solver.rb +9 -10
  39. data/lib/dentaku/calculator.rb +83 -34
  40. data/lib/dentaku/date_arithmetic.rb +2 -2
  41. data/lib/dentaku/exceptions.rb +105 -13
  42. data/lib/dentaku/numeric_parser.rb +1 -1
  43. data/lib/dentaku/parser.rb +24 -48
  44. data/lib/dentaku/token_matcher.rb +31 -37
  45. data/lib/dentaku/token_matchers.rb +3 -3
  46. data/lib/dentaku/tokenizer.rb +1 -15
  47. data/lib/dentaku/version.rb +1 -1
  48. data/spec/ast/arithmetic_spec.rb +93 -0
  49. data/spec/ast/case_spec.rb +7 -0
  50. data/spec/bulk_expression_solver_spec.rb +13 -3
  51. data/spec/calculator_spec.rb +146 -0
  52. data/spec/exceptions_spec.rb +87 -0
  53. data/spec/external_function_spec.rb +33 -0
  54. data/spec/gemspec_spec.rb +10 -0
  55. data/spec/identifiers_spec.rb +66 -0
  56. data/spec/parser_spec.rb +35 -0
  57. data/spec/print_visitor_spec.rb +5 -0
  58. data/spec/spec_helper.rb +9 -0
  59. data/spec/volatile_functions_spec.rb +121 -0
  60. metadata +21 -63
  61. data/.travis.yml +0 -10
@@ -1,4 +1,5 @@
1
1
  require_relative './operation'
2
+ require 'dentaku/exceptions'
2
3
 
3
4
  module Dentaku
4
5
  module AST
@@ -20,10 +21,52 @@ module Dentaku
20
21
  :logical
21
22
  end
22
23
 
24
+ def dependencies(context = {})
25
+ return super if static_mode?(context)
26
+
27
+ left_deps = left.dependencies(context)
28
+ right_deps = right.dependencies(context)
29
+ return [] if left_deps.empty? && left.pure? && decisive?(left.value(context))
30
+ return [] if right_deps.empty? && right.pure? && decisive?(right.value(context))
31
+
32
+ (left_deps + right_deps).uniq
33
+ rescue Dentaku::Error
34
+ super
35
+ end
36
+
37
+ def value(context = {})
38
+ left_value = begin
39
+ left.value(context)
40
+ rescue UnboundVariableError => unbound
41
+ unbound
42
+ end
43
+
44
+ unless left_value.is_a?(UnboundVariableError)
45
+ return left_value if decisive?(left_value)
46
+
47
+ return right.value(context)
48
+ end
49
+
50
+ # The left operand is unbound; the right operand can still decide the
51
+ # result on its own. If it does not, the left value was needed.
52
+ right_value = right.value(context)
53
+ raise left_value unless decisive?(right_value)
54
+
55
+ right_value
56
+ end
57
+
23
58
  private
24
59
 
60
+ # static dependency check: this runs at parse time, and evaluating
61
+ # short-circuit guards here would execute user functions (#197)
25
62
  def valid_node?(node)
26
- node && (node.dependencies.any? || node.type == :logical)
63
+ node && (node.dependencies(Node::STATIC_CONTEXT).any? || node.type == :logical)
64
+ end
65
+
66
+ # whether a single operand with this value already determines the
67
+ # result, regardless of the other operand
68
+ def decisive?(operand_value)
69
+ raise NotImplementedError
27
70
  end
28
71
  end
29
72
 
@@ -32,8 +75,10 @@ module Dentaku
32
75
  :and
33
76
  end
34
77
 
35
- def value(context = {})
36
- left.value(context) && right.value(context)
78
+ private
79
+
80
+ def decisive?(operand_value)
81
+ !operand_value
37
82
  end
38
83
  end
39
84
 
@@ -42,8 +87,10 @@ module Dentaku
42
87
  :or
43
88
  end
44
89
 
45
- def value(context = {})
46
- left.value(context) || right.value(context)
90
+ private
91
+
92
+ def decisive?(operand_value)
93
+ !!operand_value
47
94
  end
48
95
  end
49
96
  end
@@ -21,7 +21,7 @@ module Dentaku
21
21
 
22
22
  l.public_send(operator, r)
23
23
  rescue ::ArgumentError, ::TypeError => e
24
- raise Dentaku::ArgumentError.for(:incompatible_type, value: r, for: l.class), e.message
24
+ raise Dentaku::ArgumentError.for(:incompatible_type, actual: r, expected: l.class), e.message
25
25
  end
26
26
 
27
27
  private
@@ -34,8 +34,7 @@ module Dentaku
34
34
 
35
35
  def validate_value(value)
36
36
  unless value.respond_to?(operator)
37
- raise Dentaku::ArgumentError.for(:invalid_operator, operation: self.class, operator: operator),
38
- "#{ self.class } requires operands that respond to #{operator}"
37
+ raise Dentaku::ArgumentError.for(:invalid_operator, operation: self.class, operator: operator)
39
38
  end
40
39
 
41
40
  value
@@ -22,12 +22,19 @@ module Dentaku
22
22
  .flat_map { |a, _| a.dependencies(context) }
23
23
  end
24
24
 
25
+ # volatile functions may not be evaluated during dependency
26
+ # resolution; registry-generated classes override this per the
27
+ # volatile: option passed at registration
28
+ def self.volatile?
29
+ false
30
+ end
31
+
25
32
  def self.get(name)
26
33
  registry.get(name)
27
34
  end
28
35
 
29
- def self.register(name, type, implementation)
30
- registry.register(name, type, implementation)
36
+ def self.register(name, type, implementation, volatile: false)
37
+ registry.register(name, type, implementation, volatile: volatile)
31
38
  end
32
39
 
33
40
  def self.register_class(name, function_class)
@@ -37,6 +44,12 @@ module Dentaku
37
44
  def self.registry
38
45
  @registry ||= FunctionRegistry.new
39
46
  end
47
+
48
+ private
49
+
50
+ def compute_pure?
51
+ !self.class.volatile? && args.all?(&:pure?)
52
+ end
40
53
  end
41
54
  end
42
55
  end
@@ -8,7 +8,7 @@ module Dentaku
8
8
  nil
9
9
  end
10
10
 
11
- def register(name, type, implementation, callback = nil)
11
+ def register(name, type, implementation, callback = nil, volatile: false)
12
12
  function = Class.new(Function) do
13
13
  def self.name=(name)
14
14
  @name = name.to_s
@@ -18,6 +18,21 @@ module Dentaku
18
18
  @name
19
19
  end
20
20
 
21
+ # only the first registration of a given name claims the constant
22
+ # under Dentaku::AST::Function, so later ones stay anonymous and
23
+ # would otherwise render as "#<Class:0x...>" in error messages
24
+ def self.display_name=(display_name)
25
+ @display_name = display_name
26
+ end
27
+
28
+ def self.to_s
29
+ @display_name || super
30
+ end
31
+
32
+ def self.inspect
33
+ to_s
34
+ end
35
+
21
36
  def self.implementation=(impl)
22
37
  @implementation = impl
23
38
  end
@@ -42,6 +57,14 @@ module Dentaku
42
57
  @callback
43
58
  end
44
59
 
60
+ def self.volatile=(volatile)
61
+ @volatile = volatile
62
+ end
63
+
64
+ def self.volatile?
65
+ @volatile
66
+ end
67
+
45
68
  def self.arity
46
69
  @implementation.arity < 0 ? nil : @implementation.arity
47
70
  end
@@ -64,12 +87,15 @@ module Dentaku
64
87
  end
65
88
  end
66
89
 
90
+ function.name = name
91
+ function.display_name = "#{Function}::#{normalize_name(name)}"
92
+
67
93
  define_class(name, function)
68
94
 
69
- function.name = name
70
95
  function.type = type
71
96
  function.implementation = implementation
72
97
  function.callback = callback
98
+ function.volatile = volatile
73
99
 
74
100
  self[function_name(name)] = function
75
101
  end
@@ -5,8 +5,8 @@ Dentaku::AST::Function.register(:and, :logical, lambda { |*args|
5
5
  if args.empty?
6
6
  raise Dentaku::ArgumentError.for(
7
7
  :too_few_arguments,
8
- function_name: 'AND()', at_least: 1, given: 0
9
- ), 'AND() requires at least one argument'
8
+ function_name: 'AND', expected: 1.., actual: 0
9
+ )
10
10
  end
11
11
 
12
12
  args.all? do |arg|
@@ -18,8 +18,8 @@ Dentaku::AST::Function.register(:and, :logical, lambda { |*args|
18
18
  else
19
19
  raise Dentaku::ArgumentError.for(
20
20
  :incompatible_type,
21
- function_name: 'AND()', expect: :logical, actual: arg.class
22
- ), 'AND() requires arguments to be logical expressions'
21
+ function_name: 'AND', expected: :logical, actual: arg
22
+ )
23
23
  end
24
24
  end
25
25
  })
@@ -5,8 +5,8 @@ Dentaku::AST::Function.register(:avg, :numeric, ->(*args) {
5
5
  if flatten_args.empty?
6
6
  raise Dentaku::ArgumentError.for(
7
7
  :too_few_arguments,
8
- function_name: 'AVG()', at_least: 1, given: 0
9
- ), 'AVG() requires at least one argument'
8
+ function_name: 'AVG', expected: 1.., actual: 0
9
+ )
10
10
  end
11
11
 
12
12
  flatten_args.map { |arg| Dentaku::NumericParser.ensure_numeric!(arg) }.reduce(0, :+) / BigDecimal(flatten_args.length)
@@ -25,7 +25,7 @@ module Dentaku
25
25
  when /months?/ then :month
26
26
  when /days?/ then :day
27
27
  else
28
- raise Dentaku::ArgumentError.for(:incompatible_type, value: unit, for: Duration),
28
+ raise Dentaku::ArgumentError.for(:incompatible_type, actual: unit, expected: Duration),
29
29
  "'#{unit || unit.class}' is not a valid duration unit"
30
30
  end
31
31
  end
@@ -36,8 +36,16 @@ module Dentaku
36
36
  end
37
37
 
38
38
  def dependencies(context = {})
39
+ return all_arg_dependencies(context) if static_mode?(context) || !predicate.pure?
40
+
39
41
  predicate.value(context) ? left.dependencies(context) : right.dependencies(context)
40
- rescue Dentaku::Error, Dentaku::ArgumentError, Dentaku::ZeroDivisionError
42
+ rescue Dentaku::Error
43
+ all_arg_dependencies(context)
44
+ end
45
+
46
+ private
47
+
48
+ def all_arg_dependencies(context)
41
49
  args.flat_map { |arg| arg.dependencies(context) }.uniq
42
50
  end
43
51
  end
@@ -4,7 +4,7 @@ Dentaku::AST::Function.register(:intercept, :list, ->(*args) {
4
4
  if args.length != 2
5
5
  raise Dentaku::ArgumentError.for(
6
6
  :wrong_number_of_arguments,
7
- function_name: 'INTERCEPT()', exact: 2, given: args.length
7
+ function_name: 'INTERCEPT', expected: 2, actual: args.length
8
8
  ), 'INTERCEPT() requires exactly two arrays of numbers'
9
9
  end
10
10
 
@@ -12,7 +12,7 @@ Dentaku::AST::Function.register(:intercept, :list, ->(*args) {
12
12
  if !x_values.is_a?(Array) || !y_values.is_a?(Array) || x_values.length != y_values.length
13
13
  raise Dentaku::ArgumentError.for(
14
14
  :invalid_value,
15
- function_name: 'INTERCEPT()'
15
+ function_name: 'INTERCEPT'
16
16
  ), 'INTERCEPT() requires arrays of equal length'
17
17
  end
18
18
 
@@ -4,8 +4,8 @@ Dentaku::AST::Function.register(:mul, :numeric, ->(*args) {
4
4
  if args.empty?
5
5
  raise Dentaku::ArgumentError.for(
6
6
  :too_few_arguments,
7
- function_name: 'MUL()', at_least: 1, given: 0
8
- ), 'MUL() requires at least one argument'
7
+ function_name: 'MUL', expected: 1.., actual: 0
8
+ )
9
9
  end
10
10
 
11
11
  args.flatten.map { |arg| Dentaku::NumericParser.ensure_numeric!(arg) }.reduce(1, :*)
@@ -5,8 +5,8 @@ Dentaku::AST::Function.register(:or, :logical, lambda { |*args|
5
5
  if args.empty?
6
6
  raise Dentaku::ArgumentError.for(
7
7
  :too_few_arguments,
8
- function_name: 'OR()', at_least: 1, given: 0
9
- ), 'OR() requires at least one argument'
8
+ function_name: 'OR', expected: 1.., actual: 0
9
+ )
10
10
  end
11
11
 
12
12
  args.any? do |arg|
@@ -18,8 +18,8 @@ Dentaku::AST::Function.register(:or, :logical, lambda { |*args|
18
18
  else
19
19
  raise Dentaku::ArgumentError.for(
20
20
  :incompatible_type,
21
- function_name: 'OR()', expect: :logical, actual: arg.class
22
- ), 'OR() requires arguments to be logical expressions'
21
+ function_name: 'OR', expected: :logical, actual: arg
22
+ )
23
23
  end
24
24
  end
25
25
  })
@@ -16,7 +16,7 @@ module Dentaku
16
16
  collection = Array(@args[0].value(context))
17
17
 
18
18
  unless collection.all? { |elem| elem.is_a?(Hash) }
19
- raise ArgumentError.for(:incompatible_type, value: collection),
19
+ raise ArgumentError.for(:incompatible_type, actual: collection),
20
20
  'PLUCK() requires first argument to be an array of hashes'
21
21
  end
22
22
 
@@ -11,7 +11,7 @@ module Dentaku
11
11
  def negative_argument_failure(fun, arg = 'length')
12
12
  raise Dentaku::ArgumentError.for(
13
13
  :invalid_value,
14
- function_name: "#{fun}()"
14
+ function_name: fun
15
15
  ), "#{fun}() requires #{arg} to be positive"
16
16
  end
17
17
  end
@@ -4,8 +4,8 @@ Dentaku::AST::Function.register(:sum, :numeric, ->(*args) {
4
4
  if args.empty?
5
5
  raise Dentaku::ArgumentError.for(
6
6
  :too_few_arguments,
7
- function_name: 'SUM()', at_least: 1, given: 0
8
- ), 'SUM() requires at least one argument'
7
+ function_name: 'SUM', expected: 1.., actual: 0
8
+ )
9
9
  end
10
10
 
11
11
  args.flatten.map { |arg| Dentaku::NumericParser.ensure_numeric!(arg) }.reduce(0, :+)
@@ -16,8 +16,8 @@ module Dentaku
16
16
  if @args.empty?
17
17
  raise Dentaku::ArgumentError.for(
18
18
  :too_few_arguments,
19
- function_name: 'XOR()', at_least: 1, given: 0
20
- ), 'XOR() requires at least one argument'
19
+ function_name: 'XOR', expected: 1.., actual: 0
20
+ )
21
21
  end
22
22
 
23
23
  true_arg_count = 0
@@ -31,8 +31,8 @@ module Dentaku
31
31
  else
32
32
  raise Dentaku::ArgumentError.for(
33
33
  :incompatible_type,
34
- function_name: 'XOR()', expect: :logical, actual: arg.class
35
- ), 'XOR() requires arguments to be logical expressions'
34
+ function_name: 'XOR', expected: :logical, actual: arg
35
+ )
36
36
  end
37
37
  end
38
38
  true_arg_count == 1
@@ -18,6 +18,12 @@ module Dentaku
18
18
  def dependencies(context = {})
19
19
  @node.dependencies(context)
20
20
  end
21
+
22
+ private
23
+
24
+ def compute_pure?
25
+ @node.pure?
26
+ end
21
27
  end
22
28
  end
23
29
  end
@@ -46,8 +46,15 @@ module Dentaku
46
46
 
47
47
  private
48
48
 
49
+ # bypasses Operation's binary initialize, so left/right are unset
50
+ def compute_pure?
51
+ @node.pure?
52
+ end
53
+
54
+ # static dependency check: this runs at parse time, and evaluating
55
+ # short-circuit guards here would execute user functions (#197)
49
56
  def valid_node?(node)
50
- node && (node.dependencies.any? || node.type == :numeric)
57
+ node && (node.dependencies(Node::STATIC_CONTEXT).any? || node.type == :numeric)
51
58
  end
52
59
  end
53
60
  end
@@ -1,6 +1,12 @@
1
1
  module Dentaku
2
2
  module AST
3
3
  class Node
4
+ # reserved context key (double-underscore prefix, like
5
+ # __evaluation_mode) that switches dependencies() into static mode:
6
+ # guards are never evaluated and every branch is reported
7
+ STATIC_MODE_KEY = "__static_dependencies".freeze
8
+ STATIC_CONTEXT = { STATIC_MODE_KEY => true }.freeze
9
+
4
10
  def self.precedence
5
11
  0
6
12
  end
@@ -17,6 +23,15 @@ module Dentaku
17
23
  []
18
24
  end
19
25
 
26
+ # whether this subtree may be evaluated during dependency resolution
27
+ # without running volatile user code; a property of the parsed AST,
28
+ # computed once and memoized
29
+ def pure?
30
+ return @pure if defined?(@pure)
31
+
32
+ @pure = compute_pure?
33
+ end
34
+
20
35
  def type
21
36
  nil
22
37
  end
@@ -24,6 +39,16 @@ module Dentaku
24
39
  def name
25
40
  self.class.name.to_s.split("::").last.upcase
26
41
  end
42
+
43
+ private
44
+
45
+ def static_mode?(context)
46
+ context[STATIC_MODE_KEY] == true
47
+ end
48
+
49
+ def compute_pure?
50
+ true
51
+ end
27
52
  end
28
53
  end
29
54
  end
@@ -39,6 +39,13 @@ module Dentaku
39
39
  def operator_spacing
40
40
  " "
41
41
  end
42
+
43
+ private
44
+
45
+ # compact: unary operations (e.g. Percentage) only populate left
46
+ def compute_pure?
47
+ [left, right].compact.all?(&:pure?)
48
+ end
42
49
  end
43
50
  end
44
51
  end
@@ -11,8 +11,8 @@ module Dentaku
11
11
  @calculator = calculator
12
12
  end
13
13
 
14
- def evaluate(*args)
15
- @calculator.evaluate!(*args)
14
+ def evaluate(*args, &block)
15
+ @calculator.evaluate!(*args, &block)
16
16
  end
17
17
  end
18
18
 
@@ -22,10 +22,9 @@ module Dentaku
22
22
  @block = block || ->(*) { :undefined }
23
23
  end
24
24
 
25
- def evaluate(*args)
26
- @calculator.evaluate(*args) { |expr, ex|
27
- @block.call(ex)
28
- }
25
+ def evaluate(*args, &block)
26
+ handler = block || ->(_expr, ex) { @block.call(ex) }
27
+ @calculator.evaluate(*args, &handler)
29
28
  end
30
29
  end
31
30
 
@@ -79,7 +78,7 @@ module Dentaku
79
78
 
80
79
  def expression_with_exception_handler(var_name, &block)
81
80
  ->(_expr, ex) {
82
- ex.recipient_variable = var_name
81
+ ex.assigned_to = var_name
83
82
  block.call(ex)
84
83
  }
85
84
  end
@@ -106,7 +105,7 @@ module Dentaku
106
105
  context.merge(results),
107
106
  &expression_with_exception_handler(var_name, &block)
108
107
  ).tap { |res|
109
- res.recipient_variable = var_name if res.respond_to?(:recipient_variable=)
108
+ res.assigned_to = var_name if res.respond_to?(:assigned_to=)
110
109
  res
111
110
  }
112
111
  end
@@ -120,7 +119,7 @@ module Dentaku
120
119
  def with_rescues(var_name, results, block)
121
120
  yield
122
121
  rescue Dentaku::UnboundVariableError, Dentaku::ZeroDivisionError, Dentaku::ArgumentError => ex
123
- ex.recipient_variable = var_name
122
+ ex.assigned_to = var_name
124
123
  results[var_name] = block.call(ex)
125
124
  ensure
126
125
  stored_name = normalized_name(var_name)
@@ -157,7 +156,7 @@ module Dentaku
157
156
  cache_key = "#{!!calculator.case_sensitive}|#{expressions.keys.map(&:to_s).sort.join("|")}"
158
157
  @ordered_deps ||= self.class.dependency_cache.fetch(cache_key) {
159
158
  DependencyResolver.find_resolve_order(dependencies, calculator.case_sensitive).tap do |d|
160
- self.class.dependency_cache[cache_key] = d if Dentaku.cache_dependency_order?
159
+ self.class.dependency_cache[cache_key] = d if calculator.cache_dependency_order?
161
160
  end
162
161
  }
163
162
  end