dentaku 3.5.7 → 4.0.0

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 (72) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/rspec.yml +2 -6
  3. data/.github/workflows/rubocop.yml +1 -1
  4. data/.rubocop.yml +1 -1
  5. data/CHANGELOG.md +91 -0
  6. data/Gemfile +14 -1
  7. data/README.md +87 -0
  8. data/dentaku.gemspec +14 -14
  9. data/lib/dentaku/ast/access.rb +18 -1
  10. data/lib/dentaku/ast/arithmetic.rb +5 -17
  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 +49 -4
  20. data/lib/dentaku/ast/comparators.rb +3 -7
  21. data/lib/dentaku/ast/function.rb +12 -13
  22. data/lib/dentaku/ast/function_registry.rb +11 -2
  23. data/lib/dentaku/ast/functions/abs.rb +1 -1
  24. data/lib/dentaku/ast/functions/and.rb +4 -4
  25. data/lib/dentaku/ast/functions/avg.rb +3 -3
  26. data/lib/dentaku/ast/functions/duration.rb +1 -1
  27. data/lib/dentaku/ast/functions/if.rb +9 -1
  28. data/lib/dentaku/ast/functions/intercept.rb +4 -4
  29. data/lib/dentaku/ast/functions/max.rb +1 -1
  30. data/lib/dentaku/ast/functions/min.rb +1 -1
  31. data/lib/dentaku/ast/functions/mul.rb +3 -3
  32. data/lib/dentaku/ast/functions/or.rb +4 -4
  33. data/lib/dentaku/ast/functions/pluck.rb +1 -1
  34. data/lib/dentaku/ast/functions/round.rb +1 -1
  35. data/lib/dentaku/ast/functions/rounddown.rb +2 -2
  36. data/lib/dentaku/ast/functions/roundup.rb +2 -2
  37. data/lib/dentaku/ast/functions/ruby_math.rb +5 -2
  38. data/lib/dentaku/ast/functions/string_functions.rb +5 -5
  39. data/lib/dentaku/ast/functions/sum.rb +3 -3
  40. data/lib/dentaku/ast/functions/xor.rb +4 -4
  41. data/lib/dentaku/ast/grouping.rb +6 -0
  42. data/lib/dentaku/ast/negation.rb +5 -0
  43. data/lib/dentaku/ast/node.rb +25 -0
  44. data/lib/dentaku/ast/operation.rb +7 -0
  45. data/lib/dentaku/bulk_expression_solver.rb +22 -16
  46. data/lib/dentaku/calculator.rb +45 -17
  47. data/lib/dentaku/date_arithmetic.rb +2 -2
  48. data/lib/dentaku/dependency_resolver.rb +12 -5
  49. data/lib/dentaku/exceptions.rb +105 -13
  50. data/lib/dentaku/numeric_parser.rb +68 -0
  51. data/lib/dentaku/parser.rb +24 -48
  52. data/lib/dentaku/print_visitor.rb +1 -0
  53. data/lib/dentaku/token_matcher.rb +31 -37
  54. data/lib/dentaku/token_matchers.rb +3 -3
  55. data/lib/dentaku/token_scanner.rb +3 -4
  56. data/lib/dentaku/tokenizer.rb +2 -16
  57. data/lib/dentaku/version.rb +1 -1
  58. data/spec/ast/case_spec.rb +7 -0
  59. data/spec/ast/function_spec.rb +11 -25
  60. data/spec/bulk_expression_solver_spec.rb +48 -3
  61. data/spec/calculator_spec.rb +77 -0
  62. data/spec/dependency_resolver_spec.rb +14 -0
  63. data/spec/exceptions_spec.rb +87 -0
  64. data/spec/gemspec_spec.rb +10 -0
  65. data/spec/identifiers_spec.rb +66 -0
  66. data/spec/numeric_parser_spec.rb +66 -0
  67. data/spec/parser_spec.rb +35 -0
  68. data/spec/print_visitor_spec.rb +5 -0
  69. data/spec/tokenizer_spec.rb +6 -0
  70. data/spec/volatile_functions_spec.rb +121 -0
  71. metadata +27 -150
  72. data/.travis.yml +0 -10
@@ -83,7 +83,7 @@ module Dentaku
83
83
 
