triggerable 0.1.0

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