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.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/Rakefile +20 -0
- data/lib/combinaut_stagehand.rb +2 -0
- data/lib/stagehand/active_record_extensions.rb +110 -0
- data/lib/stagehand/auditor/checklist_visualizer.rb +90 -0
- data/lib/stagehand/auditor.rb +90 -0
- data/lib/stagehand/cache.rb +12 -0
- data/lib/stagehand/configuration.rb +45 -0
- data/lib/stagehand/connection_adapter_extensions.rb +75 -0
- data/lib/stagehand/controller_extensions.rb +35 -0
- data/lib/stagehand/database.rb +197 -0
- data/lib/stagehand/engine.rb +26 -0
- data/lib/stagehand/key.rb +23 -0
- data/lib/stagehand/production/controller.rb +12 -0
- data/lib/stagehand/production.rb +132 -0
- data/lib/stagehand/schema/statements.rb +53 -0
- data/lib/stagehand/schema.rb +145 -0
- data/lib/stagehand/schema_extensions.rb +10 -0
- data/lib/stagehand/staging/checklist.rb +202 -0
- data/lib/stagehand/staging/commit.rb +168 -0
- data/lib/stagehand/staging/commit_entry.rb +180 -0
- data/lib/stagehand/staging/controller.rb +27 -0
- data/lib/stagehand/staging/model.rb +29 -0
- data/lib/stagehand/staging/synchronizer.rb +198 -0
- data/lib/stagehand/staging.rb +11 -0
- data/lib/stagehand/version.rb +3 -0
- data/lib/stagehand.rb +5 -0
- data/lib/tasks/stagehand_tasks.rake +43 -0
- metadata +166 -0
|
@@ -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
|