historiographer 4.1.5 → 4.1.8

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: 28891ab31232a7eae8efccd015269069d992eb775259c6a6b9e738da1df69aed
4
+ data.tar.gz: 7a794f1a26351b47d194a0b8d18eb0222d49e9dd7d2b6cdcf90467d94cea62b0
5
5
  SHA512:
6
- metadata.gz: 0fc94e06266d5d21d28c4521e5ee9da3f90af4cbcea5594814b1916474929b42f4db37907acf0c104477b98db8e4f78082887a24f9495b7699b237f11bc1c9f9
7
- data.tar.gz: 4b819a457d62ac154994b445b88ed7574341d4a366a24318ca64708d110d1081b6fb1032a9d505115b47eaffaf1efcc6e2bdabe39a56153c345ebcb8a8e57934
6
+ metadata.gz: 16ee4305c1aa8c6f09a13b042185afceea10fd18c0a19f7dba360bcab65a7f1a270dce78e24f15f2a30806fa4f4bcdf5ea6c10ecb81060b3dca02724918d7f5e
7
+ data.tar.gz: e66e7f2a31b5f65689f112ae6b43adeff5e286c42aeb6ed2ffc8931e1a75426dac3f4cb615b2fc06a537c74f070f4f2a245fe4668be8814b8382bd5084336651
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
 
@@ -0,0 +1,98 @@
1
+ module Historiographer
2
+ module History
3
+ module InstanceMethods
4
+ def destroy
5
+ false
6
+ end
7
+
8
+ def destroy!
9
+ false
10
+ end
11
+
12
+ def save(*args, **kwargs)
13
+ if persisted? && (changes.keys - %w(history_ended_at snapshot_id)).any?
14
+ false
15
+ else
16
+ super(*args, **kwargs)
17
+ end
18
+ end
19
+
20
+ def save!(*args, **kwargs)
21
+ if persisted? && (changes.keys - %w(history_ended_at snapshot_id)).any?
22
+ false
23
+ else
24
+ super(*args, **kwargs)
25
+ end
26
+ end
27
+
28
+ def snapshot
29
+ raise "Cannot snapshot a history model!"
30
+ end
31
+
32
+ def original_class
33
+ self.class.original_class
34
+ end
35
+
36
+ private
37
+
38
+ def dummy_instance
39
+ return @dummy_instance if @dummy_instance
40
+
41
+ cannot_keep_cols = %w(history_started_at history_ended_at history_user_id snapshot_id)
42
+ cannot_keep_cols += [self.class.inheritance_column.to_sym] if self.original_class.sti_enabled?
43
+ cannot_keep_cols += [self.class.history_foreign_key]
44
+ cannot_keep_cols.map!(&:to_s)
45
+
46
+ attrs = attributes.clone
47
+ attrs[original_class.primary_key] = attrs[self.class.history_foreign_key]
48
+
49
+ instance = original_class.find_or_initialize_by(original_class.primary_key => attrs[original_class.primary_key])
50
+ instance.assign_attributes(attrs.except(*cannot_keep_cols))
51
+
52
+ # Create a module to hold methods from the history class
53
+ history_methods_module = Module.new
54
+
55
+ # Get methods defined directly in the history class, excluding baseline methods
56
+ history_methods = self.class.instance_methods(false) - baseline_methods
57
+
58
+ history_methods.each do |method_name|
59
+ next if instance.singleton_class.method_defined?(method_name)
60
+
61
+ method = self.class.instance_method(method_name)
62
+ history_methods_module.define_method(method_name) do |*args, &block|
63
+ method.bind(self.instance_variable_get(:@_history_instance)).call(*args, &block)
64
+ end
65
+ end
66
+
67
+ instance.singleton_class.prepend(history_methods_module)
68
+
69
+ self.class.reflect_on_all_associations.each do |reflection|
70
+ instance.singleton_class.class_eval do
71
+ define_method(reflection.name) do |*args, &block|
72
+ history_instance = instance.instance_variable_get(:@_history_instance)
73
+ history_instance.send(reflection.name, *args, &block)
74
+ end
75
+ end
76
+ end
77
+
78
+ instance.singleton_class.class_eval do
79
+ define_method(:class) do
80
+ history_instance = instance.instance_variable_get(:@_history_instance)
81
+ history_instance.class
82
+ end
83
+ end
84
+
85
+ instance.instance_variable_set(:@_history_instance, self)
86
+ @dummy_instance = instance
87
+ end
88
+
89
+ def forward_method(method_name, *args, &block)
90
+ if method_name == :class || method_name == 'class'
91
+ self.class
92
+ else
93
+ dummy_instance.send(method_name, *args, &block)
94
+ end
95
+ end
96
+ end
97
+ end
98
+ end
@@ -61,6 +61,7 @@ module Historiographer
61
61
  extend ActiveSupport::Concern
