combinaut_stagehand 1.2.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,202 @@
1
+ module Stagehand
2
+ module Staging
3
+ class Checklist
4
+ extend Cache
5
+ include Cache
6
+
7
+ def self.related_commits(commit)
8
+ Commit.find(related_commit_ids(commit))
9
+ end
10
+
11
+ def self.related_commit_ids(commit)
12
+ related_entries(commit.entries).collect(&:commit_id).select(&:present?).uniq
13
+ end
14
+
15
+ def self.related_entries(entries, relation_filter = nil)
16
+ entries = Array.wrap(entries)
17
+ related_entries = []
18
+
19
+ entries_to_spider = Array.wrap(entries)
20
+ while entries_to_spider.present?
21
+ committed_matching = CommitEntry.committed.matching(entries_to_spider)
22
+ committed_matching = committed_matching.where(id: committed_matching.select(&relation_filter)) if relation_filter
23
+
24
+ matching_commit_entries = CommitEntry.where(:commit_id => committed_matching.select(:commit_id))
25
+
26
+ # Spider using content operations. Don't spider control operations to avoid extending the list of results unnecessarily
27
+ content_operations, control_operations = matching_commit_entries.partition(&:content_operation?)
28
+ entries_to_spider = content_operations - related_entries
29
+
30
+ # Record the spidered entries and the control entries
31
+ related_entries.concat(entries_to_spider)
32
+ related_entries.concat(control_operations)
33
+ end
34
+
35
+ # Also include uncontained commit entries that matched
36
+ related_entries.concat(CommitEntry.uncontained.matching(entries + related_entries))
37
+ related_entries.uniq!
38
+
39
+ return related_entries
40
+ end
41
+
42
+ def self.associated_records(entries, association_filter = nil)
43
+ records = preload_records(compact_entries(entries)).select(&:record).flat_map do |entry|
44
+ associated_associations(entry.record_class).flat_map do |association|
45
+ entry.record.send(association)
46
+ end
47
+ end
48
+
49
+ records.uniq!
50
+ records.compact!
51
+ records.select! {|record| stagehand_class?(record.class) }
52
+ records.select!(&association_filter) if association_filter
53
+
54
+ return records
55
+ end
56
+
57
+ # Returns a list of entries that only includes a single entry for each record.
58
+ # The entries are prioritized by the list of operations as given by `:priority`.
59
+ def self.compact_entries(entries, priority: [:delete, :update, :insert])
60
+ compact_entries = group_entries(entries)
61
+ compact_entries = compact_entries.values_at(*priority).flatten
62
+ compact_entries.uniq!(&:key)
63
+
64
+ return compact_entries
65
+ end
66
+
67
+ # Groups entries by their operation
68
+ def self.group_entries(entries)
69
+ group_entries = Hash.new {|h,k| h[k] = [] }
70
+ group_entries.merge! entries.group_by(&:operation).symbolize_keys!
71
+
72
+ return group_entries
73
+ end
74
+
75
+ def self.preload_records(entries)
76
+ entries.group_by(&:table_name).each do |table_name, group_entries|
77
+ klass = CommitEntry.infer_base_class(table_name)
78
+ records = klass.where(:id => group_entries.collect(&:record_id))
79
+ records = records.includes(associated_associations(klass))
80
+ records_by_id = records.collect {|r| [r.id, r] }.to_h
81
+ group_entries.each do |entry|
82
+ entry.record = records_by_id[entry.record_id]
83
+ end
84
+ end
85
+
86
+ return entries
87
+ end
88
+
89
+ private
90
+
91
+ def self.associated_associations(klass)
92
+ cache("#{klass.name}_associated_associations") do
93
+ reflections = klass.reflect_on_all_associations(:belongs_to)
94
+
95
+ reflections.select! do |reflection|
96
+ begin
97
+ reflection.check_preloadable!
98
+ next true
99
+ rescue ArgumentError
100
+ next false
101
+ end
102
+ end
103
+
104
+ reflections.collect(&:name)
105
+ end
106
+ end
107
+
108
+ def self.stagehand_class?(klass)
109
+ cache("#{klass.name}_stagehand_class?") { Schema.has_stagehand?(klass.table_name) }
110
+ end
111
+
112
+ public
113
+
114
+ def initialize(subject, confirmation_filter: Configuration.checklist_confirmation_filter, association_filter: Configuration.checklist_association_filter, relation_filter: Configuration.checklist_relation_filter)
115
+ @subject = subject
116
+ @confirmation_filter = confirmation_filter
117
+ @association_filter = association_filter
118
+ @relation_filter = relation_filter
119
+ affected_entries # Init the affected_entries changes can be rolled back without affecting the checklist
120
+ end
121
+
122
+ def confirm_create
123
+ cache(:confirm_create) { grouped_required_confirmation_entries[:insert].collect(&:record) }
124
+ end
125
+
126
+ def confirm_delete
127
+ cache(:confirm_delete) { grouped_required_confirmation_entries[:delete].collect(&:record).compact }
128
+ end
129
+
130
+ def confirm_update
131
+ cache(:confirm_update) { grouped_required_confirmation_entries[:update].collect(&:record) }
132
+ end
133
+
134
+ # Returns true if there are any changes in the checklist that require confirmation
135
+ def requires_confirmation?
136
+ cache(:requires_confirmation?) { grouped_required_confirmation_entries.values.flatten.present? }
137
+ end
138
+
139
+ # Returns a list of records that exist in commits where the staging_record is not in the start operation
140
+ def requires_confirmation
141
+ cache(:requires_confirmation) { grouped_required_confirmation_entries.values.flatten.collect(&:record).compact }
142
+ end
143
+
144
+ def syncing_entries
145
+ cache(:syncing_entries) { self.class.compact_entries(affected_entries, priority: Synchronizer::ENTRY_SYNC_ORDER) }
146
+ end
147
+
148
+ def affected_records
149
+ cache(:affected_records) { affected_entries.uniq(&:key).collect(&:record).compact }
150
+ end
151
+
152
+ def affected_entries
153
+ cache(:affected_entries) do
154
+ from_subject = subject_entries
155
+ from_subject |= CommitEntry.where(commit_id: subject_entries.select(:commit_id))
156
+ related = self.class.related_entries(from_subject, @relation_filter)
157
+ associated = self.class.associated_records(related, @association_filter)
158
+ associated_related = self.class.related_entries(associated, @relation_filter)
159
+
160
+ (from_subject + related + associated_related).uniq
161
+ end
162
+ end
163
+
164
+ def subject_entries
165
+ cache(:subject_entries) { CommitEntry.not_in_progress.matching(@subject) }
166
+ end
167
+
168
+ private
169
+
170
+ def grouped_required_confirmation_entries
171
+ cache(:grouped_required_confirmation_entries) do
172
+ entries = affected_entries.dup
173
+ subject_entries, subject_records = Array.wrap(@subject).partition {|model| model.is_a?(CommitEntry) }
174
+
175
+ # Don't need to confirm entries that were not part of a commit
176
+ entries.select!(&:commit_id)
177
+
178
+ # Don't need to confirm entries that exactly match a subject commit entry
179
+ entries -= subject_entries
180
+
181
+ # Don't need to confirm entries that match the checklist subject records
182
+ entries.reject! {|entry| entry.matches?(subject_records) }
183
+
184
+ # Don't need to confirm entries that are part of a commits whose subject is a checklist subject record
185
+ staging_record_start_operation_ids = affected_entries.select do |entry|
186
+ entry.start_operation? && entry.matches?(subject_records)
187
+ end.collect(&:id)
188
+ entries.reject! {|entry| staging_record_start_operation_ids.include?(entry.commit_id) }
189
+
190
+ entries = self.class.compact_entries(entries, priority: [:delete, :insert, :update])
191
+ entries = filter_entries(entries)
192
+ entries = self.class.group_entries(entries)
193
+ end
194
+ end
195
+
196
+ def filter_entries(entries)
197
+ return entries unless @confirmation_filter
198
+ return entries.select {|entry| @confirmation_filter.call(entry.record) if entry.record }
199
+ end
200
+ end
201
+ end
202
+ end
@@ -0,0 +1,168 @@
1
+ module Stagehand
2
+ module Staging
3
+ class Commit
4
+ def self.all
5
+ CommitEntry.start_operations.committed.pluck(:commit_id).collect {|id| find(id) }.compact
6
+ end
7
+
8
+ def self.empty
9
+ all.select(&:empty?)
10
+ end
11
+
12
+ def self.capturing?
13
+ !!@capturing
14
+ end
15
+
16
+ def self.capture(subject_record = nil, except: [], &block)
17
+ @capturing = true
18
+ start_operation = start_commit(subject_record)
19
+ set_session_commit_id(start_operation.commit_id)
20
+
21
+ begin
22
+ block.call(start_operation)
23
+ rescue Exception => e # Rescue Exception because we don't want to swallow them by returning from the ensure block
24
+ raise(e)
25
+ ensure
26
+ commit = end_commit(start_operation, except) unless e.is_a?(CommitError) || e.is_a?(ActiveRecord::Rollback)
27
+ set_session_commit_id(nil) # Stop recording entries to this commit
28
+ return commit unless e
29
+ end
30
+
31
+ ensure
32
+ @capturing = false
33
+ end
34
+
35
+ def self.containing(record)
36
+ find(CommitEntry.contained.matching(record).pluck(:commit_id))
37
+ end
38
+
39
+ def self.find(start_ids)
40
+ if start_ids.respond_to?(:each)
41
+ start_ids.to_a.uniq.collect {|id| find(id) }.compact
42
+ else
43
+ new(start_ids)
44
+ end
45
+ rescue CommitNotFound
46
+ end
47
+
48
+ private
49
+
50
+ def self.start_commit(subject_record)
51
+ start_operation = CommitEntry.start_operations.new
52
+
53
+ # Make it easy to set the subject for the duration of the commit block
54
+ def start_operation.subject=(record)
55
+ if record&.id
56
+ raise NonStagehandSubject unless record.has_stagehand?
57
+ self.assign_attributes :record_id => record.id, :table_name => record.class.table_name
58
+ end
59
+ save!
60
+ end
61
+
62
+ start_operation.subject = subject_record
63
+
64
+ return start_operation
65
+ end
66
+
67
+ # Sets the commit_id on all the entries between the start and end op.
68
+ # Returns the commit object for those entries
69
+ def self.end_commit(start_operation, except)
70
+ scope = CommitEntry.where(:commit_id => start_operation.id)
71
+
72
+ # Remove any commit entries that are supposed to be excluded from the commit
73
+ if except.present? && Array(except).collect(&:to_s).exclude?(start_operation.table_name)
74
+ scope.content_operations.where(:table_name => except).update_all(:commit_id => nil)
75
+ end
76
+
77
+ end_operation = scope.end_operations.create
78
+ entries = scope.to_a
79
+
80
+ if entries.count(&:control_operation?) < 2 # Unless we found at least 2 entries (start and end), abort the commit
81
+ CommitEntry.logger.warn "Commit entries not found for Commit #{start_operation.id}. Was the Commit rolled back in a transaction?"
82
+ return nil
83
+ elsif entries.none?(&:content_operation?) # Destroy empty commit
84
+ scope.delete_all
85
+ return nil
86
+ else
87
+ CommitEntry.where(id: entries.map(&:id)).update_all(:committed => true) # Allow these entries to be considered when spidering and determining auto syncing.
88
+ return new(start_operation.id)
89
+ end
90
+ end
91
+
92
+ # Ensure all entries created on this connection from now on match the given commit_id
93
+ def self.set_session_commit_id(commit_id)
94
+ CommitEntry.connection.execute <<~SQL
95
+ SET @stagehand_commit_id = #{commit_id || 'NULL'};
96
+ SQL
97
+ end
98
+
99
+ public
100
+
101
+ def initialize(start_id)
102
+ @start_id, @end_id = CommitEntry.control_operations
103
+ .limit(2)
104
+ .where(:commit_id => start_id)
105
+ .where('id >= ?', start_id)
106
+ .reorder(:id => :asc)
107
+ .pluck(:id)
108
+
109
+ return if @start_id && @end_id
110
+
111
+ missing = []
112
+ missing << CommitEntry::START_OPERATION unless @start_id == start_id
113
+ missing << CommitEntry::END_OPERATION if @start_id == start_id
114
+
115
+ raise CommitNotFound, "Couldn't find #{missing.join(', ')} entry for Commit #{start_id}"
116
+ end
117
+
118
+ def id
119
+ @start_id
120
+ end
121
+
122
+ def include?(record)
123
+ entries.where(:record_id => record.id, :table_name => record.class.table_name).exists?
124
+ end
125
+
126
+ def hash
127
+ id
128
+ end
129
+
130
+ def eql?(other)
131
+ self == other
132
+ end
133
+
134
+ def ==(other)
135
+ id == other.id
136
+ end
137
+
138
+ def subject
139
+ start_entry.record
140
+ end
141
+
142
+ def start_entry
143
+ CommitEntry.find(@start_id)
144
+ end
145
+
146
+ def end_entry
147
+ entries.end_operations.first
148
+ end
149
+
150
+ def empty?
151
+ entries.content_operations.empty?
152
+ end
153
+
154
+ def destroy
155
+ entries.delete_all
156
+ end
157
+
158
+ def entries
159
+ CommitEntry.where(:id => @start_id..@end_id).where(:commit_id => id)
160
+ end
161
+ end
162
+ end
163
+
164
+ # EXCEPTIONS
165
+ class CommitError < StandardError; end
166
+ class CommitNotFound < CommitError; end
167
+ class NonStagehandSubject < CommitError; end
168
+ end
@@ -0,0 +1,180 @@
1
+ module Stagehand
2
+ module Staging
3
+ class CommitEntry < ActiveRecord::Base
4
+ attr_writer :record
5
+
6
+ self.table_name = 'stagehand_commit_entries'
7
+
8
+ START_OPERATION = 'commit_start'
9
+ END_OPERATION = 'commit_end'
10
+ INSERT_OPERATION = 'insert'
11
+ UPDATE_OPERATION = 'update'
12
+ DELETE_OPERATION = 'delete'
13
+
14
+ CONTROL_OPERATIONS = [START_OPERATION, END_OPERATION]
15
+ CONTENT_OPERATIONS = [INSERT_OPERATION, UPDATE_OPERATION, DELETE_OPERATION]
16
+ SAVE_OPERATIONS = [INSERT_OPERATION, UPDATE_OPERATION]
17
+
18
+ scope :start_operations, lambda { where(:operation => START_OPERATION) }
19
+ scope :end_operations, lambda { where(:operation => END_OPERATION) }
20
+ scope :control_operations, lambda { where(:operation => CONTROL_OPERATIONS) }
21
+ scope :content_operations, lambda { where(:operation => CONTENT_OPERATIONS) }
22
+ scope :save_operations, lambda { where(:operation => SAVE_OPERATIONS) }
23
+ scope :insert_operations, lambda { where(:operation => INSERT_OPERATION) }
24
+ scope :update_operations, lambda { where(:operation => UPDATE_OPERATION) }
25
+ scope :delete_operations, lambda { where(:operation => DELETE_OPERATION) }
26
+ scope :with_record, lambda { where.not(:record_id => nil) }
27
+ scope :in_progress, lambda { contained.uncommitted }
28
+ scope :not_in_progress, lambda { uncontained.or(committed) }
29
+ scope :committed, lambda { where(:committed => true) }
30
+ scope :uncommitted, lambda { where.not(:committed => true) }
31
+ scope :uncontained, lambda { where(:commit_id => nil) }
32
+ scope :contained, lambda { where.not(:commit_id => nil) }
33
+ scope :no_newer_than, lambda {|entry| where("id <= ?", entry) }
34
+ scope :with_uncontained_keys, lambda { uncontained.joins_contained("LEFT OUTER").where("contained.record_id IS NULL") }
35
+
36
+ after_create :init_commit_id, if: :start_operation? # Perform this as a callback so it is wrapped in a transaction and nobody gets to see the start entry without a commit_id
37
+
38
+ def self.joins_contained(type = "INNER")
39
+ joins("#{type} JOIN (#{ unscoped.contained.select('record_id, table_name').to_sql}) AS contained
40
+ ON contained.record_id = #{table_name}.record_id AND contained.table_name = #{table_name}.table_name")
41
+ end
42
+
43
+ def self.matching(object)
44
+ keys = Array.wrap(object).collect {|entry| Stagehand::Key.generate(entry) }.compact
45
+ sql = []
46
+ interpolates = []
47
+ groups = keys.group_by(&:first)
48
+
49
+ # If passed control operation commit entries, ensure they are returned since their keys match the CommitEntry's primary key
50
+ if commit_entry_group = groups.delete(CommitEntry.table_name)
51
+ sql << 'id IN (?)'
52
+ interpolates << commit_entry_group.collect(&:last)
53
+ end
54
+
55
+ groups.each do |table_name, keys|
56
+ sql << "(table_name = ? AND record_id IN (?))"
57
+ interpolates << table_name
58
+ interpolates << keys.collect(&:last)
59
+ end
60
+
61
+ return keys.present? ? where(sql.join(' OR '), *interpolates) : none
62
+ end
63
+
64
+ def self.infer_base_class(table_name)
65
+ classes = ActiveRecord::Base.descendants
66
+ classes.select! {|klass| klass.table_name == table_name }
67
+ classes.reject! {|klass| klass < Stagehand::Database::Probe }
68
+ return classes.first || table_name.classify.constantize.base_class # Try loading the class if it isn't loaded yet
69
+ rescue NameError
70
+ raise(IndeterminateRecordClass, "Can't determine class from table name: #{table_name}")
71
+ end
72
+
73
+ validates_presence_of :record_id, :if => :table_name
74
+ validates_presence_of :table_name, :if => :record_id
75
+
76
+ def record
77
+ @record ||= delete_operation? ? build_deleted_record : record_class.find_by_id(record_id) if record_id?
78
+ end
79
+
80
+ def control_operation?
81
+ operation.in?(CONTROL_OPERATIONS)
82
+ end
83
+
84
+ def content_operation?
85
+ operation.in?(CONTENT_OPERATIONS)
86
+ end
87
+
88
+ def save_operation?
89
+ operation.in?(SAVE_OPERATIONS)
90
+ end
91
+
92
+ def insert_operation?
93
+ operation == INSERT_OPERATION
94
+ end
95
+
96
+ def update_operation?
97
+ operation == UPDATE_OPERATION
98
+ end
99
+
100
+ def delete_operation?
101
+ operation == DELETE_OPERATION
102
+ end
103
+
104
+ def start_operation?
105
+ operation == START_OPERATION
106
+ end
107
+
108
+ def end_operation?
109
+ operation == END_OPERATION
110
+ end
111
+
112
+ def matches?(others)
113
+ Array.wrap(others).any? {|other| key == Stagehand::Key.generate(other) }
114
+ end
115
+
116
+ def key
117
+ @key ||= Stagehand::Key.generate(self)
118
+ end
119
+
120
+ def record_class
121
+ @record_class ||= infer_class
122
+ rescue IndeterminateRecordClass
123
+ @record_class ||= self.class.build_missing_model(table_name)
124
+ end
125
+
126
+ def production_record
127
+ @production_record = Stagehand::Production.find(record_id, table_name) unless defined?(@production_record)
128
+ return @production_record
129
+ end
130
+
131
+ private
132
+
133
+ def init_commit_id
134
+ update_column(:commit_id, id)
135
+ end
136
+
137
+ def infer_class
138
+ klass = self.class.infer_base_class(table_name)
139
+ klass = infer_sti_class(klass) || infer_production_sti_class(klass) || klass if record_id
140
+ return klass
141
+ end
142
+
143
+ def infer_sti_class(root_class)
144
+ root_class.find_by_id(record_id)&.class
145
+ end
146
+
147
+ def infer_production_sti_class(root_class)
148
+ return unless production_record
149
+ record_type = production_record[root_class.inheritance_column]
150
+ record_type&.constantize
151
+ rescue NameError
152
+ raise(IndeterminateRecordClass, "Can't determine class from table name: #{record_type}")
153
+ end
154
+
155
+ def build_deleted_record
156
+ return unless production_record
157
+ deleted_record = production_record.becomes(record_class)
158
+ deleted_record.readonly!
159
+ deleted_record.singleton_class.include(DeletedRecord)
160
+ return deleted_record
161
+ end
162
+
163
+ def self.build_missing_model(table_name)
164
+ raise MissingTable, "Can't find table specified in entry: #{table_name}" unless Database.staging_connection.tables.include?(table_name)
165
+ klass = Class.new(ActiveRecord::Base) { self.table_name = table_name }
166
+ DummyClass.const_set(table_name.classify, klass)
167
+ end
168
+ end
169
+ end
170
+
171
+ # UTILITY MODULES
172
+
173
+ module DummyClass; end # A namespace for dummy classes of records with an IndeterminateRecordClass
174
+ module DeletedRecord; end # Serves as a way of tagging an instance to see if it is_a?(Stagehand::DeletedRecord)
175
+
176
+ # EXCEPTIONS
177
+
178
+ class IndeterminateRecordClass < StandardError; end
179
+ class MissingTable < StandardError; end
180
+ end
@@ -0,0 +1,27 @@
1
+ module Stagehand
2
+ module Staging
3
+ module Controller
4
+ extend ActiveSupport::Concern
5
+ include Stagehand::ControllerExtensions
6
+
7
+ included do
8
+ use_staging_database
9
+ end
10
+
11
+ # Creates a stagehand commit to log database changes associated with the given record
12
+ def stage_changes(*args, &block)
13
+ Staging::Commit.capture(*args, &block)
14
+ end
15
+
16
+ # Syncs the given record and all affected records to the production database
17
+ def sync_record(record)
18
+ Stagehand::Staging::Synchronizer.sync_record(record)
19
+ end
20
+
21
+ # Syncs the checklist's affected records to the production database
22
+ def sync_checklist(checklist)
23
+ Stagehand::Staging::Synchronizer.sync_checklist(checklist)
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,29 @@
1
+ module Stagehand
2
+ module Staging
3
+ module Model
4
+ extend ActiveSupport::Concern
5
+
6
+ included do
7
+ Stagehand::Configuration.staging_model_tables << table_name
8
+ end
9
+
10
+ class_methods do
11
+ def quoted_table_name
12
+ if connection.prefix_table_name_with_database?(table_name)
13
+ @prefixed_quoted_table_name ||= connection.quote_table_name(table_name)
14
+ else
15
+ super
16
+ end
17
+ end
18
+
19
+ def connection
20
+ if Configuration.ghost_mode?
21
+ super
22
+ else
23
+ Stagehand::Database::StagingProbe.connection
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end