culturecode_stagehand 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/Rakefile +12 -0
- data/lib/culturecode_stagehand.rb +2 -0
- data/lib/stagehand.rb +4 -0
- data/lib/stagehand/configuration.rb +27 -0
- data/lib/stagehand/controller_extensions.rb +27 -0
- data/lib/stagehand/engine.rb +19 -0
- data/lib/stagehand/helpers.rb +57 -0
- data/lib/stagehand/production.rb +78 -0
- data/lib/stagehand/production/controller.rb +12 -0
- data/lib/stagehand/schema.rb +81 -0
- data/lib/stagehand/schema/statements.rb +24 -0
- data/lib/stagehand/staging.rb +11 -0
- data/lib/stagehand/staging/checklist.rb +147 -0
- data/lib/stagehand/staging/commit.rb +96 -0
- data/lib/stagehand/staging/commit_entry.rb +118 -0
- data/lib/stagehand/staging/controller.rb +22 -0
- data/lib/stagehand/staging/model.rb +11 -0
- data/lib/stagehand/staging/synchronizer.rb +71 -0
- data/lib/stagehand/version.rb +3 -0
- data/lib/tasks/stagehand_tasks.rake +16 -0
- metadata +110 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: 0ba8e79e0039e00f41cb0ac4042c677aeed631f5
|
4
|
+
data.tar.gz: 962467e5d04f3303c2a184e796a61ec06dcde83a
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: e5aa81f338bacc2eaf0a4926d3b357d92be1ffea5ecd04f4db1365abeeee8729f592c3ee2bfe908d8a76feaaf593632a89b107e7b42e3dac27a37e648f8c98f8
|
7
|
+
data.tar.gz: 8c1fea3d8ac972451119a1e01797d0ea704c0ec2eed20654306ae59f228e6ae76a4515d53a798583cf1ff2b31f85029db4745a0f93817af99ac51f4ee59a77d5
|
data/MIT-LICENSE
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
Copyright 2016 Nicholas Jakobsen
|
2
|
+
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
4
|
+
a copy of this software and associated documentation files (the
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
9
|
+
the following conditions:
|
10
|
+
|
11
|
+
The above copyright notice and this permission notice shall be
|
12
|
+
included in all copies or substantial portions of the Software.
|
13
|
+
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/Rakefile
ADDED
@@ -0,0 +1,12 @@
|
|
1
|
+
begin
|
2
|
+
require 'bundler/setup'
|
3
|
+
rescue LoadError
|
4
|
+
puts 'You must `gem install bundler` and `bundle install` to run rake tasks'
|
5
|
+
end
|
6
|
+
|
7
|
+
APP_RAKEFILE = File.expand_path("../spec/dummy/Rakefile", __FILE__)
|
8
|
+
load 'rails/tasks/engine.rake'
|
9
|
+
|
10
|
+
load 'rails/tasks/statistics.rake'
|
11
|
+
|
12
|
+
Bundler::GemHelper.install_tasks
|
data/lib/stagehand.rb
ADDED
@@ -0,0 +1,27 @@
|
|
1
|
+
module Stagehand
|
2
|
+
extend self
|
3
|
+
|
4
|
+
def configuration
|
5
|
+
Configuration
|
6
|
+
end
|
7
|
+
|
8
|
+
module Configuration
|
9
|
+
extend self
|
10
|
+
|
11
|
+
def staging_connection_name
|
12
|
+
Rails.configuration.x.stagehand.staging_connection_name || raise(StagingConnectionNameNotSet)
|
13
|
+
end
|
14
|
+
|
15
|
+
def production_connection_name
|
16
|
+
Rails.configuration.x.stagehand.production_connection_name || raise(ProductionConnectionNameNotSet)
|
17
|
+
end
|
18
|
+
|
19
|
+
def ghost_mode?
|
20
|
+
!!Rails.configuration.x.stagehand.ghost_mode
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
# EXCEPTIONS
|
25
|
+
class StagingConnectionNameNotSet < StandardError; end
|
26
|
+
class ProductionConnectionNameNotSet < StandardError; end
|
27
|
+
end
|
@@ -0,0 +1,27 @@
|
|
1
|
+
module Stagehand
|
2
|
+
module ControllerExtensions
|
3
|
+
extend ActiveSupport::Concern
|
4
|
+
|
5
|
+
class_methods do
|
6
|
+
def use_staging_database(options = {})
|
7
|
+
skip_action_callback :use_production_database, options
|
8
|
+
prepend_around_action :use_staging_database, options
|
9
|
+
end
|
10
|
+
|
11
|
+
def use_production_database(options = {})
|
12
|
+
skip_action_callback :use_staging_database, options
|
13
|
+
prepend_around_action :use_production_database, options
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
private
|
18
|
+
|
19
|
+
def use_staging_database(&block)
|
20
|
+
Database.with_connection(Configuration.staging_connection_name, &block)
|
21
|
+
end
|
22
|
+
|
23
|
+
def use_production_database(&block)
|
24
|
+
Database.with_connection(Configuration.production_connection_name, &block)
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
module Stagehand
|
2
|
+
class Engine < ::Rails::Engine
|
3
|
+
isolate_namespace Stagehand
|
4
|
+
|
5
|
+
config.generators do |g|
|
6
|
+
g.test_framework :rspec
|
7
|
+
end
|
8
|
+
|
9
|
+
# These require the rails application to be intialized because configuration variables are used
|
10
|
+
initializer "stagehand.load_modules" do
|
11
|
+
require "stagehand/configuration"
|
12
|
+
require "stagehand/controller_extensions"
|
13
|
+
require "stagehand/staging"
|
14
|
+
require "stagehand/schema"
|
15
|
+
require "stagehand/production"
|
16
|
+
require "stagehand/helpers"
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
@@ -0,0 +1,57 @@
|
|
1
|
+
module Stagehand
|
2
|
+
module Key
|
3
|
+
extend self
|
4
|
+
|
5
|
+
def generate(staging_record, options = {})
|
6
|
+
case staging_record
|
7
|
+
when Staging::CommitEntry
|
8
|
+
id = staging_record.record_id || staging_record.id
|
9
|
+
table_name = staging_record.table_name || staging_record.class.table_name
|
10
|
+
when ActiveRecord::Base
|
11
|
+
id = staging_record.id
|
12
|
+
table_name = staging_record.class.table_name
|
13
|
+
else
|
14
|
+
id = staging_record
|
15
|
+
table_name = options[:table_name]
|
16
|
+
end
|
17
|
+
|
18
|
+
raise 'Invalid input' unless table_name && id
|
19
|
+
|
20
|
+
return [table_name, id]
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
module Database
|
25
|
+
extend self
|
26
|
+
|
27
|
+
@@connection_name_stack = [Rails.env.to_sym]
|
28
|
+
|
29
|
+
def with_connection(connection_name)
|
30
|
+
different = !Configuration.ghost_mode? && current_connection_name != connection_name.to_sym
|
31
|
+
|
32
|
+
@@connection_name_stack.push(connection_name.to_sym)
|
33
|
+
Rails.logger.debug "Connecting to #{current_connection_name}"
|
34
|
+
connect_to(current_connection_name) if different
|
35
|
+
|
36
|
+
yield
|
37
|
+
ensure
|
38
|
+
@@connection_name_stack.pop
|
39
|
+
Rails.logger.debug "Restoring connection to #{current_connection_name}"
|
40
|
+
connect_to(current_connection_name) if different
|
41
|
+
end
|
42
|
+
|
43
|
+
def set_connection_for_model(model, connection_name)
|
44
|
+
connect_to(connection_name, model) unless Configuration.ghost_mode?
|
45
|
+
end
|
46
|
+
|
47
|
+
private
|
48
|
+
|
49
|
+
def connect_to(connection_name, model = ActiveRecord::Base)
|
50
|
+
model.establish_connection(connection_name)
|
51
|
+
end
|
52
|
+
|
53
|
+
def current_connection_name
|
54
|
+
@@connection_name_stack.last
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
@@ -0,0 +1,78 @@
|
|
1
|
+
require 'stagehand/production/controller'
|
2
|
+
|
3
|
+
module Stagehand
|
4
|
+
module Production
|
5
|
+
extend self
|
6
|
+
|
7
|
+
# Outputs a symbol representing the status of the staging record as it exists in the production database
|
8
|
+
def status(staging_record)
|
9
|
+
if !exists?(staging_record)
|
10
|
+
:new
|
11
|
+
elsif modified?(staging_record)
|
12
|
+
:modified
|
13
|
+
else
|
14
|
+
:not_modified
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
def save(staging_record)
|
19
|
+
attributes = staging_record_attributes(staging_record)
|
20
|
+
|
21
|
+
return unless attributes.present?
|
22
|
+
|
23
|
+
is_new = lookup(staging_record).update_all(attributes).zero?
|
24
|
+
|
25
|
+
# Ensure we always return a record, even when updating instead of creating
|
26
|
+
Record.new.tap do |record|
|
27
|
+
record.assign_attributes(attributes)
|
28
|
+
record.save if is_new
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
def delete(staging_record, table_name = nil)
|
33
|
+
lookup(staging_record, table_name).delete_all
|
34
|
+
end
|
35
|
+
|
36
|
+
def exists?(staging_record, table_name = nil)
|
37
|
+
lookup(staging_record, table_name).exists?
|
38
|
+
end
|
39
|
+
|
40
|
+
# Returns true if the staging record's attributes are different from the production record's attributes
|
41
|
+
# Returns true if the staging_record does not exist on production
|
42
|
+
# Returns false if the staging record is identical to the production record
|
43
|
+
def modified?(staging_record)
|
44
|
+
production_record_attributes(staging_record) != staging_record_attributes(staging_record)
|
45
|
+
end
|
46
|
+
|
47
|
+
# Returns a scope that limits results any occurrences of the specified record.
|
48
|
+
# Record can be specified by passing a staging record, or an id and table_name.
|
49
|
+
def lookup(staging_record, table_name = nil)
|
50
|
+
table_name, id = Stagehand::Key.generate(staging_record, :table_name => table_name)
|
51
|
+
prepare_to_modify(table_name)
|
52
|
+
return Record.where(:id => id)
|
53
|
+
end
|
54
|
+
|
55
|
+
private
|
56
|
+
|
57
|
+
def prepare_to_modify(table_name)
|
58
|
+
raise "Can't prepare to modify production records without knowning the table_name" unless table_name.present?
|
59
|
+
Record.establish_connection(Configuration.production_connection_name) and @connection_established = true unless @connection_established
|
60
|
+
Record.table_name = table_name
|
61
|
+
end
|
62
|
+
|
63
|
+
def production_record_attributes(staging_record)
|
64
|
+
Record.connection.select_one(lookup(staging_record))
|
65
|
+
end
|
66
|
+
|
67
|
+
def staging_record_attributes(staging_record, table_name = nil)
|
68
|
+
table_name, id = Stagehand::Key.generate(staging_record, :table_name => table_name)
|
69
|
+
Stagehand::Staging::CommitEntry.connection.select_one("SELECT * FROM #{table_name} WHERE id = #{id}")
|
70
|
+
end
|
71
|
+
|
72
|
+
# CLASSES
|
73
|
+
|
74
|
+
class Record < ActiveRecord::Base
|
75
|
+
self.record_timestamps = false
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
@@ -0,0 +1,81 @@
|
|
1
|
+
require "stagehand/schema/statements"
|
2
|
+
|
3
|
+
module Stagehand
|
4
|
+
module Schema
|
5
|
+
UNTRACKED_TABLES = ['schema_migrations', Stagehand::Staging::CommitEntry.table_name]
|
6
|
+
|
7
|
+
def self.add_stagehand!(options = {})
|
8
|
+
ActiveRecord::Schema.define do
|
9
|
+
unless Stagehand::Staging::CommitEntry.table_exists?
|
10
|
+
create_table :stagehand_commit_entries do |t|
|
11
|
+
t.integer :record_id
|
12
|
+
t.string :table_name
|
13
|
+
t.string :operation, :null => false
|
14
|
+
t.integer :commit_id
|
15
|
+
t.string :session
|
16
|
+
end
|
17
|
+
|
18
|
+
add_index :stagehand_commit_entries, :commit_id
|
19
|
+
add_index :stagehand_commit_entries, :operation
|
20
|
+
|
21
|
+
# Create trigger to initialize session using a function
|
22
|
+
ActiveRecord::Base.connection.execute("DROP TRIGGER IF EXISTS stagehand_session_trigger;")
|
23
|
+
ActiveRecord::Base.connection.execute("
|
24
|
+
CREATE TRIGGER stagehand_session_trigger BEFORE INSERT ON stagehand_commit_entries
|
25
|
+
FOR EACH ROW SET NEW.session = CONNECTION_ID();")
|
26
|
+
end
|
27
|
+
|
28
|
+
table_names = ActiveRecord::Base.connection.tables
|
29
|
+
table_names -= UNTRACKED_TABLES
|
30
|
+
table_names -= Array(options[:except]).collect(&:to_s)
|
31
|
+
table_names &= Array(options[:only]).collect(&:to_s) if options[:only].present?
|
32
|
+
|
33
|
+
table_names.each do |table_name|
|
34
|
+
Stagehand::Schema.drop_trigger(table_name, 'insert')
|
35
|
+
Stagehand::Schema.drop_trigger(table_name, 'update')
|
36
|
+
Stagehand::Schema.drop_trigger(table_name, 'delete')
|
37
|
+
|
38
|
+
Stagehand::Schema.create_trigger(table_name, 'insert', 'NEW')
|
39
|
+
Stagehand::Schema.create_trigger(table_name, 'update', 'NEW')
|
40
|
+
Stagehand::Schema.create_trigger(table_name, 'delete', 'OLD')
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
def self.remove_stagehand!(options = {})
|
46
|
+
ActiveRecord::Schema.define do
|
47
|
+
table_names = ActiveRecord::Base.connection.tables
|
48
|
+
table_names &= Array(options[:only]).collect(&:to_s) if options[:only].present?
|
49
|
+
|
50
|
+
table_names.each do |table_name|
|
51
|
+
Stagehand::Schema.drop_trigger(table_name, 'insert')
|
52
|
+
Stagehand::Schema.drop_trigger(table_name, 'update')
|
53
|
+
Stagehand::Schema.drop_trigger(table_name, 'delete')
|
54
|
+
end
|
55
|
+
|
56
|
+
drop_table :stagehand_commit_entries unless options[:only].present?
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
private
|
61
|
+
|
62
|
+
def self.create_trigger(table_name, trigger_action, record)
|
63
|
+
ActiveRecord::Base.connection.execute("
|
64
|
+
CREATE TRIGGER #{trigger_name(table_name, trigger_action)} AFTER #{trigger_action.upcase} ON #{table_name}
|
65
|
+
FOR EACH ROW
|
66
|
+
BEGIN
|
67
|
+
INSERT INTO stagehand_commit_entries (record_id, table_name, operation)
|
68
|
+
VALUES (#{record}.id, '#{table_name}', '#{trigger_action}');
|
69
|
+
END;
|
70
|
+
")
|
71
|
+
end
|
72
|
+
|
73
|
+
def self.drop_trigger(table_name, trigger_action)
|
74
|
+
ActiveRecord::Base.connection.execute("DROP TRIGGER IF EXISTS #{trigger_name(table_name, trigger_action)};")
|
75
|
+
end
|
76
|
+
|
77
|
+
def self.trigger_name(table_name, trigger_action)
|
78
|
+
"stagehand_#{trigger_action}_trigger_#{table_name}"
|
79
|
+
end
|
80
|
+
end
|
81
|
+
end
|
@@ -0,0 +1,24 @@
|
|
1
|
+
module Stagehand
|
2
|
+
module Schema
|
3
|
+
module Statements
|
4
|
+
# Ensure that developers are aware they need to make a determination of whether stagehand should track this table or not
|
5
|
+
def create_table(table_name, options = {})
|
6
|
+
case options.symbolize_keys[:stagehand]
|
7
|
+
when true
|
8
|
+
super
|
9
|
+
Schema.add_stagehand! :only => table_name
|
10
|
+
when false
|
11
|
+
super
|
12
|
+
else
|
13
|
+
raise TableOptionNotSet, "If this table contains data to sync to the production database, pass #{{:stagehand => true}}" unless UNTRACKED_TABLES.include?(table_name)
|
14
|
+
super
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
# EXCEPTIONS
|
21
|
+
class TableOptionNotSet < ActiveRecord::ActiveRecordError; end
|
22
|
+
end
|
23
|
+
|
24
|
+
ActiveRecord::Base.connection.class.include Stagehand::Schema::Statements
|
@@ -0,0 +1,11 @@
|
|
1
|
+
require 'stagehand/staging/commit'
|
2
|
+
require 'stagehand/staging/commit_entry'
|
3
|
+
require 'stagehand/staging/checklist'
|
4
|
+
require 'stagehand/staging/controller'
|
5
|
+
require 'stagehand/staging/model'
|
6
|
+
require 'stagehand/staging/synchronizer'
|
7
|
+
|
8
|
+
module Stagehand
|
9
|
+
module Staging
|
10
|
+
end
|
11
|
+
end
|
@@ -0,0 +1,147 @@
|
|
1
|
+
module Stagehand
|
2
|
+
module Staging
|
3
|
+
class Checklist
|
4
|
+
def self.related_commits(commit)
|
5
|
+
Commit.find(related_commit_ids(commit))
|
6
|
+
end
|
7
|
+
|
8
|
+
def self.related_commit_ids(commit)
|
9
|
+
related_entries(commit.entries).collect(&:commit_id).select(&:present?).uniq
|
10
|
+
end
|
11
|
+
|
12
|
+
def self.related_entries(entries)
|
13
|
+
entries = Array.wrap(entries)
|
14
|
+
related_entries = []
|
15
|
+
|
16
|
+
entries_to_spider = Array.wrap(entries)
|
17
|
+
while entries_to_spider.present?
|
18
|
+
contained_matching = CommitEntry.contained.matching(entries_to_spider)
|
19
|
+
matching_commit_entries = CommitEntry.where(:commit_id => contained_matching.select(:commit_id))
|
20
|
+
|
21
|
+
# Spider using content operations. Don't spider control operations to avoid extending the list of results unnecessarily
|
22
|
+
content_operations, control_operations = matching_commit_entries.partition(&:content_operation?)
|
23
|
+
entries_to_spider = content_operations - related_entries
|
24
|
+
|
25
|
+
# Record the spidered entries and the control entries
|
26
|
+
related_entries.concat(entries_to_spider)
|
27
|
+
related_entries.concat(control_operations)
|
28
|
+
end
|
29
|
+
|
30
|
+
# Also include uncontained commit entries that matched
|
31
|
+
related_entries.concat(CommitEntry.uncontained.matching(entries + related_entries))
|
32
|
+
related_entries.uniq!
|
33
|
+
|
34
|
+
return related_entries
|
35
|
+
end
|
36
|
+
|
37
|
+
def initialize(subject, &confirmation_filter)
|
38
|
+
@subject = subject
|
39
|
+
@confirmation_filter = confirmation_filter
|
40
|
+
@cache = {}
|
41
|
+
affected_entries # Init the affected_entries changes can be rolled back without affecting the checklist
|
42
|
+
end
|
43
|
+
|
44
|
+
def confirm_create
|
45
|
+
cache(:confirm_create) { grouped_required_confirmation_entries[:insert].collect(&:record) }
|
46
|
+
end
|
47
|
+
|
48
|
+
def confirm_delete
|
49
|
+
cache(:confirm_delete) { grouped_required_confirmation_entries[:delete].collect(&:record).compact }
|
50
|
+
end
|
51
|
+
|
52
|
+
def confirm_update
|
53
|
+
cache(:confirm_update) { grouped_required_confirmation_entries[:update].collect(&:record) }
|
54
|
+
end
|
55
|
+
|
56
|
+
# Returns true if there are any changes in the checklist that require confirmation
|
57
|
+
def requires_confirmation?
|
58
|
+
cache(:requires_confirmation?) { grouped_required_confirmation_entries.values.flatten.present? }
|
59
|
+
end
|
60
|
+
|
61
|
+
# Returns a list of records that exist in commits where the staging_record is not in the start operation
|
62
|
+
def requires_confirmation
|
63
|
+
cache(:requires_confirmation) { grouped_required_confirmation_entries.values.flatten.collect(&:record).compact }
|
64
|
+
end
|
65
|
+
|
66
|
+
def syncing_entries
|
67
|
+
cache(:syncing_entries) { compact_entries(affected_entries) }
|
68
|
+
end
|
69
|
+
|
70
|
+
def affected_records
|
71
|
+
cache(:affected_records) { affected_entries.collect(&:record).uniq }
|
72
|
+
end
|
73
|
+
|
74
|
+
def affected_entries
|
75
|
+
cache(:affected_entries) { self.class.related_entries(@subject) }
|
76
|
+
end
|
77
|
+
|
78
|
+
private
|
79
|
+
|
80
|
+
def grouped_required_confirmation_entries
|
81
|
+
cache(:grouped_required_confirmation_entries) do
|
82
|
+
staging_record_start_operation_ids = affected_entries.select do |entry|
|
83
|
+
entry.start_operation? && entry.matches?(@subject)
|
84
|
+
end.collect(&:id)
|
85
|
+
|
86
|
+
# Don't need to confirm entries that were part of a commits kicked off by the staging record
|
87
|
+
entries = affected_entries.reject {|entry| staging_record_start_operation_ids.include?(entry.commit_id) }
|
88
|
+
|
89
|
+
# Don't need to confirm entries that were not part of a commit
|
90
|
+
entries = entries.select(&:commit_id)
|
91
|
+
|
92
|
+
entries = compact_entries(entries)
|
93
|
+
entries = preload_records(entries)
|
94
|
+
entries = filter_entries(entries)
|
95
|
+
entries = group_entries(entries)
|
96
|
+
end
|
97
|
+
end
|
98
|
+
|
99
|
+
def filter_entries(entries)
|
100
|
+
@confirmation_filter ? entries.select {|entry| @confirmation_filter.call(entry.record) } : entries
|
101
|
+
end
|
102
|
+
|
103
|
+
# Returns a list of entries that only includes a single entry for each record.
|
104
|
+
# The type of entry chosen prioritizes creates over updates, and deletes over creates.
|
105
|
+
def compact_entries(entries)
|
106
|
+
compact_entries = group_entries(entries)
|
107
|
+
compact_entries = compact_entries[:delete] + compact_entries[:insert] + compact_entries[:update]
|
108
|
+
compact_entries.uniq!(&:key)
|
109
|
+
|
110
|
+
return compact_entries
|
111
|
+
end
|
112
|
+
|
113
|
+
# Groups entries by their operation
|
114
|
+
def group_entries(entries)
|
115
|
+
group_entries = Hash.new {|h,k| h[k] = [] }
|
116
|
+
group_entries.merge! entries.group_by(&:operation).symbolize_keys!
|
117
|
+
|
118
|
+
return group_entries
|
119
|
+
end
|
120
|
+
|
121
|
+
def preload_records(entries)
|
122
|
+
entries.group_by(&:table_name).each do |table_name, group_entries|
|
123
|
+
klass = CommitEntry.infer_class(table_name)
|
124
|
+
records = klass.where(:id => group_entries.collect(&:record_id))
|
125
|
+
records_by_id = records.collect {|r| [r.id, r] }.to_h
|
126
|
+
group_entries.each do |entry|
|
127
|
+
entry.record = records_by_id[entry.record_id]
|
128
|
+
end
|
129
|
+
end
|
130
|
+
|
131
|
+
return entries
|
132
|
+
end
|
133
|
+
|
134
|
+
def cache(key, &block)
|
135
|
+
if @cache.key?(key)
|
136
|
+
@cache[key]
|
137
|
+
else
|
138
|
+
@cache[key] = block.call
|
139
|
+
end
|
140
|
+
end
|
141
|
+
|
142
|
+
def clear_cache
|
143
|
+
@cache.clear
|
144
|
+
end
|
145
|
+
end
|
146
|
+
end
|
147
|
+
end
|
@@ -0,0 +1,96 @@
|
|
1
|
+
module Stagehand
|
2
|
+
module Staging
|
3
|
+
class Commit
|
4
|
+
def self.all
|
5
|
+
CommitEntry.start_operations.pluck(:id).collect {|id| find(id) }
|
6
|
+
end
|
7
|
+
|
8
|
+
def self.capture(subject_record = nil, &block)
|
9
|
+
start_operation = start_commit(subject_record)
|
10
|
+
block.call
|
11
|
+
return end_commit(start_operation)
|
12
|
+
rescue => e
|
13
|
+
end_commit(start_operation)
|
14
|
+
raise(e)
|
15
|
+
end
|
16
|
+
|
17
|
+
def self.containing(record)
|
18
|
+
find(CommitEntry.contained.matching(record).pluck(:commit_id))
|
19
|
+
end
|
20
|
+
|
21
|
+
def self.find(start_ids)
|
22
|
+
if start_ids.respond_to?(:each)
|
23
|
+
start_ids.to_a.uniq.collect {|id| find(id) }.compact
|
24
|
+
else
|
25
|
+
new(start_ids)
|
26
|
+
end
|
27
|
+
rescue CommitNotFound
|
28
|
+
end
|
29
|
+
|
30
|
+
private
|
31
|
+
|
32
|
+
def self.start_commit(subject_record)
|
33
|
+
start_operation = CommitEntry.start_operations.new
|
34
|
+
|
35
|
+
if subject_record
|
36
|
+
start_operation.record_id = subject_record.id
|
37
|
+
start_operation.table_name = subject_record.class.table_name
|
38
|
+
end
|
39
|
+
|
40
|
+
start_operation.save
|
41
|
+
|
42
|
+
return start_operation.reload # Reload to ensure session is set
|
43
|
+
end
|
44
|
+
|
45
|
+
# Sets the commit_id on all the entries between the start and end op.
|
46
|
+
# Returns the commit object for those entries
|
47
|
+
def self.end_commit(start_operation)
|
48
|
+
end_operation = CommitEntry.end_operations.create(:session => start_operation.session)
|
49
|
+
|
50
|
+
CommitEntry
|
51
|
+
.where(:id => start_operation.id..end_operation.id)
|
52
|
+
.where(:session => start_operation.session)
|
53
|
+
.update_all(:commit_id => start_operation.id)
|
54
|
+
|
55
|
+
return new(start_operation.id)
|
56
|
+
end
|
57
|
+
|
58
|
+
public
|
59
|
+
|
60
|
+
def initialize(start_id)
|
61
|
+
@start_id, @end_id = CommitEntry.control_operations
|
62
|
+
.where(:commit_id => start_id)
|
63
|
+
.where('id >= ?', start_id).limit(2).pluck(:id)
|
64
|
+
|
65
|
+
raise CommitNotFound unless @start_id && @end_id
|
66
|
+
end
|
67
|
+
|
68
|
+
def id
|
69
|
+
@start_id
|
70
|
+
end
|
71
|
+
|
72
|
+
def include?(record)
|
73
|
+
entries.where(:record_id => record.id, :table_name => record.class.table_name).exists?
|
74
|
+
end
|
75
|
+
|
76
|
+
def hash
|
77
|
+
id
|
78
|
+
end
|
79
|
+
|
80
|
+
def eql?(other)
|
81
|
+
self == other
|
82
|
+
end
|
83
|
+
|
84
|
+
def ==(other)
|
85
|
+
id == other.id
|
86
|
+
end
|
87
|
+
|
88
|
+
def entries
|
89
|
+
CommitEntry.where(:id => @start_id..@end_id).where(:commit_id => id)
|
90
|
+
end
|
91
|
+
end
|
92
|
+
end
|
93
|
+
|
94
|
+
# EXCEPTIONS
|
95
|
+
class CommitNotFound < StandardError; end
|
96
|
+
end
|
@@ -0,0 +1,118 @@
|
|
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 :delete_operations, lambda { where(:operation => DELETE_OPERATION) }
|
24
|
+
scope :with_record, lambda { where.not(:record_id => nil) }
|
25
|
+
scope :uncontained, lambda { where(:commit_id => nil) }
|
26
|
+
scope :contained, lambda { where.not(:commit_id => nil) }
|
27
|
+
scope :not_in_progress, lambda {
|
28
|
+
joins("LEFT OUTER JOIN (#{ unscoped.select('session, MAX(id) AS start_id').uncontained.start_operations.group('session').to_sql }) AS active_starts
|
29
|
+
ON active_starts.session = stagehand_commit_entries.session AND active_starts.start_id <= stagehand_commit_entries.id")
|
30
|
+
.where("active_starts.start_id IS NULL") }
|
31
|
+
|
32
|
+
def self.matching(object)
|
33
|
+
keys = Array.wrap(object).collect {|entry| Stagehand::Key.generate(entry) }.compact
|
34
|
+
sql = []
|
35
|
+
interpolates = []
|
36
|
+
|
37
|
+
keys.group_by(&:first).each do |table_name, keys|
|
38
|
+
sql << "(table_name = ? AND record_id IN (?))"
|
39
|
+
interpolates << table_name
|
40
|
+
interpolates << keys.collect(&:last)
|
41
|
+
end
|
42
|
+
|
43
|
+
return keys.present? ? where(sql.join(' OR '), *interpolates) : none
|
44
|
+
end
|
45
|
+
|
46
|
+
def self.infer_class(table_name)
|
47
|
+
klass = ActiveRecord::Base.descendants.detect {|klass| klass.table_name == table_name && klass != Stagehand::Production::Record }
|
48
|
+
klass ||= table_name.classify.constantize # Try loading the class if it isn't loaded yet
|
49
|
+
|
50
|
+
rescue NameError
|
51
|
+
raise(IndeterminateRecordClass, "Can't determine class from table name: #{table_name}")
|
52
|
+
end
|
53
|
+
|
54
|
+
def record
|
55
|
+
@record ||= delete_operation? ? build_production_record : record_class.find_by_id(record_id) if content_operation?
|
56
|
+
end
|
57
|
+
|
58
|
+
def control_operation?
|
59
|
+
operation.in?(CONTROL_OPERATIONS)
|
60
|
+
end
|
61
|
+
|
62
|
+
def content_operation?
|
63
|
+
operation.in?(CONTENT_OPERATIONS)
|
64
|
+
end
|
65
|
+
|
66
|
+
def save_operation?
|
67
|
+
operation.in?(SAVE_OPERATIONS)
|
68
|
+
end
|
69
|
+
|
70
|
+
def insert_operation?
|
71
|
+
operation == INSERT_OPERATION
|
72
|
+
end
|
73
|
+
|
74
|
+
def update_operation?
|
75
|
+
operation == UPDATE_OPERATION
|
76
|
+
end
|
77
|
+
|
78
|
+
def delete_operation?
|
79
|
+
operation == DELETE_OPERATION
|
80
|
+
end
|
81
|
+
|
82
|
+
def start_operation?
|
83
|
+
operation == START_OPERATION
|
84
|
+
end
|
85
|
+
|
86
|
+
def end_operation?
|
87
|
+
operation == END_OPERATION
|
88
|
+
end
|
89
|
+
|
90
|
+
def matches?(others)
|
91
|
+
Array.wrap(others).any? {|other| key == Stagehand::Key.generate(other) }
|
92
|
+
end
|
93
|
+
|
94
|
+
def key
|
95
|
+
@key ||= Stagehand::Key.generate(self)
|
96
|
+
end
|
97
|
+
|
98
|
+
def record_class
|
99
|
+
@record_class ||= self.class.infer_class(table_name)
|
100
|
+
end
|
101
|
+
|
102
|
+
private
|
103
|
+
|
104
|
+
def build_production_record
|
105
|
+
production_record = Stagehand::Production.lookup(record_id, table_name).first
|
106
|
+
return unless production_record
|
107
|
+
|
108
|
+
production_record = record_class.new(production_record.attributes)
|
109
|
+
production_record.readonly!
|
110
|
+
|
111
|
+
return production_record
|
112
|
+
end
|
113
|
+
end
|
114
|
+
end
|
115
|
+
|
116
|
+
# EXCEPTIONS
|
117
|
+
class IndeterminateRecordClass < StandardError; end
|
118
|
+
end
|
@@ -0,0 +1,22 @@
|
|
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(subject_record = nil, &block)
|
13
|
+
Staging::Commit.capture(subject_record, &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
|
+
end
|
21
|
+
end
|
22
|
+
end
|
@@ -0,0 +1,71 @@
|
|
1
|
+
module Stagehand
|
2
|
+
module Staging
|
3
|
+
module Synchronizer
|
4
|
+
extend self
|
5
|
+
|
6
|
+
# Immediately attempt to sync the changes from the block if possible
|
7
|
+
# The block is wrapped in a transaction to prevent changes to records while being synced
|
8
|
+
def sync_now(&block)
|
9
|
+
raise SyncBlockRequired unless block_given?
|
10
|
+
|
11
|
+
ActiveRecord::Base.transaction do
|
12
|
+
checklist = Checklist.new(Commit.capture(&block).entries)
|
13
|
+
sync_checklist(checklist) unless checklist.requires_confirmation?
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
def auto_sync(delay = 5.seconds)
|
18
|
+
scope = autosyncable_entries.limit(1000)
|
19
|
+
|
20
|
+
loop do
|
21
|
+
puts "Synced #{sync_entries(scope.reload)} entries"
|
22
|
+
sleep(delay) if delay
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
def sync
|
27
|
+
sync_entries(autosyncable_entries.limit(1000))
|
28
|
+
end
|
29
|
+
|
30
|
+
# Copies all the affected records from the staging database to the production database
|
31
|
+
def sync_record(record)
|
32
|
+
sync_checklist(Checklist.new(record))
|
33
|
+
end
|
34
|
+
|
35
|
+
private
|
36
|
+
|
37
|
+
def sync_checklist(checklist)
|
38
|
+
sync_entries(checklist.syncing_entries)
|
39
|
+
CommitEntry.delete(checklist.affected_entries)
|
40
|
+
end
|
41
|
+
|
42
|
+
def sync_entries(entries)
|
43
|
+
ActiveRecord::Base.transaction do
|
44
|
+
entries.each do |entry|
|
45
|
+
Rails.logger.info "Synchronizing #{entry.table_name} #{entry.record_id}"
|
46
|
+
if entry.delete_operation?
|
47
|
+
Stagehand::Production.delete(entry)
|
48
|
+
elsif entry.save_operation?
|
49
|
+
Stagehand::Production.save(entry)
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
return entries.length
|
55
|
+
end
|
56
|
+
|
57
|
+
def autosyncable_entries
|
58
|
+
if Configuration.ghost_mode?
|
59
|
+
CommitEntry
|
60
|
+
else
|
61
|
+
CommitEntry.where(:id =>
|
62
|
+
CommitEntry.select('MAX(id) AS id').content_operations.not_in_progress.group('record_id, table_name').having('count(commit_id) = 0'))
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
# EXCEPTIONS
|
69
|
+
|
70
|
+
class SyncBlockRequired < StandardError; end
|
71
|
+
end
|
@@ -0,0 +1,16 @@
|
|
1
|
+
namespace :stagehand do
|
2
|
+
desc "Polls the commit entries table for changes to sync to production"
|
3
|
+
task :auto_sync, [:delay] => :environment do |t, args|
|
4
|
+
Stagehand::Staging::Synchronizer.auto_sync(args[:delay] ||= 5.seconds)
|
5
|
+
end
|
6
|
+
|
7
|
+
desc "Migrate both databases used by stagehand"
|
8
|
+
task :migrate => :environment do
|
9
|
+
[Rails.configuration.x.stagehand.staging_connection_name,
|
10
|
+
Rails.configuration.x.stagehand.production_connection_name].each do |config_key|
|
11
|
+
puts "Migrating #{config_key}"
|
12
|
+
ActiveRecord::Base.establish_connection(ActiveRecord::Base.configurations[config_key.to_s])
|
13
|
+
ActiveRecord::Migrator.migrate('db/migrate')
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
metadata
ADDED
@@ -0,0 +1,110 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: culturecode_stagehand
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Nicholas Jakobsen
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2016-03-31 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: rails
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - "~>"
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '4.2'
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - "~>"
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '4.2'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: mysql2
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - ">="
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '0'
|
34
|
+
type: :runtime
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - ">="
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '0'
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: rspec-rails
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - "~>"
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '3.0'
|
48
|
+
type: :development
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - "~>"
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '3.0'
|
55
|
+
description: Simplify the management of a sanbox database that can sync content to
|
56
|
+
a production database. Content changes can be bundled so partial syncs of the database
|
57
|
+
can occur.
|
58
|
+
email:
|
59
|
+
- nicholas.jakobsen@gmail.com
|
60
|
+
executables: []
|
61
|
+
extensions: []
|
62
|
+
extra_rdoc_files: []
|
63
|
+
files:
|
64
|
+
- MIT-LICENSE
|
65
|
+
- Rakefile
|
66
|
+
- lib/culturecode_stagehand.rb
|
67
|
+
- lib/stagehand.rb
|
68
|
+
- lib/stagehand/configuration.rb
|
69
|
+
- lib/stagehand/controller_extensions.rb
|
70
|
+
- lib/stagehand/engine.rb
|
71
|
+
- lib/stagehand/helpers.rb
|
72
|
+
- lib/stagehand/production.rb
|
73
|
+
- lib/stagehand/production/controller.rb
|
74
|
+
- lib/stagehand/schema.rb
|
75
|
+
- lib/stagehand/schema/statements.rb
|
76
|
+
- lib/stagehand/staging.rb
|
77
|
+
- lib/stagehand/staging/checklist.rb
|
78
|
+
- lib/stagehand/staging/commit.rb
|
79
|
+
- lib/stagehand/staging/commit_entry.rb
|
80
|
+
- lib/stagehand/staging/controller.rb
|
81
|
+
- lib/stagehand/staging/model.rb
|
82
|
+
- lib/stagehand/staging/synchronizer.rb
|
83
|
+
- lib/stagehand/version.rb
|
84
|
+
- lib/tasks/stagehand_tasks.rake
|
85
|
+
homepage: https://github.com/culturecode/stagehand
|
86
|
+
licenses:
|
87
|
+
- MIT
|
88
|
+
metadata: {}
|
89
|
+
post_install_message:
|
90
|
+
rdoc_options: []
|
91
|
+
require_paths:
|
92
|
+
- lib
|
93
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
94
|
+
requirements:
|
95
|
+
- - ">="
|
96
|
+
- !ruby/object:Gem::Version
|
97
|
+
version: '0'
|
98
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
99
|
+
requirements:
|
100
|
+
- - ">="
|
101
|
+
- !ruby/object:Gem::Version
|
102
|
+
version: '0'
|
103
|
+
requirements: []
|
104
|
+
rubyforge_project:
|
105
|
+
rubygems_version: 2.4.6
|
106
|
+
signing_key:
|
107
|
+
specification_version: 4
|
108
|
+
summary: Simplify the management of a sanbox database that can sync content to a production
|
109
|
+
database
|
110
|
+
test_files: []
|