62
62
 
63
63
  included do |base|
64
+ clear_validators! if respond_to?(: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,68 @@ 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
+ class_variable_set(:@@method_map, {})
98
+
99
+ # Add method_added hook to the original class
100
+ foreign_class.singleton_class.class_eval do
101
+ # Keep track of original method_added if it exists
102
+ if method_defined?(:method_added)
103
+ alias_method :original_method_added, :method_added
104
+ end
105
+
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.set_method_map(method_name, false)
125
+ foreign_class.history_class.class_eval do
126
+ define_method(method_name) do |*args, &block|
127
+ forward_method(method_name, *args, &block)
128
+ end
129
+ end
130
+ ensure
131
+ Thread.current[:defining_historiographer_method] = false
132
+ end
133
+ end
134
+ end
135
+
136
+ foreign_class.columns.map(&:name).each do |method_name|
137
+ define_method(method_name) do |*args, &block|
138
+ forward_method(method_name, *args, &block)
139
+ end
140
+ end
141
+
142
+ # Add method_missing for any methods we might have missed
143
+ def method_missing(method_name, *args, &block)
144
+ original_class = self.class.class_variable_get(:@@original_class)
145
+ if original_class.method_defined?(method_name)
146
+ forward_method(method_name, *args, &block)
147
+ else
148
+ super
149
+ end
150
+ end
151
+
152
+ def respond_to_missing?(method_name, include_private = false)
153
+ original_class = self.class.class_variable_get(:@@original_class)
154
+ original_class.method_defined?(method_name) || super
155
+ end
156
+
94
157
  #
95
158
  # Historiographer will automatically setup the association
96
159
  # to the primary class (e.g. RetailerProduct)
@@ -147,19 +210,19 @@ module Historiographer
147
210
  #
148
211
  # If the record was not already persisted, proceed as normal.
149
212
  #
150
- def save(*args)
213
+ def save(*args, **kwargs)
151
214
  if persisted? && (changes.keys - %w(history_ended_at snapshot_id)).any?
152
215
  false
153
216
  else
154
- super
217
+ super(*args, **kwargs)
155
218
  end
156
219
  end
157
220
 
158
- def save!(*args)
221
+ def save!(*args, **kwargs)
159
222
  if persisted? && (changes.keys - %w(history_ended_at snapshot_id)).any?
160
223
  false
161
224
  else
162
- super
225
+ super(*args, **kwargs)
163
226
  end
164
227
  end
165
228
 
@@ -167,11 +230,87 @@ module Historiographer
167
230
  # Orders by history_started_at and id to handle cases where multiple records
168
231
  # have the same history_started_at timestamp
169
232
  scope :latest_snapshot, -> {
170
- where.not(snapshot_id: nil).order('id DESC').limit(1)&.first || none
233
+ where.not(snapshot_id: nil)
234
+ .select('DISTINCT ON (snapshot_id) *')
235
+ .order('snapshot_id, history_started_at DESC, id DESC')
171
236
  }
237
+
238
+ # Dynamically define associations on the history class
239
+ foreign_class.reflect_on_all_associations.each do |association|
240
+ define_history_association(association)
241
+ end
242
+
243
+ def snapshot
244
+ raise "Cannot snapshot a history model!"
245
+ end
246
+
172
247
  end
173
248
 
174
249
  class_methods do
250
+ def method_added(method_name)
251
+ set_method_map(method_name, true)
252
+ end
253
+
254
+ def set_method_map(method_name, is_overridden)
255
+ mm = method_map
256
+ mm[method_name.to_sym] = is_overridden
257
+ class_variable_set(:@@method_map, mm)
258
+ end
259
+
260
+ def method_map
261
+ unless class_variable_defined?(:@@method_map)
262
+ class_variable_set(:@@method_map, {})
263
+ end
264
+ class_variable_get(:@@method_map) || {}
265
+ end
266
+
267
+ def original_class
268
+ unless class_variable_defined?(:@@original_class)
269
+ class_variable_set(:@@original_class, self.name.gsub(/History$/, '').constantize)
270
+ end
271
+
272
+ class_variable_get(:@@original_class)
273
+ end
274
+
275
+ def define_history_association(association)
276
+ if association.is_a?(Symbol) || association.is_a?(String)
277
+ association = original_class.reflect_on_association(association)
278
+ end
279
+ assoc_name = association.name
280
+ assoc_module = association.active_record.module_parent
281
+ assoc_history_class_name = "#{association.class_name}History"
282
+
283
+ begin
284
+ assoc_module.const_get(assoc_history_class_name)
285
+ assoc_history_class_name = "#{assoc_module}::#{assoc_history_class_name}" unless assoc_history_class_name.match?(Regexp.new("#{assoc_module}::"))
286
+ rescue
287
+ end
288
+
289
+ assoc_foreign_key = association.foreign_key
290
+
291
+ # Skip through associations to history classes to avoid infinite loops
292
+ return if association.class_name.end_with?('History')
293
+
294
+ # Always use the history class if it exists
295
+ assoc_class = assoc_history_class_name.safe_constantize || OpenStruct.new(name: association.class_name)
296
+ assoc_class_name = assoc_class.name
297
+
298
+ # Define the scope to filter by snapshot_id for history associations
299
+ scope = if assoc_class_name.match?(/History/)
300
+ ->(history_instance) { where(snapshot_id: history_instance.snapshot_id) }
301
+ else
302
+ ->(history_instance) { all }
303
+ end
304
+
305
+ case association.macro
306
+ when :belongs_to
307
+ belongs_to assoc_name, scope, class_name: assoc_class_name, foreign_key: assoc_foreign_key, primary_key: assoc_foreign_key
308
+ when :has_one
309
+ has_one assoc_name, scope, class_name: assoc_class_name, foreign_key: assoc_foreign_key, primary_key: history_foreign_key
310
+ when :has_many
311
+ has_many assoc_name, scope, class_name: assoc_class_name, foreign_key: assoc_foreign_key, primary_key: history_foreign_key
312
+ end
313
+ end
175
314
  #
176
315
  # The foreign key to the primary class.
177
316
  #
@@ -180,7 +319,8 @@ module Historiographer
180
319
  def history_foreign_key
181
320
  return @history_foreign_key if @history_foreign_key
182
321
 
183
- @history_foreign_key = sti_base_class.table_name.singularize.foreign_key
322
+ # CAN THIS BE TABLE OR MODEL?
323
+ @history_foreign_key = sti_base_class.name.singularize.foreign_key
184
324
  end
185
325
 
186
326
  def sti_base_class
@@ -194,5 +334,71 @@ module Historiographer
194
334
  @sti_base_class = base_class
195
335
  end
196
336
  end
337
+
338
+ def original_class
339
+ self.class.original_class
340
+ end
341
+
342
+ private
343
+ def dummy_instance
344
+ return @dummy_instance if @dummy_instance
345
+
346
+ cannot_keep_cols = %w(history_started_at history_ended_at history_user_id snapshot_id)
347
+ cannot_keep_cols += [self.class.inheritance_column.to_sym] if self.original_class.sti_enabled?
348
+ cannot_keep_cols += [self.class.history_foreign_key]
349
+ cannot_keep_cols.map!(&:to_s)
350
+
351
+ attrs = attributes.clone
352
+ attrs[original_class.primary_key] = attrs[self.class.history_foreign_key]
353
+
354
+ instance = original_class.find_or_initialize_by(original_class.primary_key => attrs[original_class.primary_key])
355
+ instance.assign_attributes(attrs.except(*cannot_keep_cols))
356
+
357
+ # Filter out any methods that are not overridden on the history class
358
+ history_methods = self.class.instance_methods(false)
359
+ history_class_location = Module.const_source_location(self.class.name).first
360
+ history_methods.select! do |method|
361
+ self.class.instance_method(method).source_location.first == history_class_location
362
+ end
363
+
364
+ history_methods.each do |method_name|
365
+ instance.singleton_class.class_eval do
366
+ define_method(method_name) do |*args, &block|
367
+ history_instance = instance.instance_variable_get(:@_history_instance)
368
+ history_instance.send(method_name, *args, &block)
369
+ end
370
+ end
371
+ end
372
+
373
+ # For each association in the history class
374
+ self.class.reflect_on_all_associations.each do |reflection|
375
+ # Define a method that forwards to the history association
376
+ instance.singleton_class.class_eval do
377
+ define_method(reflection.name) do |*args, &block|
378
+ history_instance = instance.instance_variable_get(:@_history_instance)
379
+ history_instance.send(reflection.name, *args, &block)
380
+ end
381
+ end
382
+ end
383
+
384
+ # Override class method to return history class
385
+ instance.singleton_class.class_eval do
386
+ define_method(:class) do
387
+ history_instance = instance.instance_variable_get(:@_history_instance)
388
+ history_instance.class
389
+ end
390
+ end
391
+
392
+ instance.instance_variable_set(:@_history_instance, self)
393
+ @dummy_instance = instance
394
+ end
395
+
396
+ def forward_method(method_name, *args, &block)
397
+ if method_name == :class || method_name == 'class'
398
+ self.class
399
+ else
400
+ dummy_instance.send(method_name, *args, &block)
401
+ end
402
+ end
197
403
  end
198
404
  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.8"
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,26 @@ 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
+ return if is_history_class?
220
+ history_class.define_history_association(name)
221
221
  end
222
222
 
223
223
  def has_one(name, scope = nil, **options, &extension)
224
224
  super
225
- define_history_association(name, :has_one, options)
225
+ return if is_history_class?
226
+ history_class.define_history_association(name)
226
227
  end
227
228
 
228
229
  def has_many(name, scope = nil, **options, &extension)
229
230
  super
230
- define_history_association(name, :has_many, options)
231
+ return if is_history_class?
232
+ history_class.define_history_association(name)
231
233
  end
232
234
 
233
235
  def has_and_belongs_to_many(name, scope = nil, **options, &extension)
234
236
  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
237
  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
238
+ history_class.define_history_association(name)
277
239
  end
278
240
  end)
