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.
- checksums.yaml +4 -4
- data/.github/workflows/rspec.yml +2 -4
- data/.github/workflows/rubocop.yml +1 -1
- data/.rubocop.yml +1 -1
- data/CHANGELOG.md +108 -1
- data/Gemfile +1 -1
- data/README.md +123 -0
- data/dentaku.gemspec +15 -8
- data/lib/dentaku/ast/access.rb +18 -1
- data/lib/dentaku/ast/arithmetic.rb +22 -9
- data/lib/dentaku/ast/array.rb +6 -0
- data/lib/dentaku/ast/bitwise.rb +6 -4
- data/lib/dentaku/ast/case/case_conditional.rb +6 -0
- data/lib/dentaku/ast/case/case_else.rb +6 -0
- data/lib/dentaku/ast/case/case_switch_variable.rb +6 -0
- data/lib/dentaku/ast/case/case_then.rb +6 -0
- data/lib/dentaku/ast/case/case_when.rb +7 -0
- data/lib/dentaku/ast/case.rb +30 -3
- data/lib/dentaku/ast/combinators.rb +52 -5
- data/lib/dentaku/ast/comparators.rb +2 -3
- data/lib/dentaku/ast/function.rb +15 -2
- data/lib/dentaku/ast/function_registry.rb +28 -2
- data/lib/dentaku/ast/functions/and.rb +4 -4
- data/lib/dentaku/ast/functions/avg.rb +2 -2
- data/lib/dentaku/ast/functions/duration.rb +1 -1
- data/lib/dentaku/ast/functions/if.rb +9 -1
- data/lib/dentaku/ast/functions/intercept.rb +2 -2
- data/lib/dentaku/ast/functions/mul.rb +2 -2
- data/lib/dentaku/ast/functions/or.rb +4 -4
- data/lib/dentaku/ast/functions/pluck.rb +1 -1
- data/lib/dentaku/ast/functions/string_functions.rb +1 -1
- data/lib/dentaku/ast/functions/sum.rb +2 -2
- data/lib/dentaku/ast/functions/xor.rb +4 -4
- data/lib/dentaku/ast/grouping.rb +6 -0
- data/lib/dentaku/ast/negation.rb +8 -1
- data/lib/dentaku/ast/node.rb +25 -0
- data/lib/dentaku/ast/operation.rb +7 -0
- data/lib/dentaku/bulk_expression_solver.rb +9 -10
- data/lib/dentaku/calculator.rb +83 -34
- data/lib/dentaku/date_arithmetic.rb +2 -2
- data/lib/dentaku/exceptions.rb +105 -13
- data/lib/dentaku/numeric_parser.rb +1 -1
- data/lib/dentaku/parser.rb +24 -48
- data/lib/dentaku/token_matcher.rb +31 -37
- data/lib/dentaku/token_matchers.rb +3 -3
- data/lib/dentaku/tokenizer.rb +1 -15
- data/lib/dentaku/version.rb +1 -1
- data/spec/ast/arithmetic_spec.rb +93 -0
- data/spec/ast/case_spec.rb +7 -0
- data/spec/bulk_expression_solver_spec.rb +13 -3
- data/spec/calculator_spec.rb +146 -0
- data/spec/exceptions_spec.rb +87 -0
- data/spec/external_function_spec.rb +33 -0
- data/spec/gemspec_spec.rb +10 -0
- data/spec/identifiers_spec.rb +66 -0
- data/spec/parser_spec.rb +35 -0
- data/spec/print_visitor_spec.rb +5 -0
- data/spec/spec_helper.rb +9 -0
- data/spec/volatile_functions_spec.rb +121 -0
- metadata +21 -63
- data/.travis.yml +0 -10
data/spec/ast/arithmetic_spec.rb
CHANGED
|
@@ -123,6 +123,99 @@ describe Dentaku::AST::Arithmetic do
|
|
|
123
123
|
expect { add(x, one, 'x' => [1]) }.to raise_error(Dentaku::ArgumentError)
|
|
124
124
|
end
|
|
125
125
|
|
|
126
|
+
describe 'parse-time operand validation (#197)' do
|
|
127
|
+
let(:calculator) do
|
|
128
|
+
Dentaku::Calculator.new.tap do |c|
|
|
129
|
+
c.add_function(:explode, :numeric, ->(*) { raise "function executed at parse time" })
|
|
130
|
+
c.add_function(:explode_flag, :logical, ->(*) { raise "function executed at parse time" })
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
it 'does not execute functions when an IF is an arithmetic operand' do
|
|
135
|
+
expect {
|
|
136
|
+
calculator.ast("IF(explode(1) = 1, 1, 2) + category")
|
|
137
|
+
}.not_to raise_error
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
it 'does not execute functions when an IF is negated' do
|
|
141
|
+
expect {
|
|
142
|
+
calculator.ast("-IF(explode(1) = 1, 1, 2)")
|
|
143
|
+
}.not_to raise_error
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
it 'does not execute functions when an IF is a combinator operand' do
|
|
147
|
+
expect {
|
|
148
|
+
calculator.ast("IF(explode_flag(), true, false) AND x")
|
|
149
|
+
}.not_to raise_error
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
it 'does not execute functions when a CASE is an arithmetic operand' do
|
|
153
|
+
expect {
|
|
154
|
+
calculator.ast("(CASE explode(1) WHEN 1 THEN 1 ELSE 2 END) + category")
|
|
155
|
+
}.not_to raise_error
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
it 'reports identifiers across both branches without evaluating' do
|
|
159
|
+
expect(calculator.identifiers("IF(explode(1) = 1, 1, 2) + category")).to eq(["category"])
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
it 'still validates operand types' do
|
|
163
|
+
expect { Dentaku::Calculator.new.ast("'abc' + 1") }.to raise_error(Dentaku::ParseError)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
it 'leaves evaluation and short-circuiting unchanged' do
|
|
167
|
+
plain = Dentaku::Calculator.new
|
|
168
|
+
|
|
169
|
+
expect(plain.evaluate!("IF(a = 1, 10, 20) + 5", a: 1)).to eq(15)
|
|
170
|
+
expect(plain.evaluate!("IF(a = 1, 10, 20) + 5", a: 2)).to eq(25)
|
|
171
|
+
expect(plain.evaluate!("-IF(a = 1, 10, 20)", a: 1)).to eq(-10)
|
|
172
|
+
expect(plain.dependencies("IF(a, b, c)", a: true)).to eq(["b"])
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
describe 'operations that reject well-typed operands (#332)' do
|
|
177
|
+
let(:oversized) { "999999999999999 ^ 99999999999999" }
|
|
178
|
+
|
|
179
|
+
it 'wraps the raw Ruby error so it is a Dentaku::Error' do
|
|
180
|
+
expect {
|
|
181
|
+
Dentaku::Calculator.new.evaluate!(oversized)
|
|
182
|
+
}.to raise_error(Dentaku::ArgumentError, /exponent is too large/)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
it 'is rescuable as Dentaku::Error' do
|
|
186
|
+
error = begin
|
|
187
|
+
Dentaku::Calculator.new.evaluate!(oversized)
|
|
188
|
+
nil
|
|
189
|
+
rescue Dentaku::Error => e
|
|
190
|
+
e
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
expect(error).to be_a(Dentaku::ArgumentError)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
it 'returns nil from the non-bang evaluate rather than raising' do
|
|
197
|
+
expect(Dentaku::Calculator.new.evaluate(oversized)).to be_nil
|
|
198
|
+
expect(Dentaku::Calculator.new.evaluate("2 ^ 99999999999")).to be_nil
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
it 'yields to the block form' do
|
|
202
|
+
handled = nil
|
|
203
|
+
Dentaku::Calculator.new.evaluate(oversized) { |_expr, ex| handled = ex }
|
|
204
|
+
|
|
205
|
+
expect(handled).to be_a(Dentaku::ArgumentError)
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
it 'still evaluates exponentiation that fits' do
|
|
209
|
+
expect(Dentaku::Calculator.new.evaluate!("2 ^ 10")).to eq(1024)
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
it 'does not swallow Dentaku errors raised by the operation itself' do
|
|
213
|
+
expect {
|
|
214
|
+
Dentaku::Calculator.new.evaluate!("1 / 0")
|
|
215
|
+
}.to raise_error(Dentaku::ZeroDivisionError)
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
|
|
126
219
|
private
|
|
127
220
|
|
|
128
221
|
def add(left, right, context = ctx)
|
data/spec/ast/case_spec.rb
CHANGED
|
@@ -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
|
|
@@ -195,7 +195,7 @@ RSpec.describe Dentaku::BulkExpressionSolver do
|
|
|
195
195
|
described_class.new(expressions, calculator).solve do |ex|
|
|
196
196
|
exception = ex
|
|
197
197
|
end
|
|
198
|
-
expect(exception.
|
|
198
|
+
expect(exception.assigned_to).to eq('more_apples')
|
|
199
199
|
end
|
|
200
200
|
|
|
201
201
|
it 'stores the recipient variable on the exception when there is an unbound variable' do
|
|
@@ -204,7 +204,17 @@ RSpec.describe Dentaku::BulkExpressionSolver do
|
|
|
204
204
|
described_class.new(expressions, calculator).solve do |ex|
|
|
205
205
|
exception = ex
|
|
206
206
|
end
|
|
207
|
-
expect(exception.
|
|
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')
|
|
208
218
|
end
|
|
209
219
|
|
|
210
220
|
it 'stores the recipient variable on the exception when there is an ArgumentError' do
|
|
@@ -213,7 +223,7 @@ RSpec.describe Dentaku::BulkExpressionSolver do
|
|
|
213
223
|
described_class.new(expressions, calculator).solve do |ex|
|
|
214
224
|
exception = ex
|
|
215
225
|
end
|
|
216
|
-
expect(exception.
|
|
226
|
+
expect(exception.assigned_to).to eq('more_apples')
|
|
217
227
|
end
|
|
218
228
|
|
|
219
229
|
it 'safely handles argument errors' do
|
data/spec/calculator_spec.rb
CHANGED
|
@@ -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])
|
|
@@ -967,6 +982,25 @@ describe Dentaku::Calculator do
|
|
|
967
982
|
end
|
|
968
983
|
end
|
|
969
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
|
+
|
|
970
1004
|
describe 'aliases' do
|
|
971
1005
|
it 'accepts aliases as instance option' do
|
|
972
1006
|
expect(with_aliases.evaluate('rrround(5.1)')).to eq(5)
|
|
@@ -975,6 +1009,39 @@ describe Dentaku::Calculator do
|
|
|
975
1009
|
it 'accepts aliases with whitespace before parentheses' do
|
|
976
1010
|
expect(with_aliases.evaluate('rrround (5.1)')).to eq(5)
|
|
977
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
|
|
978
1045
|
end
|
|
979
1046
|
|
|
980
1047
|
describe 'nested_data' do
|
|
@@ -1009,5 +1076,84 @@ describe Dentaku::Calculator do
|
|
|
1009
1076
|
called
|
|
1010
1077
|
}.from(12).to(1)
|
|
1011
1078
|
end
|
|
1079
|
+
|
|
1080
|
+
it 'does not carry cached identifier values across evaluations' do
|
|
1081
|
+
# there is no public disable, so save/restore around the example
|
|
1082
|
+
was_enabled = Dentaku.cache_identifier?
|
|
1083
|
+
Dentaku.enable_identifier_cache!
|
|
1084
|
+
called = 0
|
|
1085
|
+
calculator.store_formula("A1", "B1+B1")
|
|
1086
|
+
calculator.store_formula("B1", "C1")
|
|
1087
|
+
calculator.store("C1", proc { called += 1; 1 })
|
|
1088
|
+
|
|
1089
|
+
calculator.evaluate("A1")
|
|
1090
|
+
after_first = called
|
|
1091
|
+
calculator.evaluate("A1")
|
|
1092
|
+
|
|
1093
|
+
expect(after_first).to be > 0
|
|
1094
|
+
expect(called).to eq(after_first * 2)
|
|
1095
|
+
ensure
|
|
1096
|
+
Dentaku.instance_variable_set(:@enable_identifier_caching, was_enabled)
|
|
1097
|
+
end
|
|
1098
|
+
end
|
|
1099
|
+
|
|
1100
|
+
describe 'block-scoped store (#336)' do
|
|
1101
|
+
it 'removes keys the block introduced' do
|
|
1102
|
+
calculator.store(a: 1)
|
|
1103
|
+
result = calculator.store(b: 2) { calculator.evaluate!("a + b") }
|
|
1104
|
+
|
|
1105
|
+
expect(result).to eq(3)
|
|
1106
|
+
expect(calculator.memory.keys).to eq(["a"])
|
|
1107
|
+
end
|
|
1108
|
+
|
|
1109
|
+
it 'restores keys the block shadowed' do
|
|
1110
|
+
calculator.store(a: 1)
|
|
1111
|
+
inner = calculator.store(a: 99) { calculator.evaluate!("a") }
|
|
1112
|
+
|
|
1113
|
+
expect(inner).to eq(99)
|
|
1114
|
+
expect(calculator.evaluate!("a")).to eq(1)
|
|
1115
|
+
end
|
|
1116
|
+
|
|
1117
|
+
it 'restores when the block raises' do
|
|
1118
|
+
calculator.store(a: 1)
|
|
1119
|
+
|
|
1120
|
+
expect {
|
|
1121
|
+
calculator.store(a: 99) { raise "boom" }
|
|
1122
|
+
}.to raise_error("boom")
|
|
1123
|
+
|
|
1124
|
+
expect(calculator.evaluate!("a")).to eq(1)
|
|
1125
|
+
expect(calculator.memory.keys).to eq(["a"])
|
|
1126
|
+
end
|
|
1127
|
+
|
|
1128
|
+
it 'restores correctly when nested' do
|
|
1129
|
+
calculator.store(a: 1)
|
|
1130
|
+
innermost = calculator.store(a: 2) { calculator.store(a: 3) { calculator.evaluate!("a") } }
|
|
1131
|
+
|
|
1132
|
+
expect(innermost).to eq(3)
|
|
1133
|
+
expect(calculator.evaluate!("a")).to eq(1)
|
|
1134
|
+
end
|
|
1135
|
+
|
|
1136
|
+
it 'does not leak the internal evaluation mode key into memory' do
|
|
1137
|
+
calculator.store(a: 1)
|
|
1138
|
+
calculator.evaluate("a + 1")
|
|
1139
|
+
|
|
1140
|
+
expect(calculator.memory.keys).to eq(["a"])
|
|
1141
|
+
end
|
|
1142
|
+
|
|
1143
|
+
it 'restores in place rather than copying the whole memory hash' do
|
|
1144
|
+
# the old implementation snapshotted memory up front and reassigned
|
|
1145
|
+
# @memory to the snapshot, so evaluation cost scaled with memory size.
|
|
1146
|
+
# Restoring in place keeps the same hash object throughout.
|
|
1147
|
+
was_enabled = Dentaku.cache_identifier?
|
|
1148
|
+
Dentaku.instance_variable_set(:@enable_identifier_caching, false)
|
|
1149
|
+
|
|
1150
|
+
calculator.store(a: 1, b: 2)
|
|
1151
|
+
before = calculator.memory.object_id
|
|
1152
|
+
|
|
1153
|
+
expect(calculator.evaluate!("a + b")).to eq(3)
|
|
1154
|
+
expect(calculator.memory.object_id).to eq(before)
|
|
1155
|
+
ensure
|
|
1156
|
+
Dentaku.instance_variable_set(:@enable_identifier_caching, was_enabled)
|
|
1157
|
+
end
|
|
1012
1158
|
end
|
|
1013
1159
|
end
|
data/spec/exceptions_spec.rb
CHANGED
|
@@ -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
|
|
@@ -109,6 +109,7 @@ describe Dentaku::Calculator do
|
|
|
109
109
|
end
|
|
110
110
|
|
|
111
111
|
it 'exposes the `callback` method of a function' do
|
|
112
|
+
custom_calculator # the `let` is lazy, and it is what registers the function
|
|
112
113
|
expect(Dentaku::AST::Function::Callback_lambda.callback.call()).to eq("lambda executed")
|
|
113
114
|
end
|
|
114
115
|
|
|
@@ -155,6 +156,38 @@ describe Dentaku::Calculator do
|
|
|
155
156
|
}.to raise_error(Dentaku::ParseError)
|
|
156
157
|
end
|
|
157
158
|
|
|
159
|
+
describe 'error messages (#264)' do
|
|
160
|
+
it 'names the function even when another calculator claimed the constant' do
|
|
161
|
+
messages = 2.times.map do
|
|
162
|
+
calculator = described_class.new
|
|
163
|
+
calculator.add_function(:custom, :integer, -> { 1 })
|
|
164
|
+
|
|
165
|
+
begin
|
|
166
|
+
calculator.ast("CUSTOM(1,2)")
|
|
167
|
+
nil
|
|
168
|
+
rescue Dentaku::ParseError => e
|
|
169
|
+
e.message
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
expect(messages).to all(include("Dentaku::AST::Function::Custom"))
|
|
174
|
+
messages.each { |message| expect(message).not_to include("#<Class:") }
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
it 'gives re-registered functions a stable string representation' do
|
|
178
|
+
first = described_class.new
|
|
179
|
+
first.add_function(:repeated, :numeric, ->(x) { x })
|
|
180
|
+
second = described_class.new
|
|
181
|
+
second.add_function(:repeated, :numeric, ->(x) { x * 2 })
|
|
182
|
+
|
|
183
|
+
registry = ->(c) { c.instance_variable_get(:@function_registry).get("repeated") }
|
|
184
|
+
|
|
185
|
+
expect(registry.call(second).to_s).to eq("Dentaku::AST::Function::Repeated")
|
|
186
|
+
expect(registry.call(second).to_s).to eq(registry.call(first).to_s)
|
|
187
|
+
expect(registry.call(second).name).to eq("repeated")
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
158
191
|
describe 'Dentaku::Calculator.add_function' do
|
|
159
192
|
it 'adds a function to default/global function registry' do
|
|
160
193
|
described_class.add_function(:global_function, :numeric, ->(x) { 10 + x**2 })
|
|
@@ -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
|
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
|
data/spec/print_visitor_spec.rb
CHANGED
|
@@ -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')
|
data/spec/spec_helper.rb
CHANGED
|
@@ -20,6 +20,15 @@ RSpec.configure do |c|
|
|
|
20
20
|
Dentaku.aliases = { roundup: ['roundupup'] }
|
|
21
21
|
end
|
|
22
22
|
}
|
|
23
|
+
|
|
24
|
+
# the caching opt-ins are module-level and have no public "disable", so an
|
|
25
|
+
# example that turns one on leaks into every example that runs after it.
|
|
26
|
+
# Reset to the documented defaults so the suite does not depend on order.
|
|
27
|
+
c.before(:each) {
|
|
28
|
+
Dentaku.instance_variable_set(:@enable_ast_caching, false)
|
|
29
|
+
Dentaku.instance_variable_set(:@enable_dependency_order_caching, false)
|
|
30
|
+
Dentaku.instance_variable_set(:@enable_identifier_caching, false)
|
|
31
|
+
}
|
|
23
32
|
end
|
|
24
33
|
|
|
25
34
|
# automatically create a token stream from bare values
|