historiographer 4.1.5 → 4.1.7

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d5c265010aca1b01c9eda903723571b16456965c208879096542e9aadad079df
4
- data.tar.gz: 59553cbf0764e820570dcb2bce9f6f6188d6920f16f0bf4fbb0cf5c4bb6ea52b
3
+ metadata.gz: fdfddfba56fcb69bb91c73edcf7c1ddff76adc25f517d4b225bcf87bdd356540
4
+ data.tar.gz: 01e398edc375e2bcf17f22a81c4eb06ab2d507483b05db80b8014ea291f2ac82
5
5
  SHA512:
6
- metadata.gz: 0fc94e06266d5d21d28c4521e5ee9da3f90af4cbcea5594814b1916474929b42f4db37907acf0c104477b98db8e4f78082887a24f9495b7699b237f11bc1c9f9
7
- data.tar.gz: 4b819a457d62ac154994b445b88ed7574341d4a366a24318ca64708d110d1081b6fb1032a9d505115b47eaffaf1efcc6e2bdabe39a56153c345ebcb8a8e57934
6
+ metadata.gz: 64b288645843cad4862b06724619ec8c98405745b4d6ba572d00baabbc1ce0d4f706cbf95b42c7e1e6214c870ac0c335fb37ea760f599830037f11a94cf5443e
7
+ data.tar.gz: '09663a6452efd27ce2f566b3764e7dd9f29499a61cb5eecc7b488e8fe31270d57a6ceb84489de983cbd4851ac8906cf6140127113ef06751fc6cb19839821906'
data/README.md CHANGED
@@ -137,7 +137,7 @@ Historiographer fully supports Single Table Inheritance, both with the default `
137
137
  ### Default STI with `type` column
138
138
 
139
139
  ```ruby
140
- class Post < ApplicationRecord
140
+ class Post < ActiveRecord::Base
141
141
  include Historiographer
142
142
  end
143
143
 
@@ -145,7 +145,7 @@ class PrivatePost < Post
145
145
  end
146
146
 
147
147
  # The history classes follow the same inheritance pattern:
148
- class PostHistory < ApplicationRecord
148
+ class PostHistory < ActiveRecord::Base
149
149
  include Historiographer::History
150
150
  end
151
151
 
@@ -170,9 +170,9 @@ history.type #=> "PrivatePostHistory"
170
170
  You can also use a custom column for STI instead of the default `type`:
171
171
 
172
172
  ```ruby
173
- class MLModel < ApplicationRecord
174
- include Historiographer
173
+ class MLModel < ActiveRecord::Base
175
174
  self.inheritance_column = :model_type
175
+ include Historiographer
176
176
  end
177
177
 
178
178
  class XGBoost < MLModel
@@ -291,12 +291,45 @@ trained_version = model.histories.find_by(metadata: { stage: "post_training" })
291
291
  ```
292
292
 
293
293
  This combination of STI and snapshots is particularly valuable for:
294
+
294
295
  - Model governance and compliance
295
296
  - A/B testing different model types
296
297
  - Debugging model behavior
297
298
  - Reproducing historical predictions
298
299
  - Maintaining audit trails for regulatory requirements
299
300
 
301
+ ## Namespaced Models
302
+
303
+ When using namespaced models, Rails handles foreign key naming differently than with non-namespaced models. For example, if you have a model namespaced like this:
304
+
305
+ ```ruby
306
+ module EasyML
307
+ class Dataset
308
+ self.table_name = "easy_ml_datasets"
309
+ end
310
+ end
311
+ ```
312
+
313
+ Rails will expect foreign keys to be formatted using just the model name (without the namespace) like this:
314
+
315
+ ```ruby
316
+ :dataset_id
317
+ ```
318
+
319
+ Therefore, when creating history migrations for namespaced models, you need to specify the foreign key name explicitly:
320
+
321
+ ```ruby
322
+ class CreateEasyMLDatasetHistories < ActiveRecord::Migration
323
+ def change
324
+ create_table :easy_ml_dataset_histories do |t|
325
+ t.histories(foreign_key: :dataset_id) # instead of using the table name — easy_ml_dataset_id
326
+ end
327
+ end
328
+ end
329
+ ```
330
+
331
+ This ensures that the foreign key relationships are properly established between your namespaced models and their history tables.
332
+
300
333
  ## Getting Started
301
334
 
302
335
  Whenever you include the `Historiographer` gem in your ActiveRecord model, it allows you to insert, update, or delete data as you normally would.
