segmentor 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Segmentor
4
+ # Receipt is generated when a notification is sent to a target.
5
+ class Receipt < ActiveRecord::Base
6
+ self.table_name = :segmentor_receipts
7
+ store :metadata, accessors: %i[target_id session_id], coder: JSON
8
+
9
+ belongs_to :segment, class_name: '::Segmentor::Segment'
10
+ belongs_to :user
11
+
12
+ validates :sent_at, presence: true
13
+
14
+ def self.record_receipt(target, rendered_value = nil)
15
+ receipt = ::Segmentor::Receipt.new
16
+ receipt.segment = target.segment
17
+ receipt.user_id = target.user_id
18
+ receipt.target_id = target.id
19
+ receipt.session_id = target.session_id
20
+ receipt.rendered_value = rendered_value
21
+ receipt.sent_at = Time.now.utc
22
+ receipt.save!
23
+
24
+ receipt
25
+ end
26
+
27
+ def next
28
+ segment.receipts.where('id > ?', id).first
29
+ end
30
+
31
+ def prev
32
+ segment.receipts.where('id < ?', id).last
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,151 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_record'
4
+
5
+ module Segmentor
6
+ # Segment is a slice of data, based on a condition.
7
+ class Segment < ActiveRecord::Base
8
+ self.table_name = :segmentor_segments
9
+
10
+ NO_REPEAT = 0
11
+
12
+ has_many :receipts, class_name: '::Segmentor::Receipt', dependent: :destroy
13
+ has_many :sessions, class_name: '::Segmentor::Session', dependent: :destroy
14
+ has_one :source, class_name: '::Segmentor::Source', inverse_of: :segment, dependent: :destroy
15
+ has_one :notifier_context, class_name: '::Segmentor::NotifierContext', inverse_of: :segment, dependent: :destroy
16
+
17
+ accepts_nested_attributes_for :source
18
+ accepts_nested_attributes_for :notifier_context
19
+
20
+ class_attribute :_after_session_change
21
+
22
+ def self.after_session_change(method_name)
23
+ self._after_session_change = method_name
24
+ end
25
+
26
+ # repeat_frequency is in days, should be between 0 and 365. 0 means no repeat
27
+ validates :repeat_frequency, numericality: { only_integer: true,
28
+ greater_than_or_equal_to: 0,
29
+ less_than_or_equal_to: 365 }
30
+
31
+ validates :name, length: { minimum: 3, maximum: 255 }, presence: true
32
+ validates :description, length: { maximum: 255 }
33
+
34
+ # generate all the targets for this segment
35
+ # it then returns the session
36
+ def generate!
37
+ # start a new session
38
+
39
+ session = start_session
40
+ begin
41
+ # get the data from the source
42
+ returned_targets = source.execute
43
+ # replace the data in the segment in a transaction
44
+ returned_targets.each do |target|
45
+ target.session = session
46
+ target.save!
47
+ end
48
+
49
+ # mark the session as draft
50
+ end_session(session)
51
+
52
+ activate(session) if auto_activate
53
+
54
+ session
55
+ rescue ::Segmentor::Errors::EvaluationError => e
56
+ # mark the session as failed with a reason. don't raise
57
+ session.mark_as_failed!(e.message)
58
+ session
59
+ rescue StandardError
60
+ # delete the session and raise
61
+ session.destroy
62
+ raise
63
+ end
64
+ end
65
+
66
+ # notify! calls the given block for each target that can be notified
67
+ # if the block raises a Segmentor::Errors::NotificationError, the target is skipped
68
+ # and no receipt is issued but the rest of the targets are still notified
69
+ # as most notification systems do not support transactions, it is important
70
+ # to ensure only ::Segmentor::Errors::NotificationError is raised unless you are
71
+ # sure you want to rollback all notification receipts
72
+ def notify!(&block)
73
+ # get the active targets from the segment
74
+ # for each target, check if its receipt is within segment repeat frequency
75
+ # if not, send the notification and generate a receipt
76
+ # if so, do nothing
77
+ raise ::Segmentor::Errors::NoSessionError, 'no active session' unless active_session
78
+
79
+ ::ActiveRecord::Base.transaction do
80
+ active_session.targets.each do |target|
81
+ next unless target.can_be_notified?
82
+
83
+ # send the notification
84
+ # generate a receipt
85
+ result = block.call(target, notifier_context) if block_given?
86
+ target.issue_receipt!(result)
87
+ # catch ::Segmentor::Errors::NotificationError and do nothing
88
+ rescue ::Segmentor::Errors::NotificationError
89
+ # do nothing. so we only skip issue_receipt! but continue to the rest of the targets
90
+ # any other error will be raised and rollback the transaction
91
+ end
92
+ active_session.touch
93
+ end
94
+ end
95
+
96
+ # resets the last session of this segment to be in progress again
97
+ def reset!
98
+ latest_session.mark_as_draft!
99
+ end
100
+
101
+ # turns a session from draft into active and marks
102
+ # the current active session as archived.
103
+ # if no current active session is found. the latest session is marked as active
104
+ def activate(session)
105
+ ::ActiveRecord::Base.transaction do
106
+ # mark the current active session as archived
107
+ as = active_session
108
+ if as
109
+ as.status = ::Segmentor::Session::ARCHIVED
110
+ as.save!
111
+ end
112
+
113
+ # mark the new session as active
114
+ session.status = ::Segmentor::Session::ACTIVE
115
+ session.save!
116
+ end
117
+ end
118
+
119
+ def active_session
120
+ sessions.active.first
121
+ end
122
+
123
+ def active_session!
124
+ as = active_session
125
+ raise ::Segmentor::Errors::NoSessionError, 'no active session' unless as
126
+
127
+ as
128
+ end
129
+
130
+ def latest_session
131
+ # pick the latest session that's not in progress or archived
132
+ sessions.where
133
+ .not(status: [::Segmentor::Session::ARCHIVED, ::Segmentor::Session::IN_PROGRESS])
134
+ .order(created_at: :desc)
135
+ .first
136
+ end
137
+
138
+ private
139
+
140
+ def start_session
141
+ session = ::Segmentor::Session.new(segment: self)
142
+ session.save!
143
+
144
+ session
145
+ end
146
+
147
+ def end_session(session)
148
+ session.mark_as_draft!
149
+ end
150
+ end
151
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Segmentor
4
+ # Session is a single segmentation session. It can be
5
+ # draft, in progress, or active.
6
+ # at any point, there is only 1 active session per segment is allowed
7
+ # while draft and in progress, there can be multiple sessions
8
+ # in progress is when a source is still generating targets and they
9
+ # are being saved.
10
+ # draft is when a session has the targets, but they are not the ones
11
+ # used for notification. This can be used to test a new source code for example.
12
+ class Session < ActiveRecord::Base
13
+ self.table_name = :segmentor_sessions
14
+
15
+ IN_PROGRESS = 0 # default
16
+ DRAFT = 1
17
+ ACTIVE = 2
18
+ ARCHIVED = 3
19
+ FAILED = 4
20
+
21
+ belongs_to :segment, class_name: '::Segmentor::Segment'
22
+ has_many :targets, class_name: '::Segmentor::Target', dependent: :destroy
23
+
24
+ validates :status, presence: true
25
+ validates :status, inclusion: { in: [IN_PROGRESS, DRAFT, ACTIVE, ARCHIVED, FAILED] }
26
+ validates :segment_id, presence: true
27
+ validate :only_one_active_session_per_segment
28
+ validates :reason, presence: true, if: :failed?
29
+
30
+ scope :active, -> { where(status: ACTIVE) }
31
+ scope :latest, -> { order(created_at: :desc).limit(1).first }
32
+
33
+ before_create :generate_session_id
34
+ after_commit :call_segment_callbacks
35
+
36
+ def mark_as_active!
37
+ # only draft sessions can be made active
38
+ raise ::Segmentor::Errors::SessionStateError, 'only draft sessions can be made active' unless status == DRAFT
39
+
40
+ self.status = ACTIVE
41
+ save!
42
+ end
43
+
44
+ def mark_as_draft!
45
+ # archived and failed sessions cannot be turned into draft
46
+ if status == ARCHIVED || status == FAILED
47
+ raise ::Segmentor::Errors::SessionStateError, 'failed and archived sessions cannot be turned into draft'
48
+ end
49
+
50
+ self.status = DRAFT
51
+ self.reason = nil
52
+ save!
53
+ end
54
+
55
+ def mark_as_archived!
56
+ # only active and failed sessions can be archived
57
+ unless status == ACTIVE || status == FAILED
58
+ raise ::Segmentor::Errors::SessionStateError, 'only active and failed sessions can be archived'
59
+ end
60
+
61
+ self.status = ARCHIVED
62
+ save!
63
+ end
64
+
65
+ def mark_as_failed!(reason)
66
+ # archived sessions cannot be marked as failed
67
+ raise ::Segmentor::Errors::SessionStateError, 'archived sessions cannot be marked as failed' if status == ARCHIVED
68
+
69
+ self.status = FAILED
70
+ self.reason = reason
71
+ save!
72
+ end
73
+
74
+ private
75
+
76
+ def only_one_active_session_per_segment
77
+ # only one active session per segment is allowed
78
+ if status == ACTIVE && ::Segmentor::Session.where(status: ACTIVE, segment_id: segment_id).count.positive?
79
+ raise ::Segmentor::Errors::SessionStateError, 'only one active session per segment is allowed'
80
+ end
81
+ end
82
+
83
+ def generate_session_id
84
+ # generate a session id
85
+ self.session_id ||= SecureRandom.uuid
86
+ end
87
+
88
+ def failed?
89
+ status == FAILED
90
+ end
91
+
92
+ def call_segment_callbacks
93
+ segment.send(segment.class._after_session_change, self) if segment.class._after_session_change.present?
94
+ end
95
+
96
+ end
97
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Segmentor
4
+ # Source runs the code and returns an array of ::Segmentor::Target
5
+ # you can dervie from this for different types of code sources (ie Ruby, JS, etc)
6
+ class Source < ActiveRecord::Base
7
+ self.table_name = 'segmentor_sources'
8
+
9
+ belongs_to :segment, class_name: '::Segmentor::Segment', foreign_key: :segment_id
10
+
11
+ validates :code, presence: true
12
+ validates :type, presence: true
13
+ validates :segment, presence: true
14
+
15
+ def execute
16
+ raise NotImplementedError, 'execute must be implemented in a subclass'
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,26 @@
1
+ module Segmentor
2
+ module Sources
3
+ # RubySource is a source that evaluates ruby code to generate a list of targets
4
+ # DO NOT USE THIS SOURCE IN UNSECURE ENVIRONMENTS
5
+ class RubySource < ::Segmentor::Source
6
+ def execute
7
+ # evaluate the code
8
+ # return the result
9
+ begin
10
+ targets = eval(code)
11
+ rescue StandardError => e
12
+ raise ::Segmentor::Errors::EvaluationError, e.message
13
+ end
14
+
15
+ targets = targets.is_a?(Array) ? targets : [targets]
16
+
17
+ # all targets should be a ::Segment::Target
18
+ if targets.any? { |target| !target.is_a?(::Segmentor::Target) }
19
+ raise ::Segmentor::Errors::EvaluationError, 'all returned items should be ::Segment::Target'
20
+ end
21
+
22
+ targets
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Segmentor
4
+ # Target is single piece of data, returned by a
5
+ class Target < ActiveRecord::Base
6
+ self.table_name = :segmentor_targets
7
+
8
+ belongs_to :session, class_name: '::Segmentor::Session'
9
+ belongs_to :segment, class_name: '::Segmentor::Segment'
10
+ belongs_to :user
11
+
12
+ validates :user_id, presence: true
13
+ validates :user_id, uniqueness: { scope: :session_id }
14
+ validates :segment_id, presence: true
15
+ validates :session_id, presence: true
16
+
17
+ before_validation :set_segment_if_nil
18
+
19
+ def self.new_with_user(user)
20
+ ::Segmentor::Target.new(user_id: user.id, payload: {})
21
+ end
22
+
23
+ def self.new_with_user_and_payload(user, payload)
24
+ ::Segmentor::Target.new(user_id: user.id, payload: payload)
25
+ end
26
+
27
+ def issue_receipt!(rendered_value = nil)
28
+ ::Segmentor::Receipt.record_receipt(self, rendered_value)
29
+ end
30
+
31
+ def most_recent_receipt
32
+ segment.receipts.where(user_id: user_id).order(:sent_at).last
33
+ end
34
+
35
+ def get_binding
36
+ binding
37
+ end
38
+
39
+ def preview(&block)
40
+ raise ArgumentError, 'block required' unless block_given?
41
+
42
+ block.call(self, segment.notifier_context)
43
+ end
44
+
45
+ def can_be_notified?
46
+ most_recent_receipt = segment.receipts.where(user_id: user_id).order(:sent_at).last
47
+
48
+ # notification can be sent if there is a receipt for this target that
49
+ # is older than the repeat frequency of its segment
50
+ return true if most_recent_receipt.nil?
51
+
52
+ # we have a receipt. notify only if:
53
+ # segment repeat frequency is not NO_REPEAT
54
+ # receipt is older than segment repeat frequency
55
+ segment.repeat_frequency != ::Segmentor::Segment::NO_REPEAT &&
56
+ most_recent_receipt.sent_at < segment.repeat_frequency.days.ago
57
+ end
58
+
59
+ private
60
+
61
+ def set_segment_if_nil
62
+ self.segment_id ||= session.segment_id
63
+ end
64
+
65
+ end
66
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Segmentor
4
+ VERSION ||= "0.0.1"
5
+ end
data/lib/segmentor.rb ADDED
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/all'
4
+ require 'rails/all'
5
+
6
+ Dir["#{File.dirname(__FILE__)}/segmentor/**/*.rb"].each { |f| load(f) }
7
+ load "#{File.dirname(__FILE__)}/generators/segmentor/segmentor.rb"
8
+
9
+ # Segmentor module
10
+ module Segmentor
11
+ end
data/segmentor.gemspec ADDED
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ lib = File.expand_path('lib', __dir__)
4
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
5
+ require 'segmentor/version'
6
+
7
+ Gem::Specification.new do |spec|
8
+ spec.name = 'segmentor'
9
+ spec.version = Segmentor::VERSION
10
+ spec.authors = ['Khash Sajadi']
11
+ spec.email = ['khash@cloud66.com']
12
+ spec.license = 'Apache-2.0'
13
+ spec.homepage = 'https://github.com/cloud66-oss/segmentor'
14
+ spec.required_ruby_version = '>= 3.0.0'
15
+
16
+ spec.summary = 'Segmentor creates slices from your data that can be used for notifications'
17
+
18
+ # Specify which files should be added to the gem when it is released.
19
+ # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
20
+ spec.files = Dir.chdir(File.expand_path(__dir__)) do
21
+ `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
22
+ end
23
+ spec.bindir = 'exe'
24
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
25
+ spec.require_paths = ['lib']
26
+
27
+ spec.add_dependency 'actionpack', '~> 6.1'
28
+ spec.add_dependency 'activerecord', '~> 6.1'
29
+ spec.add_dependency 'activesupport', '~> 6.1'
30
+
31
+ spec.add_development_dependency 'bootsnap', '~> 1.9'
32
+ spec.add_development_dependency 'bundler', '~> 2.0'
33
+ spec.add_development_dependency 'factory_bot', '~> 6.2'
34
+ spec.add_development_dependency 'generator_spec', '~> 0.9'
35
+ spec.add_development_dependency 'parser', '3.0.0.0'
36
+ spec.add_development_dependency 'pry', '~> 0.13.1'
37
+ spec.add_development_dependency 'rails', '~> 6.1'
38
+ spec.add_development_dependency 'rake', '~> 13.0'
39
+ spec.add_development_dependency 'rspec', '~> 3.10'
40
+ spec.add_development_dependency 'rspec-rails', '~> 5.0'
41
+ spec.add_development_dependency 'rubocop', '~> 1.4'
42
+ spec.add_development_dependency 'rubocop-performance', '~> 1.9'
43
+ spec.add_development_dependency 'rufo', '~> 0.12'
44
+ spec.add_development_dependency 'sqlite3', '~> 1.4'
45
+ end