84
84
  def alias_regex
85
85
  values = @aliases.values.flatten.join('|')
86
- /(?<=\p{Punct}|[[:space:]]|\A)(#{values})(?=\()/i
86
+ /(?<=\p{Punct}|[[:space:]]|\A)(#{values})(?=\s*\()/i
87
87
  end
88
88
 
89
89
  private
@@ -94,21 +94,7 @@ module Dentaku
94
94
  end
95
95
 
96
96
  def fail!(reason, **meta)
97
- message =
98
- case reason
99
- when :parse_error
100
- "parse error at: '#{meta.fetch(:at)}'"
101
- when :too_many_opening_parentheses
102
- "too many opening parentheses"
103
- when :too_many_closing_parentheses
104
- "too many closing parentheses"
105
- when :unexpected_zero_width_match
106
- "unexpected zero-width match (:#{meta.fetch(:category)}) at '#{meta.fetch(:at)}'"
107
- else
108
- raise ::ArgumentError, "Unhandled #{reason}"
109
- end
110
-
111
- raise TokenizerError.for(reason, **meta), message
97
+ raise TokenizerError.for(reason, **meta)
112
98
  end
113
99
  end
114
100
  end
@@ -1,3 +1,3 @@
1
1
  module Dentaku
2
- VERSION = "3.5.7"
2
+ VERSION = "4.0.0"
3
3
  end
@@ -80,5 +80,12 @@ describe Dentaku::AST::Case do
80
80
  node = described_class.new(switch, conditional1, conditional2, else2)
81
81
  expect(node.dependencies).to eq([:fruit, :tax, :fallback])
82
82
  end
83
+
84
+ it 'short-circuits to the matched branch when the switch is resolvable' do
85
+ node = described_class.new(switch, conditional1, conditional2, else2)
86
+ expect(node.dependencies(fruit: 'apple')).to eq([])
87
+ expect(node.dependencies(fruit: 'banana')).to eq([:tax])
88
+ expect(node.dependencies(fruit: 'orange')).to eq([:fallback])
89
+ end
83
90
  end
84
91
  end
@@ -37,33 +37,19 @@ describe Dentaku::AST::Function do
37
37
  end
38
38
  end
39
39
 
40
- it 'casts a String to an Integer if possible' do
41
- expect(described_class.numeric('3')).to eq(3)
42
- end
43
-
44
- it 'casts a String to a BigDecimal if possible and if Integer would loose information' do
45
- expect(described_class.numeric('3.2')).to eq(3.2)
46
- end
47
-
48
- it 'casts a String to a BigDecimal with a negative number' do
49
- expect(described_class.numeric('-3.2')).to eq(-3.2)
50
- end
51
-
52
- it 'casts a String to a BigDecimal without a leading zero' do
53
- expect(described_class.numeric('-.2')).to eq(-0.2)
54
- end
55
-
56
- it 'raises an error if the value could not be cast to a Numeric' do
57
- expect { described_class.numeric('flarble') }.to raise_error Dentaku::ArgumentError
58
- expect { described_class.numeric('-') }.to raise_error Dentaku::ArgumentError
59
- expect { described_class.numeric('') }.to raise_error Dentaku::ArgumentError
60
- expect { described_class.numeric(nil) }.to raise_error Dentaku::ArgumentError
61
- expect { described_class.numeric('7.') }.to raise_error Dentaku::ArgumentError
62
- expect { described_class.numeric(true) }.to raise_error Dentaku::ArgumentError
63
- end
64
-
65
40
  it "allows read access to arguments" do
66
41
  fn = described_class.new(1, 2, 3)
67
42
  expect(fn.args).to eq([1, 2, 3])
68
43
  end
44
+
45
+ describe "#name" do
46
+ it "is a String for functions registered with a Symbol" do
47
+ fn = described_class.register(:symbol_named, :numeric, ->(x) { x })
48
+ expect(fn.name).to eq("symbol_named")
49
+ end
50
+
51
+ it "is a String for functions imported from Ruby's Math module" do
52
+ expect(described_class.get("sqrt").name).to eq("sqrt")
53
+ end
54
+ end
69
55
  end
@@ -130,6 +130,41 @@ RSpec.describe Dentaku::BulkExpressionSolver do
130
130
  )
131
131
  end
132
132
 
133
+ it 'resolves mixed-case keys declared out of order when case-sensitive' do
134
+ calculator = Dentaku::Calculator.new(case_sensitive: true)
135
+ expressions = {
136
+ 'TopLevel' => 'MidLevel * 2',
137
+ 'MidLevel' => 'BottomLevel * 2',
138
+ 'BottomLevel' => '2'
139
+ }
140
+
141
+ result = described_class.new(expressions, calculator).solve
142
+
143
+ expect(result).to eq(
144
+ 'TopLevel' => 8,
145
+ 'MidLevel' => 4,
146
+ 'BottomLevel' => 2
147
+ )
148
+ end
149
+
150
+ it 'does not expose a downcased alias of a constant when case-sensitive' do
151
+ calculator = Dentaku::Calculator.new(case_sensitive: true)
152
+ expressions = { 'BottomLevel' => '2', 'TopLevel' => 'bottomlevel * 2' }
153
+
154
+ expect {
155
+ described_class.new(expressions, calculator).solve!
156
+ }.to raise_error(Dentaku::UnboundVariableError, /bottomlevel/)
157
+ end
158
+
159
+ it 'falls back to mixed-case memory values when case-sensitive' do
160
+ calculator = Dentaku::Calculator.new(case_sensitive: true)
161
+ calculator.store('MyVar' => 7)
162
+
163
+ result = described_class.new({ 'MyVar' => 'Missing + 1' }, calculator).solve
164
+
165
+ expect(result).to eq('MyVar' => 7)
166
+ end
167
+
133
168
  it "returns :undefined when variables are unbound" do
134
169
  expressions = {more_apples: "apples + 1"}
135
170
  expect(described_class.new(expressions, calculator).solve)
@@ -160,7 +195,7 @@ RSpec.describe Dentaku::BulkExpressionSolver do
160
195
  described_class.new(expressions, calculator).solve do |ex|
161
196
  exception = ex
162
197
  end
163
- expect(exception.recipient_variable).to eq('more_apples')
198
+ expect(exception.assigned_to).to eq('more_apples')
164
199
  end
165
200
 
166
201
  it 'stores the recipient variable on the exception when there is an unbound variable' do
@@ -169,7 +204,17 @@ RSpec.describe Dentaku::BulkExpressionSolver do
169
204
  described_class.new(expressions, calculator).solve do |ex|
170
205
  exception = ex
171
206
  end
172
- expect(exception.recipient_variable).to eq('more_apples')
207
+ expect(exception.assigned_to).to eq('more_apples')
208
+ end
209
+
210
+ it 'sets the recipient variable before the solve block is invoked' do
211
+ expressions = {more_apples: "apples + 1"}
212
+ seen = :unset
213
+ described_class.new(expressions, calculator).solve do |ex|
214
+ seen = ex.assigned_to
215
+ :err
216
+ end
217
+ expect(seen).to eq('more_apples')
173
218
  end
174
219
 
175
220
  it 'stores the recipient variable on the exception when there is an ArgumentError' do
@@ -178,7 +223,7 @@ RSpec.describe Dentaku::BulkExpressionSolver do
178
223
  described_class.new(expressions, calculator).solve do |ex|
179
224
  exception = ex
180
225
  end
181
- expect(exception.recipient_variable).to eq('more_apples')
226
+ expect(exception.assigned_to).to eq('more_apples')
182
227
  end
183
228
 
184
229
  it 'safely handles argument errors' do
@@ -195,6 +195,21 @@ describe Dentaku::Calculator do
195
195
  expect(calculator.evaluate!('a[x+1]', x: 1)).to eq(3)
196
196
  end
197
197
 
198
+ it 'accesses into strings and nested arrays' do
199
+ expect(calculator.evaluate!('a[1]', a: 'abc')).to eq('b')
200
+ expect(calculator.evaluate!('a[1][0]', a: [[1], [2, 3]])).to eq(2)
201
+ end
202
+
203
+ it 'raises an argument error when accessing into a non-indexable value' do
204
+ expect { calculator.evaluate!('a[0]', a: 5) }.to raise_error(Dentaku::ArgumentError)
205
+ expect { calculator.evaluate!('a[0]', a: nil) }.to raise_error(Dentaku::ArgumentError)
206
+ expect { calculator.evaluate!('a[0]', a: true) }.to raise_error(Dentaku::ArgumentError)
207
+ end
208
+
209
+ it 'raises an argument error when the index type is incompatible' do
210
+ expect { calculator.evaluate!("a['one']", a: [1, 2, 3]) }.to raise_error(Dentaku::ArgumentError)
211
+ end
212
+
198
213
  it 'evaluates arrays' do
199
214
  expect(calculator.evaluate([1, 2, 3])).to eq([1, 2, 3])
200
215
  expect(calculator.evaluate!('{1,2,3}')).to eq([1, 2, 3])
@@ -961,10 +976,72 @@ describe Dentaku::Calculator do
961
976
  end
962
977
  end
963
978
 
979
+ describe 'functions with whitespace before parentheses' do
980
+ it 'accepts regular functions' do
981
+ expect(calculator.evaluate('round (5.1)')).to eq(5)
982
+ end
983
+ end
984
+
985
+ describe 'logical operators with unbound operands' do
986
+ it 'short-circuits OR when the bound operand is true' do
987
+ expect(calculator.evaluate!('a OR b', a: true)).to eq(true)
988
+ expect(calculator.evaluate!('a OR b', b: true)).to eq(true)
989
+ end
990
+
991
+ it 'short-circuits AND when the bound operand is false' do
992
+ expect(calculator.evaluate!('a AND b', a: false)).to eq(false)
993
+ expect(calculator.evaluate!('a AND b', b: false)).to eq(false)
994
+ end
995
+
996
+ it 'still raises when the unbound operand is needed to decide' do
997
+ expect { calculator.evaluate!('a OR b', a: false) }
998
+ .to raise_error(Dentaku::UnboundVariableError)
999
+ expect { calculator.evaluate!('a AND b', a: true) }
1000
+ .to raise_error(Dentaku::UnboundVariableError)
1001
+ end
1002
+ end
1003
+
964
1004
  describe 'aliases' do
965
1005
  it 'accepts aliases as instance option' do
966
1006
  expect(with_aliases.evaluate('rrround(5.1)')).to eq(5)
967
1007
  end
1008
+
1009
+ it 'accepts aliases with whitespace before parentheses' do
1010
+ expect(with_aliases.evaluate('rrround (5.1)')).to eq(5)
1011
+ end
1012
+
1013
+ it 'applies module-level aliases set after calculator creation' do
1014
+ Dentaku.aliases = { round: ['redondear'] }
1015
+ expect(calculator.evaluate('redondear(5.1)')).to eq(5)
1016
+ ensure
1017
+ Dentaku.aliases = {}
1018
+ end
1019
+ end
1020
+
1021
+ describe 'constructor options' do
1022
+ it 'raises on unknown options' do
1023
+ expect { described_class.new(case_sensitve: true) }
1024
+ .to raise_error(::ArgumentError, /unknown keyword/)
1025
+ end
1026
+
1027
+ it 'accepts a pre-warmed AST cache' do
1028
+ node = calculator.ast('1+1')
1029
+ warmed = described_class.new(ast_cache: { '1+1' => node })
1030
+ expect(warmed.ast_cache).to eq('1+1' => node)
1031
+ end
1032
+
1033
+ it 'caches ASTs when caching is enabled on the instance' do
1034
+ caching = described_class.new(cache_ast: true)
1035
+ caching.ast('1+1')
1036
+ expect(caching.ast_cache.keys).to eq(['1+1'])
1037
+ end
1038
+
1039
+ it 'does not cache ASTs when disabled on the instance, even if enabled globally' do
1040
+ allow(Dentaku).to receive(:cache_ast?) { true }
1041
+ non_caching = described_class.new(cache_ast: false)
1042
+ non_caching.ast('1+1')
1043
+ expect(non_caching.ast_cache).to be_empty
1044
+ end
968
1045
  end
969
1046
 
970
1047
  describe 'nested_data' do
@@ -15,4 +15,18 @@ describe Dentaku::DependencyResolver do
15
15
  ["THIRD", "SeCoNd", "FIRST"]
16
16
  )