@@ -305,6 +338,11 @@ Whenever you include the `Historiographer` gem in your ActiveRecord model, it al
305
338
  class Post < ActiveRecord::Base
306
339
  include Historiographer
307
340
  end
341
+
342
+ class PostHistory < ActiveRecord::Base
343
+ self.table_name = "post_histories"
344
+ include Historiographer::History
345
+ end
308
346
  ```
309
347
 
310
348
  ### History Modes
@@ -337,6 +375,29 @@ class Comment < ActiveRecord::Base
337
375
  end
338
376
  ```
339
377
 
378
+ The class-level mode setting takes precedence over the global configuration. This allows you to:
379
+
380
+ - Have different history tracking strategies for different models
381
+ - Set most models to use snapshots while keeping detailed history for critical models
382
+ - Optimize storage by only tracking detailed history where needed
383
+
384
+ For example:
385
+
386
+ ```ruby
387
+ # Global setting for most models
388
+ Historiographer::Configuration.mode = :snapshot_only
389
+
390
+ class Order < ActiveRecord::Base
391
+ include Historiographer
392
+ # Uses global :snapshot_only mode
393
+ end
394
+
395
+ class Payment < ActiveRecord::Base
396
+ include Historiographer
397
+ historiographer_mode :histories # Override to record histories of every change
398
+ end
399
+ ```
400
+
340
401
  ## Create A Migration
341
402
 
342
403
  You need a separate table to store histories for each model.
@@ -391,12 +452,18 @@ The primary model should include `Historiographer`:
391
452
  class Post < ActiveRecord::Base
392
453
  include Historiographer
393
454
  end
455
+
456
+ class PostHistory < ActiveRecord::Base
457
+ self.table_name = "post_histories"
458
+ include Historiographer::History
459
+ end
394
460
  ```
395
461
 
396
462
  You should also make a `PostHistory` class if you're going to query `PostHistory` from Rails:
397
463
 
398
464
  ```ruby
399
465
  class PostHistory < ActiveRecord::Base
466
+ self.table_name = "post_histories"
400
467
  end
