iron_trail 0.0.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: 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: []