combinaut_stagehand 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,197 @@
1
+ require 'thread'
2
+ require 'stagehand/active_record_extensions'
3
+
4
+ module Stagehand
5
+ module Database
6
+ extend self
7
+
8
+ def transaction
9
+ raise InvalidConnectionError, "Calling Stagehand::Database.transaction is not valid unless connected to staging" unless connected_to_staging?
10
+
11
+ success = false
12
+ attempts = 0
13
+ output = nil
14
+ ActiveRecord::Base.transaction do
15
+ Production::Record.transaction do
16
+ attempts += 1
17
+
18
+ raise NoRetryError, "Retrying is not allowed in Stagehand::Database.transaction" if attempts > 1
19
+
20
+ output = yield
21
+
22
+ success = true
23
+ end
24
+
25
+ raise ActiveRecord::Rollback unless success
26
+ end
27
+
28
+ return output
29
+ ensure
30
+ Rails.logger.warn "Stagehand::Database transaction was rolled back" unless success
31
+ end
32
+
33
+ def each(&block)
34
+ with_production_connection(&block) unless Configuration.single_connection?
35
+ with_staging_connection(&block)
36
+ end
37
+
38
+ def connected_to_production?
39
+ current_connection_name == Configuration.production_connection_name
40
+ end
41
+
42
+ def connected_to_staging?
43
+ current_connection_name == Configuration.staging_connection_name
44
+ end
45
+
46
+ def production_connection
47
+ ProductionProbe.connection
48
+ end
49
+
50
+ def staging_connection
51
+ StagingProbe.connection
52
+ end
53
+
54
+ def production_database_name
55
+ database_name(Configuration.production_connection_name)
56
+ end
57
+
58
+ def staging_database_name
59
+ database_name(Configuration.staging_connection_name)
60
+ end
61
+
62
+ def staging_database_versions
63
+ Stagehand::Database.staging_connection.select_values(versions_scope)
64
+ end
65
+
66
+ def production_database_versions
67
+ Stagehand::Database.production_connection.select_values(versions_scope)
68
+ end
69
+
70
+ def with_staging_connection(&block)
71
+ with_connection(Configuration.staging_connection_name, &block)
72
+ end
73
+
74
+ def with_production_connection(&block)
75
+ with_connection(Configuration.production_connection_name, &block)
76
+ end
77
+
78
+ def with_connection(connection_name, &block)
79
+ if current_connection_name != connection_name.to_sym
80
+ Rails.logger.debug "Connecting to #{connection_name}"
81
+ output = swap_connection(connection_name, &block)
82
+ Rails.logger.debug "Restoring connection to #{current_connection_name}"
83
+ else
84
+ Rails.logger.debug "Already connected to #{connection_name}"
85
+ output = yield connection_name
86
+ end
87
+ return output
88
+ end
89
+
90
+ private
91
+
92
+ def swap_connection(connection_name)
93
+ pushed = ConnectionStack.push(connection_name.to_sym)
94
+ cache = ActiveRecord::Base.connection_pool.query_cache_enabled
95
+ ActiveRecord::Base.connection_specification_name = current_connection_name
96
+ ActiveRecord::Base.connection_pool.enable_query_cache! if cache
97
+
98
+ yield connection_name
99
+ ensure
100
+ ConnectionStack.pop if pushed
101
+ ActiveRecord::Base.connection_specification_name = current_connection_name
102
+ ActiveRecord::Base.connection_pool.enable_query_cache! if cache
103
+ end
104
+
105
+ def current_connection_name
106
+ ConnectionStack.last
107
+ end
108
+
109
+ def database_name(connection_name)
110
+ database_configuration.dig(connection_name.to_s, 'database')
111
+ end
112
+
113
+ def database_configuration
114
+ @database_configuration ||= Rails.configuration.database_configuration
115
+ end
116
+
117
+ def versions_scope
118
+ ActiveRecord::SchemaMigration.order(:version)
119
+ end
120
+
121
+ # CLASSES
122
+
123
+ class Probe < ActiveRecord::Base
124
+ self.abstract_class = true
125
+ self.stagehand_threadsafe_connections = false # We don't want to track connection per-thread for Probes
126
+
127
+ # We fake the class name so we can create a connection pool with the desired connection name instead of the name of the class
128
+ def self.init_connection(connection_name)
129
+ @probe_name = connection_name
130
+ establish_connection(connection_name)
131
+ ensure
132
+ @probe_name = nil
133
+ end
134
+
135
+ def self.name
136
+ @probe_name || super
137
+ end
138
+ end
139
+
140
+ class StagingProbe < Probe
141
+ self.abstract_class = true
142
+
143
+ def self.init_connection
144
+ super(Configuration.staging_connection_name)
145
+ end
146
+
147
+ def self.connection
148
+ if Stagehand::Database.connected_to_staging?
149
+ ActiveRecord::Base.connection # Reuse existing connection so we stay within the current transaction
150
+ else
151
+ super
152
+ end
153
+ end
154
+
155
+ init_connection
156
+ end
157
+
158
+ class ProductionProbe < Probe
159
+ self.abstract_class = true
160
+
161
+ def self.init_connection
162
+ super(Configuration.production_connection_name)
163
+ end
164
+
165
+ init_connection unless Configuration.single_connection?
166
+ end
167
+
168
+ # Threadsafe tracking of the connection stack
169
+ module ConnectionStack
170
+ def self.push(connection_name)
171
+ current_stack.push connection_name
172
+ end
173
+
174
+ def self.pop
175
+ current_stack.pop
176
+ end
177
+
178
+ def self.last
179
+ current_stack.last
180
+ end
181
+
182
+ def self.current_stack
183
+ if stack = Thread.current.thread_variable_get('sparkle_connection_name_stack')
184
+ stack
185
+ else
186
+ stack = Concurrent::Array.new
187
+ stack << Rails.env.to_sym
188
+ Thread.current.thread_variable_set('sparkle_connection_name_stack', stack)
189
+ stack
190
+ end
191
+ end
192
+ end
193
+
194
+ class InvalidConnectionError < StandardError; end
195
+ class NoRetryError < StandardError; end
196
+ end
197
+ end
@@ -0,0 +1,26 @@
1
+ require 'stagehand/configuration'
2
+
3
+ module Stagehand
4
+ class Engine < ::Rails::Engine
5
+ isolate_namespace Stagehand
6
+
7
+ config.generators do |g|
8
+ g.test_framework :rspec
9
+ end
10
+
11
+ # These require the rails application to be initialized because configuration variables are used
12
+ initializer 'stagehand.load_modules' do
13
+ require 'stagehand/cache'
14
+ require 'stagehand/key'
15
+ require 'stagehand/database'
16
+ require 'stagehand/connection_adapter_extensions'
17
+ require 'stagehand/controller_extensions'
18
+ require 'stagehand/active_record_extensions'
19
+ require 'stagehand/schema_extensions'
20
+ require 'stagehand/staging'
21
+ require 'stagehand/production'
22
+ require 'stagehand/schema'
23
+ require 'stagehand/auditor'
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,23 @@
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
+ 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,132 @@
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, table_name = nil)
9
+ if !exists?(staging_record, table_name)
10
+ :new
11
+ elsif modified?(staging_record, table_name)
12
+ :modified
13
+ else
14
+ :not_modified
15
+ end
16
+ end
17
+
18
+ def save(staging_record, table_name = nil)
19
+ attributes = staging_record_attributes(staging_record, table_name)
20
+
21
+ return unless attributes.present?
22
+
23
+ write(staging_record, attributes, table_name)
24
+ end
25
+
26
+ def write(staging_record, attributes, table_name = nil)
27
+ table_name, id = Stagehand::Key.generate(staging_record, :table_name => table_name)
28
+
29
+ production_record = Connection.with_production_writes do
30
+ prepare_to_modify(table_name)
31
+
32
+ if update(table_name, id, attributes).nonzero?
33
+ Record.find(id)
34
+ else
35
+ Record.find(insert(table_name, attributes))
36
+ end
37
+ end
38
+
39
+ return production_record
40
+ end
41
+
42
+ def delete(staging_record, table_name = nil)
43
+ Connection.with_production_writes do
44
+ matching(staging_record, table_name).delete_all
45
+ end
46
+ end
47
+
48
+ def exists?(staging_record, table_name = nil)
49
+ matching(staging_record, table_name).exists?
50
+ end
51
+
52
+ # Returns true if the staging record's attributes are different from the production record's attributes
53
+ # Returns true if the staging_record does not exist on production
54
+ # Returns false if the staging record is identical to the production record
55
+ def modified?(staging_record, table_name = nil)
56
+ production_record_attributes(staging_record, table_name) != staging_record_attributes(staging_record, table_name)
57
+ end
58
+
59
+ def find(*args)
60
+ matching(*args).first
61
+ end
62
+
63
+ # Returns a scope that limits results any occurrences of the specified record.
64
+ # Record can be specified by passing a staging record, or an id and table_name.
65
+ def matching(staging_record, table_name = nil)
66
+ table_name, id = Stagehand::Key.generate(staging_record, :table_name => table_name)
67
+ prepare_to_modify(table_name)
68
+ return Record.where(:id => id)
69
+ end
70
+
71
+ private
72
+
73
+ def production_record_attributes(staging_record, table_name = nil)
74
+ Record.connection.select_one(matching(staging_record, table_name))
75
+ end
76
+
77
+ def staging_record_attributes(staging_record, table_name = nil)
78
+ table_name, id = Stagehand::Key.generate(staging_record, :table_name => table_name)
79
+ hash = select(table_name, id)
80
+ hash.except(*ignored_columns(table_name)) if hash
81
+ end
82
+
83
+ def ignored_columns(table_name)
84
+ Array.wrap(Configuration.ignored_columns[table_name]).map(&:to_s)
85
+ end
86
+
87
+ def select(table_name, id)
88
+ table = Arel::Table.new(table_name)
89
+ statement = Arel::SelectManager.new
90
+ statement.from table
91
+ statement.project Arel.star
92
+ statement.where table[:id].eq(id)
93
+
94
+ Stagehand::Database::StagingProbe.connection.select_one(statement)
95
+ end
96
+
97
+ def update(table_name, id, attributes)
98
+ table = Arel::Table.new(table_name)
99
+ statement = Arel::UpdateManager.new
100
+ statement.table table
101
+ statement.set attributes.map {|attribute, value| [table[attribute], value] }
102
+ statement.where table[:id].eq(id)
103
+
104
+ Record.connection.update(statement)
105
+ end
106
+
107
+ def insert(table_name, attributes)
108
+ table = Arel::Table.new(table_name)
109
+ statement = Arel::InsertManager.new
110
+ statement.into table
111
+ statement.insert attributes.map {|attribute, value| [table[attribute], value] }
112
+
113
+ Record.connection.insert(statement)
114
+ end
115
+
116
+ def prepare_to_modify(table_name)
117
+ raise "Can't prepare to modify production records without knowning the table_name" unless table_name.present?
118
+
119
+ return if Record.table_name == table_name
120
+
121
+ Record.table_name = table_name
122
+ Record.reset_column_information
123
+ end
124
+
125
+ # CLASSES
126
+
127
+ class Record < Stagehand::Database::ProductionProbe
128
+ self.record_timestamps = false
129
+ self.inheritance_column = nil
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,53 @@
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
+ super
7
+ options = options.symbolize_keys
8
+
9
+ return if Database.connected_to_production? && !Stagehand::Configuration.single_connection?
10
+ return if options[:stagehand] == false
11
+ return if UNTRACKED_TABLES.include?(table_name)
12
+
13
+ Schema.add_stagehand! :only => table_name
14
+ end
15
+
16
+ def rename_table(old_table_name, new_table_name, *)
17
+ return super unless Schema.has_stagehand?(old_table_name)
18
+
19
+ Schema.remove_stagehand!(:only => old_table_name, :remove_entries => false)
20
+ super
21
+ Schema.add_stagehand!(:only => new_table_name)
22
+ Staging::CommitEntry.where(:table_name => old_table_name).update_all(:table_name => new_table_name)
23
+ end
24
+
25
+ def drop_table(table_name, *)
26
+ return super unless Schema.has_stagehand?(table_name) && table_exists?(Staging::CommitEntry.table_name)
27
+
28
+ Schema.remove_stagehand!(:only => table_name, :remove_entries => true)
29
+ super
30
+ end
31
+ end
32
+
33
+ # Allow dumping of stagehand create_table directive
34
+ # e.g. create_table "comments", stagehand: true do |t|
35
+ module DumperExtensions
36
+ def table(table_name, stream)
37
+ stagehand = Stagehand::Schema.has_stagehand?(table_name)
38
+ stagehand = ':commit_entries' if table_name == Staging::CommitEntry.table_name
39
+
40
+ table_stream = StringIO.new
41
+ super(table_name, table_stream)
42
+ table_stream.rewind
43
+ table_schema = table_stream.read.gsub(/create_table (.+) do/, 'create_table \1' + ", stagehand: #{stagehand} do")
44
+ stream.puts table_schema
45
+
46
+ return stream
47
+ end
48
+ end
49
+ end
50
+ end
51
+
52
+ ActiveRecord::Base.connection.class.include Stagehand::Schema::Statements
53
+ ActiveRecord::SchemaDumper.prepend Stagehand::Schema::DumperExtensions
@@ -0,0 +1,145 @@
1
+ require "stagehand/schema/statements"
2
+
3
+ module Stagehand
4
+ module Schema
5
+ extend self
6
+
7
+ UNTRACKED_TABLES = ['ar_internal_metadata', 'schema_migrations', 'data_migrations', Stagehand::Staging::CommitEntry.table_name]
8
+
9
+ def init_stagehand!(**table_options)
10
+ ActiveRecord::Schema.define do
11
+ create_table :stagehand_commit_entries do |t|
12
+ t.integer :record_id
13
+ t.string :table_name
14
+ t.string :operation, :null => false
15
+ t.integer :commit_id
16
+ t.boolean :committed, :null => false, :default => false
17
+ t.datetime :created_at
18
+ end
19
+
20
+ add_index :stagehand_commit_entries, :commit_id # Used for looking up all entries within a commit
21
+ add_index :stagehand_commit_entries, [:record_id, :table_name, :committed], :name => 'index_stagehand_commit_entries_for_matching' # Used for 'matching' scope
22
+ add_index :stagehand_commit_entries, [:operation, :committed, :commit_id], :name => 'index_stagehand_commit_entries_for_loading' # Used for looking up start entries
23
+ end
24
+
25
+ Stagehand::Staging::CommitEntry.reset_column_information
26
+
27
+ add_stagehand!(table_options)
28
+ end
29
+
30
+
31
+ def add_stagehand!(force: false, **table_options)
32
+ return if Database.connected_to_production? && !Stagehand::Configuration.single_connection?
33
+
34
+ ActiveRecord::Schema.define do
35
+ Stagehand::Schema.send :each_table, table_options do |table_name|
36
+ Stagehand::Schema.send :create_operation_trigger, table_name, 'insert', 'NEW', force: force
37
+ Stagehand::Schema.send :create_operation_trigger, table_name, 'update', 'NEW', force: force
38
+ Stagehand::Schema.send :create_operation_trigger, table_name, 'delete', 'OLD', force: force
39
+ end
40
+ end
41
+ end
42
+
43
+ def remove_stagehand!(remove_entries: true, **table_options)
44
+ ActiveRecord::Schema.define do
45
+ Stagehand::Schema.send :each_table, table_options do |table_name|
46
+ next unless Stagehand::Schema.send :has_stagehand_triggers?, table_name
47
+ Stagehand::Schema.send :drop_trigger, table_name, 'insert'
48
+ Stagehand::Schema.send :drop_trigger, table_name, 'update'
49
+ Stagehand::Schema.send :drop_trigger, table_name, 'delete'
50
+ Stagehand::Schema.send :expunge, table_name if remove_entries
51
+ end
52
+
53
+ drop_table :stagehand_commit_entries unless table_options[:only].present?
54
+ end
55
+ end
56
+
57
+ def has_stagehand?(table_name = nil)
58
+ if UNTRACKED_TABLES.include?(table_name.to_s)
59
+ return false
60
+ elsif table_name
61
+ has_stagehand_triggers?(table_name)
62
+ else
63
+ ActiveRecord::Base.connection.table_exists?(Stagehand::Staging::CommitEntry.table_name)
64
+ end
65
+ end
66
+
67
+ private
68
+
69
+ def each_table(only: nil, except: nil)
70
+ table_names = ActiveRecord::Base.connection.tables
71
+ table_names -= UNTRACKED_TABLES
72
+ table_names -= Array(except).collect(&:to_s)
73
+ table_names &= Array(only).collect(&:to_s) if only.present?
74
+
75
+ table_names.each do |table_name|
76
+ yield table_name
77
+ end
78
+ end
79
+
80
+ def create_operation_trigger(table_name, trigger_event, record, force: false)
81
+ if force
82
+ drop_trigger(table_name, trigger_event)
83
+ elsif trigger_exists?(table_name, trigger_event)
84
+ return
85
+ end
86
+
87
+ create_trigger(table_name, trigger_event, :after, <<-SQL)
88
+ BEGIN
89
+ INSERT INTO stagehand_commit_entries (record_id, table_name, operation, commit_id, created_at)
90
+ VALUES (#{record}.id, '#{table_name}', '#{trigger_event}', @stagehand_commit_id, CURRENT_TIMESTAMP());
91
+ END;
92
+ SQL
93
+ end
94
+
95
+ def create_trigger(table_name, trigger_event, trigger_time, row_action)
96
+ ActiveRecord::Base.connection.execute <<-SQL
97
+ CREATE TRIGGER #{trigger_name(table_name, trigger_event)} #{trigger_time} #{trigger_event}
98
+ ON #{table_name} FOR EACH ROW #{row_action}
99
+ SQL
100
+ end
101
+
102
+ def drop_trigger(table_name, trigger_event)
103
+ ActiveRecord::Base.connection.execute("DROP TRIGGER IF EXISTS #{trigger_name(table_name, trigger_event)};")
104
+ end
105
+
106
+ def trigger_exists?(table_name, trigger_event)
107
+ ActiveRecord::Base.connection.select_one("SHOW TRIGGERS where `trigger` = '#{trigger_name(table_name, trigger_event)}'").present?
108
+ end
109
+
110
+ def has_stagehand_triggers?(table_name)
111
+ get_triggers(table_name).present?
112
+ end
113
+
114
+ def trigger_name(table_name, trigger_event)
115
+ "stagehand_#{trigger_event}_trigger_#{table_name}".downcase
116
+ end
117
+
118
+ def get_triggers(table_name = nil)
119
+ statement = <<~SQL
120
+ SHOW TRIGGERS WHERE `Trigger` LIKE 'stagehand_%'
121
+ SQL
122
+ statement << " AND `Table` LIKE #{ActiveRecord::Base.connection.quote(table_name)}" if table_name.present?
123
+
124
+ return ActiveRecord::Base.connection.select_all(statement)
125
+ end
126
+
127
+ def expunge(table_name)
128
+ commit_ids = [] # Keep track of commits that we need to clean up if they're now empty
129
+
130
+ # Remove records from the table as the subject of any commits
131
+ Stagehand::Staging::CommitEntry.start_operations.where(:table_name => table_name).in_batches do |batch|
132
+ commit_ids.concat batch.contained.distinct.pluck(:commit_id)
133
+ batch.update_all(:record_id => nil, :table_name => nil)
134
+ end
135
+
136
+ # Remove commit entries for records from the table
137
+ Stagehand::Staging::CommitEntry.content_operations.where(:table_name => table_name).in_batches do |batch|
138
+ commit_ids.concat batch.contained.distinct.pluck(:commit_id)
139
+ batch.delete_all
140
+ end
141
+
142
+ Stagehand::Staging::Commit.find(commit_ids).select(&:empty?).each(&:destroy)
143
+ end
144
+ end
145
+ end
@@ -0,0 +1,10 @@
1
+ module Stagehand
2
+ module SchemaExtensions
3
+ def define(*)
4
+ # Allow production writes during Schema.define to allow Rails to write to ar_internal_metadata table
5
+ Stagehand::Connection.with_production_writes { super }
6
+ end
7
+ end
8
+ end
9
+
10
+ ActiveRecord::Schema.prepend(Stagehand::SchemaExtensions)