segmentor 0.0.1

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.
@@ -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