noteikumi 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/Gemfile +11 -0
- data/lib/noteikumi.rb +37 -0
- data/lib/noteikumi/engine.rb +98 -0
- data/lib/noteikumi/result.rb +88 -0
- data/lib/noteikumi/rule.rb +310 -0
- data/lib/noteikumi/rule_condition_validator.rb +85 -0
- data/lib/noteikumi/rule_execution_scope.rb +34 -0
- data/lib/noteikumi/rules.rb +121 -0
- data/lib/noteikumi/state.rb +233 -0
- data/lib/noteikumi/version.rb +3 -0
- metadata +53 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: 54301cbe45d5868cbfe3335a90a713e0b14d20c8
|
4
|
+
data.tar.gz: 72cd7208a75bd59dc401a627eedfe11d7b81c07b
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 947dd850767a14be17f9fa6c61a22ddd206a107fe5d9d5c4a0dcb47bb887b0d4d4fc8d0ba19b731c9f4a87009ae11d74c84e83c51492785f57b630858415f2c1
|
7
|
+
data.tar.gz: c26d4f9c130b0160172bb446ac12c3e5615243db59e1f574271ad33949d6fa8a36f8f08fa65d81f02ec9d83b2742d60d8fe730d6903698c5f2920abf394fa586
|
data/Gemfile
ADDED
@@ -0,0 +1,11 @@
|
|
1
|
+
source 'https://rubygems.org'
|
2
|
+
|
3
|
+
gem 'rspec', '~> 3.4.0'
|
4
|
+
gem 'mocha', '~> 1.1.0'
|
5
|
+
gem 'rake', '~> 10.5.0'
|
6
|
+
gem 'guard', '~> 2.13.0', :require => false
|
7
|
+
gem 'guard-shell',:require => false
|
8
|
+
gem 'guard-compat',:require => false
|
9
|
+
gem 'rubocop', :require => false
|
10
|
+
gem 'yard', :require => false
|
11
|
+
gem 'rubygems-tasks', :require => false
|
data/lib/noteikumi.rb
ADDED
@@ -0,0 +1,37 @@
|
|
1
|
+
require "logger"
|
2
|
+
|
3
|
+
require "noteikumi/engine"
|
4
|
+
require "noteikumi/result"
|
5
|
+
require "noteikumi/rule"
|
6
|
+
require "noteikumi/rule_condition_validator"
|
7
|
+
require "noteikumi/rule_execution_scope"
|
8
|
+
require "noteikumi/rules"
|
9
|
+
require "noteikumi/state"
|
10
|
+
require "noteikumi/version"
|
11
|
+
|
12
|
+
# A light weight rule engine
|
13
|
+
#
|
14
|
+
# Visit https://github.com/ripienaar/noteikumi for more information
|
15
|
+
class Noteikumi
|
16
|
+
# Helper to create a new rule from a block
|
17
|
+
#
|
18
|
+
# @param rule_name [String,Symbol] unique name for this rule, Symbols preferred
|
19
|
+
# @param blk [Proc] the rule body with access to methods on {Rule}
|
20
|
+
# @return [Rule]
|
21
|
+
def self.rule(rule_name, &blk)
|
22
|
+
rule = Rule.new(rule_name)
|
23
|
+
|
24
|
+
rule.instance_eval(&blk)
|
25
|
+
|
26
|
+
rule
|
27
|
+
end
|
28
|
+
|
29
|
+
# Helper to create a new {Engine}
|
30
|
+
#
|
31
|
+
# @param path [String] a File::PATH_SEPARATOR seperated list of paths to load rules from
|
32
|
+
# @param logger [Logger] a logger to use
|
33
|
+
# @return [Engine]
|
34
|
+
def self.new_engine(path, logger)
|
35
|
+
Engine.new(path, logger)
|
36
|
+
end
|
37
|
+
end
|
@@ -0,0 +1,98 @@
|
|
1
|
+
class Noteikumi
|
2
|
+
# The main driver of the rule set
|
3
|
+
#
|
4
|
+
# @example create a engine and process some data
|
5
|
+
#
|
6
|
+
# engine = Engine.new("rules")
|
7
|
+
# state = engine.create_state
|
8
|
+
#
|
9
|
+
# state[:thing] = data_to_process
|
10
|
+
#
|
11
|
+
# engine.process_state(state)
|
12
|
+
#
|
13
|
+
# puts "%d rules ran" % [state.results.size]
|
14
|
+
class Engine
|
15
|
+
# The paths this engine consulted for rules
|
16
|
+
#
|
17
|
+
# @return [Array<String>] list of paths
|
18
|
+
attr_reader :path
|
19
|
+
|
20
|
+
# Creates an instance of the rule engine
|
21
|
+
#
|
22
|
+
# @param path [String] a File::PATH_SEPARATOR list of paths to load rules from
|
23
|
+
# @param logger [Logger]
|
24
|
+
# @return [Engine]
|
25
|
+
def initialize(path, logger=Logger.new(STDOUT))
|
26
|
+
@logger = logger
|
27
|
+
@path = parse_path(path)
|
28
|
+
|
29
|
+
rules_collection.load_rules
|
30
|
+
end
|
31
|
+
|
32
|
+
# Parse a File::PATH_SEPARATOR seperated path into expanded directories
|
33
|
+
#
|
34
|
+
# @api private
|
35
|
+
# @param path [String] The path to parse, should be a File::PATH_SEPARATOR list of paths
|
36
|
+
# @return [Array<String>]
|
37
|
+
def parse_path(path)
|
38
|
+
path.split(File::PATH_SEPARATOR).map do |part|
|
39
|
+
File.expand_path(part)
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
# Reset the run count on all loaded rules
|
44
|
+
#
|
45
|
+
# @api private
|
46
|
+
# @return [void]
|
47
|
+
def reset_rule_counts
|
48
|
+
rules_collection.rules.each(&:reset_counter)
|
49
|
+
end
|
50
|
+
|
51
|
+
# Given a state object process all the loaded rules
|
52
|
+
#
|
53
|
+
# @note the rule set is processed once only
|
54
|
+
# @param state [State]
|
55
|
+
# @return [Array<Result>]
|
56
|
+
def process_state(state)
|
57
|
+
raise("No rules have been loaded into engine %s" % self) if rules_collection.empty?
|
58
|
+
|
59
|
+
reset_rule_counts
|
60
|
+
|
61
|
+
rules_collection.by_priority.each do |rule|
|
62
|
+
state.process_rule(rule)
|
63
|
+
end
|
64
|
+
|
65
|
+
state.results
|
66
|
+
end
|
67
|
+
|
68
|
+
# Creates a new state that has an associated with this {Engine}
|
69
|
+
#
|
70
|
+
# @return [State]
|
71
|
+
def create_state
|
72
|
+
State.new(self, @logger)
|
73
|
+
end
|
74
|
+
|
75
|
+
# Iterates all the rules in the {Rules} collection
|
76
|
+
#
|
77
|
+
# @yieldparam rule [Rule]
|
78
|
+
# @return [void]
|
79
|
+
def each_rule
|
80
|
+
rules_collection.rules.each do |rule|
|
81
|
+
yield(rule)
|
82
|
+
end
|
83
|
+
end
|
84
|
+
|
85
|
+
# Creates and caches a rules collection
|
86
|
+
#
|
87
|
+
# @return [Rules]
|
88
|
+
def rules_collection
|
89
|
+
@rules ||= Rules.new(@path, @logger)
|
90
|
+
end
|
91
|
+
|
92
|
+
# :nodoc:
|
93
|
+
# @return [String]
|
94
|
+
def inspect
|
95
|
+
"#<%s:%s %d rules from %s>" % [self.class, object_id, rules_collection.count, @path.inspect]
|
96
|
+
end
|
97
|
+
end
|
98
|
+
end
|
@@ -0,0 +1,88 @@
|
|
1
|
+
class Noteikumi
|
2
|
+
# Represents the result of running a specific rule
|
3
|
+
class Result
|
4
|
+
# The time it took for the rule to be processed
|
5
|
+
# @return [Float]
|
6
|
+
attr_reader :run_time
|
7
|
+
|
8
|
+
# The time the rule started
|
9
|
+
# @return [Time,nil]
|
10
|
+
attr_reader :start_time
|
11
|
+
|
12
|
+
# The time the rule ended
|
13
|
+
# @return [Time,nil]
|
14
|
+
attr_reader :end_time
|
15
|
+
|
16
|
+
# Any output produced by the rule
|
17
|
+
attr_reader :output
|
18
|
+
|
19
|
+
# The rule the result relates to
|
20
|
+
# @return [Rule,nil]
|
21
|
+
attr_reader :rule
|
22
|
+
|
23
|
+
# The exception a rule raised
|
24
|
+
# @return [Exception,nil]
|
25
|
+
attr_accessor :exception
|
26
|
+
|
27
|
+
# Any output returned from the rule run block
|
28
|
+
# @return [Object,nil]
|
29
|
+
attr_accessor :output
|
30
|
+
|
31
|
+
# Creates a result for a rule
|
32
|
+
#
|
33
|
+
# @param rule [Rule]
|
34
|
+
# @return [Result]
|
35
|
+
def initialize(rule)
|
36
|
+
@rule = rule
|
37
|
+
@start_time = nil
|
38
|
+
@end_time = nil
|
39
|
+
@run_time = nil
|
40
|
+
@exception = nil
|
41
|
+
@output = nil
|
42
|
+
@ran = false
|
43
|
+
end
|
44
|
+
|
45
|
+
# The rule name
|
46
|
+
#
|
47
|
+
# @return [String,Symbol]
|
48
|
+
def name
|
49
|
+
@rule.name
|
50
|
+
end
|
51
|
+
|
52
|
+
# If the result has an exception
|
53
|
+
#
|
54
|
+
# @return [Boolean]
|
55
|
+
def error?
|
56
|
+
!!exception
|
57
|
+
end
|
58
|
+
|
59
|
+
# Determines if this rule ran
|
60
|
+
#
|
61
|
+
# @return [Boolean]
|
62
|
+
def ran?
|
63
|
+
@ran
|
64
|
+
end
|
65
|
+
|
66
|
+
# Records the start time for the rule process
|
67
|
+
#
|
68
|
+
# @return [Time]
|
69
|
+
def start_processing
|
70
|
+
@ran = true
|
71
|
+
@start_time = Time.now
|
72
|
+
end
|
73
|
+
|
74
|
+
# Records that processing have ended
|
75
|
+
#
|
76
|
+
# @return [Time]
|
77
|
+
def stop_processing
|
78
|
+
@end_time = Time.now
|
79
|
+
@run_time = @end_time - @start_time
|
80
|
+
end
|
81
|
+
|
82
|
+
# :nodoc:
|
83
|
+
# @return [String]
|
84
|
+
def inspect
|
85
|
+
"#<%s:%s rule: %s ran: %s error: %s>" % [self.class, object_id, name, ran?, error?]
|
86
|
+
end
|
87
|
+
end
|
88
|
+
end
|
@@ -0,0 +1,310 @@
|
|
1
|
+
class Noteikumi
|
2
|
+
# A class that represents an individual rule used by the engine
|
3
|
+
#
|
4
|
+
# Rules are generally stored in files named something_rule.rb in a rule
|
5
|
+
# directory, there are several samples of these in the examples dir
|
6
|
+
# and in docs on the wiki at GitHub
|
7
|
+
class Rule
|
8
|
+
# The priority for this fule
|
9
|
+
# @return [Fixnum]
|
10
|
+
attr_reader :priority
|
11
|
+
|
12
|
+
# The state this rule is being evaluated against
|
13
|
+
# @api private
|
14
|
+
# @return [State,nil]
|
15
|
+
attr_reader :state
|
16
|
+
|
17
|
+
# Named conditions for this rule
|
18
|
+
# @api private
|
19
|
+
# @see {condition}
|
20
|
+
# @return [Hash]
|
21
|
+
attr_reader :conditions
|
22
|
+
|
23
|
+
# Items the rule expect on the state
|
24
|
+
# @api private
|
25
|
+
# @see {requirement}
|
26
|
+
# @return [Array]
|
27
|
+
attr_reader :needs
|
28
|
+
|
29
|
+
# The concurrency safe level
|
30
|
+
# @api private
|
31
|
+
# @see {concurrency=}
|
32
|
+
# @return [:safe, :unsafe]
|
33
|
+
attr_reader :concurrent
|
34
|
+
|
35
|
+
# The run conditions for this rule
|
36
|
+
# @api private
|
37
|
+
# @see {run_when}
|
38
|
+
# @return [Proc]
|
39
|
+
attr_reader :run_condition
|
40
|
+
|
41
|
+
# The logic to run
|
42
|
+
# @api private
|
43
|
+
# @see {run}
|
44
|
+
# @return [Proc]
|
45
|
+
attr_reader :run_logic
|
46
|
+
|
47
|
+
# How many times this rule have been run
|
48
|
+
# @api private
|
49
|
+
# @return [Fixnum]
|
50
|
+
attr_reader :run_count
|
51
|
+
|
52
|
+
# The file the rule was found in
|
53
|
+
# @api private
|
54
|
+
# @return [String]
|
55
|
+
attr_accessor :file
|
56
|
+
|
57
|
+
# The rule name
|
58
|
+
# @api private
|
59
|
+
# @return [String,Symbol]
|
60
|
+
attr_accessor :name
|
61
|
+
|
62
|
+
# The logger used by this rule
|
63
|
+
# @api private
|
64
|
+
# @return [Logger]
|
65
|
+
attr_accessor :logger
|
66
|
+
|
67
|
+
# Creates a new rule
|
68
|
+
#
|
69
|
+
# @param name [String,Symbol] the name of the rule
|
70
|
+
# @return [Rule]
|
71
|
+
def initialize(name)
|
72
|
+
@name = name
|
73
|
+
@priority = 500
|
74
|
+
@concurrent = :unsafe
|
75
|
+
@needs = []
|
76
|
+
@conditions = {}
|
77
|
+
@state = nil
|
78
|
+
@file = "unknown file"
|
79
|
+
@run_count = 0
|
80
|
+
|
81
|
+
run_when { true }
|
82
|
+
run { raise("No execution logic provided for rule") }
|
83
|
+
end
|
84
|
+
|
85
|
+
# Checks if a condition matching the name has been created on the rule
|
86
|
+
#
|
87
|
+
# @see {#condition}
|
88
|
+
# @param condition [Symbol] condition name
|
89
|
+
# @return [Boolean]
|
90
|
+
def has_condition?(condition)
|
91
|
+
@conditions.include?(condition)
|
92
|
+
end
|
93
|
+
|
94
|
+
# Assign the provided state to the rule
|
95
|
+
#
|
96
|
+
# @param state [State] the state to store
|
97
|
+
# @return [void]
|
98
|
+
def assign_state(state)
|
99
|
+
@state = state
|
100
|
+
end
|
101
|
+
|
102
|
+
# Resets the state to nil state
|
103
|
+
#
|
104
|
+
# @return [void]
|
105
|
+
def reset_state
|
106
|
+
@state = nil
|
107
|
+
end
|
108
|
+
|
109
|
+
# Resets the run count for the rule to 0
|
110
|
+
#
|
111
|
+
# @return [void]
|
112
|
+
def reset_counter
|
113
|
+
@run_count = 0
|
114
|
+
end
|
115
|
+
|
116
|
+
# Assigns the state, yields to the block and resets it
|
117
|
+
#
|
118
|
+
# @param state [State] a state to act on
|
119
|
+
# @return [Object] the outcome from the block
|
120
|
+
def with_state(state)
|
121
|
+
assign_state(state)
|
122
|
+
|
123
|
+
yield
|
124
|
+
ensure
|
125
|
+
reset_state
|
126
|
+
end
|
127
|
+
|
128
|
+
# Runs the rule logic
|
129
|
+
#
|
130
|
+
# Rules are run within an instance of {RuleExecutionScope}
|
131
|
+
#
|
132
|
+
# @return [Result]
|
133
|
+
def run_rule_logic
|
134
|
+
@run_count += 1
|
135
|
+
|
136
|
+
result = new_result
|
137
|
+
|
138
|
+
begin
|
139
|
+
result.start_processing
|
140
|
+
result.output = RuleExecutionScope.new(self).run
|
141
|
+
rescue => e
|
142
|
+
logger.error("Error during processing of rule: %s: %s: %s" % [self, e.class, e.to_s])
|
143
|
+
logger.debug(e.backtrace.join("\n\t"))
|
144
|
+
result.exception = e
|
145
|
+
ensure
|
146
|
+
result.stop_processing
|
147
|
+
end
|
148
|
+
|
149
|
+
result
|
150
|
+
end
|
151
|
+
|
152
|
+
# Construct a result object for this rule
|
153
|
+
#
|
154
|
+
# @return [Result]
|
155
|
+
def new_result
|
156
|
+
Result.new(self)
|
157
|
+
end
|
158
|
+
|
159
|
+
# Process a rule after first checking all the requirements are met
|
160
|
+
#
|
161
|
+
# @param state [State] the state to use as scope
|
162
|
+
# @return [Result,nil] nil when the rule never ran due to state checks
|
163
|
+
def process(state)
|
164
|
+
result = nil
|
165
|
+
|
166
|
+
with_state(state) do
|
167
|
+
if state_meets_requirements?
|
168
|
+
if satisfies_run_condition?
|
169
|
+
logger.debug("Processing rule %s" % self)
|
170
|
+
result = run_rule_logic
|
171
|
+
else
|
172
|
+
logger.debug("Skipping processing rule due to run_when block returning false on %s" % self)
|
173
|
+
end
|
174
|
+
else
|
175
|
+
logger.debug("Skipping processing rule %s due to state check failing" % self)
|
176
|
+
end
|
177
|
+
end
|
178
|
+
|
179
|
+
result
|
180
|
+
end
|
181
|
+
|
182
|
+
# Determines if the run_when block is satisfied
|
183
|
+
#
|
184
|
+
# @return [Boolean]
|
185
|
+
def satisfies_run_condition?
|
186
|
+
validator = RuleConditionValidator.new(self)
|
187
|
+
validator.__should_run?
|
188
|
+
end
|
189
|
+
|
190
|
+
# Checks every requirement against the state
|
191
|
+
#
|
192
|
+
# @return [Boolean]
|
193
|
+
def state_meets_requirements?
|
194
|
+
@needs.each do |requirement|
|
195
|
+
valid, reason = state.meets_requirement?(requirement)
|
196
|
+
|
197
|
+
unless valid
|
198
|
+
logger.debug("State does not meet the requirements %s: %s" % [self, reason])
|
199
|
+
return false
|
200
|
+
end
|
201
|
+
end
|
202
|
+
|
203
|
+
true
|
204
|
+
end
|
205
|
+
|
206
|
+
# :nodoc:
|
207
|
+
# @return [String]
|
208
|
+
def to_s
|
209
|
+
"#<%s:%s run_count: %d priority: %d name: %s @ %s>" % [self.class, object_id, run_count, priority, name, file]
|
210
|
+
end
|
211
|
+
|
212
|
+
# Logic to execute once state has met to determine if the rule should be run
|
213
|
+
#
|
214
|
+
# @see #condition for an example
|
215
|
+
# @param blk [Proc] the checking logic that should return boolean
|
216
|
+
# @return [void]
|
217
|
+
def run_when(&blk)
|
218
|
+
raise("A block is needed to evaluate for run_when") unless block_given?
|
219
|
+
@run_condition = blk
|
220
|
+
end
|
221
|
+
|
222
|
+
# Creates the logic that will be run when all the conditions are met
|
223
|
+
#
|
224
|
+
# @see #requirement
|
225
|
+
# @see #condition
|
226
|
+
# @see #run_when
|
227
|
+
# @param blk [Proc] the logic to run
|
228
|
+
# @return [void]
|
229
|
+
def run(&blk)
|
230
|
+
raise("A block is needed to run") unless block_given?
|
231
|
+
@run_logic = blk
|
232
|
+
end
|
233
|
+
|
234
|
+
# Sets the rule priority
|
235
|
+
#
|
236
|
+
# @param priority [Fixnum]
|
237
|
+
# @return [Fixnum]
|
238
|
+
def rule_priority(priority)
|
239
|
+
@priority = Integer(priority)
|
240
|
+
end
|
241
|
+
|
242
|
+
# Sets the concurrency safe level
|
243
|
+
#
|
244
|
+
# This is mainly not used now but will result in the state becoming immutable
|
245
|
+
# when the level is :safe. This is with an eye on supporting parallel or threaded
|
246
|
+
# execution of rules in the long term
|
247
|
+
#
|
248
|
+
# @param level [:safe, :unsafe]
|
249
|
+
# @return [:safe, :unsafe]
|
250
|
+
def concurrency=(level)
|
251
|
+
raise("Concurrency has to be one of :safe, :unsafe") unless [:safe, :unsafe].include?(level)
|
252
|
+
|
253
|
+
@concurrent = level
|
254
|
+
end
|
255
|
+
|
256
|
+
# Determines if the concurrency level is :safe
|
257
|
+
#
|
258
|
+
# @return [Boolean]
|
259
|
+
def concurrent_safe?
|
260
|
+
@concurrent == :safe
|
261
|
+
end
|
262
|
+
|
263
|
+
# Creates a named condition
|
264
|
+
#
|
265
|
+
# @example create and use a condition
|
266
|
+
#
|
267
|
+
# condition(:weekend?) { Time.now.wday > 5 }
|
268
|
+
# condition(:daytime?) { Time.now.hour.between?(9, 18) }
|
269
|
+
#
|
270
|
+
# run_when { weekend? || !daytime? }
|
271
|
+
#
|
272
|
+
# @note these blocks must always return boolean and will be coerced to that
|
273
|
+
# @param name [Symbol] a unique name for this condition
|
274
|
+
# @param blk [Proc] the code to run when this condition is called
|
275
|
+
# @return [void]
|
276
|
+
def condition(name, &blk)
|
277
|
+
raise("Duplicate condition name %s" % name) if @conditions[name]
|
278
|
+
raise("A block is required for condition %s" % name) unless block_given?
|
279
|
+
|
280
|
+
@conditions[name] = blk
|
281
|
+
|
282
|
+
nil
|
283
|
+
end
|
284
|
+
|
285
|
+
# Sets a requirement that the state should meet
|
286
|
+
#
|
287
|
+
# @example require any scope item with a specific type
|
288
|
+
#
|
289
|
+
# requirement nil, String
|
290
|
+
#
|
291
|
+
# @example require that a specific item should be of a type
|
292
|
+
#
|
293
|
+
# requirement :thing, String
|
294
|
+
#
|
295
|
+
# @param args [Array] of requirements
|
296
|
+
# @return [void]
|
297
|
+
def requirement(*args)
|
298
|
+
case args.size
|
299
|
+
when 1
|
300
|
+
@needs << [nil, args[0]]
|
301
|
+
when 2
|
302
|
+
@needs << args
|
303
|
+
else
|
304
|
+
raise("Unsupported requirement input %s" % args.inspect)
|
305
|
+
end
|
306
|
+
end
|
307
|
+
|
308
|
+
nil
|
309
|
+
end
|
310
|
+
end
|
@@ -0,0 +1,85 @@
|
|
1
|
+
class Noteikumi
|
2
|
+
# This is a class used by {Rule#satisfies_run_condition?} to create a
|
3
|
+
# clean room to evaluate the state conditions in and provides helpers
|
4
|
+
# to expose named conditions as methods for use by {Rule#run_when}
|
5
|
+
#
|
6
|
+
# @api private
|
7
|
+
class RuleConditionValidator
|
8
|
+
# Creates a new validator
|
9
|
+
#
|
10
|
+
# @param rule [Rule]
|
11
|
+
# @return [RuleConditionValidator]
|
12
|
+
def initialize(rule)
|
13
|
+
@__rule = rule
|
14
|
+
end
|
15
|
+
|
16
|
+
# Checks if this is the first time the rule is being ran
|
17
|
+
#
|
18
|
+
# @return [Boolean]
|
19
|
+
def first_run?
|
20
|
+
@__rule.run_count == 0
|
21
|
+
end
|
22
|
+
|
23
|
+
# Checks if the state had any past failures
|
24
|
+
#
|
25
|
+
# @return [Boolean]
|
26
|
+
def state_had_failures?
|
27
|
+
@__rule.state.had_failures?
|
28
|
+
end
|
29
|
+
|
30
|
+
# Checks if a rule with a specific name acted on the state
|
31
|
+
#
|
32
|
+
# @param rule [Symbol,Rule]
|
33
|
+
# @return [Boolean]
|
34
|
+
def state_processed_by?(rule)
|
35
|
+
@__rule.state.processed_by?(rule)
|
36
|
+
end
|
37
|
+
|
38
|
+
# Runs the rules run condition
|
39
|
+
#
|
40
|
+
# @return [Boolean]
|
41
|
+
def __should_run?
|
42
|
+
instance_eval(&@__rule.run_condition)
|
43
|
+
end
|
44
|
+
|
45
|
+
# Determines if the rule has a condition by name
|
46
|
+
#
|
47
|
+
# @param condition [Symbol] the condition name
|
48
|
+
# @return [Boolean]
|
49
|
+
def __known_condition?(condition)
|
50
|
+
@__rule.has_condition?(condition)
|
51
|
+
end
|
52
|
+
|
53
|
+
# Retrieves a named condition from the rule
|
54
|
+
#
|
55
|
+
# @param condition [Symbol] the condition name
|
56
|
+
# @return [Proc,nil]
|
57
|
+
def __condition(condition)
|
58
|
+
@__rule.conditions[condition]
|
59
|
+
end
|
60
|
+
|
61
|
+
# Evaluate a named condition
|
62
|
+
#
|
63
|
+
# @param condition [Symbol] the condition name
|
64
|
+
# @param args [Array<Object>] arguments to pass to the condition
|
65
|
+
# @return [Boolean]
|
66
|
+
def __evaluate_condition(condition, *args)
|
67
|
+
result = !!__condition(condition).call(*args)
|
68
|
+
@__rule.logger.debug("Condition %s returned %s on %s" % [condition, result.inspect, @__rule])
|
69
|
+
result
|
70
|
+
end
|
71
|
+
|
72
|
+
# Provide method access to named based conditions
|
73
|
+
#
|
74
|
+
# @see {__evaluate_condition}
|
75
|
+
# @return [Boolean]
|
76
|
+
# @raise [NoMethodError] for unknown conditions
|
77
|
+
def method_missing(method, *args, &blk)
|
78
|
+
if __known_condition?(method)
|
79
|
+
__evaluate_condition(method, *args)
|
80
|
+
else
|
81
|
+
super
|
82
|
+
end
|
83
|
+
end
|
84
|
+
end
|
85
|
+
end
|
@@ -0,0 +1,34 @@
|
|
1
|
+
class Noteikumi
|
2
|
+
# A class that exist to execute the logic of the rule
|
3
|
+
# @api private
|
4
|
+
class RuleExecutionScope
|
5
|
+
# The rule being ran
|
6
|
+
# @return [Rule]
|
7
|
+
attr_reader :rule
|
8
|
+
|
9
|
+
# The state the rule is processing
|
10
|
+
# @return [State]
|
11
|
+
attr_reader :state
|
12
|
+
|
13
|
+
# The active logger
|
14
|
+
# @return [Logger]
|
15
|
+
attr_reader :logger
|
16
|
+
|
17
|
+
# Creates a new scope object
|
18
|
+
#
|
19
|
+
# @param rule [Rule]
|
20
|
+
# @return [RuleExecutionScope
|
21
|
+
def initialize(rule)
|
22
|
+
@rule = rule
|
23
|
+
@state = rule.state
|
24
|
+
@logger = rule.logger
|
25
|
+
end
|
26
|
+
|
27
|
+
# Runs the rule logic within this scope
|
28
|
+
#
|
29
|
+
# @return [Object] the output from the rule
|
30
|
+
def run
|
31
|
+
@rule.run_logic.call
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
@@ -0,0 +1,121 @@
|
|
1
|
+
class Noteikumi
|
2
|
+
# A collection of rules with various methods to load and find rules
|
3
|
+
class Rules
|
4
|
+
# The loaded rules
|
5
|
+
# @return [Array<Rule>]
|
6
|
+
attr_reader :rules
|
7
|
+
|
8
|
+
# The logger
|
9
|
+
# @api private
|
10
|
+
# @return [Logger]
|
11
|
+
attr_reader :logger
|
12
|
+
|
13
|
+
# Creates a rule collection
|
14
|
+
#
|
15
|
+
# @param rules_dir [Array<String>,String] a directory or list of directories to look for rules in
|
16
|
+
# @param logger [Logger] a logger to use
|
17
|
+
# @return Rules
|
18
|
+
def initialize(rules_dir, logger)
|
19
|
+
@rules = []
|
20
|
+
@logger = logger
|
21
|
+
@rules_dir = Array(rules_dir)
|
22
|
+
end
|
23
|
+
|
24
|
+
# Use a block to select rules out of the overall set
|
25
|
+
#
|
26
|
+
# @param blk [Proc] logic to use when selecting rules
|
27
|
+
# @return [Array<Rule>]
|
28
|
+
def select(&blk)
|
29
|
+
@rules.select(&blk)
|
30
|
+
end
|
31
|
+
|
32
|
+
# Return the rule ordered by priority
|
33
|
+
#
|
34
|
+
# @return [Array<Rule>]
|
35
|
+
def by_priority
|
36
|
+
@rules.sort_by(&:priority)
|
37
|
+
end
|
38
|
+
|
39
|
+
# The amont of rules loaded
|
40
|
+
#
|
41
|
+
# @return [Fixnum]
|
42
|
+
def size
|
43
|
+
@rules.size
|
44
|
+
end
|
45
|
+
alias_method :count, :size
|
46
|
+
|
47
|
+
# Determines if any rules are loaded
|
48
|
+
#
|
49
|
+
# @return [Boolean]
|
50
|
+
def empty?
|
51
|
+
@rules.empty?
|
52
|
+
end
|
53
|
+
|
54
|
+
# Append a rule to the collection
|
55
|
+
#
|
56
|
+
# @param rule [Rule]
|
57
|
+
# @return [Rule]
|
58
|
+
def <<(rule)
|
59
|
+
@rules << rule
|
60
|
+
end
|
61
|
+
|
62
|
+
# Get the names of all the rules
|
63
|
+
#
|
64
|
+
# @return [Array<String,Symbol>]
|
65
|
+
def rule_names
|
66
|
+
@rules.map(&:name)
|
67
|
+
end
|
68
|
+
|
69
|
+
# Load a rule from a file
|
70
|
+
#
|
71
|
+
# @param file [String] the file to load the rule from
|
72
|
+
# @return [Rule]
|
73
|
+
def load_rule(file)
|
74
|
+
raise("The rule %s is not readable" % file) unless File.readable?(file)
|
75
|
+
|
76
|
+
body = File.read(file)
|
77
|
+
|
78
|
+
clean = Object.new
|
79
|
+
rule = clean.instance_eval(body, file, 1)
|
80
|
+
|
81
|
+
rule.file = file
|
82
|
+
rule.logger = @logger
|
83
|
+
|
84
|
+
logger.debug("Loaded rule %s from %s" % [rule.name, file])
|
85
|
+
|
86
|
+
rule
|
87
|
+
end
|
88
|
+
|
89
|
+
# Load all the rules from the configured paths
|
90
|
+
#
|
91
|
+
# @return [void]
|
92
|
+
def load_rules
|
93
|
+
@rules_dir.each do |directory|
|
94
|
+
find_rules(directory).each do |rule|
|
95
|
+
rule = load_rule(File.join(directory, rule))
|
96
|
+
|
97
|
+
if rule_names.include?(rule.name)
|
98
|
+
raise("Already have a rule called %s, cannot load another" % rule.name)
|
99
|
+
end
|
100
|
+
|
101
|
+
self << rule
|
102
|
+
end
|
103
|
+
end
|
104
|
+
end
|
105
|
+
|
106
|
+
# Find all rules in a given directory
|
107
|
+
#
|
108
|
+
# Valid rules have names ending in _\_rule.rb_
|
109
|
+
#
|
110
|
+
# @param directory [String] the directory to look in
|
111
|
+
# @return [Array<String>] list of rule names
|
112
|
+
def find_rules(directory)
|
113
|
+
if File.directory?(directory)
|
114
|
+
Dir.entries(directory).grep(/_rule.rb$/)
|
115
|
+
else
|
116
|
+
@logger.debug("Could not find directory %s while looking for rules" % directory)
|
117
|
+
[]
|
118
|
+
end
|
119
|
+
end
|
120
|
+
end
|
121
|
+
end
|
@@ -0,0 +1,233 @@
|
|
1
|
+
class Noteikumi
|
2
|
+
# The state a rule will process, this is not a state machine
|
3
|
+
# but rather can be thought of like a scope
|
4
|
+
class State
|
5
|
+
# The list of result obtained from each rule
|
6
|
+
# @return [Array<Result>]
|
7
|
+
attr_reader :results
|
8
|
+
|
9
|
+
# A list of rules that acted on this state
|
10
|
+
# @return [Array<Rule>]
|
11
|
+
attr_reader :processed_by
|
12
|
+
|
13
|
+
# The engine this state is associated with
|
14
|
+
# @api private
|
15
|
+
# @return [Engine]
|
16
|
+
attr_reader :engine
|
17
|
+
|
18
|
+
# The logger used
|
19
|
+
# @api private
|
20
|
+
# @return [Logger]
|
21
|
+
attr_reader :logger
|
22
|
+
|
23
|
+
# Set the state mutable or not
|
24
|
+
# @return [Boolean]
|
25
|
+
attr_writer :mutable
|
26
|
+
|
27
|
+
# Creates a new state
|
28
|
+
#
|
29
|
+
# @param engine [Engine]
|
30
|
+
# @param logger [Logger]
|
31
|
+
# @return [State]
|
32
|
+
def initialize(engine, logger)
|
33
|
+
@items = {}
|
34
|
+
@results = []
|
35
|
+
@processed_by = []
|
36
|
+
@engine = engine
|
37
|
+
@logger = logger
|
38
|
+
@mutable = true
|
39
|
+
end
|
40
|
+
|
41
|
+
# Allow the state to be modified
|
42
|
+
#
|
43
|
+
# @return [void]
|
44
|
+
def allow_mutation
|
45
|
+
@mutable = true
|
46
|
+
end
|
47
|
+
|
48
|
+
# Prevent the state from being modified
|
49
|
+
#
|
50
|
+
# @return [void]
|
51
|
+
def prevent_mutation
|
52
|
+
@mutable = false
|
53
|
+
end
|
54
|
+
|
55
|
+
# Process a rule with the state
|
56
|
+
#
|
57
|
+
# @return [Result, nil] nil when the rule did not run
|
58
|
+
def process_rule(rule)
|
59
|
+
rule.concurrent_safe? ? prevent_mutation : allow_mutation
|
60
|
+
|
61
|
+
result = rule.process(self)
|
62
|
+
|
63
|
+
allow_mutation
|
64
|
+
|
65
|
+
record_rule(rule, result)
|
66
|
+
end
|
67
|
+
|
68
|
+
# Yields each recorded result
|
69
|
+
#
|
70
|
+
# @yieldparam result [Result] for every rule that ran
|
71
|
+
# @return [void]
|
72
|
+
def each_result
|
73
|
+
@results.each do |result|
|
74
|
+
yield(result)
|
75
|
+
end
|
76
|
+
end
|
77
|
+
|
78
|
+
# Adds a result to the list of results
|
79
|
+
#
|
80
|
+
# @param result [Result]
|
81
|
+
# @return [Result]
|
82
|
+
def add_result(result)
|
83
|
+
@results << result if result
|
84
|
+
end
|
85
|
+
|
86
|
+
# Checks all results for failures
|
87
|
+
#
|
88
|
+
# @return [Boolean] true when there were failures
|
89
|
+
def had_failures?
|
90
|
+
@results.map(&:error?).include?(true)
|
91
|
+
end
|
92
|
+
|
93
|
+
# Determines if a rule with a specific name acted on this state
|
94
|
+
#
|
95
|
+
# @param rule [Rule,Symbol] the rule name or a rule
|
96
|
+
# @return [Boolean]
|
97
|
+
def processed_by?(rule)
|
98
|
+
if rule.is_a?(Rule)
|
99
|
+
@processed_by.include?(rule)
|
100
|
+
else
|
101
|
+
@processed_by.map(&:name).include?(rule)
|
102
|
+
end
|
103
|
+
end
|
104
|
+
|
105
|
+
# Selects any item that has a certain ruby class type
|
106
|
+
#
|
107
|
+
# @param type [Class] the type to search for
|
108
|
+
# @return [Hash] items found
|
109
|
+
def items_by_type(type)
|
110
|
+
@items.select {|_, v| v.is_a?(type)} || []
|
111
|
+
end
|
112
|
+
|
113
|
+
# Checks if a given requirement is matched by this state
|
114
|
+
#
|
115
|
+
# @example
|
116
|
+
#
|
117
|
+
# state[:one] = "hello world"
|
118
|
+
#
|
119
|
+
# state.meets_requirements?(:one, String) => [true, "reason"]
|
120
|
+
# state.meets_requirements?(nil, String) => [true, "reason"]
|
121
|
+
# state.meets_requirements?(nil, Fixnum) => [false, "State has no items of class Fixnum"]
|
122
|
+
# state.meets_requirements?(:one, Fixnum) => [false, "State item :one is not a Fixnum"]
|
123
|
+
# state.meets_requirements?(:not_set, Fixnum) => [false, "State has no item not_set"]
|
124
|
+
#
|
125
|
+
# @param requirement [Array<key,type>]
|
126
|
+
# @return [Array<Boolean,String>]
|
127
|
+
def meets_requirement?(requirement)
|
128
|
+
key, klass = requirement
|
129
|
+
|
130
|
+
if key
|
131
|
+
return([false, "State has no item %s" % key]) unless include?(key)
|
132
|
+
|
133
|
+
unless self[key].is_a?(klass)
|
134
|
+
return [false, "State item %s is not a %s" % [key, klass]]
|
135
|
+
end
|
136
|
+
end
|
137
|
+
|
138
|
+
unless has_item_of_type?(klass)
|
139
|
+
return [false, "State has no items of class %s" % klass]
|
140
|
+
end
|
141
|
+
|
142
|
+
[true, "Valid state found"]
|
143
|
+
end
|
144
|
+
|
145
|
+
# Determines if any item in the State has a certain type
|
146
|
+
#
|
147
|
+
# @param type [Class] a ruby class to look for
|
148
|
+
# @return [Boolean]
|
149
|
+
def has_item_of_type?(type)
|
150
|
+
!items_by_type(type).empty?
|
151
|
+
end
|
152
|
+
|
153
|
+
# Determines if the state can be mutated
|
154
|
+
def mutable?
|
155
|
+
!!@mutable
|
156
|
+
end
|
157
|
+
|
158
|
+
# Records a rule having acted on this state
|
159
|
+
#
|
160
|
+
# If the result is not nil the actor will be record
|
161
|
+
# and the result stored, else it's a noop
|
162
|
+
#
|
163
|
+
# @param rule [Rule]
|
164
|
+
# @param result [Result]
|
165
|
+
# @return [void]
|
166
|
+
def record_rule(rule, result)
|
167
|
+
if result
|
168
|
+
@processed_by << rule
|
169
|
+
@results << result
|
170
|
+
end
|
171
|
+
|
172
|
+
result
|
173
|
+
end
|
174
|
+
|
175
|
+
# Checks if a item is in the state
|
176
|
+
#
|
177
|
+
# @param item [Symbol] item name
|
178
|
+
# @return [Boolean]
|
179
|
+
def include?(item)
|
180
|
+
@items.include?(item)
|
181
|
+
end
|
182
|
+
alias_method :has?, :include?
|
183
|
+
|
184
|
+
# sets the value of an item without checking if it's already set
|
185
|
+
#
|
186
|
+
# @param item [Symbol] the name of the item being stored
|
187
|
+
# @param value [Object] the item to store
|
188
|
+
# @return [Object] the item being stored
|
189
|
+
# @raise [StandardError] when the state is not mutable
|
190
|
+
def set(item, value)
|
191
|
+
raise("State is not mustable") unless mutable?
|
192
|
+
|
193
|
+
@items[item] = value
|
194
|
+
end
|
195
|
+
alias_method :[]=, :set
|
196
|
+
|
197
|
+
# Deletes an item
|
198
|
+
#
|
199
|
+
# @param item [Symbol] item to delete
|
200
|
+
# @raise [StandardError] when the state is not mutable
|
201
|
+
def delete(item)
|
202
|
+
raise("State is not mustable") unless mutable?
|
203
|
+
|
204
|
+
@items.delete(item)
|
205
|
+
end
|
206
|
+
|
207
|
+
# Adds an item
|
208
|
+
#
|
209
|
+
# See {#set} for a version of this that does not error if the
|
210
|
+
# item is already on the state
|
211
|
+
#
|
212
|
+
# @param item [Symbol] item to delete
|
213
|
+
# @param value [Object] item to store
|
214
|
+
# @return [Object] the value set
|
215
|
+
# @raise [StandardError] when the state is not mutable
|
216
|
+
# @raise [StandardError] when the item is already in the state
|
217
|
+
def add(item, value)
|
218
|
+
raise("State is not mustable") unless mutable?
|
219
|
+
raise("Already have item %s" % item) if has?(item)
|
220
|
+
|
221
|
+
set(item, value)
|
222
|
+
end
|
223
|
+
|
224
|
+
# Retrieves a item from the state
|
225
|
+
#
|
226
|
+
# @param item [Symbol] the item name
|
227
|
+
# @return [Object,nil] the value stored
|
228
|
+
def get(item)
|
229
|
+
@items[item]
|
230
|
+
end
|
231
|
+
alias_method :[], :get
|
232
|
+
end
|
233
|
+
end
|
metadata
ADDED
@@ -0,0 +1,53 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: noteikumi
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.0.1
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- R.I.Pienaar
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2016-02-09 00:00:00.000000000 Z
|
12
|
+
dependencies: []
|
13
|
+
description: 'description: Light weight Rule Engine'
|
14
|
+
email: rip@devco.net
|
15
|
+
executables: []
|
16
|
+
extensions: []
|
17
|
+
extra_rdoc_files: []
|
18
|
+
files:
|
19
|
+
- lib/noteikumi.rb
|
20
|
+
- lib/noteikumi/rules.rb
|
21
|
+
- lib/noteikumi/engine.rb
|
22
|
+
- lib/noteikumi/rule_condition_validator.rb
|
23
|
+
- lib/noteikumi/rule_execution_scope.rb
|
24
|
+
- lib/noteikumi/result.rb
|
25
|
+
- lib/noteikumi/state.rb
|
26
|
+
- lib/noteikumi/version.rb
|
27
|
+
- lib/noteikumi/rule.rb
|
28
|
+
- Gemfile
|
29
|
+
homepage: http://devco.net/
|
30
|
+
licenses:
|
31
|
+
- Apache-2
|
32
|
+
metadata: {}
|
33
|
+
post_install_message:
|
34
|
+
rdoc_options: []
|
35
|
+
require_paths:
|
36
|
+
- lib
|
37
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
38
|
+
requirements:
|
39
|
+
- - '>='
|
40
|
+
- !ruby/object:Gem::Version
|
41
|
+
version: '0'
|
42
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
43
|
+
requirements:
|
44
|
+
- - '>='
|
45
|
+
- !ruby/object:Gem::Version
|
46
|
+
version: '0'
|
47
|
+
requirements: []
|
48
|
+
rubyforge_project:
|
49
|
+
rubygems_version: 2.0.14
|
50
|
+
signing_key:
|
51
|
+
specification_version: 4
|
52
|
+
summary: noteikumi
|
53
|
+
test_files: []
|