dentaku 4.0.2 → 4.1.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 498d10748b47ba3ab1930e2d3df6685d8d557e1e4f7b95a0887952507557325a
4
- data.tar.gz: a47a69280b8c298a299edbba9758b0dc132fdcceecb055eaa4008c0427d785de
3
+ metadata.gz: 75997c319d555caca8df23e6b1e1995f5a99b1fbd041f25d1e876ac40b487b49
4
+ data.tar.gz: ee629e3909f7b5040d9f39c1cad871f3d7599be8a76d6b2753652bcdbc9aefbc
5
5
  SHA512:
6
- metadata.gz: '0466598da5b7018075787a018873cc72f4185faa40eabe3596b1666646625d70d29c199a8ac91d5fe0c4436d5b960f5cc9c95efbfe6ff2ea91a4855928074288'
7
- data.tar.gz: '0440877441b431ffb55da23343fe1fff2271afd6ef4868f86a860927ba26f6e37ffa586514dee83a3c018cea4506615377e092ead2135b1cf39e440006a3a933'
6
+ metadata.gz: 4f7342cd68c9b1edbd2c992b01784a3b66caa6f13f0cc698a26447883a01eb6ffde1c6262790d9810ded42d5bdd6ac601ab2c7585e4c6d5f5467964cb6fe92ce
7
+ data.tar.gz: 8b1c85908541da4fe4896b3fcb90741fe8ea907c29fc162fcb64eca9ae60bd043ecdece5fe428490c0452a6f1072174a52b090ab575f50d117cbe3f976ad0265
data/CHANGELOG.md CHANGED
@@ -1,5 +1,33 @@
1
1
  # Change Log
2
2
 
3
+ ## [v4.1.0] 2026-10-14
4
+ - `NumericParser` compiles its regular expressions once instead of on every
5
+ call. `Comparator#cast` runs one per string operand of every comparison,
6
+ which made the regex compilation the single largest cost of evaluating a
7
+ typical condition: `country = 'US'` evaluated 30k times drops from 0.47s
8
+ to 0.07s
9
+ - fix dependency analysis of `AND` / `OR` chains taking exponential time
10
+ when a short-circuit probe raises (e.g. a zero denominator), a 4.0.0
11
+ regression on the normal `evaluate!` path (#355)
12
+ - `duration()` only accepts exact unit names (`day`, `days`, `month`,
13
+ `months`, `year`, `years`); units merely containing one, such as
14
+ `dayofmonth`, are now rejected
15
+ - `case_sensitive` is no longer shared between calculator instances. The
16
+ tokenizer read the setting from a class-level variable that every
17
+ calculator overwrote, so calculators with different settings running in
18
+ parallel threads could silently fold identifiers to the wrong case and
19
+ return wrong results. Casing is now applied only by the calculator's own
20
+ parser; `Tokenizer#tokenize` keeps identifiers' source casing and
21
+ `TokenScanner.case_sensitive` is removed
22
+ - key the bulk-solver dependency-order cache by the resolved dependency graph,
23
+ so changing formulas or stored context cannot reuse an incompatible order
24
+ from another solve with the same variable names.
25
+ - **behavior change:** `XOR` with three or more arguments is now the
26
+ exclusive OR of all of them (true when an odd number are true), matching
27
+ Excel and `XOR(XOR(a, b), c)`. It previously returned true only when
28
+ exactly one argument was true, so `XOR(true, true, true)` changes from
29
+ `false` to `true`. Results with one or two arguments are unchanged (#349)
30
+
3
31
  ## [v4.0.2] 2026-08-02
4
32
  - the 4.0.1 guard against arithmetic that rejects a well-typed operand only
5
33
  worked on Ruby 3.4+. Ruby 3.2 and 3.3 do not raise for an oversized
@@ -21,17 +21,35 @@ module Dentaku
21
21
  :logical
22
22
  end
23
23
 
24
+ # A short-circuit probe that raises tells us nothing about which operands are
25
+ # referenced, so fall back to the union of both sides. It has to be the union we
26
+ # already computed rather than `super`: `super` re-derives it by walking both
27
+ # subtrees again, and because the probe raises at every level of a chain, each
28
+ # level would walk its subtree twice -- making analysis exponential in the
29
+ # length of the chain.
24
30
  def dependencies(context = {})
25
31
  return super if static_mode?(context)
26
32
 
27
33
  left_deps = left.dependencies(context)
28
34
  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))
