culturecode_stagehand 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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
@@ -0,0 +1,2 @@
1
+ # Wrapper because Rubygems.org already had a 'stagehand' gem. This avoids needing a :require option in Bundler.
2
+ require "stagehand"
data/lib/stagehand.rb ADDED
@@ -0,0 +1,4 @@
1
+ require "stagehand/engine"
2
+
3
+ module Stagehand
4
+ end
@@ -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,12 @@
1
+ module Stagehand
2
+ module Production
3
+ module Controller
4
+ extend ActiveSupport::Concern
5
+ include Stagehand::ControllerExtensions
6
+
7
+ included do
8
+ use_production_database
9
+ end
10
+ end
11
+ end
12
+ 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,11 @@
1
+ module Stagehand
2
+ module Staging
3
+ module Model
4
+ extend ActiveSupport::Concern
5
+
6
+ included do
7
+ Stagehand::Database.set_connection_for_model(self, Configuration.staging_connection_name)
8
+ end
9
+ end
10
+ end
11
+ 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,3 @@
1
+ module Stagehand
2
+ VERSION = "0.1.0"
3
+ 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: []