17
17
  end
18
+
19
+ it 'sorts mixed-case expressions in dependency order when case-sensitive' do
20
+ dependencies = {"TopLevel" => ["MidLevel"], "MidLevel" => ["BottomLevel"], "BottomLevel" => []}
21
+ expect(described_class.find_resolve_order(dependencies, true)).to eq(
22
+ ["BottomLevel", "MidLevel", "TopLevel"]
23
+ )
24
+ end
25
+
26
+ it 'treats variables that differ only in case as distinct when case-sensitive' do
27
+ dependencies = {"foo" => ["FOO"], "FOO" => []}
28
+ expect(described_class.find_resolve_order(dependencies, true)).to eq(
29
+ ["FOO", "foo"]
30
+ )
31
+ end
18
32
  end
@@ -7,3 +7,90 @@ describe Dentaku::UnboundVariableError do
7
7
  expect(exception.unbound_variables).to include('length')
8
8
  end
9
9
  end
10
+
11
+ describe Dentaku::Error do
12
+ it 'is included in every Dentaku exception, even those subclassing Ruby built-ins' do
13
+ [
14
+ Dentaku::UnboundVariableError.new(['x']),
15
+ Dentaku::MathDomainError.new(:asin, [2]),
16
+ Dentaku::NodeError.new(:numeric, :string, :left),
17
+ Dentaku::ParseError.for(:node_invalid),
18
+ Dentaku::TokenizerError.for(:parse_error),
19
+ Dentaku::ArgumentError.for(:invalid_value),
20
+ Dentaku::ZeroDivisionError.new,
21
+ ].each do |exception|
22
+ expect(exception).to be_a(described_class)
23
+ end
24
+ end
25
+
26
+ it 'preserves stdlib ancestry for exceptions subclassing Ruby built-ins' do
27
+ expect(Dentaku::ArgumentError.for(:invalid_value)).to be_a(::ArgumentError)
28
+ expect(Dentaku::ZeroDivisionError.new).to be_a(::ZeroDivisionError)
29
+ end
30
+ end
31
+
32
+ describe Dentaku::ParseError do
33
+ it 'builds a message from reason and metadata' do
34
+ exception = described_class.for(:too_few_operands, operation: Dentaku::AST::Addition, expected: 2, actual: 1)
35
+ expect(exception.message).to eq('Dentaku::AST::Addition has too few operands (given 1, expected 2)')
36
+ end
37
+
38
+ it 'constructs with sparse metadata' do
39
+ expect(described_class.for(:node_invalid).message).to eq('Node is invalid')
40
+ end
41
+
42
+ it 'describes an :incompatible expectation as requiring compatible operands' do
43
+ exception = described_class.for(:node_invalid, operation: Dentaku::AST::Addition, expected: :incompatible, actual: :string)
44
+ expect(exception.message).to eq('Dentaku::AST::Addition requires compatible operands, but got string')
45
+ end
46
+
47
+ it 'allows call sites to override the default message' do
48
+ expect {
49
+ raise described_class.for(:node_invalid), 'Case missing switch variable'
50
+ }.to raise_error(described_class, 'Case missing switch variable')
51
+ end
52
+ end
53
+
54
+ describe Dentaku::TokenizerError do
55
+ it 'builds a message from reason and metadata' do
56
+ exception = described_class.for(:parse_error, at: '$5')
57
+ expect(exception.message).to eq("parse error at: '$5'")
58
+ end
59
+ end
60
+
61
+ describe Dentaku::ArgumentError do
62
+ it 'builds an incompatible_type message from a function context' do
63
+ exception = described_class.for(:incompatible_type, function_name: 'AND', expected: :logical, actual: 5)
64
+ expect(exception.message).to eq('AND() requires logical arguments, but got Integer')
65
+ end
66
+
67
+ it 'builds an incompatible_type message from expected and actual' do
68
+ exception = described_class.for(:incompatible_type, expected: Integer, actual: 2.5)
69
+ expect(exception.message).to eq('Float is not compatible with Integer')
70
+ end
71
+
72
+ it 'builds an invalid_operator message' do
73
+ exception = described_class.for(:invalid_operator, operation: Dentaku::AST::Addition, operator: :+)
74
+ expect(exception.message).to eq('Dentaku::AST::Addition requires operands that respond to +')
75
+ end
76
+
77
+ it 'describes an at-least arity as a range' do
78
+ exception = described_class.for(:too_few_arguments, function_name: 'SUM', expected: 1.., actual: 0)
79
+ expect(exception.message).to eq('SUM() has too few arguments (given 0, expected at least 1)')
80
+ end
81
+
82
+ it 'describes an exact arity as a count' do
83
+ exception = described_class.for(:wrong_number_of_arguments, function_name: 'INTERCEPT', expected: 2, actual: 4)
84
+ expect(exception.message).to eq('INTERCEPT() has the wrong number of arguments (given 4, expected 2)')
85
+ end
86
+
87
+ it 'constructs with sparse metadata' do
88
+ expect(described_class.for(:invalid_value).message).to eq('Invalid value')
89
+ end
90
+
91
+ it 'allows call sites to override the default message' do
92
+ expect {
93
+ raise described_class.for(:invalid_value), 'INTERCEPT() requires arrays of equal length'
94
+ }.to raise_error(described_class, 'INTERCEPT() requires arrays of equal length')
95
+ end
96
+ end
@@ -0,0 +1,10 @@
1
+ require 'spec_helper'
2
+
3
+ describe 'dentaku.gemspec' do
4
+ # tsort no longer ships as a default gem in Ruby 4.1 (PR #334)
5
+ it 'declares tsort as a runtime dependency' do
6
+ gemspec = Gem::Specification.load(File.expand_path('../dentaku.gemspec', __dir__))
7
+ runtime_deps = gemspec.dependencies.select { |d| d.type == :runtime }.map(&:name)
8
+ expect(runtime_deps).to include('tsort')
9
+ end
10
+ end
@@ -0,0 +1,66 @@
1
+ require 'spec_helper'
2
+ require 'dentaku'
3
+ require 'dentaku/calculator'
4
+
5
+ describe Dentaku::Calculator do
6
+ describe '#identifiers' do
7
+ let(:calculator) { described_class.new }
8
+
9
+ it 'does not prune literal guards' do
10
+ expect(calculator.identifiers('IF(1 > 0, a, b)')).to eq(%w(a b))
11
+ end
12
+
13
+ it 'includes the guard identifiers themselves' do
14
+ expect(calculator.identifiers('IF(x > 5, y, z)')).to eq(%w(x y z))
15
+ end
16
+
17
+ it 'never executes functions, pure or volatile' do
18
+ count = { pure: 0, volatile: 0 }
19
+ calculator.add_function(:purefn, :numeric, ->(x) { count[:pure] += 1; x })
20
+ calculator.add_function(:volfn, :numeric, ->(x) { count[:volatile] += 1; x }, volatile: true)
21
+
22
+ expect(calculator.identifiers('IF(purefn(1) > 0, a, IF(volfn(2) > 0, b, c))')).to eq(%w(a b c))
23
+ expect(count).to eq(pure: 0, volatile: 0)
24
+ end
25
+
26
+ it 'includes switch and every branch of CASE' do
27
+ formula = 'CASE fruit WHEN w1 THEN t1 WHEN 2 THEN t2 ELSE e1 END'
28
+ expect(calculator.identifiers(formula)).to eq(%w(fruit w1 t1 t2 e1))
29
+ end
30
+
31
+ it 'includes both operands of AND/OR even when one is decisive' do
32
+ expect(calculator.identifiers('1 > 2 AND x')).to eq(['x'])
33
+ expect(calculator.identifiers('a AND b OR c')).to eq(%w(a b c))
34
+ end
35
+
36
+ it 'excludes enum-bound variables' do
37
+ expect(calculator.identifiers('MAP(users, u, u.age + bonus)')).to eq(%w(users bonus))
38
+ end
39
+
40
+ it 'ignores stored memory' do
41
+ calculator.store(a: 1)
42
+ expect(calculator.identifiers('a + b')).to eq(%w(a b))
43
+ end
44
+
45
+ it 'accepts an AST node' do
46
+ node = calculator.ast('x + y')
47
+ expect(calculator.identifiers(node)).to eq(%w(x y))
48
+ end
49
+
50
+ it 'accepts an array of expressions and deduplicates' do
51
+ expect(calculator.identifiers(['a + b', 'b + c'])).to eq(%w(a b c))
52
+ end
53
+
54
+ it 'deduplicates repeated identifiers' do
55
+ expect(calculator.identifiers('a + a * a')).to eq(['a'])
56
+ end
57
+
58
+ it 'lists all inputs of a formula using external-state functions' do
59
+ user_attr = ->(name, default = 0) { default }
60
+ calculator.add_function(:user_attr, :numeric, user_attr)
61
+
62
+ identifiers = calculator.identifiers("IF(user_attr('level') > 50, high_level_value, low_level_value)")
63
+ expect(identifiers).to eq(%w(high_level_value low_level_value))
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,66 @@
1
+ require 'dentaku/numeric_parser'
2
+
3
+ describe Dentaku::NumericParser do
4
+ it 'casts a String to an Integer if possible' do
5
+ result = Dentaku::NumericParser.ensure_numeric!('3')
6
+ expect(result).to eq(3)
7
+ expect(result).to be_a(Integer)
8
+ end
9
+
10
+ it 'casts a String to a BigDecimal if possible and if Integer would loose information' do
11
+ result = Dentaku::NumericParser.ensure_numeric!('3.2')
12
+ expect(result).to eq(3.2)
13
+ expect(result).to be_a(BigDecimal)
14
+ end
15
+
16
+ it 'casts a String to a BigDecimal with a negative number' do
17
+ result = Dentaku::NumericParser.ensure_numeric!('-3.2')
18
+ expect(result).to eq(-3.2)
19
+ expect(result).to be_a(BigDecimal)
20
+ end
21
+
22
+ it 'casts a String to a BigDecimal without a leading zero' do
23
+ result = Dentaku::NumericParser.ensure_numeric!('-.2')
24
+ expect(result).to eq(-0.2)
25
+ expect(result).to be_a(BigDecimal)
26
+ end
27
+
28
+ it 'casts a String with Sientific notiation to a BigDecimal' do
29
+ result = Dentaku::NumericParser.ensure_numeric!('12E1')
30
+ expect(result).to eq(12e1)
31
+ expect(result).to be_a(BigDecimal)
32
+ end
33
+
34
+ it 'casts a String with negative Sientific notiation to a BigDecimal' do
35
+ result = Dentaku::NumericParser.ensure_numeric!('12e-2')
36
+ expect(result).to eq(0.12)
37
+ expect(result).to be_a(BigDecimal)
38
+ end
39
+
40
+ it 'casts a String with hexadecimal to an Integer' do
41
+ result1 = Dentaku::NumericParser.ensure_numeric!('0xFFD')
42
+ result2 = Dentaku::NumericParser.ensure_numeric!('0x00000001')
43
+ expect(result1).to eq(4093)
44
+ expect(result2).to eq(1)
45
+ expect(result1).to be_a(Integer)
46
+ expect(result2).to be_a(Integer)
47
+ end
48
+
49
+ it 'casts a String with a negative hexadecimal to an Integer' do
50
+ result = Dentaku::NumericParser.ensure_numeric!('-0xFF')
51
+ expect(result).to eq(-0xFF)
52
+ expect(result).to be_a(Integer)
53
+ end
54
+
55
+ it 'raises an error if the value could not be cast to a Numeric' do
56
+ expect { Dentaku::NumericParser.ensure_numeric!('flarble') }.to raise_error Dentaku::ArgumentError
57
+ expect { Dentaku::NumericParser.ensure_numeric!('-') }.to raise_error Dentaku::ArgumentError
58
+ expect { Dentaku::NumericParser.ensure_numeric!('') }.to raise_error Dentaku::ArgumentError
59
+ expect { Dentaku::NumericParser.ensure_numeric!(nil) }.to raise_error Dentaku::ArgumentError
60
+ expect { Dentaku::NumericParser.ensure_numeric!('7.') }.to raise_error Dentaku::ArgumentError
61
+ expect { Dentaku::NumericParser.ensure_numeric!(true) }.to raise_error Dentaku::ArgumentError
62
+ expect { Dentaku::NumericParser.ensure_numeric!("1 - 2") }.to raise_error Dentaku::ArgumentError
63
+ expect { Dentaku::NumericParser.ensure_numeric!([1]) }.to raise_error Dentaku::ArgumentError
64
+ expect { Dentaku::NumericParser.ensure_numeric!("1f") }.to raise_error Dentaku::ArgumentError
65
+ end
66
+ end
data/spec/parser_spec.rb CHANGED
@@ -84,6 +84,17 @@ describe Dentaku::Parser do
84
84
  expect(node.value("x" => 1, "y" => "A", "Y" => "B")).to eq(3)