35
+
36
+ if left_deps.empty? && left.pure?
37
+ begin
38
+ return [] if decisive?(left.value(context))
39
+ rescue Dentaku::Error
40
+ return (left_deps + right_deps).uniq
41
+ end
42
+ end
43
+
44
+ if right_deps.empty? && right.pure?
45
+ begin
46
+ return [] if decisive?(right.value(context))
47
+ rescue Dentaku::Error
48
+ return (left_deps + right_deps).uniq
49
+ end
50
+ end
31
51
 
32
52
  (left_deps + right_deps).uniq
33
- rescue Dentaku::Error
34
- super
35
53
  end
36
54
 
37
55
  def value(context = {})
@@ -21,9 +21,9 @@ module Dentaku
21
21
 
22
22
  def validate_unit(unit)
23
23
  case unit.downcase
24
- when /years?/ then :year
25
- when /months?/ then :month
26
- when /days?/ then :day
24
+ when /\Ayears?\z/ then :year
25
+ when /\Amonths?\z/ then :month
26
+ when /\Adays?\z/ then :day
27
27
  else
28
28
  raise Dentaku::ArgumentError.for(:incompatible_type, actual: unit, expected: Duration),
29
29
  "'#{unit || unit.class}' is not a valid duration unit"
@@ -20,14 +20,14 @@ module Dentaku
20
20
  )
21
21
  end
22
22
 
23
- true_arg_count = 0
24
- @args.each do |arg|
23
+ # exclusive OR of all arguments: true when an odd number are true, so
24
+ # XOR(a, b, c) == XOR(XOR(a, b), c)
25
+ true_arg_count = @args.count do |arg|
25
26
  case arg.value(context)
26
27
  when TrueClass
27
- true_arg_count += 1
28
- break if true_arg_count > 1
28
+ true
29
29
  when FalseClass, nil
30
- next
30
+ false
31
31
  else
32
32
  raise Dentaku::ArgumentError.for(
33
33
  :incompatible_type,
@@ -35,7 +35,7 @@ module Dentaku
35
35
  )
36
36
  end
37
37
  end
38
- true_arg_count == 1
38
+ true_arg_count.odd?
39
39
  end
40
40
  end
41
41
  end
@@ -153,12 +153,15 @@ module Dentaku
153
153
  end
154
154
 
155
155
  def variables_in_resolve_order
156
- cache_key = "#{!!calculator.case_sensitive}|#{expressions.keys.map(&:to_s).sort.join("|")}"
157
- @ordered_deps ||= self.class.dependency_cache.fetch(cache_key) {
158
- DependencyResolver.find_resolve_order(dependencies, calculator.case_sensitive).tap do |d|
159
- self.class.dependency_cache[cache_key] = d if calculator.cache_dependency_order?
160
- end
161
- }
156
+ @ordered_deps ||= begin
157
+ dependency_graph = dependencies
158
+ cache_key = [!!calculator.case_sensitive, dependency_graph]
159
+ self.class.dependency_cache.fetch(cache_key) {
160
+ DependencyResolver.find_resolve_order(dependency_graph, calculator.case_sensitive).tap do |d|
161
+ self.class.dependency_cache[cache_key] = d if calculator.cache_dependency_order?
162
+ end
163
+ }
164
+ end
162
165
  end
163
166
  end
164
167
  end
@@ -5,20 +5,21 @@ module Dentaku
5
5
  HEXADECIMAL_PATTERN = '(0x[0-9a-f]+)\b'.freeze
6
6
  # e.g: [0x1A3F]
7
7
 
