Module: Provenance::Trackable
- Extended by:
- ActiveSupport::Concern
- Defined in:
- lib/provenance/trackers/trackable.rb
Overview
Model-side concern. Captures create/update/destroy through ActiveRecord callbacks, groups changes by transaction key, discards them on rollback and flushes the audit log once every transaction has committed. Also tracks ‘has_and_belongs_to_many` join-table changes, which bypass model callbacks, via SQL notifications.
Constant Summary collapse
- INSERT_DELETE_REGEX =
/\b(INSERT|DELETE)\s+(INTO|FROM)\b/i- INSERT_INTO_REGEX =
/\bINSERT\s+INTO\b/i- TABLE_NAME_REGEX =
/(?:INSERT\s+INTO|DELETE\s+FROM)\s+["`]?(\w+)["`]?/i- INSERT_COLUMNS_REGEX =
/INSERT\s+INTO\s+["`]?\w+["`]?\s*\(([^)]+)\)/i- WHERE_REGEX =
/WHERE\s+(.+?)(?:\s+RETURNING|\s*$)/i
Class Method Summary collapse
- .extract_delete_values(sql, binds, foreign_key, association_foreign_key) ⇒ Object
- .extract_habtm_values(sql, binds, action, foreign_key, association_foreign_key) ⇒ Object
- .extract_insert_values(sql, binds, foreign_key, association_foreign_key) ⇒ Object
- .extract_value(bind) ⇒ Object
- .habtm_join_tables ⇒ Object
- .included(base) ⇒ Object
-
.register_habtm_join_table_from_association(association, model_class_name, association_name) ⇒ Object
Registry of join tables we watch, keyed by table name.
-
.track_habtm_sql_changes(payload) ⇒ Object
Inspects a SQL statement; if it touches a watched join table, reconstructs the affected ids and folds the change into the journal as an update.
Class Method Details
.extract_delete_values(sql, binds, foreign_key, association_foreign_key) ⇒ Object
146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 |
# File 'lib/provenance/trackers/trackable.rb', line 146 def self.extract_delete_values(sql, binds, foreign_key, association_foreign_key) conditions = sql.match(WHERE_REGEX)&.[](1) return nil unless conditions fk_pattern = /(?:["`]\w+["`]\.)?["`]?#{Regexp.escape(foreign_key)}["`]?\s*[=<>]\s*\$(\d+)/i fk_match = conditions.match(fk_pattern) return nil unless fk_match fk_idx = fk_match[1].to_i - 1 return nil unless binds[fk_idx] afk_pattern = /(?:["`]\w+["`]\.)?["`]?#{Regexp.escape(association_foreign_key)}["`]?\s*[=<>]\s*\$(\d+)/i afk_match = conditions.match(afk_pattern) if afk_match afk_idx = afk_match[1].to_i - 1 return nil unless binds[afk_idx] [extract_value(binds[fk_idx]), extract_value(binds[afk_idx])] else in_params = conditions.match(/#{Regexp.escape(association_foreign_key)}["`]?\s+IN\s*\(([^)]+)\)/i)&.[](1) return nil unless in_params param_indices = in_params.scan(/\$(\d+)/).flatten.map(&:to_i).map { |i| i - 1 } associated_ids = param_indices.filter_map { |idx| extract_value(binds[idx]) if binds[idx] } return nil if associated_ids.empty? [extract_value(binds[fk_idx]), associated_ids] end end |
.extract_habtm_values(sql, binds, action, foreign_key, association_foreign_key) ⇒ Object
124 125 126 127 128 129 130 131 132 |
# File 'lib/provenance/trackers/trackable.rb', line 124 def self.extract_habtm_values(sql, binds, action, foreign_key, association_foreign_key) return nil unless binds if action == :add extract_insert_values(sql, binds, foreign_key, association_foreign_key) else extract_delete_values(sql, binds, foreign_key, association_foreign_key) end end |
.extract_insert_values(sql, binds, foreign_key, association_foreign_key) ⇒ Object
134 135 136 137 138 139 140 141 142 143 144 |
# File 'lib/provenance/trackers/trackable.rb', line 134 def self.extract_insert_values(sql, binds, foreign_key, association_foreign_key) columns = sql.match(INSERT_COLUMNS_REGEX)&.[](1) return nil unless columns columns = columns.split(",").map { |c| c.strip.delete('"`') } fk_idx = columns.index(foreign_key) afk_idx = columns.index(association_foreign_key) return nil unless fk_idx && afk_idx && binds[fk_idx] && binds[afk_idx] [extract_value(binds[fk_idx]), extract_value(binds[afk_idx])] end |
.extract_value(bind) ⇒ Object
177 178 179 180 181 182 |
# File 'lib/provenance/trackers/trackable.rb', line 177 def self.extract_value(bind) return bind.value if bind.respond_to?(:value) return bind.value_for_database if bind.respond_to?(:value_for_database) bind end |
.habtm_join_tables ⇒ Object
94 95 96 |
# File 'lib/provenance/trackers/trackable.rb', line 94 def self.habtm_join_tables @habtm_join_tables ||= {} end |
.included(base) ⇒ Object
21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 |
# File 'lib/provenance/trackers/trackable.rb', line 21 def self.included(base) super base.class_eval do before_create :capture_transaction_id before_update :capture_transaction_id before_destroy :capture_transaction_id after_update :capture_update after_create :capture_create after_destroy :capture_destroy after_commit :try_send_audit_after_commit after_rollback :clear_tracked_changes class << self alias_method :original_has_and_belongs_to_many, :has_and_belongs_to_many def has_and_belongs_to_many(name, scope = nil, **, &extension) original_has_and_belongs_to_many(name, scope, **, &extension).tap do association = reflect_on_association(name) next unless association Provenance::Trackable.register_habtm_join_table_from_association(association, self.name, name) end end end base.setup_habtm_tracking end # has_and_belongs_to_many join-table writes bypass model callbacks, so we # observe them through SQL notifications and fold them into the journal. # The change is recorded synchronously on the request thread; delivery is # handled by Auditable once every transaction has committed, so no extra # post-commit scheduling is required here. return if @habtm_sql_subscribed ActiveSupport::Notifications.subscribe("sql.active_record") do |_name, _start, _finish, _id, payload| Provenance::Trackable.track_habtm_sql_changes(payload) end @habtm_sql_subscribed = true end |
.register_habtm_join_table_from_association(association, model_class_name, association_name) ⇒ Object
Registry of join tables we watch, keyed by table name. Populated when a model that includes Trackable declares a has_and_belongs_to_many.
83 84 85 86 87 88 89 90 91 92 |
# File 'lib/provenance/trackers/trackable.rb', line 83 def self.register_habtm_join_table_from_association(association, model_class_name, association_name) join_table = association.join_table (@habtm_join_tables ||= {})[join_table] ||= [] @habtm_join_tables[join_table] << { model_class_name: model_class_name, association_name: association_name, foreign_key: association.foreign_key, association_foreign_key: association.association_foreign_key } end |
.track_habtm_sql_changes(payload) ⇒ Object
Inspects a SQL statement; if it touches a watched join table, reconstructs the affected ids and folds the change into the journal as an update.
100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 |
# File 'lib/provenance/trackers/trackable.rb', line 100 def self.track_habtm_sql_changes(payload) journal = Provenance::Context.journal return unless journal sql = payload[:sql].to_s return unless sql.match?(INSERT_DELETE_REGEX) table_name = sql.match(TABLE_NAME_REGEX)&.[](1) return unless table_name && (info_list = habtm_join_tables[table_name]) action = sql.match?(INSERT_INTO_REGEX) ? :add : :remove info_list.each do |info| values_list = extract_habtm_values(sql, payload[:binds], action, info[:foreign_key], info[:association_foreign_key]) next unless values_list model = info[:model_class_name].constantize.find_by(id: values_list[0]) next unless model&.persisted? associated_ids = Array(values_list[1]) model.send(:track_habtm_changes, info[:association_name], action, associated_ids) end end |