85
85
  end
86
86
 
87
+ it 'evaluates a case statement with an unparenthesized operation as switch' do
88
+ node = parse('CASE a % 5 WHEN 0 THEN a ELSE b END')
89
+ expect(node.value("a" => 10, "b" => 1)).to eq(10)
90
+ expect(node.value("a" => 7, "b" => 1)).to eq(1)
91
+ end
92
+
93
+ it 'evaluates a case statement with a comparator as switch' do
94
+ node = parse('CASE a > 5 WHEN true THEN a ELSE b END')
95
+ expect(node.value("a" => 10, "b" => 1)).to eq(10)
96
+ end
97
+
87
98
  it 'evaluates arrays' do
88
99
  node = parse('{}')
89
100
  expect(node.value).to eq([])
@@ -111,6 +122,21 @@ describe Dentaku::Parser do
111
122
  }.to raise_error(Dentaku::ParseError)
112
123
  end
113
124
 
125
+ it 'preserves the node-level message for incompatible operands' do
126
+ expect {
127
+ parse('"hello" + 1')
128
+ }.to raise_error(
129
+ Dentaku::ParseError,
130
+ /requires operands that are numeric or compatible types, not string/
131
+ )
132
+ end
133
+
134
+ it 'preserves the node-level message for non-logical combinator operands' do
135
+ expect {
136
+ parse('1 AND 2')
137
+ }.to raise_error(Dentaku::ParseError, /requires logical operands/)
138
+ end
139
+
114
140
  it 'raises a parse error for too many operands' do
