noteikumi 0.0.1
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 +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: []
|