279
241
 
@@ -318,7 +280,6 @@ module Historiographer
318
280
  save!(*args, &block)
319
281
  @no_history = false
320
282
  end
321
-
322
283
 
323
284
  def snapshot(tree = {}, snapshot_id = nil)
324
285
  return if is_history_class?
@@ -334,10 +295,11 @@ module Historiographer
334
295
  return if existing_snapshot.present?
335
296
 
336
297
  null_snapshot = history_class.where(foreign_key => attrs[primary_key], snapshot_id: nil)
298
+ snapshot = nil
337
299
  if null_snapshot.present?
338
- null_snapshot.update(snapshot_id: snapshot_id)
300
+ snapshot = null_snapshot.update(snapshot_id: snapshot_id)
339
301
  else
340
- record_history(snapshot_id: snapshot_id)
302
+ snapshot = record_history(snapshot_id: snapshot_id)
341
303
  end
342
304
 
343
305
  # Recursively snapshot associations, avoiding infinite loops
@@ -356,6 +318,8 @@ module Historiographer
356
318
  record.snapshot(new_tree, snapshot_id) if record.respond_to?(:snapshot)
357
319
  end
358
320
  end
321
+
322
+ snapshot
359
323
  end
360
324
  end
361
325
 
@@ -383,8 +347,12 @@ module Historiographer
383
347
  attrs
