pigeons 0.0.1pre
Sign up to get free protection for your applications and to get access to all the features.
- data/.gitignore +17 -0
- data/Gemfile +4 -0
- data/LICENSE.txt +676 -0
- data/README.md +29 -0
- data/Rakefile +10 -0
- data/lib/generators/pigeons/install_generator.rb +42 -0
- data/lib/generators/pigeons/templates/pigeon_letter.rb +30 -0
- data/lib/generators/pigeons/templates/pigeon_letter_migration.rb +16 -0
- data/lib/generators/pigeons/templates/pigeon_mailer.rb +8 -0
- data/lib/generators/pigeons/templates/pigeons.json +5 -0
- data/lib/pigeons/checks.rb +98 -0
- data/lib/pigeons/elements.rb +35 -0
- data/lib/pigeons/errors.rb +8 -0
- data/lib/pigeons/extensions.rb +34 -0
- data/lib/pigeons/logger.rb +15 -0
- data/lib/pigeons/pigeons.rb +156 -0
- data/lib/pigeons/pigeons_tasks.rb +8 -0
- data/lib/pigeons/scope.rb +205 -0
- data/lib/pigeons/version.rb +3 -0
- data/lib/pigeons.rb +10 -0
- data/pigeons.gemspec +29 -0
- data/tasks/pigeons.rake +142 -0
- data/test/test_helper.rb +143 -0
- data/test/test_pigeons.rb +429 -0
- metadata +200 -0
data/README.md
ADDED
@@ -0,0 +1,29 @@
|
|
1
|
+
# Pigeons
|
2
|
+
|
3
|
+
TODO: Write a gem description
|
4
|
+
|
5
|
+
## Installation
|
6
|
+
|
7
|
+
Add this line to your application's Gemfile:
|
8
|
+
|
9
|
+
gem 'pigeons'
|
10
|
+
|
11
|
+
And then execute:
|
12
|
+
|
13
|
+
$ bundle
|
14
|
+
|
15
|
+
Or install it yourself as:
|
16
|
+
|
17
|
+
$ gem install pigeons
|
18
|
+
|
19
|
+
## Usage
|
20
|
+
|
21
|
+
TODO: Write usage instructions here
|
22
|
+
|
23
|
+
## Contributing
|
24
|
+
|
25
|
+
1. Fork it
|
26
|
+
2. Create your feature branch (`git checkout -b my-new-feature`)
|
27
|
+
3. Commit your changes (`git commit -am 'Add some feature'`)
|
28
|
+
4. Push to the branch (`git push origin my-new-feature`)
|
29
|
+
5. Create new Pull Request
|
data/Rakefile
ADDED
@@ -0,0 +1,42 @@
|
|
1
|
+
require 'rails/generators'
|
2
|
+
require 'rails/generators/active_record'
|
3
|
+
|
4
|
+
module Pigeons
|
5
|
+
|
6
|
+
class InstallGenerator < Rails::Generators::Base
|
7
|
+
include Rails::Generators::Migration
|
8
|
+
|
9
|
+
source_root File.expand_path('../templates', __FILE__)
|
10
|
+
|
11
|
+
# desc "Some description of my generator here"
|
12
|
+
|
13
|
+
# Commandline options can be defined here using Thor-like options:
|
14
|
+
# class_option :my_opt, :type => :boolean, :default => false, :desc => "My Option"
|
15
|
+
|
16
|
+
# I can later access that option using:
|
17
|
+
# options[:my_opt]
|
18
|
+
|
19
|
+
# Generator Code. Remember this is just suped-up Thor so methods are executed in order
|
20
|
+
def generate_pigeons_json
|
21
|
+
copy_file "pigeons.json", "config/pigeons.json"
|
22
|
+
end
|
23
|
+
|
24
|
+
def generate_pigeon_letter
|
25
|
+
copy_file "pigeon_letter.rb", "app/models/pigeon_letter.rb"
|
26
|
+
end
|
27
|
+
|
28
|
+
def generate_pigeon_letter_migration
|
29
|
+
migration_template "pigeon_letter_migration.rb", "db/migrate/create_pigeon_letters"
|
30
|
+
end
|
31
|
+
|
32
|
+
def generate_pigeon_mailer
|
33
|
+
copy_file "pigeon_mailer.rb", "app/mailers/pigeon_mailer.rb"
|
34
|
+
end
|
35
|
+
|
36
|
+
def self.next_migration_number(dirname)
|
37
|
+
ActiveRecord::Generators::Base.next_migration_number(dirname)
|
38
|
+
end
|
39
|
+
|
40
|
+
end
|
41
|
+
|
42
|
+
end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
# Represents a letter sent by Pigeons
|
2
|
+
#
|
3
|
+
# Attributes
|
4
|
+
# letter_type:: Type of letter being sent
|
5
|
+
# cargo_id:: Associated object id (e.g. a <user_id>) *polymorphic
|
6
|
+
# cargo_type:: Assoication type (e.g. User) *polymorphic
|
7
|
+
# sent_at:: Time letter was sent
|
8
|
+
# flight:: What flight was this sent on?
|
9
|
+
class PigeonLetter < ActiveRecord::Base
|
10
|
+
|
11
|
+
### Associations
|
12
|
+
belongs_to :cargo, polymorphic: true
|
13
|
+
|
14
|
+
### Attributes
|
15
|
+
attr_accessible :letter_type, :cargo, :cargo_id, :cargo_type, :flight
|
16
|
+
|
17
|
+
### Validations
|
18
|
+
validates_presence_of :letter_type
|
19
|
+
validates_presence_of :cargo_id
|
20
|
+
validates_presence_of :cargo_type
|
21
|
+
validates_presence_of :flight
|
22
|
+
|
23
|
+
### Member Functions
|
24
|
+
|
25
|
+
def send!
|
26
|
+
PigeonMailer.send(letter_type, cargo, flight).deliver
|
27
|
+
self.update_attribute(:sent_at, Time.now)
|
28
|
+
end
|
29
|
+
|
30
|
+
end
|
@@ -0,0 +1,16 @@
|
|
1
|
+
class CreatePigeonLetters < ActiveRecord::Migration
|
2
|
+
def change
|
3
|
+
create_table :pigeon_letters do |t|
|
4
|
+
t.string :letter_type
|
5
|
+
t.string :flight
|
6
|
+
t.string :cargo_type
|
7
|
+
t.integer :cargo_id
|
8
|
+
t.datetime :sent_at
|
9
|
+
|
10
|
+
t.timestamps
|
11
|
+
end
|
12
|
+
|
13
|
+
add_index :pigeon_letters, [ :cargo_id, :cargo_type ], :name => "index_pigeon_letters_cargo_id_cargo_type"
|
14
|
+
add_index :pigeon_letters, [ :letter_type ], :name => "index_pigeon_letters_letter_type"
|
15
|
+
end
|
16
|
+
end
|
@@ -0,0 +1,98 @@
|
|
1
|
+
module Pigeons
|
2
|
+
|
3
|
+
module Checks
|
4
|
+
|
5
|
+
# We're going to try to factor all major queries into individual functions
|
6
|
+
def self.no_recent_letter_check(args)
|
7
|
+
scope = args[:scope]
|
8
|
+
pigeon_arel = args[:pigeon]
|
9
|
+
source_arel = args[:source]
|
10
|
+
|
11
|
+
scope.where(
|
12
|
+
PigeonLetter.where(
|
13
|
+
pigeon_arel[:cargo_id].eq(source_arel[:id]).and(
|
14
|
+
pigeon_arel[:cargo_type].eq(source_arel.name.classify)
|
15
|
+
).and(
|
16
|
+
pigeon_arel[:created_at].gt(Pigeons::Settings.cooldown.ago)
|
17
|
+
)
|
18
|
+
).exists.not
|
19
|
+
)
|
20
|
+
end
|
21
|
+
|
22
|
+
def self.non_recurring_check(args)
|
23
|
+
scope = args[:scope]
|
24
|
+
pigeon_arel = args[:pigeon]
|
25
|
+
source_arel = args[:source]
|
26
|
+
letter = args[:letter]
|
27
|
+
|
28
|
+
scope.where(
|
29
|
+
PigeonLetter.where(
|
30
|
+
pigeon_arel[:cargo_id].eq(source_arel[:id]).and(
|
31
|
+
pigeon_arel[:cargo_type].eq(source_arel.name.classify)
|
32
|
+
).and(
|
33
|
+
pigeon_arel[:created_at].not_eq(nil)
|
34
|
+
).and(
|
35
|
+
pigeon_arel[:letter_type].eq(letter)
|
36
|
+
)
|
37
|
+
).exists.not
|
38
|
+
)
|
39
|
+
end
|
40
|
+
|
41
|
+
def self.recurring_check(args)
|
42
|
+
scope = args[:scope]
|
43
|
+
pigeon_arel = args[:pigeon]
|
44
|
+
source_arel = args[:source]
|
45
|
+
letter = args[:letter]
|
46
|
+
relative_time = args[:relative_time]
|
47
|
+
|
48
|
+
scope.where(
|
49
|
+
PigeonLetter.where(
|
50
|
+
pigeon_arel[:cargo_id].eq(source_arel[:id]).and(
|
51
|
+
pigeon_arel[:cargo_type].eq(source_arel.name.classify)
|
52
|
+
).and(
|
53
|
+
pigeon_arel[:created_at].gt(relative_time)
|
54
|
+
).and(
|
55
|
+
pigeon_arel[:letter_type].eq(letter)
|
56
|
+
)
|
57
|
+
).exists.not
|
58
|
+
)
|
59
|
+
end
|
60
|
+
|
61
|
+
def self.after_letter_check(args)
|
62
|
+
scope = args[:scope]
|
63
|
+
pigeon_arel = args[:pigeon]
|
64
|
+
source_arel = args[:source]
|
65
|
+
letters = args[:letters]
|
66
|
+
relative_action = args[:relative_action]
|
67
|
+
relative_time = args[:relative_time]
|
68
|
+
|
69
|
+
# TODO: We really need to sit down and think about what this check means
|
70
|
+
# Right now, it's going to mean
|
71
|
+
# All letters of letters type must have been made after relative_time ago
|
72
|
+
# And there must exist a letter of some such type
|
73
|
+
# As in, "we need the existence of a letter of type X or Y, and no letter of type X or Y may have been sent in the last .. days"
|
74
|
+
|
75
|
+
scope.where(
|
76
|
+
PigeonLetter.where(
|
77
|
+
pigeon_arel[:cargo_id].eq(source_arel[:id]).and(
|
78
|
+
pigeon_arel[:cargo_type].eq(source_arel.name.classify)
|
79
|
+
).and(
|
80
|
+
pigeon_arel[:letter_type].in(letters)
|
81
|
+
)
|
82
|
+
).exists
|
83
|
+
).where(
|
84
|
+
PigeonLetter.where(
|
85
|
+
pigeon_arel[:cargo_id].eq(source_arel[:id]).and(
|
86
|
+
pigeon_arel[:cargo_type].eq(source_arel.name.classify)
|
87
|
+
).and(
|
88
|
+
pigeon_arel[:letter_type].in(letters)
|
89
|
+
).and(
|
90
|
+
relative_action == :after ?
|
91
|
+
pigeon_arel[:created_at].gt(relative_time) : nil # TODO: nil?
|
92
|
+
)
|
93
|
+
).exists.not
|
94
|
+
)
|
95
|
+
end
|
96
|
+
|
97
|
+
end
|
98
|
+
end
|
@@ -0,0 +1,35 @@
|
|
1
|
+
|
2
|
+
module Pigeons
|
3
|
+
|
4
|
+
module Elements
|
5
|
+
|
6
|
+
# This is the beast of our regex base parsing library
|
7
|
+
# He is going to parse all discernable elements from the string
|
8
|
+
# And send it back as elements
|
9
|
+
def self.parse_elements(letter)
|
10
|
+
article = /a(?:[n]?|ll)/ix
|
11
|
+
base_element = /(#{article}\s+)? (?<base_element>[\w\s]+?)/ix
|
12
|
+
conditionals = /(who((\'ve)|(\s+ have))?\s+) (?<conditionals>[\w\s]+)/ix
|
13
|
+
letter_type = /(#{article}\s+)? (?<letter>[\w\s]+) \s+ letter/ix
|
14
|
+
time_metric = /( (?<time_metric_coefficient>\d+) \s+)? (?<time_metric_unit>time|second|minute|hour|day|week|fortnight|month|year)(s?)/ix
|
15
|
+
time_qualifier = /(?<relative>after) \s+ (?<time_item>[^.!$]+)/ix
|
16
|
+
time_clause = /((?<recurring>every)\s+)? (?<time_metric>#{time_metric})? (\s*#{time_qualifier})?/ix
|
17
|
+
|
18
|
+
grammar = /^\s* # Allow whitespace
|
19
|
+
((?<joiner>and|then)\s+)? # Joiner is and or then
|
20
|
+
(#{base_element} \s+)? # Base element as ActiveRecord type or base block
|
21
|
+
(#{conditionals} \s+)? # Who ... condition block
|
22
|
+
get(s)? \s+ # get
|
23
|
+
#{letter_type} # a ... letter
|
24
|
+
(\s+ #{time_clause})? # after letter or event block
|
25
|
+
\s*[.!]? # optional punctuation
|
26
|
+
\s* # allow whitespace
|
27
|
+
$/ix
|
28
|
+
|
29
|
+
# Pigeons::Logger.debug [ 'Grammar', grammar ]
|
30
|
+
grammar.match(letter)
|
31
|
+
end
|
32
|
+
|
33
|
+
end
|
34
|
+
|
35
|
+
end
|
@@ -0,0 +1,34 @@
|
|
1
|
+
module Pigeons
|
2
|
+
|
3
|
+
# Extensions will tell Pigeon how to parse flights for app-specific context
|
4
|
+
class Extension
|
5
|
+
# base /regexp/ { return scope }
|
6
|
+
|
7
|
+
protected
|
8
|
+
|
9
|
+
def self.base(matcher, &clause)
|
10
|
+
matcher = Regexp.new(matcher, "i") if matcher.is_a?(String)
|
11
|
+
raise ArgumentError, "First argument must be a string or regular expression" unless matcher.is_a?(Regexp)
|
12
|
+
raise ArgumentError, "Must include block which returns scope" unless block_given?
|
13
|
+
|
14
|
+
Pigeons.add_base(matcher, clause)
|
15
|
+
end
|
16
|
+
|
17
|
+
def self.condition(matcher, &clause)
|
18
|
+
matcher = Regexp.new(matcher, "i") if matcher.is_a?(String)
|
19
|
+
raise ArgumentError, "First argument must be a string or regular expression" unless matcher.is_a?(Regexp)
|
20
|
+
raise ArgumentError, "Must include block which returns scope" unless block_given?
|
21
|
+
|
22
|
+
Pigeons.add_conditional(matcher, clause)
|
23
|
+
end
|
24
|
+
|
25
|
+
def self.event(matcher, &clause)
|
26
|
+
matcher = Regep.new(matcher, "i") if matcher.is_a?(String)
|
27
|
+
raise ArgumentError, "First argument must be a string or regular expression" unless matcher.is_a?(Regexp)
|
28
|
+
raise ArgumentError, "Must include block which returns scope" unless block_given?
|
29
|
+
|
30
|
+
Pigeons.add_event(matcher, clause)
|
31
|
+
end
|
32
|
+
|
33
|
+
end
|
34
|
+
end
|
@@ -0,0 +1,156 @@
|
|
1
|
+
|
2
|
+
# Pigeon controls the Carrier Pigeon system
|
3
|
+
# Note, we're going to try to make this not require Rails - but it may require some aspects of ActiveSupport, etc.
|
4
|
+
|
5
|
+
module Pigeons
|
6
|
+
|
7
|
+
class Settings
|
8
|
+
# Initialize class attributes
|
9
|
+
class_attribute :pigeon_config_file
|
10
|
+
class_attribute :cooldown # Limit time between sending entity a second letter
|
11
|
+
class_attribute :bases, :conditionals, :events
|
12
|
+
|
13
|
+
# Set Defaults
|
14
|
+
self.pigeon_config_file = nil
|
15
|
+
self.cooldown = 2.days
|
16
|
+
|
17
|
+
# Initialize
|
18
|
+
self.bases = []
|
19
|
+
self.conditionals = []
|
20
|
+
self.events = []
|
21
|
+
end
|
22
|
+
|
23
|
+
# returns will be { flight_name: [ { letter: letter_a, query: sql query, count: result count, entity: type of entity }, { ... } ] } ...
|
24
|
+
def self.assemble(options={})
|
25
|
+
opts = {
|
26
|
+
debug: false,
|
27
|
+
deep_debug: false, # include flight results in debug output
|
28
|
+
send: true
|
29
|
+
}.with_indifferent_access.merge(options)
|
30
|
+
|
31
|
+
'''Assembles all base entities deserving a post in this flight'''
|
32
|
+
|
33
|
+
# First, we're going to check that PigeonMailer and PigeonLetter exist (and are as we'd expect)
|
34
|
+
require 'pigeon_letter' unless defined?(PigeonLetter)
|
35
|
+
require 'pigeon_mailer' unless defined?(PigeonMailer)
|
36
|
+
|
37
|
+
# We'll set a natural default location if Rails is defined
|
38
|
+
Settings.pigeon_config_file ||= "#{Rails.root}/config/pigeons.json" if defined?(Rails)
|
39
|
+
|
40
|
+
raise PigeonError::PigeonConfigError, "Must create PigeonMailer mailer (TODO: instructions)" unless defined?(PigeonMailer) && ( PigeonMailer < ActionMailer::Base )
|
41
|
+
raise PigeonError::PigeonConfigError, "Must create PigeonLetter model (TODO: instructions)" unless defined?(PigeonLetter) && ( PigeonLetter < ActiveRecord::Base )
|
42
|
+
|
43
|
+
# Next, we're going to pull the configuration JSON file
|
44
|
+
raise PigeonError::PigeonConfigError, "Must set pigeon_config_file" if Settings.pigeon_config_file.blank?
|
45
|
+
raise PigeonError::PigeonConfigError, "Missing pigeon configuration file: #{Settings.pigeon_config_file}" if !File.exists?(Settings.pigeon_config_file)
|
46
|
+
|
47
|
+
pigeon_config = begin
|
48
|
+
JSON(File.read(Settings.pigeon_config_file)) # TODO: Caching?
|
49
|
+
rescue JSON::ParserError => e
|
50
|
+
raise PigeonError::PigeonConfigError, "Error parsing pigeon configuration file: #{e.inspect}"
|
51
|
+
end
|
52
|
+
|
53
|
+
# Flights are the "cohorts" defined what and how to send letters to our entities (e.g. users)
|
54
|
+
flights = Hash[pigeon_config['flights'].sort]
|
55
|
+
raise PigeonError::PigeonConfigError, "Configuration must include flights object" if flights.nil?
|
56
|
+
|
57
|
+
results = {}
|
58
|
+
|
59
|
+
# Note, order here is important (for mod i), and thus, we've sorted flights alphabeticaly to maintain consistency
|
60
|
+
flights.each_with_index do |(flight, flight_info), flight_id|
|
61
|
+
Pigeons::Logger.log [ 'Pigeons::', 'Flight::', flight ] if opts[:debug]
|
62
|
+
|
63
|
+
results[flight] = []
|
64
|
+
|
65
|
+
# We'll allow flight: [ ... ] instead of flight: { letters: [ ... ] } for shorthand if other options are not needed
|
66
|
+
flight_info = { 'letters' => flight_info } if flight_info.is_a?(Array)
|
67
|
+
|
68
|
+
base_scope = default_base_scope = if flight_info['base'] # Note, this will usually be nil, but it can be set specifically
|
69
|
+
Scope.get_base_scope(flight_info['base'])
|
70
|
+
end
|
71
|
+
|
72
|
+
raise PigeonError::PigeonFlightConfigError, "Letters must be present in flight" if flight_info['letters'].empty?
|
73
|
+
|
74
|
+
previous_letters = []
|
75
|
+
|
76
|
+
flight_info['letters'].each_with_index do |letter_info, i|
|
77
|
+
|
78
|
+
Pigeons::Logger.debug [ 'Pigeons::', "Flight #{flight}", "Letter Info", letter_info ] if opts[:debug]
|
79
|
+
# This is going to parse the letter info and make sure it makes sense
|
80
|
+
elements = Elements.parse_elements(letter_info)
|
81
|
+
|
82
|
+
# Let's see if we were able to parse letter info
|
83
|
+
raise PigeonError::PigeonFlightConfigError, "Failed to parse letter info for flight #{flight}: \"#{letter_info}\". Should be like \"someone\" gets a \"type a\" letter \"sometime\" after \"some event\"" if elements.nil?
|
84
|
+
|
85
|
+
# Otherwise, convert capture groups to a nice hash
|
86
|
+
elements = Hash[elements.names.zip(elements.captures)].with_indifferent_access
|
87
|
+
|
88
|
+
# args such as default base scope? maybe we should change to an arguments hash
|
89
|
+
# note: for running conditionals, we could verify they skip the previous scopes, but this is going to
|
90
|
+
# end up a complicated query. we're going to rely on letter-existence skipping instead
|
91
|
+
scope_res = Scope.get_scope(elements: elements, previous_base_scope: base_scope, previous_letters: previous_letters, flights: flights, flight: flight, flight_id: flight_id, letter_info: letter_info, opts: opts)
|
92
|
+
|
93
|
+
scope = scope_res[:scope]
|
94
|
+
base_scope = scope_res[:base_scope]
|
95
|
+
previous_letters = scope_res[:previous_letters]
|
96
|
+
letter = scope_res[:letter]
|
97
|
+
entity = scope_res[:entity]
|
98
|
+
|
99
|
+
Pigeons::Logger.debug [ 'Pigeons::', "Flight #{flight} Letter", letter_info ] if opts[:debug]
|
100
|
+
Pigeons::Logger.debug [ 'Pigeons::', "Flight #{flight} Scope", scope.explain ] if opts[:debug]
|
101
|
+
Pigeons::Logger.debug [ 'Pigeons::', "Flight #{flight} Count", scope.count ] if opts[:debug]
|
102
|
+
Pigeons::Logger.debug [ 'Pigeons::', "Flight #{flight} Results", scope ] if opts[:deep_debug]
|
103
|
+
|
104
|
+
results[flight].push(letter: letter, query: scope.to_sql, count: scope.count, entity: entity, scope: scope)
|
105
|
+
|
106
|
+
# TODO: We could break this out?
|
107
|
+
self.send_letter(scope, letter, flight) if opts[:send]
|
108
|
+
end
|
109
|
+
end
|
110
|
+
|
111
|
+
results
|
112
|
+
end
|
113
|
+
|
114
|
+
# Add base will add a match clause to our checks in parsing
|
115
|
+
# scope_block is a proc that will return a scope used in further flight tests
|
116
|
+
def self.add_base(matcher, clause)
|
117
|
+
Settings.bases.push(matcher: matcher, clause: clause)
|
118
|
+
end
|
119
|
+
|
120
|
+
# Add a conditional block
|
121
|
+
# If a condition matches this conditional block, it will append its where clause
|
122
|
+
def self.add_conditional(matcher, clause)
|
123
|
+
Settings.conditionals.push(matcher: matcher, clause: clause)
|
124
|
+
end
|
125
|
+
|
126
|
+
def self.add_event(matcher, clause)
|
127
|
+
Settings.events.push(matcher: matcher, clause: clause)
|
128
|
+
end
|
129
|
+
|
130
|
+
public # TODO: should these change to private?
|
131
|
+
|
132
|
+
# This is responsible for the actual send letter aspect
|
133
|
+
# It will create PigeonLetters and call delivery
|
134
|
+
# to PigeonMailer
|
135
|
+
def self.send_letter(scope, letter_type, flight)
|
136
|
+
# The question we face here is, do we create letters one-by-one, or do we create the letters in bulk
|
137
|
+
# possibly updating sent_at after sending
|
138
|
+
|
139
|
+
# Also, how do we send letters?
|
140
|
+
# Is that asynchronous?
|
141
|
+
|
142
|
+
# Let's just do this quick and dirty and come back to this
|
143
|
+
scope.find_each do |cargo|
|
144
|
+
letter = PigeonLetter.create!(cargo: cargo, letter_type: letter_type, flight: flight)
|
145
|
+
|
146
|
+
# Send the letter -- note, we may want to also send info about flights for tracking
|
147
|
+
begin
|
148
|
+
letter.send!
|
149
|
+
rescue => e # We're going to rescue here to save ourselves the humiliation of failing.
|
150
|
+
# It would be nice to log this at the PigeonLetter (database) or return level
|
151
|
+
p [ 'Pigeons::SendLetter', 'Failed to send letter', e ]
|
152
|
+
end
|
153
|
+
end
|
154
|
+
end
|
155
|
+
|
156
|
+
end
|