401
468
  ```
402
469
 
@@ -61,6 +61,7 @@ module Historiographer
61
61
  extend ActiveSupport::Concern
62
62
 
63
63
  included do |base|
64
+ clear_validators!
64
65
  #
65
66
  # A History class (e.g. RetailerProductHistory) will gain
66
67
  # access to a current scope, returning
@@ -91,6 +92,67 @@ module Historiographer
91
92
  foreign_class = foreign_class_name.constantize
92
93
  association_name = foreign_class_name.split("::").last.underscore.to_sym # e.g. "RetailerProduct" => :retailer_product
93
94
 
95
+ # Store the original class for method delegation
96
+ class_variable_set(:@@original_class, foreign_class)
97
+
98
+ # Add method_added hook to the original class
99
+ foreign_class.singleton_class.class_eval do
100
+ # Keep track of original method_added if it exists
101
+ if method_defined?(:method_added)
102
+ alias_method :original_method_added, :method_added
103
+ end
104
+
105
+ method_map = Hash.new(0)
106
+ define_method(:method_added) do |method_name|
107
+ # Skip if we're already in the process of defining a method
108
+ return if Thread.current[:defining_historiographer_method]
109
+
110
+ begin
111
+ Thread.current[:defining_historiographer_method] = true
112
+
113
+ # Call original method_added if it exists
114
+ original_method_added(method_name) if respond_to?(:original_method_added)
115
+
116
+ # Get the method object to check if it's from our class (not inherited)
117
+ method_obj = instance_method(method_name)
118
+ return unless method_obj.owner == self
119
+
120
+ # Skip if we've already defined this method in the history class
121
+ return if foreign_class.history_class.method_defined?(method_name)
122
+
123
+ # Define the method in the history class
124
+ foreign_class.history_class.class_eval do
125
+ define_method(method_name) do |*args, &block|
126
+ forward_method(method_name, *args, &block)
127
+ end
128
+ end
129
+ ensure
130
+ Thread.current[:defining_historiographer_method] = false
131
+ end
132
+ end
133
+ end
134
+
135
+ foreign_class.columns.map(&:name).each do |method_name|
136
+ define_method(method_name) do |*args, &block|
137
+ forward_method(method_name, *args, &block)
138
+ end
139
+ end
140
+
141
+ # Add method_missing for any methods we might have missed
142
+ def method_missing(method_name, *args, &block)
143
+ original_class = self.class.class_variable_get(:@@original_class)
144
+ if original_class.method_defined?(method_name)
145
+ forward_method(method_name, *args, &block)
146
+ else
147
+ super
148
+ end
149
+ end
150
+
151
+ def respond_to_missing?(method_name, include_private = false)
152
+ original_class = self.class.class_variable_get(:@@original_class)
153
+ original_class.method_defined?(method_name) || super
154
+ end
155
+
94
156
  #
95
157
  # Historiographer will automatically setup the association
96
158
  # to the primary class (e.g. RetailerProduct)
@@ -147,19 +209,19 @@ module Historiographer
147
209
  #
148
210
  # If the record was not already persisted, proceed as normal.
149
211
  #
150
- def save(*args)
212
+ def save(*args, **kwargs)
151
213
  if persisted? && (changes.keys - %w(history_ended_at snapshot_id)).any?
152
214
  false
153
215
  else
154
- super
216
+ super(*args, **kwargs)
155
217
  end
156
218
  end
157
219
 
158
- def save!(*args)
220
+ def save!(*args, **kwargs)
159
221
  if persisted? && (changes.keys - %w(history_ended_at snapshot_id)).any?
160
222
  false
161
223
  else
162
- super
224
+ super(*args, **kwargs)
163
225
  end
164
226
  end
165
227
 
@@ -167,11 +229,66 @@ module Historiographer
167
229
  # Orders by history_started_at and id to handle cases where multiple records
168
230
  # have the same history_started_at timestamp
169
231
  scope :latest_snapshot, -> {
170
- where.not(snapshot_id: nil).order('id DESC').limit(1)&.first || none
232
+ where.not(snapshot_id: nil)
233
+ .select('DISTINCT ON (snapshot_id) *')
234
+ .order('snapshot_id, history_started_at DESC, id DESC')
171
235
  }
236
+
237
+ # Dynamically define associations on the history class
238
+ foreign_class.reflect_on_all_associations.each do |association|
239
+ define_history_association(association)
240
+ end
241
+
172
242
  end
173
243
 
174
244
  class_methods do
245
+ def original_class
246
+ unless class_variable_defined?(:@@original_class)
247
+ class_variable_set(:@@original_class, self.name.gsub(/History$/, '').constantize)
248
+ end
249
+
250
+ class_variable_get(:@@original_class)
251
+ end
252
+
253
+ def define_history_association(association)
254
+ if association.is_a?(Symbol) || association.is_a?(String)
255
+ association = original_class.reflect_on_association(association)
256
+ end
257
+ assoc_name = association.name
258
+ assoc_history_class_name = "#{association.class_name}History"
259
+ assoc_foreign_key = association.foreign_key
260
+
261
+ # Skip if the association is already defined
262
+ return if method_defined?(assoc_name)
263
+
264
+ # Skip through associations to history classes to avoid infinite loops
265
+ return if association.class_name.end_with?('History')
266
+
267
+ # We're writing a belongs_to
268
+ # The dataset belongs_to the datasource
269
+ # dataset#datasource_id => datasource.id
270
+ #
271
+ # For the history class, we're writing a belongs_to
272
+ # the DatasetHistory belongs_to the DatasourceHistory
273
+ # dataset_history#datasource_id => datasource_history.easy_ml_datasource_id
274
+ #
275
+ # The missing piece for us here is whatever DatasourceHistory would call easy_ml_datasource_id (history foreign key?)
276
+
277
+ case association.macro
278
+ when :belongs_to
279
+ belongs_to assoc_name, ->(history_instance) {
280
+ where(snapshot_id: history_instance.snapshot_id)
281
+ }, class_name: assoc_history_class_name, foreign_key: assoc_foreign_key, primary_key: assoc_foreign_key
282
+ when :has_one
283
+ has_one assoc_name, ->(history_instance) {
284
+ where(snapshot_id: history_instance.snapshot_id)
285
+ }, class_name: assoc_history_class_name, foreign_key: assoc_foreign_key, primary_key: history_foreign_key
286
+ when :has_many
287
+ has_many assoc_name, ->(history_instance) {
288
+ where(snapshot_id: history_instance.snapshot_id)
289
+ }, class_name: assoc_history_class_name, foreign_key: assoc_foreign_key, primary_key: history_foreign_key
290
+ end
291
+ end
175
292
  #
176
293
  # The foreign key to the primary class.
177
294
  #
@@ -180,7 +297,8 @@ module Historiographer
180
297
  def history_foreign_key
181
298
  return @history_foreign_key if @history_foreign_key
182
299
 
183
- @history_foreign_key = sti_base_class.table_name.singularize.foreign_key
300
+ # CAN THIS BE TABLE OR MODEL?
301
+ @history_foreign_key = sti_base_class.name.singularize.foreign_key
184
302
  end
185
303
 
186
304
  def sti_base_class
@@ -194,5 +312,30 @@ module Historiographer
194
312
  @sti_base_class = base_class
195
313
  end
196
314
  end
315
+
316
+ def original_class
317
+ self.class.original_class
318
+ end
319
+
320
+ private
321
+ def dummy_instance
322
+ return @dummy_instance if @dummy_instance
323
+
324
+ cannot_keep_cols = %w(history_started_at history_ended_at history_user_id snapshot_id)
325
+ cannot_keep_cols += [self.class.inheritance_column.to_sym] if self.original_class.sti_enabled?
326
+ cannot_keep_cols += [self.class.history_foreign_key]
327
+ cannot_keep_cols.map!(&:to_s)
328
+
329
+ attrs = attributes.clone
330
+ attrs[original_class.primary_key] = attrs[self.class.history_foreign_key]
331
+
332
+ instance = original_class.find_or_initialize_by(original_class.primary_key => attrs[original_class.primary_key])
333
+ instance.assign_attributes(attrs.except(*cannot_keep_cols))
334
+ @dummy_instance = instance
335
+ end
336
+
337
+ def forward_method(method_name, *args, &block)
338
+ dummy_instance.send(method_name, *args, &block)
339
+ end
197
340
  end
198
341
  end
@@ -16,10 +16,11 @@ module Historiographer
16
16
  # Will automatically add user_id, history_started_at,
17
17
  # and history_ended_at columns
18
18
  #
19
- def histories(except: [], only: [], no_business_columns: false, index_names: {})
19
+ def histories(except: [], only: [], no_business_columns: false, index_names: {}, foreign_key: nil)
20
20
  index_names.symbolize_keys!
21
- original_table_name = self.name.gsub(/_histories$/) {}.pluralize
22
- foreign_key = original_table_name.singularize.foreign_key
21
+ history_table_name = self.name
22
+ original_table_name = history_table_name.gsub(/_histories$/) {}.pluralize
23
+ foreign_key ||= original_table_name.singularize.foreign_key
23
24
 
24
25
  class_definer = Class.new(ActiveRecord::Base) do
25
26
  end
@@ -1,3 +1,3 @@
1
1
  module Historiographer
2
- VERSION = "4.1.5"
2
+ VERSION = "4.1.7"
3
3
  end
@@ -85,7 +85,6 @@ module Historiographer
85
85
  after_save :record_history, if: :should_record_history?
86
86
  validate :validate_history_user_id_present, if: :should_validate_history_user_id_present?
87
87
 
88
-
89
88
  def should_alert_history_user_id_present?
90
89
  !snapshot_mode? && !is_history_class? && Thread.current[:skip_history_user_id_validation] != true
91
90
  end
@@ -184,15 +183,15 @@ module Historiographer
184
183
 
185
184
  begin
186
185
  class_name.constantize
187
- rescue StandardError
186
+ rescue NameError
188
187
  # Get the base table name without _histories suffix
189
- base_table = base.table_name.sub(/_histories$/, '')
188
+ base_table = base.table_name.singularize.sub(/_histories$/, '')
190
189
 
191
- history_class_initializer = Class.new(base) do
190
+ history_class_initializer = Class.new(ActiveRecord::Base) do
192
191
  self.table_name = "#{base_table}_histories"
193
192
 
194
193
  # Handle STI properly
195
- self.inheritance_column = base.inheritance_column if base.respond_to?(:inheritance_column)
194
+ self.inheritance_column = base.inheritance_column if base.sti_enabled?
196
195
  end
197
196
 
198
197
  # Split the class name into module parts and the actual class name
@@ -217,63 +216,22 @@ module Historiographer
217
216
  base.singleton_class.prepend(Module.new do
218
217
  def belongs_to(name, scope = nil, **options, &extension)
219
218
  super
220
- define_history_association(name, :belongs_to, options)
219
+ history_class.define_history_association(name)
221
220
  end
222
221
 
223
222
  def has_one(name, scope = nil, **options, &extension)
224
223
  super
225
- define_history_association(name, :has_one, options)
224
+ history_class.define_history_association(name)
226
225
  end
227
226
 
228
227
  def has_many(name, scope = nil, **options, &extension)
229
228
  super
230
- define_history_association(name, :has_many, options)
229
+ history_class.define_history_association(name)
231
230
  end
232
231
 
233
232
  def has_and_belongs_to_many(name, scope = nil, **options, &extension)
234
233
  super
235
- define_history_association(name, :has_and_belongs_to_many, options)
236
- end
237
-
238
- private
239
-
240
- def define_history_association(name, type, options)
241
- return if is_history_class?
242
- return if @defining_association
243
- return if %i[histories current_history].include?(name)
244
- @defining_association = true
245
-
246
- history_class = "#{self.name}History".constantize
247
- history_class_name = "#{name.to_s.singularize.camelize}History"
248
-
249
- # Get the original association's foreign key
250
- original_reflection = self.reflect_on_association(name)
251
- foreign_key = original_reflection.foreign_key
252
-
253
- if type == :has_many || type == :has_and_belongs_to_many
254
- history_class.send(
255
- type,
256
- name,
257
- -> (owner) { where("#{name.to_s.singularize}_histories.snapshot_id = ?", owner.snapshot_id) },
258
- **options.merge(
259
- class_name: history_class_name,
260
- foreign_key: foreign_key,
261
- primary_key: foreign_key
262
- )
263
- )
264
- else
265
- history_class.send(
266
- type,
267
- name,
268
- -> (owner) { where("#{name}_histories.snapshot_id = ?", owner.snapshot_id) },
269
- **options.merge(
270
- class_name: history_class_name,
271
- foreign_key: foreign_key,
272
- primary_key: foreign_key
273
- )
274
- )
275
- end
276
- @defining_association = false
234
+ history_class.define_history_association(name)
277
235
  end
278
236
  end)
279
237
 
@@ -318,7 +276,6 @@ module Historiographer
318
276
  save!(*args, &block)
319
277
  @no_history = false
320
278
  end
321
-
322
279
 
323
280
  def snapshot(tree = {}, snapshot_id = nil)
324
281
  return if is_history_class?
@@ -334,10 +291,11 @@ module Historiographer
334
291
  return if existing_snapshot.present?
335
292
 
336
293
  null_snapshot = history_class.where(foreign_key => attrs[primary_key], snapshot_id: nil)
294
+ snapshot = nil
337
295
  if null_snapshot.present?
338
- null_snapshot.update(snapshot_id: snapshot_id)
296
+ snapshot = null_snapshot.update(snapshot_id: snapshot_id)
339
297
  else
340
- record_history(snapshot_id: snapshot_id)
298
+ snapshot = record_history(snapshot_id: snapshot_id)
341
299
  end
342
300
 
343
301
  # Recursively snapshot associations, avoiding infinite loops
@@ -356,6 +314,8 @@ module Historiographer
356
314
  record.snapshot(new_tree, snapshot_id) if record.respond_to?(:snapshot)
357
315
  end
358
316
  end
317
+
318
+ snapshot
359
319
  end
360
320
  end
361
321
 
@@ -383,8 +343,12 @@ module Historiographer
383
343
  attrs
384
344
  end
385
345
 
346
+ def snapshots
347
+ histories.where.not(snapshot_id: nil)
348
+ end
349
+
386
350
  def latest_snapshot
387
- histories.where.not(snapshot_id: nil).order('id DESC').limit(1)&.first || none
351
+ snapshots.order('id DESC').limit(1)&.first || history_class.none
388
352
  end
389
353
 
390
354
  private
@@ -407,7 +371,7 @@ module Historiographer
407
371
  current_history = histories.where(history_ended_at: nil).order('id desc').limit(1).last
408
372
 
409
373
  if history_class.history_foreign_key.present? && history_class.present?
410
- history_class.create!(attrs).tap do |history|
374
+ history_class.create!(attrs).tap do |new_history|
411
375
  current_history.update!(history_ended_at: now) if current_history.present?
412
376
  end
413
377
  else
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: historiographer
3
3
  version: !ruby/object:Gem::Version
4
- version: 4.1.5
4
+ version: 4.1.7
5
5
  platform: ruby
6
6
  authors:
7
7
  - brettshollenberger
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2024-11-19 00:00:00.000000000 Z
11
+ date: 2024-11-20 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activerecord