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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: b792d867277a6bc5d64f17e50d808241ddac020d6ad02684c00f1caec51e5f9e
4
+ data.tar.gz: 6affa9483ef3c301aadc83220c606223b3372588c012b2187af388ff1bbbcf57
5
+ SHA512:
6
+ metadata.gz: d68610dfb55385f62ea8d3c202ef6e844db4cddef16129e1b57759d724041fdf6e450f5df8c390ec4d512affb7b78684004af87c7263d8a535aa56dc1b9c3490
7
+ data.tar.gz: 55d867ef57194d15c22ca576b798e9727b4204826efd83f2d0a6d92901252f8bb7ddee44e1ac068b8a1420972424667801383b809236b06579cf2c5188e7a447
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,20 @@
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/internal/Rakefile", __FILE__)
8
+ load 'rails/tasks/engine.rake'
9
+
10
+ load 'rails/tasks/statistics.rake'
11
+
12
+ Bundler::GemHelper.install_tasks
13
+
14
+
15
+ # Add Rspec tasks
16
+ require 'rspec/core/rake_task'
17
+
18
+ RSpec::Core::RakeTask.new(:spec)
19
+
20
+ task :default => :spec
@@ -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"
@@ -0,0 +1,110 @@
1
+ require 'thread'
2
+
3
+ ActiveRecord::Base.class_eval do
4
+ # SYNC CALLBACKS
5
+ ([self] + ActiveSupport::DescendantsTracker.descendants(self)).each do |klass|
6
+ klass.define_model_callbacks :sync, :sync_as_subject, :sync_as_affected
7
+ end
8
+
9
+ # SYNC STATUS
10
+ def self.inherited(subclass)
11
+ super
12
+
13
+ subclass.class_eval do
14
+ has_one :stagehand_unsynced_indicator,
15
+ lambda { where(:stagehand_commit_entries => {:table_name => subclass.table_name}).readonly },
16
+ :class_name => 'Stagehand::Staging::CommitEntry',
17
+ :foreign_key => :record_id
18
+
19
+ has_one :stagehand_unsynced_commit_indicator,
20
+ lambda { where(:stagehand_commit_entries => {:table_name => subclass.table_name}).where.not(commit_id: nil).readonly },
21
+ :class_name => 'Stagehand::Staging::CommitEntry',
22
+ :foreign_key => :record_id
23
+
24
+ def synced?
25
+ stagehand_unsynced_indicator.blank?
26
+ end
27
+
28
+ def synced_all_commits?
29
+ stagehand_unsynced_commit_indicator.blank?
30
+ end
31
+ end
32
+ end
33
+
34
+ # SCHEMA
35
+ delegate :has_stagehand?, to: :class
36
+ def self.has_stagehand?
37
+ @has_stagehand = Stagehand::Schema.has_stagehand?(table_name) unless defined?(@has_stagehand)
38
+ return @has_stagehand
39
+ end
40
+
41
+ # MULTITHREADED CONNECTION HANDLING
42
+
43
+ class_attribute :stagehand_threadsafe_connections
44
+ self.stagehand_threadsafe_connections = true
45
+
46
+ # The original implementation of remove_connection uses @connection_specification_name, which is shared across Threads.
47
+ # We need to pass in the connection that model in the current thread is using if we call remove_connection.
48
+ def self.remove_connection(name = StagehandConnectionMap.get(self))
49
+ return super unless stagehand_threadsafe_connections
50
+
51
+ StagehandConnectionMap.set(self, nil)
52
+ super
53
+ end
54
+
55
+ def self.connection_specification_name=(connection_name)
56
+ return super unless stagehand_threadsafe_connections
57
+
58
+ # ActiveRecord sets the connection pool to 'primary' by default, so we want to reuse that connection for staging
59
+ # in order to avoid using a different connection pool after our first swap back to the staging connection.
60
+ connection_name == 'primary' if connection_name == Stagehand::Configuration.staging_connection_name
61
+
62
+ StagehandConnectionMap.set(self, connection_name)
63
+ end
64
+
65
+ def self.connection_specification_name
66
+ return super unless stagehand_threadsafe_connections
67
+
68
+ StagehandConnectionMap.get(self) || super
69
+ end
70
+
71
+ # Keep track of the current connection name per-model, per-thread so multithreaded webservers don't overwrite it
72
+ module StagehandConnectionMap
73
+ def self.set(klass, connection_name)
74
+ current_map[klass.name] = connection_name
75
+ end
76
+
77
+ def self.get(klass)
78
+ current_map[klass.name]
79
+ end
80
+
81
+ def self.current_map
82
+ map = Thread.current.thread_variable_get('StagehandConnectionMap')
83
+ map = Thread.current.thread_variable_set('StagehandConnectionMap', Concurrent::Hash.new) unless map
84
+ return map
85
+ end
86
+ end
87
+ end
88
+
89
+ module StagehandAssociationReflection
90
+ # SOURCE: https://github.com/rails/rails/blob/a4581b53aae93a8dd3205abae0630398cbce9204/activerecord/lib/active_record/reflection.rb#L429
91
+ def initialize(*)
92
+ super
93
+ @association_scope_cache = StagehandAssociationScopeCache.new
94
+ end
95
+
96
+ # Ensure the association query statements are cached separately for the staging and production connections or else
97
+ # queries for Staging Models may cache the database name for the wrong connection.
98
+ class StagehandAssociationScopeCache < Delegator
99
+ def initialize
100
+ @staging_cache = Concurrent::Map.new
101
+ @production_cache = Concurrent::Map.new
102
+ end
103
+
104
+ def __getobj__
105
+ Stagehand::Database.connected_to_production? ? @production_cache : @staging_cache
106
+ end
107
+ end
108
+ end
109
+
110
+ ActiveRecord::Reflection::AssociationReflection.prepend(StagehandAssociationReflection)
@@ -0,0 +1,90 @@
1
+ require 'graphviz'
2
+
3
+ module Stagehand
4
+ module Auditor
5
+ class ChecklistVisualizer
6
+ def initialize(checklist, show_all_commits: false)
7
+ entries = checklist.affected_entries.select(&:commit_id)
8
+
9
+ @graph = GraphViz.new( :G, :type => :graph )
10
+ @commits = Hash.new {|hash, commit_id| hash[commit_id] = Stagehand::Staging::CommitEntry.find(commit_id) }
11
+ nodes = Hash.new
12
+ edges = []
13
+
14
+ # Detect edges
15
+ entries.group_by(&:key).each_value do |entries|
16
+ entries.combination(2).each do |entry_a, entry_b|
17
+ current_edge = [entry_a, entry_b]
18
+ next if edges.detect {|edge| edge.sort == current_edge.sort }
19
+ next if same_node?(entry_a, entry_b)
20
+ next if same_subject?(entry_a, entry_b)
21
+ edges << current_edge
22
+ end
23
+ end
24
+
25
+ # Create Subgraph nodes for commits with connections to other subjects
26
+ entries = edges.flatten.uniq unless show_all_commits
27
+ entries.group_by {|entry| commit_subject(entry) || entry.commit_id }.each do |group, entries|
28
+ subject = commit_subject(entries.first)
29
+ subgraph = create_subgraph(subject, @graph, id: group)
30
+ entries.each do |entry|
31
+ nodes[entry] = create_node(entry, subgraph)
32
+ end
33
+ end
34
+
35
+ # Create deduplicate edge data in case multiple entries for the same record were part of a single commit
36
+ edges = edges.map do |entry_a, entry_b|
37
+ [[nodes[entry_a], nodes[entry_b]], label: edge_label(entry_a, entry_b)]
38
+ end
39
+
40
+ # Create edges
41
+ edges.uniq.each do |(node_a, node_b), options|
42
+ create_edge(node_a, node_b, options)
43
+ end
44
+ end
45
+
46
+ def output(file_name, format: File.extname(file_name)[1..-1])
47
+ @graph.output(format => file_name)
48
+ File.open(file_name)
49
+ end
50
+
51
+ private
52
+
53
+ def create_subgraph(subject, graph, id: subject)
54
+ graph.add_graph("cluster_#{id}", :label => subject, :style => :filled, :color => :lightgrey)
55
+ end
56
+
57
+ def create_edge(node_a, node_b, options = {})
58
+ @graph.add_edges(node_a, node_b, options.reverse_merge(:color => :red, :fontcolor => :red))
59
+ end
60
+
61
+ def create_node(entry, graph)
62
+ graph.add_nodes(node_name(entry), :shape => :rect, :style => :filled, :fillcolor => :white)
63
+ end
64
+
65
+ def edge_label(entry_a, entry_b)
66
+ pretty_key(entry_a)
67
+ end
68
+
69
+ def same_node?(entry_a, entry_b)
70
+ node_name(entry_a) == node_name(entry_b)
71
+ end
72
+
73
+ def same_subject?(entry_a, entry_b)
74
+ commit_subject(entry_a) == commit_subject(entry_b) && commit_subject(entry_a)
75
+ end
76
+
77
+ def commit_subject(entry)
78
+ pretty_key(@commits[entry.commit_id])
79
+ end
80
+
81
+ def node_name(entry)
82
+ "Commit #{entry.commit_id}"
83
+ end
84
+
85
+ def pretty_key(entry)
86
+ "#{entry.table_name.classify} #{entry.record_id}" if entry.record_id && entry.table_name
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,90 @@
1
+ require 'stagehand/auditor/checklist_visualizer'
2
+
3
+ module Stagehand
4
+ module Auditor
5
+ extend self
6
+
7
+ def incomplete_commits
8
+ incomplete = []
9
+
10
+ incomplete_commit_ids.each do |commit_id|
11
+ incomplete << [commit_id, Staging::CommitEntry.where(:commit_id => commit_id)]
12
+ end
13
+
14
+ return incomplete.to_h
15
+ end
16
+
17
+ def mismatched_records(options = {})
18
+ output = {}
19
+
20
+ tables = options[:tables] || Database.staging_connection.tables.select {|table_name| Schema::has_stagehand?(table_name) }
21
+ Array(tables).each do |table_name|
22
+ print "\nChecking #{table_name} "
23
+ mismatched = Hash.new {|k,v| k[v] = {} }
24
+ limit = 1000
25
+
26
+ min_id = [
27
+ Database.staging_connection.select_value("SELECT MIN(id) FROM #{table_name}").to_i,
28
+ Database.production_connection.select_value("SELECT MIN(id) FROM #{table_name}").to_i
29
+ ].min
30
+
31
+ index = min_id / limit
32
+
33
+ max_id = [
34
+ Database.staging_connection.select_value("SELECT MAX(id) FROM #{table_name}").to_i,
35
+ Database.production_connection.select_value("SELECT MAX(id) FROM #{table_name}").to_i
36
+ ].max
37
+
38
+ loop do
39
+ production_records = Database.production_connection.select_all("SELECT * FROM #{table_name} WHERE id BETWEEN #{limit * index} AND #{limit * (index + 1)}")
40
+ staging_records = Database.staging_connection.select_all("SELECT * FROM #{table_name} WHERE id BETWEEN #{limit * index} AND #{limit * (index + 1)}")
41
+ id_column = production_records.columns.index('id')
42
+
43
+ production_differences = production_records.rows - staging_records.rows
44
+ staging_differences = staging_records.rows - production_records.rows
45
+
46
+ production_differences.each do |row|
47
+ id = row[id_column]
48
+ mismatched[id][:production] = row
49
+ end
50
+ staging_differences.each do |row|
51
+ id = row[id_column]
52
+ mismatched[id][:staging] = row
53
+ end
54
+
55
+ if production_differences.present? || staging_differences.present?
56
+ print '!'
57
+ else
58
+ print '.'
59
+ end
60
+
61
+ index += 1
62
+
63
+ break if index * limit > max_id
64
+ end
65
+
66
+ if mismatched.present?
67
+ print " #{mismatched.count} mismatched"
68
+ output[table_name] = mismatched
69
+ end
70
+ end
71
+
72
+ return output
73
+ end
74
+
75
+ def visualize(subject, output_file_name, options = {})
76
+ visualize_checklist(Staging::Checklist.new(subject), output_file_name, options)
77
+ end
78
+
79
+ def visualize_checklist(checklist, output_file_name, options = {})
80
+ ChecklistVisualizer.new(checklist, options).output(output_file_name)
81
+ end
82
+
83
+ private
84
+
85
+ # Commit that is missing a start or end operation
86
+ def incomplete_commit_ids
87
+ Staging::CommitEntry.control_operations.group(:commit_id).having("count(*) != 2").pluck("MIN(commit_id)")
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,12 @@
1
+ module Stagehand
2
+ module Cache
3
+ def cache(key, &block)
4
+ @cache ||= {}
5
+ if @cache.key?(key)
6
+ @cache[key]
7
+ else
8
+ @cache[key] = block.call
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,45 @@
1
+ module Stagehand
2
+ extend self
3
+
4
+ def configuration
5
+ yield Configuration if block_given?
6
+ Configuration
7
+ end
8
+
9
+ module Configuration
10
+ extend self
11
+
12
+ mattr_accessor :checklist_confirmation_filter, :checklist_association_filter, :checklist_relation_filter, :staging_model_tables, :ignored_columns
13
+ self.staging_model_tables = Set.new
14
+ self.ignored_columns = HashWithIndifferentAccess.new
15
+
16
+ def staging_connection_name
17
+ Rails.env.to_sym
18
+ end
19
+
20
+ def production_connection_name
21
+ Rails.configuration.x.stagehand.production_connection_name || Rails.env.to_sym
22
+ end
23
+
24
+ def ghost_mode?
25
+ !!Rails.configuration.x.stagehand.ghost_mode
26
+ end
27
+
28
+ # Allow unsynchronized writes directly to the production database? A warning will be logged if set to true.
29
+ def allow_unsynced_production_writes?
30
+ !!Rails.configuration.x.stagehand.allow_unsynced_production_writes
31
+ end
32
+
33
+ # Returns true if the production and staging connections are the same.
34
+ # Use case: Front-end devs may not have a second database set up as they are only concerned with the front end
35
+ def single_connection?
36
+ staging_connection_name == production_connection_name
37
+ end
38
+
39
+ # Columns not to copy to the production database
40
+ # e.g. table_name => [column, column, ...]
41
+ def self.ignored_columns=(hash)
42
+ super HashWithIndifferentAccess.new(hash)
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,75 @@
1
+ module Stagehand
2
+ module Connection
3
+ def self.with_production_writes(&block)
4
+ state = allow_unsynced_production_writes?
5
+ allow_unsynced_production_writes!(true)
6
+ return block.call
7
+ ensure
8
+ allow_unsynced_production_writes!(state)
9
+ end
10
+
11
+ def self.allow_unsynced_production_writes!(state = true)
12
+ Thread.current.thread_variable_set(:stagehand_allow_unsynced_production_writes, state)
13
+ end
14
+
15
+ def self.allow_unsynced_production_writes?
16
+ !!Thread.current.thread_variable_get(:stagehand_allow_unsynced_production_writes)
17
+ end
18
+
19
+ module AdapterExtensions
20
+ def quote_table_name(table_name)
21
+ if prefix_table_name_with_database?(table_name)
22
+ super("#{Stagehand::Database.staging_database_name}.#{table_name}")
23
+ else
24
+ super
25
+ end
26
+ end
27
+
28
+ def prefix_table_name_with_database?(table_name)
29
+ return false if Configuration.single_connection?
30
+ return false unless Database.connected_to_production?
31
+ return false if Connection.allow_unsynced_production_writes?
32
+ return false unless Configuration.staging_model_tables.include?(table_name)
33
+ true
34
+ end
35
+
36
+ def exec_insert(sql, *)
37
+ handle_readonly_writes!(sql)
38
+ super
39
+ end
40
+
41
+ def exec_update(sql, *)
42
+ handle_readonly_writes!(sql)
43
+ super
44
+ end
45
+
46
+ def exec_delete(sql, *)
47
+ handle_readonly_writes!(sql)
48
+ super
49
+ end
50
+
51
+ private
52
+
53
+ def write_access?
54
+ Configuration.single_connection? || @config[:database] == Database.staging_database_name || Connection.allow_unsynced_production_writes?
55
+ end
56
+
57
+ def handle_readonly_writes!(sql)
58
+ if write_access?
59
+ return
60
+ elsif Configuration.allow_unsynced_production_writes?
61
+ Rails.logger.warn "Writing directly to #{@config[:database]} database using readonly connection"
62
+ else
63
+ raise(UnsyncedProductionWrite, "Attempted to write directly to #{@config[:database]} database using readonly connection: #{sql}")
64
+ end
65
+ end
66
+ end
67
+ end
68
+
69
+
70
+ # EXCEPTIONS
71
+
72
+ class UnsyncedProductionWrite < StandardError; end
73
+ end
74
+
75
+ ActiveRecord::Base.connection.class.prepend(Stagehand::Connection::AdapterExtensions)
@@ -0,0 +1,35 @@
1
+ module Stagehand
2
+ module ControllerExtensions
3
+ extend ActiveSupport::Concern
4
+
5
+ class_methods do
6
+ def use_staging_database(options = {})
7
+ skip_around_action :use_production_database, raise: false, **options
8
+ prepend_around_action :use_staging_database, options
9
+ end
10
+
11
+ def use_production_database(options = {})
12
+ skip_around_action :use_staging_database, raise: false, **options
13
+ prepend_around_action :use_production_database, options
14
+ end
15
+ end
16
+
17
+ private
18
+
19
+ def use_staging_database(&block)
20
+ use_database(Configuration.staging_connection_name, &block)
21
+ end
22
+
23
+ def use_production_database(&block)
24
+ use_database(Configuration.production_connection_name, &block)
25
+ end
26
+
27
+ def use_database(connection_name, &block)
28
+ if Configuration.ghost_mode?
29
+ block.call
30
+ else
31
+ Database.with_connection(connection_name, &block)
32
+ end
33
+ end
34
+ end
35
+ end