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 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: []