iron_trail 0.0.1

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
+ SHA256:
3
+ metadata.gz: d4743b8660d0a3f59c82131213a4e9b85396c0efa83f2a00c2678dcb2a2d57a7
4
+ data.tar.gz: 6d53fef0dbb300ce504c8065616142ba012cb697647a5f588d4f2bea3935e293
5
+ SHA512:
6
+ metadata.gz: 7bc826ad0ff24c91b6e7d9bac08fb7c5a0f893142a91315804aafe68a95bc96e3be5b9b98ed83de9f4d293daab9b1bbce3432579116db79fa3c47ca8b184b0a7
7
+ data.tar.gz: b43255cfbe9833075c44335eaaeb5e2067e942d99f4ff3b8e35aee42c9960cdfe6e1e96cfe5c75fd2b4ae53c8dbef580dae2768cf308f92b2bb2d5a5e06ec667
data/LICENSE ADDED
@@ -0,0 +1,18 @@
1
+ Copyright © 2024 Trusted Health
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy of
4
+ this software and associated documentation files (the “Software”), to deal in
5
+ the Software without restriction, including without limitation the rights to use,
6
+ copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
7
+ Software, and to permit persons to whom the Software is furnished to do so,
8
+ subject to the following conditions:
9
+
10
+ The above copyright notice and this permission notice shall be included in all
11
+ copies or substantial portions of the Software.
12
+
13
+ THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
15
+ FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
16
+ COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
17
+ IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
18
+ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/generators'
4
+ require 'rails/generators/active_record'
5
+
6
+ module IronTrail
7
+ class MigrationGenerator < Rails::Generators::Base
8
+ include ::Rails::Generators::Migration
9
+
10
+ source_root File.expand_path('templates', __dir__)
11
+
12
+ desc 'Generates a migration adding the iron trail changes table'
13
+ def create_changes_migration_file
14
+ migration_template(
15
+ 'create_irontrail_changes.rb.erb',
16
+ 'db/migrate/create_irontrail_changes.rb'
17
+ )
18
+
19
+ migration_template(
20
+ 'create_irontrail_support_tables.rb.erb',
21
+ 'db/migrate/create_irontrail_support_tables.rb'
22
+ )
23
+
24
+ migration_template(
25
+ 'create_irontrail_trigger_function.rb.erb',
26
+ 'db/migrate/create_irontrail_trigger_function.rb'
27
+ )
28
+ end
29
+
30
+ def self.next_migration_number(dirname)
31
+ ActiveRecord::Generators::Base.next_migration_number(dirname)
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,25 @@
1
+ class CreateIrontrailChanges < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
2
+ def change
3
+ create_range_partition :irontrail_changes, partition_key: :created_at, primary_key: [:id, :created_at] do |t|
4
+ t.column :id, :bigserial, null: false
5
+ t.column :actor_type, :text
6
+ t.column :actor_id, :text
7
+ t.column :rec_table, :text
8
+ t.column :rec_id, :text
9
+ t.column :operation, :text
10
+
11
+ t.column :rec_old, :jsonb
12
+ t.column :rec_new, :jsonb
13
+ t.column :rec_delta, :jsonb
14
+ t.column :metadata, :jsonb
15
+
16
+ t.column :created_at, :timestamp, null: false
17
+ end
18
+
19
+ add_index :irontrail_changes, :rec_id
20
+ add_index :irontrail_changes, :rec_table
21
+ add_index :irontrail_changes, :actor_id
22
+ add_index :irontrail_changes, :actor_type
23
+ add_index :irontrail_changes, :created_at, using: :brin
24
+ end
25
+ end
@@ -0,0 +1,18 @@
1
+ class CreateIrontrailSupportTables < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
2
+ def change
3
+ create_table :irontrail_trigger_errors, id: :bigserial do |t|
4
+ t.column :pg_errcode, :text
5
+ t.column :pg_message, :text
6
+ t.column :err_text, :text
7
+ t.column :ex_detail, :text
8
+ t.column :ex_hint, :text
9
+ t.column :ex_ctx, :text
10
+ t.column :op, :text
11
+ t.column :table_name, :text
12
+ t.column :old_data, :jsonb
13
+ t.column :new_data, :jsonb
14
+ t.column :query, :text
15
+ t.column :created_at, :timestamp
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,12 @@
1
+ class CreateIrontrailTriggerFunction < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
2
+ def up
3
+ IronTrail::DbFunctions.new(connection).tap do |db_fun|
4
+ db_fun.install_functions
5
+ db_fun.enable_for_all_missing_tables
6
+ end
7
+ end
8
+
9
+ def down
10
+ IronTrail::DbFunctions.new(connection).remove_functions(cascade: true)
11
+ end
12
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module IronTrail
4
+ class Association < ::ActiveRecord::Associations::HasManyAssociation
5
+ def association_scope
6
+ scope = klass.unscoped
7
+
8
+ foreign_key = reflection.join_foreign_key
9
+ pk_value = owner._read_attribute(foreign_key)
10
+ pk_table = owner.class.arel_table
11
+
12
+ scope.where!('rec_id' => pk_value, 'rec_table' => pk_table.name)
13
+
14
+ scope
15
+ end
16
+
17
+ def find_target
18
+ scope.to_a
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module IronTrail
4
+ module ChangeModelConcern
5
+ extend ::ActiveSupport::Concern
6
+
7
+ module ClassMethods
8
+ def where_object_changes_to(args = {})
9
+ _where_object_changes(1, args)
10
+ end
11
+
12
+ def where_object_changes_from(args = {})
13
+ _where_object_changes(0, args)
14
+ end
15
+
16
+ private
17
+
18
+ def _where_object_changes(ary_index, args)
19
+ scope = all
20
+
21
+ args.each do |col_name, value|
22
+ scope.where!(
23
+ ::Arel::Nodes::SqlLiteral.new("rec_delta->#{connection.quote(col_name)}->>#{Integer(ary_index)}").eq(
24
+ ::Arel::Nodes::BindParam.new(value)
25
+ )
26
+ )
27
+ end
28
+
29
+ scope
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module IronTrail
4
+ class Config
5
+ DEFAULT_IGNORED_TABLES = %w[
6
+ schema_migrations
7
+ ar_internal_metadata
8
+ sessions
9
+ ].freeze
10
+
11
+ include Singleton
12
+
13
+ attr_accessor \
14
+ :track_by_default,
15
+ :enable,
16
+ :track_migrations_starting_at_version
17
+
18
+ attr_reader :ignored_tables
19
+
20
+ def initialize
21
+ @enable = true
22
+ @track_by_default = true
23
+ @ignored_tables = DEFAULT_IGNORED_TABLES.dup
24
+ @track_migrations_starting_at_version = nil
25
+ end
26
+
27
+ # To prevent ever tracking unintended tables, let's disallow setting this value
28
+ # directly.
29
+ # It is possible to call Array#clear to empty the array and remove default
30
+ # should that be desired.
31
+ def ignored_tables=(_v)
32
+ raise 'Overwriting ignored_tables is not allow. Instead, add or remove to it explicitly.'
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ module IronTrail
4
+ class DbFunctions
5
+ attr_reader :connection
6
+
7
+ def initialize(connection)
8
+ @connection = connection
9
+ end
10
+
11
+ # Creates the SQL functions in the DB. It will not run the function or create
12
+ # any triggers.
13
+ def install_functions
14
+ sql = irontrail_log_row_function
15
+ connection.execute(sql)
16
+ end
17
+
18
+ def irontrail_log_row_function
19
+ path = File.expand_path('irontrail_log_row_function.sql', __dir__)
20
+ File.read(path)
21
+ end
22
+
23
+ # Queries the database information schema and returns an array with all
24
+ # table names that have the "iron_trail_log_changes" trigger enabled.
25
+ #
26
+ # This effectively returns all tables which are currently being tracked
27
+ # by IronTrail.
28
+ def collect_tracked_table_names
29
+ stmt = <<~SQL
30
+ SELECT DISTINCT("event_object_table") AS "table"
31
+ FROM "information_schema"."triggers"
32
+ WHERE "trigger_name"='iron_trail_log_changes' AND "event_object_schema"='public'
33
+ ORDER BY "table" ASC;
34
+ SQL
35
+
36
+ connection.execute(stmt).map { |row| row['table'] }
37
+ end
38
+
39
+ def function_present?(function: 'irontrail_log_row', schema: 'public')
40
+ stmt = <<~SQL
41
+ SELECT 1 FROM "pg_proc" p
42
+ INNER JOIN "pg_namespace" ns
43
+ ON (ns.oid = p.pronamespace)
44
+ WHERE p."proname"=#{connection.quote(function)}
45
+ AND ns."nspname"=#{connection.quote(schema)}
46
+ LIMIT 1;
47
+ SQL
48
+
49
+ connection.execute(stmt).to_a.count.positive?
50
+ end
51
+
52
+ def remove_functions(cascade:)
53
+ query = +'DROP FUNCTION irontrail_log_row'
54
+ query << ' CASCADE' if cascade
55
+
56
+ connection.execute(query)
57
+ end
58
+
59
+ def trigger_errors_count
60
+ stmt = 'SELECT COUNT(*) AS c FROM "irontrail_trigger_errors"'
61
+ connection.execute(stmt).first['c']
62
+ end
63
+
64
+ def collect_all_tables(schema: 'public')
65
+ # query pg_class rather than information schema because this way
66
+ # we can get only regular tables and ignore partitions.
67
+ stmt = <<~SQL
68
+ SELECT c.relname AS "table"
69
+ FROM "pg_class" c INNER JOIN "pg_namespace" ns
70
+ ON (ns.oid = c.relnamespace)
71
+ WHERE ns.nspname=#{connection.quote(schema)}
72
+ AND c.relkind IN ('r', 'p')
73
+ AND NOT c.relispartition
74
+ ORDER BY "table" ASC;
75
+ SQL
76
+
77
+ connection.execute(stmt).map { |row| row['table'] }
78
+ end
79
+
80
+ def enable_for_all_missing_tables
81
+ collect_tables_tracking_status[:missing].each do |table_name|
82
+ enable_tracking_for_table(table_name)
83
+ end
84
+ end
85
+
86
+ def collect_tables_tracking_status
87
+ ignored_tables = OWN_TABLES + (IronTrail.config.ignored_tables || [])
88
+
89
+ all_tables = collect_all_tables - ignored_tables
90
+ tracked_tables = collect_tracked_table_names - ignored_tables
91
+
92
+ {
93
+ tracked: tracked_tables,
94
+ missing: all_tables - tracked_tables
95
+ }
96
+ end
97
+
98
+ def disable_tracking_for_table(table_name)
99
+ # NOTE: will disable even if table is ignored as this allows
100
+ # one to fix ignored tables mnore easily. Since the table is already
101
+ # ignored, it is an expected destructive operation.
102
+
103
+ stmt = <<~SQL
104
+ DROP TRIGGER "iron_trail_log_changes" ON
105
+ #{connection.quote_table_name(table_name)}
106
+ SQL
107
+
108
+ connection.execute(stmt)
109
+ end
110
+
111
+ def enable_tracking_for_table(table_name)
112
+ return false if IronTrail.ignore_table?(table_name)
113
+
114
+ stmt = <<~SQL
115
+ CREATE TRIGGER "iron_trail_log_changes" AFTER INSERT OR UPDATE OR DELETE ON
116
+ #{connection.quote_table_name(table_name)}
117
+ FOR EACH ROW EXECUTE FUNCTION irontrail_log_row();
118
+ SQL
119
+
120
+ connection.execute(stmt)
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,80 @@
1
+ CREATE OR REPLACE FUNCTION irontrail_log_row()
2
+ RETURNS TRIGGER AS $$
3
+ DECLARE
4
+ u_changes JSONB;
5
+ key TEXT;
6
+ it_meta TEXT;
7
+ it_meta_obj JSONB;
8
+ value_a JSONB;
9
+ value_b JSONB;
10
+ old_obj JSONB;
11
+ new_obj JSONB;
12
+ actor_type TEXT;
13
+ actor_id TEXT;
14
+
15
+ err_text TEXT; err_detail TEXT; err_hint TEXT; err_ctx TEXT;
16
+ BEGIN
17
+ SELECT split_part(split_part(current_query(), '/*IronTrail ', 2), ' IronTrail*/', 1) INTO it_meta;
18
+ IF (it_meta <> '') THEN
19
+ it_meta_obj = it_meta::JSONB;
20
+
21
+ IF (it_meta_obj ? '_actor_type') THEN
22
+ actor_type = it_meta_obj->>'_actor_type';
23
+ it_meta_obj = it_meta_obj - '_actor_type';
24
+ END IF;
25
+ IF (it_meta_obj ? '_actor_id') THEN
26
+ actor_id = it_meta_obj->>'_actor_id';
27
+ it_meta_obj = it_meta_obj - '_actor_id';
28
+ END IF;
29
+ END IF;
30
+
31
+ IF (TG_OP = 'INSERT') THEN
32
+ INSERT INTO "irontrail_changes" ("actor_id", "actor_type",
33
+ "rec_table", "operation", "rec_id", "rec_new", "metadata", "created_at")
34
+ VALUES (actor_id, actor_type,
35
+ TG_TABLE_NAME, 'i', NEW.id, row_to_json(NEW), it_meta_obj, NOW());
36
+
37
+ ELSIF (TG_OP = 'UPDATE') THEN
38
+ IF (OLD <> NEW) THEN
39
+ u_changes = jsonb_build_object();
40
+ old_obj = row_to_json(OLD);
41
+ new_obj = row_to_json(NEW);
42
+
43
+ FOR key IN (SELECT jsonb_object_keys(old_obj) UNION SELECT jsonb_object_keys(new_obj))
44
+ LOOP
45
+ value_a := old_obj->key;
46
+ value_b := new_obj->key;
47
+ IF value_a IS DISTINCT FROM value_b THEN
48
+ u_changes := u_changes || jsonb_build_object(key, jsonb_build_array(value_a, value_b));
49
+ END IF;
50
+ END LOOP;
51
+
52
+ INSERT INTO "irontrail_changes" ("actor_id", "actor_type", "rec_table", "operation",
53
+ "rec_id", "rec_old", "rec_new", "rec_delta", "metadata", "created_at")
54
+ VALUES (actor_id, actor_type, TG_TABLE_NAME, 'u', NEW.id, row_to_json(OLD), row_to_json(NEW),
55
+ u_changes, it_meta_obj, NOW());
56
+
57
+ END IF;
58
+ ELSIF (TG_OP = 'DELETE') THEN
59
+ INSERT INTO "irontrail_changes" ("actor_id", "actor_type", "rec_table", "operation",
60
+ "rec_id", "rec_old", "metadata", "created_at")
61
+ VALUES (actor_id, actor_type, TG_TABLE_NAME, 'd', OLD.id, row_to_json(OLD), it_meta_obj, NOW());
62
+
63
+ END IF;
64
+ RETURN NULL;
65
+ EXCEPTION
66
+ WHEN OTHERS THEN
67
+ GET STACKED DIAGNOSTICS
68
+ err_text = MESSAGE_TEXT,
69
+ err_detail = PG_EXCEPTION_DETAIL,
70
+ err_hint = PG_EXCEPTION_HINT,
71
+ err_ctx = PG_EXCEPTION_CONTEXT;
72
+
73
+ INSERT INTO "irontrail_trigger_errors" ("pg_errcode", "pg_message",
74
+ "err_text", "ex_detail", "ex_hint", "ex_ctx", "op", "table_name",
75
+ "old_data", "new_data", "query", "created_at")
76
+ VALUES (SQLSTATE, SQLERRM, err_text, err_detail, err_hint, err_ctx,
77
+ TG_OP, TG_TABLE_NAME, row_to_json(OLD), row_to_json(NEW), current_query(), NOW());
78
+ RETURN NULL;
79
+ END;
80
+ $$ LANGUAGE plpgsql;
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module IronTrail
4
+ class MetadataStore
5
+ def store_metadata(key, value)
6
+ RequestStore.store[:irontrail_metadata] ||= {}
7
+ RequestStore.store[:irontrail_metadata][key] = value
8
+ end
9
+
10
+ def merge_metadata(keys, merge_hash)
11
+ RequestStore.store[:irontrail_metadata] ||= {}
12
+ base = RequestStore.store[:irontrail_metadata]
13
+ keys.each do |key|
14
+ if base.key?(key)
15
+ base = base[key]
16
+ else
17
+ h = {}
18
+ base[key] = h
19
+ base = h
20
+ end
21
+ end
22
+ base.merge!(merge_hash)
23
+ end
24
+
25
+ def current_metadata
26
+ RequestStore.store[:irontrail_metadata]
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module IronTrail
4
+ module Migration
5
+ def method_missing(method, *args) # rubocop:disable Style/MissingRespondToMissing
6
+ running_from_schema = is_a?(ActiveRecord::Schema) ||
7
+ (defined?(ActiveRecord::Schema::Definition) && is_a?(ActiveRecord::Schema::Definition))
8
+
9
+ result = super
10
+
11
+ return result unless IronTrail.enabled? && method == :create_table
12
+
13
+ start_at_version = IronTrail.config.track_migrations_starting_at_version
14
+ return result if !running_from_schema && start_at_version && version < (Integer(start_at_version))
15
+
16
+ table_name = args.first.to_s
17
+ return result if IronTrail.ignore_table?(table_name)
18
+
19
+ if running_from_schema
20
+ @irontrail_missing_track ||= []
21
+ @irontrail_missing_track << table_name
22
+
23
+ return result
24
+ end
25
+
26
+ db_fun = IronTrail::DbFunctions.new(connection)
27
+ if db_fun.function_present?
28
+ db_fun.enable_tracking_for_table(table_name)
29
+ else
30
+ Rails.logger.warn(
31
+ "IronTrail will not create trigger for table #{table_name} " \
32
+ 'because the trigger function does not exist in the database.'
33
+ )
34
+ end
35
+
36
+ result
37
+ end
38
+ ruby2_keywords(:method_missing) if respond_to?(:ruby2_keywords, true)
39
+ end
40
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module IronTrail
4
+ # Including this module will create something akin to:
5
+ # has_many :iron_trails
6
+ #
7
+ # But a has_many association here wouldn't suffice, so IronTrail has its
8
+ # own AR reflection and association classes.
9
+ module Model
10
+ def self.included(mod)
11
+ mod.include(ClassMethods)
12
+
13
+ ::ActiveRecord::Reflection.add_reflection(
14
+ mod,
15
+ :iron_trails,
16
+ ::IronTrail::Reflection.new(:iron_trails, nil, { class_name: 'IrontrailChange' }, mod)
17
+ )
18
+ end
19
+
20
+ module ClassMethods
21
+ def iron_trails
22
+ association(:iron_trails).reader
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module IronTrail
4
+ class QueryTransformer
5
+ METADATA_MAX_LENGTH = 1_048_576 # 1 MiB
6
+
7
+ attr_reader :transformer_proc
8
+
9
+ def initialize
10
+ @transformer_proc = create_query_transformer_proc
11
+ end
12
+
13
+ def setup_active_record
14
+ ActiveRecord.query_transformers << @transformer_proc
15
+ end
16
+
17
+ private
18
+
19
+ def create_query_transformer_proc
20
+ proc do |query, adapter|
21
+ current_metadata = IronTrail.current_metadata
22
+ next query unless adapter.write_query?(query) && (current_metadata.is_a?(Hash) && !current_metadata.empty?)
23
+
24
+ metadata = JSON.dump(current_metadata)
25
+
26
+ if metadata.length > METADATA_MAX_LENGTH
27
+ Rails.logger.warn(
28
+ "IronTrail metadata is longer than maximum length! #{metadata.length} > #{METADATA_MAX_LENGTH}"
29
+ )
30
+
31
+ next query
32
+ end
33
+
34
+ safe_md = metadata.gsub('*/', '\\u002a\\u002f')
35
+ "/*IronTrail #{safe_md} IronTrail*/ #{query}"
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module IronTrail
4
+ class Railtie < ::Rails::Railtie
5
+ rake_tasks do
6
+ load 'tasks/tracking.rake'
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module IronTrail
4
+ class Reflection < ::ActiveRecord::Reflection::AssociationReflection
5
+ def collection? = true
6
+
7
+ def association_class
8
+ ::IronTrail::Association
9
+ end
10
+
11
+ def join_scope(table, foreign_table, _foreign_klass)
12
+ scope = klass_join_scope(table, nil)
13
+
14
+ foreign_key_column_names = Array(join_foreign_key)
15
+ if foreign_key_column_names.length > 1
16
+ raise "IronTrail does not support composite foreign keys (got #{foreign_key_column_names})"
17
+ end
18
+
19
+ foreign_key_column_name = foreign_key_column_names.first
20
+
21
+ # record_id is always of type text, but the foreign table primary key
22
+ # could be anything (int, uuid, ...), so let's cast it to text.
23
+ foreign_value = ::Arel::Nodes::NamedFunction.new(
24
+ 'CAST',
25
+ [
26
+ ::Arel::Nodes::As.new(
27
+ foreign_table[foreign_key_column_name],
28
+ ::Arel::Nodes::SqlLiteral.new('text')
29
+ )
30
+ ]
31
+ )
32
+
33
+ scope.where!(
34
+ table['rec_id']
35
+ .eq(foreign_value)
36
+ .and(
37
+ table['rec_table']
38
+ .eq(foreign_table.name)
39
+ )
40
+ )
41
+
42
+ scope
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ # These are not loaded by default. The user must explicitly require this
4
+ # file in order to integrate IronTrail with Sidekiq.
5
+
6
+ require 'iron_trail/sidekiq_middleware'
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module IronTrail
4
+ class SidekiqMiddleware
5
+ def call(job, _job_hash, queue)
6
+ md = {
7
+ jid: job.jid,
8
+ class: job.class.to_s,
9
+ queue:
10
+ }
11
+
12
+ # Job batch ID. Requires sidekiq-pro
13
+ md[:bid] = job.bid if job.respond_to?(:bid) && job.bid.present?
14
+
15
+ IronTrail.store_metadata(:job, md)
16
+
17
+ yield
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'iron_trail'
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module IronTrail
4
+ VERSION = '0.0.1'
5
+ end
data/lib/iron_trail.rb ADDED
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'singleton'
4
+ require 'json'
5
+ require 'forwardable'
6
+ require 'request_store'
7
+
8
+ require 'iron_trail/version'
9
+ require 'iron_trail/config'
10
+ require 'iron_trail/db_functions'
11
+ require 'iron_trail/migration'
12
+
13
+ require 'iron_trail/metadata_store'
14
+ require 'iron_trail/query_transformer'
15
+
16
+ require 'iron_trail/association'
17
+ require 'iron_trail/reflection'
18
+ require 'iron_trail/model'
19
+ require 'iron_trail/change_model_concern'
20
+
21
+ require 'iron_trail/railtie'
22
+
23
+ module IronTrail
24
+ # These tables are owned by IronTrail and will be in the default ignore list
25
+ OWN_TABLES = %w[
26
+ irontrail_trigger_errors
27
+ irontrail_changes
28
+ ].freeze
29
+
30
+ module SchemaDumper
31
+ def trailer(stream)
32
+ stream.print("\n IronTrail.post_schema_load(self, missing_tracking: @irontrail_missing_track)\n")
33
+
34
+ super
35
+ end
36
+ end
37
+
38
+ class << self
39
+ extend Forwardable
40
+
41
+ attr_reader :query_transformer
42
+
43
+ def config
44
+ @config ||= IronTrail::Config.instance
45
+ yield @config if block_given?
46
+ @config
47
+ end
48
+
49
+ def enabled?
50
+ config.enable
51
+ end
52
+
53
+ # def test_mode!
54
+ # if [ENV['RAILS_ENV'], ENV['RACK_ENV']].include?('production')
55
+ # raise "IronTrail test mode cannot be enabled in production!"
56
+ # end
57
+ # @test_mode = true
58
+ # end
59
+ #
60
+ # def test_mode?
61
+ # @test_mode
62
+ # end
63
+
64
+ def ignore_table?(name)
65
+ (OWN_TABLES + (config.ignored_tables || [])).include?(name)
66
+ end
67
+
68
+ def post_schema_load(context, missing_tracking: nil)
69
+ df = IronTrail::DbFunctions.new(context.connection)
70
+ df.install_functions
71
+
72
+ missing_tracking.each do |table|
73
+ df.enable_tracking_for_table(table)
74
+ end
75
+ end
76
+
77
+ def setup_active_record
78
+ ActiveRecord::Migration.prepend(IronTrail::Migration)
79
+ ActiveRecord::SchemaDumper.prepend(IronTrail::SchemaDumper)
80
+
81
+ @query_transformer = QueryTransformer.new
82
+ @query_transformer.setup_active_record
83
+ end
84
+
85
+ def store_instance
86
+ @store_instance ||= MetadataStore.new
87
+ end
88
+
89
+ def_delegators :store_instance,
90
+ :store_metadata,
91
+ :merge_metadata,
92
+ :current_metadata
93
+ end
94
+ end
95
+
96
+ ActiveSupport.on_load(:active_record) do
97
+ IronTrail.setup_active_record
98
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ namespace :iron_trail do
4
+ namespace :tracking do
5
+ desc 'Enables tracking for all missing tables.'
6
+ task enable: :environment do
7
+ tables = db_functions.collect_tables_tracking_status[:missing]
8
+ unless tables.length.positive?
9
+ puts 'All tables are being tracked already (no missing tables found).'
10
+ puts 'If you think this is wrong, check your ignored_tables list.'
11
+ return
12
+ end
13
+
14
+ puts "Will start tracking #{tables.length} tables."
15
+ tables.each do |table_name|
16
+ db_functions.enable_tracking_for_table(table_name)
17
+ end
18
+ end
19
+
20
+ desc 'Disables tracking all tables. Dangerous!'
21
+ task disable: :environment do
22
+ abort_when_unsafe!
23
+
24
+ tables = db_functions.collect_tables_tracking_status[:tracked]
25
+ puts "Will stop tracking #{tables.length} tables."
26
+ tables.each do |table_name|
27
+ db_functions.disable_tracking_for_table(table_name)
28
+ end
29
+
30
+ tables = db_functions.collect_tables_tracking_status[:tracked]
31
+ if tables.length.positive?
32
+ puts "WARNING: Something went wrong. There are still #{tables.length} " \
33
+ 'tables being tracked.'
34
+ else
35
+ puts 'Done!'
36
+ end
37
+ end
38
+
39
+ desc 'Shows which tables are tracking, missing and ignored.'
40
+ task status: :environment do
41
+ status = db_functions.collect_tables_tracking_status
42
+ ignored = IronTrail.config.ignored_tables || []
43
+
44
+ # We likely want to keep this structure of text untouched as someone
45
+ # could use it to perform automation (e.g. monitoring).
46
+ puts "Tracking #{status[:tracked].length} tables."
47
+ puts "Missing #{status[:missing].length} tables."
48
+ puts "There are #{ignored.length} ignored tables."
49
+
50
+ puts 'Tracked tables:'
51
+ status[:tracked].sort.each do |table_name|
52
+ puts "\t#{table_name}"
53
+ end
54
+
55
+ puts 'Missing tables:'
56
+ status[:missing].sort.each do |table_name|
57
+ puts "\t#{table_name}"
58
+ end
59
+
60
+ puts 'Ignored tables:'
61
+ ignored.sort.each do |table_name|
62
+ puts "\t#{table_name}"
63
+ end
64
+ end
65
+
66
+ private
67
+
68
+ def db_functions
69
+ @db_functions ||= IronTrail::DbFunctions.new(ActiveRecord::Base.connection)
70
+ end
71
+
72
+ def abort_when_unsafe!
73
+ run_unsafe = %w[true 1 yes].include?(ENV['IRONTRAIL_RUN_UNSAFE'])
74
+ return unless Rails.env.production? && !run_unsafe
75
+
76
+ puts 'Aborting: operation is dangerous in a production environment. ' \
77
+ 'Override this behavior by setting the IRONTRAIL_RUN_UNSAFE=1 env var.'
78
+
79
+ exit(1)
80
+ end
81
+ end
82
+ end
metadata ADDED
@@ -0,0 +1,219 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: iron_trail
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ platform: ruby
6
+ authors:
7
+ - André Diego Piske
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2024-11-26 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: '7.1'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '7.1'
27
+ - !ruby/object:Gem::Dependency
28
+ name: request_store
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '1.5'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '1.5'
41
+ - !ruby/object:Gem::Dependency
42
+ name: appraisal
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '2.5'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '2.5'
55
+ - !ruby/object:Gem::Dependency
56
+ name: rubocop
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '1.69'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '1.69'
69
+ - !ruby/object:Gem::Dependency
70
+ name: debug
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: '0'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: '0'
83
+ - !ruby/object:Gem::Dependency
84
+ name: pg_party
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: '1.8'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: '1.8'
97
+ - !ruby/object:Gem::Dependency
98
+ name: rake
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - "~>"
102
+ - !ruby/object:Gem::Version
103
+ version: '13.2'
104
+ type: :development
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - "~>"
109
+ - !ruby/object:Gem::Version
110
+ version: '13.2'
111
+ - !ruby/object:Gem::Dependency
112
+ name: rspec-rails
113
+ requirement: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - "~>"
116
+ - !ruby/object:Gem::Version
117
+ version: '7.1'
118
+ type: :development
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - "~>"
123
+ - !ruby/object:Gem::Version
124
+ version: '7.1'
125
+ - !ruby/object:Gem::Dependency
126
+ name: pg
127
+ requirement: !ruby/object:Gem::Requirement
128
+ requirements:
129
+ - - "~>"
130
+ - !ruby/object:Gem::Version
131
+ version: '1.2'
132
+ type: :development
133
+ prerelease: false
134
+ version_requirements: !ruby/object:Gem::Requirement
135
+ requirements:
136
+ - - "~>"
137
+ - !ruby/object:Gem::Version
138
+ version: '1.2'
139
+ - !ruby/object:Gem::Dependency
140
+ name: json
141
+ requirement: !ruby/object:Gem::Requirement
142
+ requirements:
143
+ - - "~>"
144
+ - !ruby/object:Gem::Version
145
+ version: '2.8'
146
+ type: :development
147
+ prerelease: false
148
+ version_requirements: !ruby/object:Gem::Requirement
149
+ requirements:
150
+ - - "~>"
151
+ - !ruby/object:Gem::Version
152
+ version: '2.8'
153
+ - !ruby/object:Gem::Dependency
154
+ name: sidekiq
155
+ requirement: !ruby/object:Gem::Requirement
156
+ requirements:
157
+ - - "~>"
158
+ - !ruby/object:Gem::Version
159
+ version: '7.2'
160
+ type: :development
161
+ prerelease: false
162
+ version_requirements: !ruby/object:Gem::Requirement
163
+ requirements:
164
+ - - "~>"
165
+ - !ruby/object:Gem::Version
166
+ version: '7.2'
167
+ description:
168
+ email: andrepiske@gmail.com
169
+ executables: []
170
+ extensions: []
171
+ extra_rdoc_files: []
172
+ files:
173
+ - LICENSE
174
+ - lib/generators/iron_trail/migration_generator.rb
175
+ - lib/generators/iron_trail/templates/create_irontrail_changes.rb.erb
176
+ - lib/generators/iron_trail/templates/create_irontrail_support_tables.rb.erb
177
+ - lib/generators/iron_trail/templates/create_irontrail_trigger_function.rb.erb
178
+ - lib/iron_trail.rb
179
+ - lib/iron_trail/association.rb
180
+ - lib/iron_trail/change_model_concern.rb
181
+ - lib/iron_trail/config.rb
182
+ - lib/iron_trail/db_functions.rb
183
+ - lib/iron_trail/irontrail_log_row_function.sql
184
+ - lib/iron_trail/metadata_store.rb
185
+ - lib/iron_trail/migration.rb
186
+ - lib/iron_trail/model.rb
187
+ - lib/iron_trail/query_transformer.rb
188
+ - lib/iron_trail/railtie.rb
189
+ - lib/iron_trail/reflection.rb
190
+ - lib/iron_trail/sidekiq.rb
191
+ - lib/iron_trail/sidekiq_middleware.rb
192
+ - lib/iron_trail/testing/rspec.rb
193
+ - lib/iron_trail/version.rb
194
+ - lib/tasks/tracking.rake
195
+ homepage: https://github.com/trusted/iron_trail
196
+ licenses:
197
+ - MIT
198
+ metadata:
199
+ rubygems_mfa_required: 'true'
200
+ post_install_message:
201
+ rdoc_options: []
202
+ require_paths:
203
+ - lib
204
+ required_ruby_version: !ruby/object:Gem::Requirement
205
+ requirements:
206
+ - - ">="
207
+ - !ruby/object:Gem::Version
208
+ version: 3.1.0
209
+ required_rubygems_version: !ruby/object:Gem::Requirement
210
+ requirements:
211
+ - - ">="
212
+ - !ruby/object:Gem::Version
213
+ version: '0'
214
+ requirements: []
215
+ rubygems_version: 3.5.23
216
+ signing_key:
217
+ specification_version: 4
218
+ summary: Creates a trail strong as iron
219
+ test_files: []