Skip to content

Commit d2111db

Browse files
committed
Optimizations on iterators and comparisons
1 parent 57421a7 commit d2111db

18 files changed

Lines changed: 228 additions & 104 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
# Changelog
22

33
All notable changes to this project will be documented in this file.
4+
## [0.3.5] - 2026-03-06
5+
### Changed
6+
- Reduces object allocations further in hot paths (iterator index tracking, early-exit via throw/catch, inline nil-wrapping, scope push simplification) for an additional ~12–18% throughput improvement over 0.3.4.
7+
- Symbol values are now coerced to String before comparisons (`==`, `===`, `!=`, `!==`, `<`, `>`, `<=`, `>=`, `in`), so Ruby Symbol data round-trips cleanly through JSONLogic rules.
8+
49
## [0.3.4] - 2026-03-06
510
### Changed
611
- Reduces object allocations in hot paths for improved performance.

Gemfile.lock

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
PATH
22
remote: .
33
specs:
4-
shiny_json_logic (0.3.4)
4+
shiny_json_logic (0.3.5)
55

66
GEM
77
remote: https://rubygems.org/

lib/shiny_json_logic/comparisons/comparable.rb

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ module Comparable
1010
module_function
1111

1212
def compare(a, b)
13-
return :nan if a.is_a?(Array) || a.is_a?(Hash) || b.is_a?(Array) || b.is_a?(Hash)
13+
a = a.to_s if a.is_a?(Symbol)
14+
b = b.to_s if b.is_a?(Symbol)
1415

1516
if a.is_a?(String) && b.is_a?(String)
1617
return a <=> b
@@ -28,15 +29,24 @@ def numerify_for_comparison(value)
2829
return 0.0 if value == false
2930
return 1.0 if value == true
3031
return 0.0 if value.nil?
31-
return value.to_f if value.is_a?(String) && Numericals::Numerify.numeric_string?(value)
32+
33+
value = value.to_s if value.is_a?(Symbol)
34+
return value.to_f if Numericals::Numerify.numeric_string?(value)
35+
3236
nil
3337
end
3438

3539
# Normalize numeric types for strict equality comparisons (=== semantics).
40+
# Also coerces Symbol → String so :foo === "foo" holds.
3641
def cast(value)
42+
value = value.to_s if value.is_a?(Symbol)
3743
value.is_a?(Numeric) ? value.to_f : value
3844
end
3945

46+
def comparable_type?(value)
47+
value.is_a?(Numeric) || value.is_a?(String) || value.is_a?(Symbol) || value == true || value == false || value.nil?
48+
end
49+
4050
# Shared loop for all chain-comparison operators.
4151
# Yields the compare result for each consecutive pair; block returns true to continue, false to short-circuit.
4252
# Returns true if all pairs pass, false otherwise. Raises on :nan or invalid args.
@@ -46,9 +56,12 @@ def compare_chain(rules, scope_stack)
4656
raise Errors::InvalidArguments if n < 2
4757

4858
prev = Engine.call(operands[0], scope_stack)
59+
raise Errors::NotANumber unless comparable_type?(prev)
60+
4961
i = 1
5062
while i < n
5163
curr = Engine.call(operands[i], scope_stack)
64+
raise Errors::NotANumber unless comparable_type?(curr)
5265
result = compare(prev, curr)
5366
raise Errors::NotANumber if result == :nan
5467
return false unless yield(result)

lib/shiny_json_logic/numericals/min_max_collection.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ module MinMaxCollection
1010
def collect_numeric_values(rules, scope_stack)
1111
values = collect_values(rules, scope_stack)
1212
raise Errors::InvalidArguments if values.empty?
13-
raise Errors::InvalidArguments unless values.all? { |v| v.is_a?(Numeric) }
13+
values.each { |v| raise Errors::InvalidArguments unless v.is_a?(Numeric) }
1414
values
1515
end
1616

lib/shiny_json_logic/operations/all.rb

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,12 @@ module Operations
88
class All < Iterable::Base
99
raise_on_dynamic_args!
1010

11-
def self.on_after(results, _scope_stack)
12-
return false if results.empty?
11+
def self.on_each(_item, filter, scope_stack)
12+
throw(:early_return, false) unless Truthy.call(Engine.call(filter, scope_stack))
13+
end
1314

14-
results.all? { |res| Truthy.call(res) }
15+
def self.on_after(results, _scope_stack)
16+
results.empty? ? false : true
1517
end
1618
end
1719
end

lib/shiny_json_logic/operations/base.rb

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,9 @@ def self.call(rules, scope_stack)
1414

