triggerable 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.
Files changed (39) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +18 -0
  3. data/.rspec +2 -0
  4. data/.travis.yml +3 -0
  5. data/Gemfile +4 -0
  6. data/LICENSE.txt +22 -0
  7. data/README.md +89 -0
  8. data/Rakefile +6 -0
  9. data/lib/triggerable.rb +78 -0
  10. data/lib/triggerable/actions.rb +27 -0
  11. data/lib/triggerable/conditions/condition.rb +42 -0
  12. data/lib/triggerable/conditions/field/exists.rb +12 -0
  13. data/lib/triggerable/conditions/field/field_condition.rb +29 -0
  14. data/lib/triggerable/conditions/field/in.rb +17 -0
  15. data/lib/triggerable/conditions/field/or_equal_to.rb +15 -0
  16. data/lib/triggerable/conditions/lambda_condition.rb +12 -0
  17. data/lib/triggerable/conditions/method_condition.rb +11 -0
  18. data/lib/triggerable/conditions/predicate/and.rb +7 -0
  19. data/lib/triggerable/conditions/predicate/or.rb +7 -0
  20. data/lib/triggerable/conditions/predicate/predicate_condition.rb +46 -0
  21. data/lib/triggerable/conditions/schedule/after.rb +28 -0
  22. data/lib/triggerable/conditions/schedule/before.rb +35 -0
  23. data/lib/triggerable/conditions/schedule/schedule_condition.rb +33 -0
  24. data/lib/triggerable/engine.rb +28 -0
  25. data/lib/triggerable/rules/automation.rb +13 -0
  26. data/lib/triggerable/rules/rule.rb +11 -0
  27. data/lib/triggerable/rules/trigger.rb +14 -0
  28. data/lib/triggerable/version.rb +3 -0
  29. data/spec/conditions_spec.rb +200 -0
  30. data/spec/integration/actions_spec.rb +37 -0
  31. data/spec/integration/automations_spec.rb +353 -0
  32. data/spec/integration/conditions_spec.rb +144 -0
  33. data/spec/integration/short_syntax_spec.rb +92 -0
  34. data/spec/models.rb +23 -0
  35. data/spec/schema.rb +27 -0
  36. data/spec/scopes_spec.rb +78 -0
  37. data/spec/spec_helper.rb +22 -0
  38. data/triggerable.gemspec +28 -0
  39. metadata +191 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: eccbfd503b44b892a1247b8dde94381fba3017ed