384
348
  end
385
349
 
350
+ def snapshots
351
+ histories.where.not(snapshot_id: nil)
352
+ end
353
+
386
354
  def latest_snapshot
387
- histories.where.not(snapshot_id: nil).order('id DESC').limit(1)&.first || none
355
+ snapshots.order('id DESC').limit(1)&.first || history_class.none
388
356
  end
389
357
 
390
358
  private
@@ -407,7 +375,7 @@ module Historiographer
407
375
  current_history = histories.where(history_ended_at: nil).order('id desc').limit(1).last
408
376
 
409
377
  if history_class.history_foreign_key.present? && history_class.present?
410
- history_class.create!(attrs).tap do |history|
378
+ history_class.create!(attrs).tap do |new_history|
411
379
  current_history.update!(history_ended_at: now) if current_history.present?
412
380
  end
413
381
  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.8
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-30 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activerecord
@@ -220,6 +220,20 @@ dependencies:
220
220
  - - ">="
221
221
  - !ruby/object:Gem::Version
222
222
  version: '0'
223
+ - !ruby/object:Gem::Dependency
224
+ name: zeitwerk
225
+ requirement: !ruby/object:Gem::Requirement
226
+ requirements:
227
+ - - ">="
228
+ - !ruby/object:Gem::Version
229
+ version: '0'
230
+ type: :development
231
+ prerelease: false
232
+ version_requirements: !ruby/object:Gem::Requirement
233
+ requirements:
234
+ - - ">="
235
+ - !ruby/object:Gem::Version
236
+ version: '0'
223
237
  description: Append-only histories + chained snapshots of your ActiveRecord tables
224
238
  email: brett.shollenberger@gmail.com
225
239
  executables: []
@@ -233,6 +247,7 @@ files:
233
247
  - lib/historiographer.rb
234
248
  - lib/historiographer/configuration.rb
235
249
  - lib/historiographer/history.rb
250
+ - lib/historiographer/history/instance_methods.rb
236
251
  - lib/historiographer/history_migration.rb
237
252
  - lib/historiographer/history_migration_mysql.rb
238
253
  - lib/historiographer/mysql_migration.rb