json_logic_ruby 0.2.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: ca5319a17275ae6b07caa25ed7fb14f8e3a507ac83d19a44c6b8bbc61783ced0
4
+ data.tar.gz: 1d728aecb2b1901441567e343c433ed45419b8da21dc6f070bdfb420a7a01609
5
+ SHA512:
6
+ metadata.gz: 7d728d93db6bbda69b82bdb6915c1ff3cfe10f05ed5c0648d264b1b8ba287d115397abb3422beb0b852593ba7b476ac2f56c881a595f4b8a191d5f817b1a209e
7
+ data.tar.gz: e5245e733f1d727a58467f2d23a301273934eed585bdfc92f87df8cabc4b102ebcaa5efc1cc4a785380c86198a545dd9669b1af9024a5791c387994d6e304a9b
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JsonLogic
4
+ module Trackable
5
+ attr_reader :tracker
6
+
7
+ def init_tracker(operator)
8
+ if @tracker.nil?
9
+ @tracker = Rule.new(operator)
10
+ elsif COMPLEX_OPERATORS.include?(operator)
11
+ @tracker = Rule.new(operator, @tracker)
12
+ end
13
+ end
14
+
15
+ def commit_rule_result!(var_name, operator, data, rules, result)
16
+ if COMPLEX_OPERATORS.include?(operator)
17
+ # change operand to parent & save result
18
+ @tracker.result = result
19
+ @tracker = @tracker.parent unless @tracker.parent.nil?
20
+ return result
21
+ end
22
+ @tracker.add_data_point(var_name, operator, rules, data, result)
23
+ result
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,167 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JsonLogic
4
+ class Evaluator
5
+ include Trackable
6
+
7
+ def apply(rules, data = {})
8
+ return rules unless rules.is_a?(Hash)
9
+
10
+ operator = rules.keys[0]
11
+ init_tracker(operator)
12
+
13
+ values = operator == 'map' ? [] : Array(rules[operator]).map { |rule| apply(rule, data) }
14
+
15
+ operators(operator, values, rules, data)
16
+ end
17
+
18
+ def operators(operator, values, rules, data)
19
+ case operator
20
+ when 'var' then get_var_value(data, *values)
21
+ when 'missing' then missing(data, *values)
22
+ when 'missing_some' then missing_some(data, *values)
23
+ when 'map' then json_logic_map(data, *rules[operator])
24
+ else
25
+ raise("Unrecognized operation #{operator}") unless OPERATIONS.key?(operator)
26
+
27
+ execute_operation(operator, rules, data, *values)
28
+ end
29
+ end
30
+
31
+ # This method traverses `rules` (expected as a nested data structure
32
+ # composed of Hash and Array objects) to extract values associated
33
+ # with the key 'var'.
34
+ #
35
+ # It's a recursive method that digs into the structure to find 'var'.
36
+ #
37
+ # If `rules` is a Hash, it iterates through each key-value pair:
38
+ # - If the key is 'var', it appends the value to `vars`.
39
+ # - If the key is not 'var', it recurses into the value (if it's an Array or Hash).
40
+ #
41
+ # If `rules` is an Array, it iterates through each element,
42
+ # which should be a Hash or Array, and recurses through it.
43
+ #
44
+ # `vars`, is an optional accumulator Array to hold collected variables.
45
+ # It starts as an empty array if not provided.
46
+ #
47
+ # @param rules [Hash, Array] a data structure composed of nested Hashes
48
+ # and arrays that we want to extract values from.
49
+ #
50
+ # @param vars [Array] an optional accumulator array to hold collected variables.
51
+ #
52
+ # @return [Array] an array of extracted variable values.
53
+
54
+ def extract_vars(rules, vars = [])
55
+ if rules.is_a?(Hash)
56
+ rules.each do |key, value|
57
+ key == 'var' ? vars << value : extract_vars(value, vars)
58
+ end
59
+ return vars
60
+ end
61
+
62
+ rules.each { |rule| extract_vars(rule, vars) } and return vars if rules.is_a?(Array)
63
+
64
+ vars
65
+ end
66
+
67
+ # This method recursively searches through the `rules` data structure
68
+ # (composed of nested Hashes and Arrays) to find all values associated
69
+ # with a specified variable name.
70
+ #
71
+ # If `rules` is an Array:
72
+ # - It checks if the first element of the Array has the specified variable.
73
+ # If so, it adds the second element of the Array (presumed to be the value
74
+ # of the variable) to `values`.
75
+ # - If not, it recurses through each element of the Array.
76
+ #
77
+ # If `rules` is a Hash, it recurses through each value in the Hash.
78
+ #
79
+ # `values` is an optional accumulator Array used to collect the found variable values.
80
+ #
81
+ # @param rules [Hash, Array] a data structure composed of nested Hashes and
82
+ # Arrays to be searched through.
83
+ #
84
+ # @param var_name [String, Symbol] the name of the variable we want to find the values for.
85
+ #
86
+ # @param values [Array] an optional accumulator array to collect found values.
87
+ #
88
+ # @return [Array] an array of all values associated with the specified variable in `rules`.
89
+
90
+ def fetch_var_values(rules, var_name, values = [])
91
+ if rules.is_a?(Array)
92
+ return values << rules[1] if rule_has_var?(rules.first, var_name)
93
+
94
+ rules.each { |rule| fetch_var_values(rule, var_name, values) }
95
+ return values
96
+ end
97
+
98
+ if rules.is_a?(Hash)
99
+ rules.each { |_, rule| fetch_var_values(rule, var_name, values) }
100
+ return values
101
+ end
102
+
103
+ values
104
+ end
105
+
106
+ private
107
+
108
+ def rule_has_var?(rule, var_name)
109
+ rule.is_a?(Hash) && rule.key?('var') && rule['var'] == var_name
110
+ end
111
+
112
+ def json_logic_map(data, items_rule, map_rule)
113
+ items = apply(items_rule, data)
114
+
115
+ Array(items).map { |item| apply(map_rule, item) }
116
+ end
117
+
118
+ def execute_operation(operator, rules, data, *)
119
+ result = OPERATIONS[operator].call(*)
120
+ var_name = get_var_name(operator, rules)
121
+
122
+ commit_rule_result!(var_name, operator, data, rules, result)
123
+ end
124
+
125
+ # This method retrieves the value of a variable with a given name from the data structure.
126
+ #
127
+ # @param data [Hash] The data structure to search for the variable value.
128
+ # @param var_name [String] The name of the variable to retrieve.
129
+ # @return [String, Numeric, nil] The value of the variable if found, otherwise nil.
130
+
131
+ def get_var_value(data, var_name, default_value = nil)
132
+ var_name.to_s.split('.').each do |key|
133
+ data = data[key]
134
+ rescue TypeError
135
+ data = data[key.to_i]
136
+ end
137
+ data || default_value
138
+ end
139
+
140
+ # This method retrieves the variable name from a hash of rules based on the given operator.
141
+ # { "<=" : [ 25, { "var": "age" }, 75] }
142
+ # { "<=" : [ { "var" : "age" }, 20 ] }
143
+ #
144
+ # @param operator [String] The operator for which to retrieve the variable name.
145
+ # @param rules [Hash] A hash containing rule data.
146
+ # @return [String, nil] The variable name if found, otherwise nil.
147
+
148
+ def get_var_name(operator, rules)
149
+ args = rules[operator]
150
+ index = COMPLEX_OPERATORS.exclude?(operator) && args.length == 3 ? 1 : 0
151
+ args.dig(index, 'var')
152
+ rescue TypeError
153
+ nil
154
+ end
155
+
156
+ def missing(data, *args)
157
+ args.select { |arg| get_var_value(data, arg).nil? }
158
+ end
159
+
160
+ def missing_some(data, min_required, args)
161
+ return [] if min_required < 1
162
+
163
+ missed_args, present_args = args.partition { |arg| get_var_value(data, arg).nil? }
164
+ present_args.length >= min_required ? [] : missed_args
165
+ end
166
+ end
167
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JsonLogic
4
+ COMPLEX_OPERATORS = %w[and or if].freeze
5
+
6
+ OPERATIONS = {
7
+ '==' => ->(a, b) { a == b },
8
+ '!=' => ->(a, b) { a != b },
9
+ '>' => ->(a, b) { to_comparable(a) > to_comparable(b) },
10
+ '>=' => ->(a, b) { to_comparable(a) >= to_comparable(b) },
11
+ '<' => ->(a, b) { to_comparable(a) < to_comparable(b) },
12
+ '<=' => lambda do |a, b, c = nil|
13
+ return to_comparable(a) <= to_comparable(b) if c.nil?
14
+
15
+ to_comparable(b).between?(to_comparable(a), to_comparable(c))
16
+ end,
17
+ '!' => ->(a) { json_logic_falsey(a) },
18
+ '!!' => ->(a) { json_logic_truthy(a) },
19
+ '%' => ->(a, b) { a % b },
20
+ 'and' => ->(*args) { args.reduce(true) { |total, arg| total && arg } },
21
+ 'or' => ->(*args) { args.reduce(false) { |total, arg| total || arg } },
22
+ '?:' => ->(a, b, c) { json_logic_truthy(a) ? b : c },
23
+ 'if' => lambda do |*args|
24
+ (0...args.length - 1).step(2) do |i|
25
+ return args[i + 1] if json_logic_truthy(args[i])
26
+ end
27
+
28
+ args.length.odd? ? args[-1] : nil
29
+ end,
30
+ 'log' => ->(a) { puts a },
31
+ 'in' => ->(a, b) { b.respond_to?(:include?) ? b.include?(a) : false },
32
+ 'cat' => ->(*args) { args.map(&:to_s).join },
33
+ '+' => ->(*args) { args.sum(&:to_f) },
34
+ '*' => ->(*args) { args.reduce(1) { |total, arg| total * arg.to_f } },
35
+ '-' => ->(*args) { args.length == 1 ? -args[0].to_f : args[0].to_f - args[1].to_f },
36
+ '/' => ->(a, b) { a.to_f / b },
37
+ 'min' => ->(*args) { args.map { |arg| to_comparable(arg) }.min },
38
+ 'max' => ->(*args) { args.map { |arg| to_comparable(arg) }.max },
39
+ 'merge' => ->(*args) { args.flat_map { |arg| arg.is_a?(Array) ? arg.to_a : arg } },
40
+ 'count' => ->(*args) { args.count { |a| a } }
41
+ }.freeze
42
+
43
+ private
44
+
45
+ COMPARATORS = {
46
+ NilClass => ->(_) { 0 },
47
+ FalseClass => ->(_) { 0 },
48
+ TrueClass => ->(_) { 1 },
49
+ Array => ->(arr) { arr.map { |item| to_comparable(item) } },
50
+ Hash => ->(hash) { hash.transform_values { |item| to_comparable(item) } },
51
+ Numeric => ->(num) { num.to_f }
52
+ }.freeze
53
+
54
+ def to_comparable(value)
55
+ comparator = COMPARATORS[value.class] || ->(val) { val }
56
+ comparator.call(value)
57
+ end
58
+
59
+ def json_logic_falsey(value)
60
+ case value
61
+ when NilClass, FalseClass, TrueClass
62
+ !value
63
+ when Numeric
64
+ value.zero?
65
+ when String, Array, Hash
66
+ value.empty?
67
+ else
68
+ false
69
+ end
70
+ end
71
+
72
+ def json_logic_truthy(value)
73
+ !json_logic_falsey(value)
74
+ end
75
+
76
+ module_function :to_comparable, :json_logic_falsey, :json_logic_truthy
77
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JsonLogic
4
+ class DataPoint
5
+ attr_accessor :name, :operation, :expected, :current, :result
6
+
7
+ def initialize(name, operation, expected, current, result)
8
+ @name = name
9
+ @operation = operation
10
+ @expected = expected
11
+ @current = current
12
+ @result = result
13
+ end
14
+
15
+ def report
16
+ "DATA: '#{name}' data:#{current || 'None'} #{operation} expected:#{expected_args}, RESULT = #{result}"
17
+ end
18
+
19
+ private
20
+
21
+ def expected_args
22
+ @expected.length == 3 ? [@expected.first, @expected.last] : @expected.last
23
+ end
24
+ end
25
+
26
+ class Rule
27
+ attr_accessor :name, :reasons, :result, :parent, :deep_level
28
+
29
+ def initialize(name, parent = nil)
30
+ @name = name
31
+ @result = false
32
+ @parent = parent
33
+ @reasons = []
34
+ @deep_level = 0
35
+ return if @parent.nil?
36
+
37
+ @deep_level = @parent.deep_level + 1
38
+ @parent.reasons << self
39
+ end
40
+
41
+ def add_data_point(var_name, operator, rules, data, result)
42
+ @result = result
43
+ current_data = data.is_a?(Hash) ? data[var_name] : data
44
+ @reasons << DataPoint.new(var_name, operator, rules[operator], current_data, result)
45
+ end
46
+
47
+ def report
48
+ report = "LOGIC: '#{name}', RESULT = #{result}\n"
49
+ report + reasons.map do |rule|
50
+ "#{Array.new(deep_level + 1, "\t").join} #{rule.report}"
51
+ end.join("\n")
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JsonLogic
4
+ class Validator
5
+ def json_logic_valid?(rules)
6
+ return validate_hash(rules) if rules.is_a?(Hash)
7
+ return validate_array(rules) if rules.is_a?(Array)
8
+
9
+ primitive?(rules)
10
+ end
11
+
12
+ private
13
+
14
+ def operator?(operator)
15
+ operators = JsonLogic::OPERATIONS.keys
16
+ operators.include?(operator)
17
+ end
18
+
19
+ def variable?(value)
20
+ return false unless value.is_a?(Hash)
21
+
22
+ var = value['var']
23
+ return false unless var
24
+
25
+ var.is_a?(String) || var.is_a?(Numeric) || var.nil?
26
+ end
27
+
28
+ def primitive?(value)
29
+ value.is_a?(String) || value.is_a?(Numeric) ||
30
+ value.is_a?(TrueClass) || value.is_a?(FalseClass) || value.is_a?(NilClass)
31
+ end
32
+
33
+ def validate_hash(hash)
34
+ hash.all? do |operator, value|
35
+ operator?(operator) && json_logic_valid?(value)
36
+ end
37
+ end
38
+
39
+ def validate_array(array)
40
+ array.all? do |value|
41
+ json_logic_valid?(value) || variable?(value) || primitive?(value)
42
+ end
43
+ end
44
+
45
+ def validate_var(value)
46
+ return false unless value.is_a?(Hash)
47
+
48
+ value.key?('var') && value.keys.count == 1
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JsonLogic
4
+ VERSION = '0.2.0'
5
+ end
data/lib/json_logic.rb ADDED
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/all'
4
+
5
+ require_relative 'json_logic/version'
6
+ require_relative 'json_logic/operations'
7
+ require_relative 'json_logic/rule'
8
+ require_relative 'json_logic/concerns/trackable'
9
+ require_relative 'json_logic/evaluator'
10
+ require_relative 'json_logic/validator'
11
+
12
+ module JsonLogic
13
+ class Error < StandardError; end
14
+ end
metadata ADDED
@@ -0,0 +1,66 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: json_logic_ruby
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.2.0
5
+ platform: ruby
6
+ authors:
7
+ - Volodymyr Stashchenko
8
+ - Andriy Savka
9
+ autorequire:
10
+ bindir: bin
11
+ cert_chain: []
12
+ date: 2023-11-23 00:00:00.000000000 Z
13
+ dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ name: activesupport
16
+ requirement: !ruby/object:Gem::Requirement
17
+ requirements:
18
+ - - "~>"
19
+ - !ruby/object:Gem::Version
20
+ version: '7.0'
21
+ type: :runtime
22
+ prerelease: false
23
+ version_requirements: !ruby/object:Gem::Requirement
24
+ requirements:
25
+ - - "~>"
26
+ - !ruby/object:Gem::Version
27
+ version: '7.0'
28
+ description: Build complex rules, serialize them as JSON, and execute them in ruby.
29
+ See https://jsonlogic.com
30
+ email:
31
+ executables: []
32
+ extensions: []
33
+ extra_rdoc_files: []
34
+ files:
35
+ - lib/json_logic.rb
36
+ - lib/json_logic/concerns/trackable.rb
37
+ - lib/json_logic/evaluator.rb
38
+ - lib/json_logic/operations.rb
39
+ - lib/json_logic/rule.rb
40
+ - lib/json_logic/validator.rb
41
+ - lib/json_logic/version.rb
42
+ homepage: https://github.com/useful-libs/json_logic
43
+ licenses:
44
+ - MIT
45
+ metadata:
46
+ rubygems_mfa_required: 'true'
47
+ post_install_message:
48
+ rdoc_options: []
49
+ require_paths:
50
+ - lib
51
+ required_ruby_version: !ruby/object:Gem::Requirement
52
+ requirements:
53
+ - - ">="
54
+ - !ruby/object:Gem::Version
55
+ version: 3.2.0
56
+ required_rubygems_version: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '0'
61
+ requirements: []
62
+ rubygems_version: 3.4.10
63
+ signing_key:
64
+ specification_version: 4
65
+ summary: Build complex rules, serialize them as JSON, and execute them in ruby.
66
+ test_files: []