json_logic_ruby 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
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: []