culturecode_stagehand 0.1.0
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 +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: []
|