@@ -0,0 +1,154 @@ |
||
| 1 |
+module SortableEvents |
|
| 2 |
+ extend ActiveSupport::Concern |
|
| 3 |
+ |
|
| 4 |
+ included do |
|
| 5 |
+ validate :validate_events_order |
|
| 6 |
+ end |
|
| 7 |
+ |
|
| 8 |
+ def description_events_order(events = 'events created in each run') |
|
| 9 |
+ <<-MD.lstrip |
|
| 10 |
+ To specify the order of #{events}, set `events_order` to an array of sort keys, each of which looks like either `expression` or `[expression, type, descending]`, as described as follows:
|
|
| 11 |
+ |
|
| 12 |
+ * _expression_ is a Liquid template to generate a string to be used as sort key. |
|
| 13 |
+ |
|
| 14 |
+ * _type_ (optional) is one of `string` (default), `number` and `time`, which specifies how to evaluate _expression_ for comparison. |
|
| 15 |
+ |
|
| 16 |
+ * _descending_ (optional) is a boolean value to determine if comparison should be done in descending (reverse) order, which defaults to `false`. |
|
| 17 |
+ |
|
| 18 |
+ Sort keys listed eariler take precedence over ones listed later. For example, if you want to sort articles by the date and then by the author, specify `[["{{date}}", "time"], "{{author}}"]`.
|
|
| 19 |
+ |
|
| 20 |
+ Sorting is done stably, so even if all events have the same set of sort key values the original order is retained. Also, a special Liquid variable `_index_` is provided, which contains the zero-based index number of each event, which means you can exactly reverse the order of events by specifying `[["{{_index_}}", "number", true]]`.
|
|
| 21 |
+ MD |
|
| 22 |
+ end |
|
| 23 |
+ |
|
| 24 |
+ module ClassMethods |
|
| 25 |
+ def can_order_created_events! |
|
| 26 |
+ raise if cannot_create_events? |
|
| 27 |
+ prepend AutomaticSorter |
|
| 28 |
+ end |
|
| 29 |
+ |
|
| 30 |
+ def can_order_created_events? |
|
| 31 |
+ include? AutomaticSorter |
|
| 32 |
+ end |
|
| 33 |
+ |
|
| 34 |
+ def cannot_order_created_events? |
|
| 35 |
+ !can_order_created_events? |
|
| 36 |
+ end |
|
| 37 |
+ end |
|
| 38 |
+ |
|
| 39 |
+ def can_order_created_events? |
|
| 40 |
+ self.class.__send__(__callee__) |
|
| 41 |
+ end |
|
| 42 |
+ |
|
| 43 |
+ def cannot_order_created_events? |
|
| 44 |
+ self.class.__send__(__callee__) |
|
| 45 |
+ end |
|
| 46 |
+ |
|
| 47 |
+ def events_order |
|
| 48 |
+ options['events_order'] |
|
| 49 |
+ end |
|
| 50 |
+ |
|
| 51 |
+ module AutomaticSorter |
|
| 52 |
+ def check |
|
| 53 |
+ return super unless events_order |
|
| 54 |
+ sorting_events do |
|
| 55 |
+ super |
|
| 56 |
+ end |
|
| 57 |
+ end |
|
| 58 |
+ |
|
| 59 |
+ def receive(incoming_events) |
|
| 60 |
+ return super unless events_order |
|
| 61 |
+ # incoming events should be processed sequentially |
|
| 62 |
+ incoming_events.each do |event| |
|
| 63 |
+ sorting_events do |
|
| 64 |
+ super([event]) |
|
| 65 |
+ end |
|
| 66 |
+ end |
|
| 67 |
+ end |
|
| 68 |
+ |
|
| 69 |
+ def create_event(attrs) |
|
| 70 |
+ if @sortable_events |
|
| 71 |
+ @sortable_events << events.build({ user: user }.merge(attrs))
|
|
| 72 |
+ else |
|
| 73 |
+ super |
|
| 74 |
+ end |
|
| 75 |
+ end |
|
| 76 |
+ |
|
| 77 |
+ private |
|
| 78 |
+ |
|
| 79 |
+ def sorting_events(&block) |
|
| 80 |
+ @sortable_events = [] |
|
| 81 |
+ yield |
|
| 82 |
+ ensure |
|
| 83 |
+ events, @sortable_events = @sortable_events, nil |
|
| 84 |
+ sort_events(events).each do |event| |
|
| 85 |
+ event.expires_at ||= new_event_expiration_date |
|
| 86 |
+ event.save! |
|
| 87 |
+ end |
|
| 88 |
+ end |
|
| 89 |
+ end |
|
| 90 |
+ |
|
| 91 |
+ private |
|
| 92 |
+ |
|
| 93 |
+ EXPRESSION_PARSER = {
|
|
| 94 |
+ 'string' => ->string { string },
|
|
| 95 |
+ 'number' => ->string { string.to_f },
|
|
| 96 |
+ 'time' => ->string { Time.zone.parse(string) },
|
|
| 97 |
+ } |
|
| 98 |
+ EXPRESSION_TYPES = EXPRESSION_PARSER.keys.freeze |
|
| 99 |
+ |
|
| 100 |
+ def validate_events_order |
|
| 101 |
+ case order_by = events_order() |
|
| 102 |
+ when nil |
|
| 103 |
+ when Array |
|
| 104 |
+ # Each tuple may be either [expression, type, desc] or just |
|
| 105 |
+ # expression. |
|
| 106 |
+ order_by.each do |expression, type, desc| |
|
| 107 |
+ case expression |
|
| 108 |
+ when String |
|
| 109 |
+ # ok |
|
| 110 |
+ else |
|
| 111 |
+ errors.add(:base, "first element of each events_order tuple must be a Liquid template") |
|
| 112 |
+ break |
|
| 113 |
+ end |
|
| 114 |
+ case type |
|
| 115 |
+ when nil, *EXPRESSION_TYPES |
|
| 116 |
+ # ok |
|
| 117 |
+ else |
|
| 118 |
+ errors.add(:base, "second element of each events_order tuple must be #{EXPRESSION_TYPES.to_sentence(last_word_connector: ' or ')}")
|
|
| 119 |
+ break |
|
| 120 |
+ end |
|
| 121 |
+ if !desc.nil? && boolify(desc).nil? |
|
| 122 |
+ errors.add(:base, "third element of each events_order tuple must be a boolean value") |
|
| 123 |
+ break |
|
| 124 |
+ end |
|
| 125 |
+ end |
|
| 126 |
+ else |
|
| 127 |
+ errors.add(:base, "events_order must be an array of arrays") |
|
| 128 |
+ end |
|
| 129 |
+ end |
|
| 130 |
+ |
|
| 131 |
+ # Sort given events in order specified by the "events_order" option |
|
| 132 |
+ def sort_events(events) |
|
| 133 |
+ order_by = events_order().presence or return events |
|
| 134 |
+ orders = order_by.map { |_, _, desc = false| boolify(desc) }
|
|
| 135 |
+ |
|
| 136 |
+ Utils.sort_tuples!( |
|
| 137 |
+ events.map.with_index { |event, index|
|
|
| 138 |
+ interpolate_with(event) {
|
|
| 139 |
+ interpolation_context['_index_'] = index |
|
| 140 |
+ order_by.map { |expression, type, _|
|
|
| 141 |
+ string = interpolate_string(expression) |
|
| 142 |
+ begin |
|
| 143 |
+ EXPRESSION_PARSER[type || 'string'.freeze][string] |
|
| 144 |
+ rescue |
|
| 145 |
+ error "Cannot parse #{string.inspect} as #{type}; treating it as string"
|
|
| 146 |
+ string |
|
| 147 |
+ end |
|
| 148 |
+ } |
|
| 149 |
+ } << index << event # index is to make sorting stable |
|
| 150 |
+ }, |
|
| 151 |
+ orders |
|
| 152 |
+ ).collect!(&:last) |
|
| 153 |
+ end |
|
| 154 |
+end |
@@ -13,6 +13,7 @@ class Agent < ActiveRecord::Base |
||
| 13 | 13 |
include HasGuid |
| 14 | 14 |
include LiquidDroppable |
| 15 | 15 |
include DryRunnable |
| 16 |
+ include SortableEvents |
|
| 16 | 17 |
|
| 17 | 18 |
markdown_class_attributes :description, :event_description |
| 18 | 19 |
|
@@ -0,0 +1,264 @@ |
||
| 1 |
+require 'spec_helper' |
|
| 2 |
+ |
|
| 3 |
+describe SortableEvents do |
|
| 4 |
+ let(:agent_class) {
|
|
| 5 |
+ Class.new(Agent) do |
|
| 6 |
+ include SortableEvents |
|
| 7 |
+ |
|
| 8 |
+ default_schedule 'never' |
|
| 9 |
+ |
|
| 10 |
+ def self.valid_type?(name) |
|
| 11 |
+ true |
|
| 12 |
+ end |
|
| 13 |
+ end |
|
| 14 |
+ } |
|
| 15 |
+ |
|
| 16 |
+ def new_agent(events_order = nil) |
|
| 17 |
+ options = {}
|
|
| 18 |
+ options['events_order'] = events_order if events_order |
|
| 19 |
+ agent_class.new(name: 'test', options: options) { |agent|
|
|
| 20 |
+ agent.user = users(:bob) |
|
| 21 |
+ } |
|
| 22 |
+ end |
|
| 23 |
+ |
|
| 24 |
+ describe 'validations' do |
|
| 25 |
+ let(:agent_class) {
|
|
| 26 |
+ Class.new(Agent) do |
|
| 27 |
+ include SortableEvents |
|
| 28 |
+ |
|
| 29 |
+ default_schedule 'never' |
|
| 30 |
+ |
|
| 31 |
+ def self.valid_type?(name) |
|
| 32 |
+ true |
|
| 33 |
+ end |
|
| 34 |
+ end |
|
| 35 |
+ } |
|
| 36 |
+ |
|
| 37 |
+ def new_agent(events_order = nil) |
|
| 38 |
+ options = {}
|
|
| 39 |
+ options['events_order'] = events_order if events_order |
|
| 40 |
+ agent_class.new(name: 'test', options: options) { |agent|
|
|
| 41 |
+ agent.user = users(:bob) |
|
| 42 |
+ } |
|
| 43 |
+ end |
|
| 44 |
+ |
|
| 45 |
+ it 'should allow events_order to be unspecified, null or an empty array' do |
|
| 46 |
+ expect(new_agent()).to be_valid |
|
| 47 |
+ expect(new_agent(nil)).to be_valid |
|
| 48 |
+ expect(new_agent([])).to be_valid |
|
| 49 |
+ end |
|
| 50 |
+ |
|
| 51 |
+ it 'should not allow events_order to be a non-array object' do |
|
| 52 |
+ agent = new_agent(0) |
|
| 53 |
+ expect(agent).not_to be_valid |
|
| 54 |
+ expect(agent.errors[:base]).to include(/events_order/) |
|
| 55 |
+ |
|
| 56 |
+ agent = new_agent('')
|
|
| 57 |
+ expect(agent).not_to be_valid |
|
| 58 |
+ expect(agent.errors[:base]).to include(/events_order/) |
|
| 59 |
+ |
|
| 60 |
+ agent = new_agent({})
|
|
| 61 |
+ expect(agent).not_to be_valid |
|
| 62 |
+ expect(agent.errors[:base]).to include(/events_order/) |
|
| 63 |
+ end |
|
| 64 |
+ |
|
| 65 |
+ it 'should not allow events_order to be an array containing unexpected objects' do |
|
| 66 |
+ agent = new_agent(['{{key}}', 1])
|
|
| 67 |
+ expect(agent).not_to be_valid |
|
| 68 |
+ expect(agent.errors[:base]).to include(/events_order/) |
|
| 69 |
+ |
|
| 70 |
+ agent = new_agent(['{{key1}}', ['{{key2}}', 'unknown']])
|
|
| 71 |
+ expect(agent).not_to be_valid |
|
| 72 |
+ expect(agent.errors[:base]).to include(/events_order/) |
|
| 73 |
+ end |
|
| 74 |
+ |
|
| 75 |
+ it 'should allow events_order to be an array containing strings and valid tuples' do |
|
| 76 |
+ agent = new_agent(['{{key1}}', ['{{key2}}'], ['{{key3}}', 'number']])
|
|
| 77 |
+ expect(agent).to be_valid |
|
| 78 |
+ |
|
| 79 |
+ agent = new_agent(['{{key1}}', ['{{key2}}'], ['{{key3}}', 'number'], ['{{key4}}', 'time', true]])
|
|
| 80 |
+ expect(agent).to be_valid |
|
| 81 |
+ end |
|
| 82 |
+ end |
|
| 83 |
+ |
|
| 84 |
+ describe 'sort_events' do |
|
| 85 |
+ let(:payloads) {
|
|
| 86 |
+ [ |
|
| 87 |
+ { 'title' => 'TitleA', 'score' => 4, 'updated_on' => '7 Jul 2015' },
|
|
| 88 |
+ { 'title' => 'TitleB', 'score' => 2, 'updated_on' => '25 Jun 2014' },
|
|
| 89 |
+ { 'title' => 'TitleD', 'score' => 10, 'updated_on' => '10 Jan 2015' },
|
|
| 90 |
+ { 'title' => 'TitleC', 'score' => 10, 'updated_on' => '9 Feb 2015' },
|
|
| 91 |
+ ] |
|
| 92 |
+ } |
|
| 93 |
+ |
|
| 94 |
+ let(:events) {
|
|
| 95 |
+ payloads.map { |payload| Event.new(payload: payload) }
|
|
| 96 |
+ } |
|
| 97 |
+ |
|
| 98 |
+ it 'should sort events by a given key' do |
|
| 99 |
+ agent = new_agent(['{{title}}'])
|
|
| 100 |
+ expect(agent.__send__(:sort_events, events).map { |e| e.payload['title'] }).to eq(%w[TitleA TitleB TitleC TitleD])
|
|
| 101 |
+ |
|
| 102 |
+ agent = new_agent([['{{title}}', 'string', true]])
|
|
| 103 |
+ expect(agent.__send__(:sort_events, events).map { |e| e.payload['title'] }).to eq(%w[TitleD TitleC TitleB TitleA])
|
|
| 104 |
+ end |
|
| 105 |
+ |
|
| 106 |
+ it 'should sort events by multiple keys' do |
|
| 107 |
+ agent = new_agent([['{{score}}', 'number'], '{{title}}'])
|
|
| 108 |
+ expect(agent.__send__(:sort_events, events).map { |e| e.payload['title'] }).to eq(%w[TitleB TitleA TitleC TitleD])
|
|
| 109 |
+ |
|
| 110 |
+ agent = new_agent([['{{score}}', 'number'], ['{{title}}', 'string', true]])
|
|
| 111 |
+ expect(agent.__send__(:sort_events, events).map { |e| e.payload['title'] }).to eq(%w[TitleB TitleA TitleD TitleC])
|
|
| 112 |
+ end |
|
| 113 |
+ |
|
| 114 |
+ it 'should sort events by time' do |
|
| 115 |
+ agent = new_agent([['{{updated_on}}', 'time']])
|
|
| 116 |
+ expect(agent.__send__(:sort_events, events).map { |e| e.payload['title'] }).to eq(%w[TitleB TitleD TitleC TitleA])
|
|
| 117 |
+ end |
|
| 118 |
+ |
|
| 119 |
+ it 'should sort events stably' do |
|
| 120 |
+ agent = new_agent(['<constant>']) |
|
| 121 |
+ expect(agent.__send__(:sort_events, events).map { |e| e.payload['title'] }).to eq(%w[TitleA TitleB TitleD TitleC])
|
|
| 122 |
+ |
|
| 123 |
+ agent = new_agent([['<constant>', 'string', true]]) |
|
| 124 |
+ expect(agent.__send__(:sort_events, events).map { |e| e.payload['title'] }).to eq(%w[TitleA TitleB TitleD TitleC])
|
|
| 125 |
+ end |
|
| 126 |
+ |
|
| 127 |
+ it 'should support _index_' do |
|
| 128 |
+ agent = new_agent([['{{_index_}}', 'number', true]])
|
|
| 129 |
+ expect(agent.__send__(:sort_events, events).map { |e| e.payload['title'] }).to eq(%w[TitleC TitleD TitleB TitleA])
|
|
| 130 |
+ end |
|
| 131 |
+ end |
|
| 132 |
+ |
|
| 133 |
+ describe 'automatic event sorter' do |
|
| 134 |
+ describe 'declaration' do |
|
| 135 |
+ let(:passive_agent_class) {
|
|
| 136 |
+ Class.new(Agent) do |
|
| 137 |
+ include SortableEvents |
|
| 138 |
+ |
|
| 139 |
+ cannot_create_events! |
|
| 140 |
+ end |
|
| 141 |
+ } |
|
| 142 |
+ |
|
| 143 |
+ let(:active_agent_class) {
|
|
| 144 |
+ Class.new(Agent) do |
|
| 145 |
+ include SortableEvents |
|
| 146 |
+ end |
|
| 147 |
+ } |
|
| 148 |
+ |
|
| 149 |
+ describe 'can_order_created_events!' do |
|
| 150 |
+ it 'should refuse to work if called from an Agent that cannot create events' do |
|
| 151 |
+ expect {
|
|
| 152 |
+ passive_agent_class.class_eval do |
|
| 153 |
+ can_order_created_events! |
|
| 154 |
+ end |
|
| 155 |
+ }.to raise_error |
|
| 156 |
+ end |
|
| 157 |
+ |
|
| 158 |
+ it 'should work if called from an Agent that can create events' do |
|
| 159 |
+ expect {
|
|
| 160 |
+ active_agent_class.class_eval do |
|
| 161 |
+ can_order_created_events! |
|
| 162 |
+ end |
|
| 163 |
+ }.not_to raise_error |
|
| 164 |
+ end |
|
| 165 |
+ end |
|
| 166 |
+ |
|
| 167 |
+ describe 'can_order_created_events?' do |
|
| 168 |
+ it 'should return false unless an Agent declares can_order_created_events!' do |
|
| 169 |
+ expect(active_agent_class.can_order_created_events?).to eq(false) |
|
| 170 |
+ expect(active_agent_class.new.can_order_created_events?).to eq(false) |
|
| 171 |
+ end |
|
| 172 |
+ |
|
| 173 |
+ it 'should return true if an Agent declares can_order_created_events!' do |
|
| 174 |
+ active_agent_class.class_eval do |
|
| 175 |
+ can_order_created_events! |
|
| 176 |
+ end |
|
| 177 |
+ |
|
| 178 |
+ expect(active_agent_class.can_order_created_events?).to eq(true) |
|
| 179 |
+ expect(active_agent_class.new.can_order_created_events?).to eq(true) |
|
| 180 |
+ end |
|
| 181 |
+ end |
|
| 182 |
+ end |
|
| 183 |
+ |
|
| 184 |
+ describe 'behavior' do |
|
| 185 |
+ class Agents::EventOrderableAgent < Agent |
|
| 186 |
+ include SortableEvents |
|
| 187 |
+ |
|
| 188 |
+ default_schedule 'never' |
|
| 189 |
+ |
|
| 190 |
+ can_order_created_events! |
|
| 191 |
+ |
|
| 192 |
+ attr_accessor :payloads_to_emit |
|
| 193 |
+ |
|
| 194 |
+ def self.valid_type?(name) |
|
| 195 |
+ true |
|
| 196 |
+ end |
|
| 197 |
+ |
|
| 198 |
+ def check |
|
| 199 |
+ payloads_to_emit.each do |payload| |
|
| 200 |
+ create_event payload: payload |
|
| 201 |
+ end |
|
| 202 |
+ end |
|
| 203 |
+ |
|
| 204 |
+ def receive(events) |
|
| 205 |
+ events.each do |event| |
|
| 206 |
+ payloads_to_emit.each do |payload| |
|
| 207 |
+ create_event payload: payload.merge('title' => payload['title'] + event.payload['title_suffix'])
|
|
| 208 |
+ end |
|
| 209 |
+ end |
|
| 210 |
+ end |
|
| 211 |
+ end |
|
| 212 |
+ |
|
| 213 |
+ def new_agent(events_order = nil) |
|
| 214 |
+ options = {}
|
|
| 215 |
+ options['events_order'] = events_order if events_order |
|
| 216 |
+ Agents::EventOrderableAgent.new(name: 'test', options: options) { |agent|
|
|
| 217 |
+ agent.user = users(:bob) |
|
| 218 |
+ agent.payloads_to_emit = payloads |
|
| 219 |
+ } |
|
| 220 |
+ end |
|
| 221 |
+ |
|
| 222 |
+ let(:payloads) {
|
|
| 223 |
+ [ |
|
| 224 |
+ { 'title' => 'TitleA', 'score' => 4, 'updated_on' => '7 Jul 2015' },
|
|
| 225 |
+ { 'title' => 'TitleB', 'score' => 2, 'updated_on' => '25 Jun 2014' },
|
|
| 226 |
+ { 'title' => 'TitleD', 'score' => 10, 'updated_on' => '10 Jan 2015' },
|
|
| 227 |
+ { 'title' => 'TitleC', 'score' => 10, 'updated_on' => '9 Feb 2015' },
|
|
| 228 |
+ ] |
|
| 229 |
+ } |
|
| 230 |
+ |
|
| 231 |
+ it 'should keep the order of created events unless events_order is specified' do |
|
| 232 |
+ [[], [nil], [[]]].each do |args| |
|
| 233 |
+ agent = new_agent(*args) |
|
| 234 |
+ agent.save! |
|
| 235 |
+ expect { agent.check }.to change { Event.count }.by(4)
|
|
| 236 |
+ events = agent.events.last(4).sort_by(&:id) |
|
| 237 |
+ expect(events.map { |event| event.payload['title'] }).to eq(%w[TitleA TitleB TitleD TitleC])
|
|
| 238 |
+ end |
|
| 239 |
+ end |
|
| 240 |
+ |
|
| 241 |
+ it 'should sort events created in check() in the order specified in events_order' do |
|
| 242 |
+ agent = new_agent([['{{score}}', 'number'], ['{{title}}', 'string', true]])
|
|
| 243 |
+ agent.save! |
|
| 244 |
+ expect { agent.check }.to change { Event.count }.by(4)
|
|
| 245 |
+ events = agent.events.last(4).sort_by(&:id) |
|
| 246 |
+ expect(events.map { |event| event.payload['title'] }).to eq(%w[TitleB TitleA TitleD TitleC])
|
|
| 247 |
+ end |
|
| 248 |
+ |
|
| 249 |
+ it 'should sort events created in receive() in the order specified in events_order' do |
|
| 250 |
+ agent = new_agent([['{{score}}', 'number'], ['{{title}}', 'string', true]])
|
|
| 251 |
+ agent.save! |
|
| 252 |
+ expect {
|
|
| 253 |
+ agent.receive([Event.new(payload: { 'title_suffix' => ' [new]' }),
|
|
| 254 |
+ Event.new(payload: { 'title_suffix' => ' [popular]' })])
|
|
| 255 |
+ }.to change { Event.count }.by(8)
|
|
| 256 |
+ events = agent.events.last(8).sort_by(&:id) |
|
| 257 |
+ expect(events.map { |event| event.payload['title'] }).to eq([
|
|
| 258 |
+ 'TitleB [new]', 'TitleA [new]', 'TitleD [new]', 'TitleC [new]', |
|
| 259 |
+ 'TitleB [popular]', 'TitleA [popular]', 'TitleD [popular]', 'TitleC [popular]', |
|
| 260 |
+ ]) |
|
| 261 |
+ end |
|
| 262 |
+ end |
|
| 263 |
+ end |
|
| 264 |
+end |