8
+ NUMERIC_OR_HEXADECIMAL_REGEX = %r{\A(-?(#{ NUMERIC_PATTERN }|#{ HEXADECIMAL_PATTERN }))\z}i
9
+ NUMERIC_REGEX = %r{\A(#{ NUMERIC_PATTERN })\z}i
10
+ HEXADECIMAL_REGEX = %r{\A(#{ HEXADECIMAL_PATTERN })\z}i
11
+
8
12
  class << self
9
13
  def match(string)
10
- regex = %r{\A(-?(#{ NUMERIC_PATTERN }|#{ HEXADECIMAL_PATTERN }))\z}i
11
- string.match(regex)
14
+ string.match(NUMERIC_OR_HEXADECIMAL_REGEX)
12
15
  end
13
16
 
14
17
  def match_numeric(string)
15
- regex = %r{\A(#{ NUMERIC_PATTERN })\z}i
16
- string.match(regex)
18
+ string.match(NUMERIC_REGEX)
17
19
  end
18
20
 
19
21
  def match_hexadecimal(string)
20
- regex = %r{\A(#{ HEXADECIMAL_PATTERN })\z}i
21
- string.match(regex)
22
+ string.match(HEXADECIMAL_REGEX)
22
23
  end
23
24
 
24
25
  def parse_numeric_string(raw)
@@ -1,13 +1,10 @@
1
1
  require 'bigdecimal'
2
2
  require 'time'
3
- require 'dentaku/string_casing'
4
3
  require 'dentaku/token'
5
4
  require 'dentaku/numeric_parser'
6
5
 
7
6
  module Dentaku
8
7
  class TokenScanner
9
- extend StringCasing
10
-
11
8
  DATE_TIME_REGEXP = /\d{2}\d{2}?-\d{1,2}-\d{1,2}([ |T]\d{1,2}:\d{1,2}:\d{1,2}(\.\d*)?)? ?(Z|((\+|\-)\d{2}\:?\d{2}))?(?!\d)/.freeze
12
9
 
13
10
  def initialize(category, regexp, converter = nil, condition = nil)
@@ -31,8 +28,6 @@ module Dentaku
31
28
  end
32
29
 
33
30
  class << self
34
- attr_reader :case_sensitive
35
-
36
31
  def available_scanners
37
32
  [
38
33
  :null,
@@ -76,7 +71,6 @@ module Dentaku
76
71
  end
77
72
 
78
73
  def scanners(options = {})
79
- @case_sensitive = options.fetch(:case_sensitive, false)
80
74
  raw_date_literals = options.fetch(:raw_date_literals, true)
81
75
 
82
76
  @scanners.select { |k, _| raw_date_literals || k != :datetime }.values
@@ -180,7 +174,7 @@ module Dentaku
180
174
  end
181
175
 
182
176
  def identifier
183
- new(:identifier, '[[[:word:]]\.]+\b', lambda { |raw| standardize_case(raw.strip) })
177
+ new(:identifier, '[[[:word:]]\.]+\b', lambda { |raw| raw.strip })
184
178
  end
185
179
 
186
180
  def quoted_identifier
@@ -17,7 +17,6 @@ module Dentaku
17
17
  input = replace_aliases(input)
18
18
 
19
19
  scanner_options = {
20
- case_sensitive: options.fetch(:case_sensitive, false),
21
20
  raw_date_literals: options.fetch(:raw_date_literals, true)
22
21
  }
23
22
 
@@ -1,3 +1,3 @@
1
1
  module Dentaku
2
- VERSION = "4.0.2"
2
+ VERSION = "4.1.0"
3
3
  end
data/spec/ast/xor_spec.rb CHANGED
@@ -15,9 +15,22 @@ describe 'Dentaku::AST::Xor' do
15
15
  expect(result).to eq(true)
16
16
  end
17
17
 
18
- it 'returns false if more than one of the arguments is true' do
19
- result = Dentaku('XOR(false, true, true)')
20
- expect(result).to eq(false)
18
+ it 'returns false if an even number of the arguments are true' do
19
+ expect(Dentaku('XOR(false, true, true)')).to eq(false)
20
+ expect(Dentaku('XOR(true, true, true, true)')).to eq(false)
21
+ end
22
+
23
+ it 'returns true if an odd number of the arguments are true' do
24
+ expect(Dentaku('XOR(true, true, true)')).to eq(true)
25
+ expect(Dentaku('XOR(true, false, true, true)')).to eq(true)
26
+ end
27
+
28
+ it 'matches XOR applied pairwise' do
29
+ [true, false].repeated_permutation(4).each do |a, b, c, d|
30
+ context = { a: a, b: b, c: c, d: d }
31
+ expect(Dentaku('XOR(a, b, c, d)', context))
32
+ .to eq(Dentaku('XOR(XOR(a, b), XOR(c, d))', context))
33
+ end
21
34
  end
22
35
 
23
36
  it 'supports nested expressions' do
@@ -8,6 +8,28 @@ RSpec.describe Dentaku::BulkExpressionSolver do
8
8
  let(:calculator) { Dentaku::Calculator.new }
9
9
 
10
10
  describe "#solve!" do
11
+ it 'does not reuse a cached order after formulas change with the same variable names' do
12
+ calculator = Dentaku::Calculator.new(cache_dependency_order: true)
13
+ calculator.store(seed: 1)
14
+ expect(calculator.solve!(a: 'b + 1', b: 'seed + 1')).to eq(a: 3, b: 2)
15
+ expect(calculator.solve!(a: 'seed + 1', b: 'a + 1')).to eq(a: 2, b: 3)
16
+ end
17
+
18
+ it 'does not share a cached order with a calculator using different dependencies' do
19
+ first = Dentaku::Calculator.new(cache_dependency_order: true).store(seed: 1)
20
+ second = Dentaku::Calculator.new(cache_dependency_order: true).store(seed: 10)
21
+ expect(first.solve!(left: 'right + 1', right: 'seed + 1')).to eq(left: 3, right: 2)
22
+ expect(second.solve!(left: 'seed + 1', right: 'left + 1')).to eq(left: 11, right: 12)
23
+ end
24
+
25
+ it 'includes memory-dependent edges in the cached order' do
26
+ first = Dentaku::Calculator.new(cache_dependency_order: true).store(seed: 1, base: 5)
27
+ second = Dentaku::Calculator.new(cache_dependency_order: true).store(seed: 1)
28
+ formulas = { result: 'base + 1', base: 'seed + 1' }
29
+ expect(first.solve!(formulas)).to eq(result: 6, base: 2)
30
+ expect(second.solve!(formulas)).to eq(result: 3, base: 2)
31
+ end
32
+
11
33
  it "evaluates properly with variables, even if some in memory" do
12
34
  expressions = {
13
35
  weekly_fruit_budget: "weekly_apple_budget + pear * 4",
@@ -556,6 +556,14 @@ describe Dentaku::Calculator do
556
556
  expect(calculator.evaluate!('value2 - value', { value: value, value2: value2 })).to eq(value2 - value)
557
557
  expect(calculator.evaluate!('value - 7200', { value: value })).to eq(Time.local(2023, 7, 13, 8, 42, 11))
558
558
  end
559
+
560
+ it 'does not accept unknown durations' do
561
+ expect { calculator.evaluate!('duration(1, foo)') }.to raise_error(Dentaku::ArgumentError, /is not a valid duration unit/)
562
+ expect { calculator.evaluate!('duration(1, ay)') }.to raise_error(Dentaku::ArgumentError, /is not a valid duration unit/)
563
+ expect { calculator.evaluate!('duration(1, daysy)') }.to raise_error(Dentaku::ArgumentError, /is not a valid duration unit/)
564
+ expect { calculator.evaluate!('duration(1, ayear)') }.to raise_error(Dentaku::ArgumentError, /is not a valid duration unit/)
565
+ expect { calculator.evaluate!('duration(1, dayofmonthofyear)') }.to raise_error(Dentaku::ArgumentError, /is not a valid duration unit/)
566
+ end
559
567
  end
560
568
 
561
569
  describe 'functions' do
@@ -1001,6 +1009,39 @@ describe Dentaku::Calculator do
1001
1009
  end
1002
1010
  end
1003
1011
 
1012
+ describe 'logical operators whose short-circuit probe raises' do
1013
+ it 'falls back to the dependencies of both operands' do
1014
+ expect(calculator.dependencies('a / b > 1 OR c', 'a' => 1, 'b' => 0)).to eq(['c'])
1015
+ expect(calculator.dependencies('a <= 1 OR c', 'a' => nil)).to eq(['c'])
1016
+ end
1017
+
1018
+ it 'still prunes when the probe is decisive' do
1019
+ expect(calculator.dependencies('1 > 2 AND a')).to eq([])
1020
+ expect(calculator.dependencies('1 < 2 OR a')).to eq([])
1021
+ expect(calculator.dependencies('a AND 1 > 2')).to eq([])
1022
+ expect(calculator.dependencies('a OR 1 < 2')).to eq([])
1023
+ end
1024
+
1025
+ it 'still reports the other operand when the probe is not decisive' do
1026
+ expect(calculator.dependencies('1 < 2 AND a')).to eq(['a'])
1027
+ expect(calculator.dependencies('1 > 2 OR a')).to eq(['a'])
1028
+ end
1029
+
1030
+ # Regression: the raising branch used to re-derive the dependency union via
1031
+ # `super`, walking both subtrees a second time at every level of the chain.
1032
+ # Twelve terms probed 2048 times instead of 12.
1033
+ it 'probes each operand of a chain once rather than exponentially' do
1034
+ call_count = { count: 0 }
1035
+ calculator.add_function(:blank, :numeric, -> { call_count[:count] += 1; nil })
1036
+
1037
+ terms = 12
1038
+ formula = (1..terms).map { |i| "(blank() <= #{i})" }.join(' OR ')
1039
+
1040
+ expect(calculator.evaluate(formula)).to be_nil
1041
+ expect(call_count[:count]).to eq(terms)
1042
+ end
1043
+ end
1044
+
1004
1045
  describe 'aliases' do
1005
1046
  it 'accepts aliases as instance option' do
1006
1047
  expect(with_aliases.evaluate('rrround(5.1)')).to eq(5)
@@ -0,0 +1,54 @@
1
+ require 'spec_helper'
2
+ require 'dentaku'
3
+ require 'dentaku/calculator'
4
+
5
+ describe Dentaku::Calculator do
6
+ let(:case_sensitive) { described_class.new(case_sensitive: true) }
7
+ let(:case_insensitive) { described_class.new }
8
+
9
+ it 'honours its own setting regardless of what other instances do' do
10
+ expect(case_sensitive.evaluate('Kiwi + 5', Kiwi: -2, kiwi: 2)).to eq(3)
11
+ expect(case_insensitive.evaluate('Kiwi + 5', Kiwi: -2, kiwi: 2)).to eq(7)
12
+
13
+ # same two settings, created and evaluated in the opposite order
14
+ other_sensitive = described_class.new(case_sensitive: true)
15
+ other_insensitive = described_class.new
16
+ expect(other_insensitive.evaluate('Kiwi + 5', Kiwi: -2, kiwi: 2)).to eq(7)
17
+ expect(other_sensitive.evaluate('Kiwi + 5', Kiwi: -2, kiwi: 2)).to eq(3)
18
+ end
19
+
20
+ # Identifier casing used to be applied twice: once by the identifier scanner,
21
+ # reading a class-level flag on TokenScanner that every calculator in the
22
+ # process shared, and again by AST::Identifier using the calculator's own
23
+ # flag. The shared flag meant one instance could overwrite another's mode
24
+ # while the first was still tokenizing -- in practice, whenever two threads
25
+ # evaluated at the same time, identifiers silently folded to the other
26
+ # instance's casing.
27
+ #
28
+ # Reproduce that interleaving deterministically: once this calculator has
29
+ # fetched its scanners (which is when the mode used to be recorded), let a
30
+ # second, case-insensitive calculator tokenize before the first one scans its
31
+ # identifiers. The first calculator must be unaffected.
32
+ it 'is unaffected by another instance tokenizing while it scans' do
33
+ interloped = false
34
+ allow(Dentaku::TokenScanner).to receive(:scanners).and_wrap_original do |original, *args|
35
+ original.call(*args).tap do
36
+ next if interloped
37
+
38
+ interloped = true
39
+ case_insensitive.evaluate('Zz', Zz: 1)
40
+ end
41
+ end
42
+
43
+ expect(case_sensitive.evaluate('Kiwi', Kiwi: -2, kiwi: 2)).to eq(-2)
44
+ expect(case_insensitive.evaluate('Kiwi', Kiwi: -2, kiwi: 2)).to eq(2)
45
+ end
46
+
47
+ # casing is applied in exactly one place, AST::Identifier, which is handed
48
+ # the mode of the calculator that owns the parse -- so the token stream keeps
49
+ # the source casing of every identifier
50
+ it 'leaves identifier casing to the parser' do
51
+ tokens = Dentaku::Tokenizer.new.tokenize('Kiwi + 5')
52
+ expect(tokens.first.value).to eq('Kiwi')
53
+ end
54
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: dentaku
3
3
  version: !ruby/object:Gem::Version
4
- version: 4.0.2
4
+ version: 4.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Solomon White
@@ -175,6 +175,7 @@ files:
175
175
  - spec/benchmark.rb
176
176
  - spec/bulk_expression_solver_spec.rb
177
177
  - spec/calculator_spec.rb
178
+ - spec/case_sensitivity_spec.rb
178
179
  - spec/dentaku_spec.rb
179
180
  - spec/dependency_resolver_spec.rb
180
181
  - spec/exceptions_spec.rb
@@ -215,7 +216,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
215
216
  - !ruby/object:Gem::Version
216
217
  version: '0'
217
218
  requirements: []
218
- rubygems_version: 3.6.9
219
+ rubygems_version: 4.0.20
219
220
  specification_version: 4
220
221
  summary: A formula language parser and evaluator
221
222
  test_files: []