flagnetic 0.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.
@@ -0,0 +1,225 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flagnetic
4
+ # Eval is the evaluator; see flagnetic/eval.rb.
5
+ module Eval
6
+ # Values reads context and condition values as the evaluator compares
7
+ # them.
8
+ module Values
9
+ module_function
10
+
11
+ # number reads a context or condition value as a number: an Integer,
12
+ # Float, Rational or other real Numeric, never a String ("5" is
13
+ # wrong-typed). NaN and the infinities are not numbers.
14
+ def number(value)
15
+ return nil unless value.is_a?(Numeric) && value.real?
16
+
17
+ f = value.to_f
18
+ f.nan? || f.infinite? ? nil : f
19
+ end
20
+
21
+ # utf8 returns a string whose bytes compare as UTF-8, whatever encoding
22
+ # the caller's string carries. Strings compare byte-wise.
23
+ def utf8(str)
24
+ str.encoding == Encoding::UTF_8 ? str : str.dup.force_encoding(Encoding::UTF_8)
25
+ end
26
+
27
+ # strings reads a list whose every element is a string.
28
+ def strings(value)
29
+ return nil unless value.is_a?(Array) && value.all?(String)
30
+
31
+ value.map { |s| utf8(s) }
32
+ end
33
+ end
34
+ private_constant :Values
35
+
36
+ # Operators compiles an attribute condition into a matcher of a context
37
+ # value. The matcher receives nil for an absent attribute. An absent,
38
+ # null or wrong-typed value makes every operator false except
39
+ # not_exists, which is then true; there is no coercion.
40
+ module Operators
41
+ # Error is an operator undefined for the attribute type, a condition
42
+ # value of the wrong JSON type, or a pattern outside RE2.
43
+ class Error < StandardError; end
44
+
45
+ EXTRACT = {
46
+ "string" => ->(v) { Values.utf8(v) if v.is_a?(String) },
47
+ "number" => ->(v) { Values.number(v) },
48
+ "boolean" => ->(v) { v if [true, false].include?(v) },
49
+ "semver" => ->(v) { Semver.parse(v) if v.is_a?(String) },
50
+ "date" => ->(v) { Instant.from_context(v) },
51
+ "list" => ->(v) { Values.strings(v) }
52
+ }.freeze
53
+ COMPARISONS = %w[eq neq lt lte gt gte].freeze
54
+
55
+ # KNOWN is every operator this evaluator knows, for any attribute
56
+ # type. One outside it is an unsupported construct, typically sent by
57
+ # a server newer than the evaluator; a known operator applied to a
58
+ # type it is not defined for is a defect of the ruleset instead.
59
+ KNOWN = %w[eq neq lt lte gt gte in not_in contains not_contains starts_with ends_with matches before after
60
+ contains_any contains_all is_empty exists not_exists].freeze
61
+ private_constant :EXTRACT, :COMPARISONS
62
+
63
+ module_function
64
+
65
+ # check returns why this evaluator cannot apply op with value to an
66
+ # attribute of type, or nil when it can: op must be defined for type
67
+ # and value must have the JSON type op needs. It does not reject what
68
+ # the evaluator treats as a false condition, such as an unparseable
69
+ # semantic version or date in the value.
70
+ def check(type, op, value)
71
+ compile(type, op, value)
72
+ nil
73
+ rescue Error => e
74
+ e.message
75
+ end
76
+
77
+ def compile(type, op, value)
78
+ extract = EXTRACT[type]
79
+ raise Error, "unknown attribute type #{type.inspect}" unless extract
80
+
81
+ case op
82
+ when "exists" then return ->(v) { !extract.call(v).nil? }
83
+ when "not_exists" then return ->(v) { extract.call(v).nil? }
84
+ end
85
+
86
+ test = send(:"#{type}_test", op, value)
87
+ raise Error, "operator #{op.inspect} is not defined for type #{type}" unless test
88
+
89
+ lambda do |v|
90
+ x = extract.call(v)
91
+ !x.nil? && test.call(x)
92
+ end
93
+ end
94
+
95
+ def string_test(op, value)
96
+ case op
97
+ when "eq", "neq", "contains", "not_contains", "starts_with", "ends_with"
98
+ want = string_value(op, value)
99
+ want_bytes = want.b
100
+ f = case op
101
+ when "eq", "neq" then ->(s) { s == want }
102
+ when "contains", "not_contains" then ->(s) { s.b.include?(want_bytes) }
103
+ when "starts_with" then ->(s) { s.b.start_with?(want_bytes) }
104
+ else ->(s) { s.b.end_with?(want_bytes) }
105
+ end
106
+ negate(f, %w[neq not_contains].include?(op))
107
+ when "in", "not_in"
108
+ set = strings_value(op, value)
109
+ negate(->(s) { set.include?(s) }, op == "not_in")
110
+ when "matches"
111
+ pattern = regex(string_value(op, value))
112
+ ->(s) { pattern.match?(s) }
113
+ end
114
+ end
115
+
116
+ def number_test(op, value)
117
+ case op
118
+ when *COMPARISONS
119
+ want = json_number(value)
120
+ raise Error, "operator #{op.inspect} needs a number value" unless want
121
+
122
+ comparison(op) { |x| x <=> want }
123
+ when "in", "not_in"
124
+ set = value.map { |e| json_number(e) } if value.is_a?(Array)
125
+ raise Error, "operator #{op.inspect} needs a list of numbers" unless set&.none?(nil)
126
+
127
+ negate(->(x) { set.include?(x) }, op == "not_in")
128
+ end
129
+ end
130
+
131
+ def boolean_test(op, value)
132
+ return unless op == "eq"
133
+ raise Error, "operator #{op.inspect} needs a boolean value" unless [true, false].include?(value)
134
+
135
+ ->(x) { x == value }
136
+ end
137
+
138
+ def semver_test(op, value)
139
+ case op
140
+ when *COMPARISONS
141
+ want = Semver.parse(string_value(op, value))
142
+ # An unparseable version makes the condition false.
143
+ return ->(_) { false } unless want
144
+
145
+ comparison(op) { |x| x <=> want }
146
+ when "in", "not_in"
147
+ set = strings_value(op, value).map { |s| Semver.parse(s) }
148
+ # An unparseable version makes the condition false, as it does for
149
+ # eq and neq: skipping it instead would make not_in ["garbage"]
150
+ # match every version.
151
+ return ->(_) { false } if set.any?(&:nil?)
152
+
153
+ negate(->(x) { set.any? { |v| (x <=> v).zero? } }, op == "not_in")
154
+ end
155
+ end
156
+
157
+ def date_test(op, value)
158
+ return unless %w[before after].include?(op)
159
+ raise Error, "operator #{op.inspect} needs an RFC 3339 string" unless value.is_a?(String)
160
+
161
+ want = Instant.parse_rfc3339(value)
162
+ # An unparseable date makes the condition false.
163
+ return ->(_) { false } unless want
164
+
165
+ op == "before" ? ->(x) { x < want } : ->(x) { x > want }
166
+ end
167
+
168
+ def list_test(op, value)
169
+ case op
170
+ when "contains", "not_contains"
171
+ want = string_value(op, value)
172
+ negate(->(x) { x.include?(want) }, op == "not_contains")
173
+ when "contains_any"
174
+ wants = strings_value(op, value)
175
+ ->(x) { wants.any? { |w| x.include?(w) } }
176
+ when "contains_all"
177
+ wants = strings_value(op, value)
178
+ ->(x) { wants.all? { |w| x.include?(w) } }
179
+ when "is_empty"
180
+ lambda(&:empty?)
181
+ end
182
+ end
183
+
184
+ # json_number reads a condition value as a number: only what JSON
185
+ # decodes to one.
186
+ def json_number(value)
187
+ Values.number(value) if value.is_a?(Integer) || value.is_a?(Float)
188
+ end
189
+
190
+ def string_value(op, value)
191
+ raise Error, "operator #{op.inspect} needs a string value" unless value.is_a?(String)
192
+
193
+ Values.utf8(value)
194
+ end
195
+
196
+ def strings_value(op, value)
197
+ Values.strings(value) or raise Error, "operator #{op.inspect} needs a list of strings"
198
+ end
199
+
200
+ def regex(pattern)
201
+ Regex.compile(pattern)
202
+ rescue Regex::Error => e
203
+ raise Error, "operator \"matches\": #{e.message}"
204
+ end
205
+
206
+ def negate(test, negated)
207
+ negated ? ->(x) { !test.call(x) } : test
208
+ end
209
+
210
+ # comparison builds eq, neq, lt, lte, gt or gte over a three-way
211
+ # comparison of the context value against the condition value.
212
+ def comparison(op, &cmp)
213
+ case op
214
+ when "eq" then ->(x) { cmp.call(x).zero? }
215
+ when "neq" then ->(x) { !cmp.call(x).zero? }
216
+ when "lt" then ->(x) { cmp.call(x).negative? }
217
+ when "lte" then ->(x) { !cmp.call(x).positive? }
218
+ when "gt" then ->(x) { cmp.call(x).positive? }
219
+ else ->(x) { !cmp.call(x).negative? }
220
+ end
221
+ end
222
+ end
223
+ private_constant :Operators
224
+ end
225
+ end