1515
def self.resolve_rules(rules, scope_stack)
1616
dynamic = op?(rules)
17-
rules = Engine.call(rules, scope_stack) if dynamic
1817
raise Errors::InvalidArguments if dynamic && raise_on_dynamic_args?
18+
19+
return Engine.call(rules, scope_stack) if dynamic
1920
rules
2021
end
2122

@@ -36,7 +37,9 @@ def self.evaluate(rule, scope_stack)
3637
end
3738

3839
def self.op?(value)
39-
return false unless value.is_a?(Hash) && !value.empty?
40+
return false unless value.is_a?(Hash)
41+
return false if value.empty?
42+
4043
OperatorSolver.operation?(value)
4144
end
4245
end

lib/shiny_json_logic/operations/filter.rb

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,19 @@ class Filter < Iterable::Base
99
raise_on_nil_filter!
1010
raise_on_dynamic_args!
1111

12-
def self.on_each(item, filter, scope_stack)
13-
Truthy.call(Engine.call(filter, scope_stack)) ? item : nil
14-
end
12+
def self.call(rules, scope_stack)
13+
rules = resolve_rules(rules, scope_stack)
14+
15+
collection, filter = setup_collection(rules, scope_stack)
1516

16-
def self.on_after(results, _scope_stack)
17-
results.compact
17+
collection.each_with_object([]) do |item, acc|
18+
scope_stack.push(item)
19+
begin
20+
acc << item if Truthy.call(Engine.call(filter, scope_stack))
21+
ensure
22+
scope_stack.pop
23+
end
24+
end
1825
end
1926
end
2027
end

lib/shiny_json_logic/operations/inclusion.rb

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,15 @@ class Inclusion < Base
88
def self.execute(rules, scope_stack)
99
needle = evaluate(rules.first, scope_stack)
1010
haystack = evaluate(rules.last, scope_stack)
11-
haystack.include?(needle)
11+
12+
# Normalize Symbols to String so :foo matches "foo" and vice-versa
13+
needle = needle.to_s if needle.is_a?(Symbol)
14+
15+
if haystack.is_a?(Array)
16+
haystack.any? { |el| (el.is_a?(Symbol) ? el.to_s : el) == needle }
17+
else
18+
haystack.include?(needle)
19+
end
1220
end
1321
end
1422
end

lib/shiny_json_logic/operations/iterable/base.rb

Lines changed: 10 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -19,24 +19,18 @@ def self.call(rules, scope_stack)
1919

2020
collection, filter = setup_collection(rules, scope_stack)
2121

22-
index_scope = { "index" => 0 }
23-
on_before(scope_stack)
24-
results = collection.each_with_index.each_with_object([]) do |(item, index), acc|
25-
index_scope["index"] = index
26-
scope_stack.push(index_scope, index: index)
27-
scope_stack.push(item, index: index)
28-
begin
29-
solved = on_each(item, filter, scope_stack)
30-
acc << solved
31-
scope_stack.pop
32-
scope_stack.pop
33-
rescue => e
34-
scope_stack.pop # item scope
35-
scope_stack.pop # iterator context scope
36-
raise e
22+
early = catch(:early_return) do
23+
results = collection.each_with_object([]) do |item, acc|
24+
scope_stack.push(item)
25+
begin
26+
acc << on_each(item, filter, scope_stack)
27+
ensure
28+
scope_stack.pop
29+
end
3730
end
31+
on_after(results, scope_stack)
3832
end
39-
on_after(results, scope_stack)
33+
early
4034
end
4135

4236
def self.setup_collection(rules, scope_stack)
@@ -52,8 +46,6 @@ def self.setup_collection(rules, scope_stack)
5246
[collection, filter]
5347
end
5448

55-
def self.on_before(_scope_stack); end
56-
5749
def self.on_each(_item, filter, scope_stack)
5850
Engine.call(filter, scope_stack)
5951
end

lib/shiny_json_logic/operations/none.rb

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,12 @@ module Operations
88
class None < Iterable::Base
99
raise_on_dynamic_args!
1010

11-
def self.on_after(results, _scope_stack)
12-
return true if results.empty?
11+
def self.on_each(_item, filter, scope_stack)
12+
throw(:early_return, false) if Truthy.call(Engine.call(filter, scope_stack))
13+
end
1314

14-
results.none? { |res| Truthy.call(res) }
15+
def self.on_after(results, _scope_stack)
16+
true
1517
end
1618
end
1719
end

0 commit comments

Comments
 (0)