historiographer 4.1.5 → 4.1.7

Sign up to get free protection for your applications and to get access to all the features.
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