4
+ data.tar.gz: a159bb8a7559a264be77616c3d8bd83f144aa0ff
5
+ SHA512:
6
+ metadata.gz: bf594d01a28d8a826c38d13db346027233b5419147406d725d53e35e4e39bfb260689693ceec44e83000b83eb708a5d2b3343498d64c4d167b8dc06435dd4c9a
7
+ data.tar.gz: 6bef7a97e9236f09954cacdc30c76b4f3b9012864b2168115c0e2a482ea136630267c5d99f8bae96bd1bf34c75f8c1da9938acae1cb093790d067ffe75f4f51e
data/.gitignore ADDED
@@ -0,0 +1,18 @@
1
+ *.gem
2
+ *.rbc
3
+ .bundle
4
+ .config
5
+ .yardoc
6
+ Gemfile.lock
7
+ InstalledFiles
8
+ _yardoc
9
+ coverage
10
+ doc/
11
+ lib/bundler/man
12
+ pkg
13
+ rdoc
14
+ spec/reports
15
+ test/tmp
16
+ test/version_tmp
17
+ tmp
18
+ .DS_Store
data/.rspec ADDED
@@ -0,0 +1,2 @@
1
+ --format documentation
2
+ --color
data/.travis.yml ADDED
@@ -0,0 +1,3 @@
1
+ language: ruby
2
+ rvm:
3
+ - 2.1.0
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in triggerable.gemspec
4
+ gemspec
data/LICENSE.txt ADDED
@@ -0,0 +1,22 @@
1
+ Copyright (c) 2014 DmitryTsepelev
2
+
3
+ MIT License
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining
6
+ a copy of this software and associated documentation files (the
7
+ "Software"), to deal in the Software without restriction, including
8
+ without limitation the rights to use, copy, modify, merge, publish,
9
+ distribute, sublicense, and/or sell copies of the Software, and to
10
+ permit persons to whom the Software is furnished to do so, subject to
11
+ the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be
14
+ included in all copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
20
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
21
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
22
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,89 @@
1
+ # Triggerable
2
+
3
+ [![Code Climate](https://codeclimate.com/github/anjlab/triggerable/badges/gpa.svg)](https://codeclimate.com/github/anjlab/triggerable)
4
+
5
+ Triggerable is a powerful engine for adding a conditional behaviour for ActiveRecord models. This logic can be defined in two ways - as triggers and automations. Triggers are called right after model creating, updating or saving, and automations are run on schedule (e.g. 2 hours after update).
6
+
7
+ ## Installation
8
+
9
+ Add this line to your application's Gemfile:
10
+
11
+ ```ruby
12
+ gem 'triggerable'
13
+ ```
14
+
15
+ And then execute:
16
+
17
+ ```shell
18
+ bundle
19
+ ```
20
+
21
+ ## Usage
22
+
23
+ Setup and defining trigger and automation:
24
+
25
+ ```ruby
26
+ class User < ActiveRecord::Base
27
+ trigger on: :after_create, if: { receives_sms: true }, do
28
+ user.send_welcome_sms
29
+ end
30
+
31
+ automation if: { created_at: { after: 24 }, confirmed: false } do
32
+ send_confirmation_email
33
+ end
34
+ end
35
+ ```
36
+
37
+ Combining different conditions and predicates:
38
+
39
+ ```ruby
40
+ trigger on: :after_create, if: { field: { is: 1 } }, ...
41
+ # short form
42
+ trigger on: :after_create, if: { field: 1 }, ...
43
+
44
+ trigger on: :after_create, if: { field: { is_not: 1 } }, ...
45
+
46
+ trigger on: :after_create, if: { field: { in: [1, 2, 3] } }, ...
47
+ # short form
48
+ trigger on: :after_create, if: { field: [1, 2, 3] }, ...
49
+
50
+ trigger on: :after_create, if: { field: { greater_then: 1 } }, ...
51
+ trigger on: :after_create, if: { field: { less_then: 1 } }, ...
52
+
53
+ trigger on: :after_create, if: { field: { exists: true } }, ...
54
+
55
+ trigger on: :after_create, if: { and: [{ field1: '1' }, { field2: 1 }] }, ...
56
+ trigger on: :after_create, if: { or: [{ field1: '1' }, { field2: 1 }] }, ...
57
+ ```
58
+
59
+ Triggerable does not run automations by itself, you should call `Engine.run_automations(interval)` using any scheduling script. Interval is a time difference between calling the method (e.g. `1.hour`). *You should avoid situations when your interval is less then the time your automations need to complete!*
60
+
61
+ If you have more complex condition or need to check associations (not supported in DSL now), you should use a lambda condition form:
62
+
63
+ ```ruby
64
+ trigger on: :after_update, if: -> { orders.any? } do
65
+ # ...
66
+ end
67
+ ```
68
+
69
+ If you need to share logic between triggers/automations bodies you can move it into separate class. It should be inherited from `Triggerable::Action` and implement a single method `run_for!(obj)` where obj is a triggered object. Then you can pass a name of your action class instead of do block.
70
+
71
+ ```ruby
72
+ class SendWelcomeSms < Triggerable::Action
73
+ def run_for! object
74
+ SmsGateway.send_to object.phone, welcome_text
75
+ end
76
+ end
77
+
78
+ class User
79
+ trigger on: :after_create, if: { receives_sms: true }, do: :send_welcome_sms
80
+ end
81
+ ```
82
+
83
+ ## Contributing
84
+
85
+ 1. Fork it
86
+ 2. Create your feature branch (`git checkout -b my-new-feature`)
87
+ 3. Commit your changes (`git commit -am 'Add some feature'`)
88
+ 4. Push to the branch (`git push origin my-new-feature`)
89
+ 5. Create new Pull Request
data/Rakefile ADDED
@@ -0,0 +1,6 @@
1
+ require "bundler/gem_tasks"
2
+ require "rspec/core/rake_task"
3
+
4
+ RSpec::Core::RakeTask.new(:spec)
5
+
6
+ task :default => :spec
@@ -0,0 +1,78 @@
1
+ require "triggerable/version"
2
+
3
+ require "triggerable/engine"
4
+
5
+ require "triggerable/rules/rule"
6
+ require "triggerable/rules/trigger"
7
+ require "triggerable/rules/automation"
8
+
9
+ require "triggerable/conditions/condition"
10
+ require "triggerable/conditions/lambda_condition"
11
+ require "triggerable/conditions/method_condition"
12
+
13
+ require "triggerable/conditions/field/field_condition"
14
+ require "triggerable/conditions/field/exists"
15
+ require "triggerable/conditions/field/in"
16
+ require "triggerable/conditions/field/or_equal_to"
17
+
18
+ require "triggerable/conditions/predicate/predicate_condition"
19
+ require "triggerable/conditions/predicate/and"
20
+ require "triggerable/conditions/predicate/or"
21
+
22
+ require "triggerable/conditions/schedule/schedule_condition"
23
+ require "triggerable/conditions/schedule/before"
24
+ require "triggerable/conditions/schedule/after"
25
+
26
+ require "triggerable/actions"
27
+
28
+ module Triggerable
29
+ extend ActiveSupport::Concern
30
+
31
+ included do
32
+ CALLBACKS = [:before_create, :before_save, :before_update, :after_create, :after_save, :after_update]
33
+ CALLBACKS.each do |callback|
34
+ method_name = "run_#{callback}_triggers"
35
+ define_method(method_name) { run_triggers(callback) }
36
+ send(callback, method_name)
37
+ end
38
+
39
+ private
40
+
41
+ def run_triggers callback
42
+ Engine.triggers_for(self.class, callback).each{|t| t.execute!(self)}
43
+ end
44
+ end
45
+
46
+ module ClassMethods
47
+ def trigger options, &block
48
+ Engine.trigger(self, options, block)
49
+ end
50
+
51
+ def automation options, &block
52
+ Engine.automation(self, options, block)
53
+ end
54
+ end
55
+ end
56
+
57
+ COMPARSIONS = [
58
+ { name: 'Is', ancestor: Conditions::FieldCondition, args: { ruby_comparator: '==', db_comparator: 'eq' } },
59
+ { name: 'GreaterThan', ancestor: Conditions::FieldCondition, args: { ruby_comparator: '>', db_comparator: 'gt' } },
60
+ { name: 'LessThan', ancestor: Conditions::FieldCondition, args: { ruby_comparator: '<', db_comparator: 'lt' } },
61
+ { name: 'IsNot', ancestor: Conditions::FieldCondition, args: { ruby_comparator: '!=', db_comparator: 'not_eq'} },
62
+
63
+ { name: 'GreaterThanOrEqualTo', ancestor: Conditions::OrEqualTo, args: { db_comparator: 'gteq', additional_condition: 'Conditions::GreaterThan' } },
64
+ { name: 'LessThanOrEqualTo', ancestor: Conditions::OrEqualTo, args: { db_comparator: 'lteq', additional_condition: 'Conditions::LessThan' } }
65
+ ]
66
+
67
+ COMPARSIONS.each do |desc|
68
+ klass = Class.new(desc[:ancestor]) do
69
+ define_method :initialize do |field, value|
70
+ desc[:args].each_pair { |name, val| instance_variable_set("@#{name}".to_sym, val) }
71
+ super(field, value)
72
+ end
73
+ end
74
+
75
+ Conditions.const_set(desc[:name], klass)
76
+ end
77
+
78
+ ActiveSupport.on_load(:active_record) { include Triggerable }
@@ -0,0 +1,27 @@
1
+ module Triggerable
2
+ class Action
3
+ def self.build source
4
+ if source.is_a?(Proc)
5
+ [LambdaAction.new(source)]
6
+ else
7
+ Array(source).map do |source|
8
+ descendant = descendants.find { |d| d == source.to_s.camelize.constantize }
9
+ descendant.new if descendant.present?
10
+ end.compact
11
+ end
12
+ end
13
+
14
+ def run_for!(object); end
15
+ end
16
+
17
+ class LambdaAction < Action
18
+ def initialize block
19
+ @block = block
20
+ end
21
+
22
+ def run_for! object
23
+ proc = @block
24
+ object.instance_eval { instance_exec(&proc) }
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,42 @@
1
+ module Conditions
2
+ class Condition
3
+ def self.build condition
4
+ return Condition.new if condition.blank?
5
+ return LambdaCondition.new(condition) if condition.is_a?(Proc)
6
+ return MethodCondition.new(condition) if condition.is_a?(Symbol)
7
+
8
+ key = condition.keys.first
9
+ value = condition[key]
10
+
11
+ if [:and, :or].include?(key)
12
+ predicate_condition(key, value)
13
+ else
14
+ field_condition(key, value)
15
+ end
16
+ end
17
+
18
+ def true_for?(object); true; end
19
+
20
+ def scope; ''; end
21
+
22
+ private
23
+
24
+ def self.predicate_condition class_name, value
25
+ condition_class(class_name).new(value)
26
+ end
27
+
28
+ def self.field_condition field, value
29
+ if value.is_a?(Array)
30
+ Conditions::In.new(field, value)
31
+ elsif value.is_a?(Hash)
32
+ condition_class(value.keys.first).new(field, value.values.first)
33
+ else
34
+ Conditions::Is.new(field, value)
35
+ end
36
+ end
37
+
38
+ def self.condition_class sym
39
+ "Conditions::#{sym.to_s.camelize}".constantize
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,12 @@
1
+ module Conditions
2
+ class Exists < FieldCondition
3
+ def true_for? object
4
+ v = field_value(object)
5
+ @value ? v.present? : v.blank?
6
+ end
7
+
8
+ def scope
9
+ "#{@field} IS #{'NOT ' if @value}NULL"
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,29 @@
1
+ module Conditions
2
+ class FieldCondition < Condition
3
+ def initialize field, value
4
+ @field = field
5
+ @value = value
6
+ end
7
+
8
+ def true_for? object
9
+ field_value(object).send(@ruby_comparator, @value)
10
+ end
11
+
12
+ def scope table
13
+ table[@field].send(@db_comparator, @value)
14
+ end
15
+
16
+ private
17
+ def field_value object
18
+ object.send(@field)
19
+ end
20
+
21
+ def sanitized_value
22
+ if @value.is_a?(Array)
23
+ @value.map { |v| ActiveRecord::Base::sanitize(v) }
24
+ else
25
+ ActiveRecord::Base::sanitize(@value)
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,17 @@
1
+ module Conditions
2
+ class In < FieldCondition
3
+ def initialize field, condition
4
+ super
5
+ @db_comparator = 'in'
6
+ end
7
+
8
+ def true_for? object
9
+ @value.include?(field_value(object))
10
+ end
11
+
12
+ private
13
+ def sanitized_value
14
+ "(#{super.join(',')})"
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,15 @@
1
+ module Conditions
2
+ class OrEqualTo < FieldCondition
3
+ def initialize field, value
4
+ super
5
+ @condition = Or.new [
6
+ @additional_condition.constantize.new(field, value),
7
+ Is.new(field, value)
8
+ ]
9
+ end
10
+
11
+ def true_for? object
12
+ @condition.true_for?(object)
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,12 @@
1
+ module Conditions
2
+ class LambdaCondition < Condition
3
+ def initialize block
4
+ @block = block
5
+ end
6
+
7
+ def true_for? object
8
+ proc = @block
9
+ object.instance_eval { instance_exec(&proc) }
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,11 @@
1
+ module Conditions
2
+ class MethodCondition < Condition
3
+ def initialize method_name
4
+ @method_name = method_name
5
+ end
6
+
7
+ def true_for? object
8
+ object.send(@method_name)
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,7 @@
1
+ module Conditions
2
+ class And < PredicateCondition
3
+ def true_for? object
4
+ true_conditions(object).count == @conditions.count
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,7 @@
1
+ module Conditions
2
+ class Or < PredicateCondition
3
+ def true_for? object
4
+ true_conditions(object).any?
5
+ end
6
+ end
7
+ end