115
141
  expect {
116
142
  parse("IF(1, 0, IF(1, 2, 3, 4))")
@@ -167,6 +193,15 @@ describe Dentaku::Parser do
167
193
  parse("undefined()")
168
194
  }.to raise_error(Dentaku::ParseError)
169
195
  end
196
+
197
+ it 'raises a parse error for an unbalanced closing parenthesis' do
198
+ # the tokenizer catches unbalanced parentheses in a string expression,
199
+ # so exercise the parser directly with a hand-built token list
200
+ rparen = Dentaku::Token.new(:grouping, :close)
201
+ expect {
202
+ described_class.new([rparen]).parse
203
+ }.to raise_error(Dentaku::ParseError)
204
+ end
170
205
  end
171
206
 
172
207
  it "evaluates explicit 'NULL' as nil" do
@@ -49,6 +49,11 @@ describe Dentaku::PrintVisitor do
49
49
  expect(repr).to eq('CASE a % 5 WHEN 0 THEN a ELSE b END')
50
50
  end
51
51
 
52
+ it 'prints a case statement that can be re-parsed' do
53
+ repr = roundtrip('case (a % 5) when 0 then a else b end')
54
+ expect(roundtrip(repr)).to eq(repr)
55
+ end
56
+
52
57
  it 'handles a bitwise operators' do
53
58
  repr = roundtrip('0xCAFE & 0xDECAF | 0xBEEF')
54
59
  expect(repr).to eq('0xCAFE & 0xDECAF | 0xBEEF')
@@ -360,6 +360,12 @@ describe Dentaku::Tokenizer do
360
360
  expect(tokenizer.replace_aliases(input)).to eq('min(4,6,2)')
361
361
  end
362
362
 
363
+ it 'replaces aliases with whitespace before parentheses' do
364
+ input = 'rrrrround! (8.2) + minimo (4,6,2)'
365
+ tokenizer.tokenize(input, aliases: aliases)
366
+ expect(tokenizer.replace_aliases(input)).to eq('round (8.2) + min (4,6,2)')
367
+ end
368
+
363
369
  it 'replace only whole aliases without word parts' do
364
370
  input = 'maximo(2,minimoooo())' # `minimoooo` doesn't match `minimo`
365
371
  tokenizer.tokenize(input, aliases: aliases)