historiographer 4.4.1 → 4.4.3

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.
Files changed (49) hide show
  1. checksums.yaml +4 -4
  2. data/DEVELOPMENT.md +124 -0
  3. data/Gemfile +2 -0
  4. data/Gemfile.lock +14 -0
  5. data/README.md +16 -1
  6. data/Rakefile +54 -0
  7. data/VERSION +1 -1
  8. data/bin/console +10 -0
  9. data/bin/setup +15 -0
  10. data/bin/test +5 -0
  11. data/bin/test-all +10 -0
  12. data/bin/test-rails +5 -0
  13. data/historiographer.gemspec +38 -4
  14. data/lib/historiographer/history.rb +193 -60
  15. data/spec/combustion_helper.rb +34 -0
  16. data/spec/db/migrate/20250823000000_create_easy_ml_columns.rb +26 -0
  17. data/spec/db/migrate/20250824000000_create_test_articles.rb +26 -0
  18. data/spec/db/migrate/20250824000001_create_test_categories.rb +26 -0
  19. data/spec/db/migrate/20250825000000_create_bylines.rb +11 -0
  20. data/spec/db/migrate/20250826000000_create_test_users.rb +8 -0
  21. data/spec/db/migrate/20250826000001_create_test_user_histories.rb +18 -0
  22. data/spec/db/migrate/20250826000002_create_test_websites.rb +9 -0
  23. data/spec/db/migrate/20250826000003_create_test_website_histories.rb +19 -0
  24. data/spec/db/schema.rb +110 -40
  25. data/spec/historiographer_spec.rb +319 -1
  26. data/spec/integration/historiographer_safe_integration_spec.rb +154 -0
  27. data/spec/internal/app/models/application_record.rb +5 -0
  28. data/spec/internal/app/models/deploy.rb +5 -0
  29. data/spec/internal/app/models/user.rb +4 -0
  30. data/spec/internal/app/models/website.rb +5 -0
  31. data/spec/internal/app/models/website_history.rb +7 -0
  32. data/spec/internal/config/database.yml +9 -0
  33. data/spec/internal/config/routes.rb +2 -0
  34. data/spec/internal/db/schema.rb +48 -0
  35. data/spec/models/author.rb +1 -0
  36. data/spec/models/byline.rb +4 -0
  37. data/spec/models/post.rb +2 -0
  38. data/spec/models/test_article.rb +4 -0
  39. data/spec/models/test_article_history.rb +3 -0
  40. data/spec/models/test_category.rb +4 -0
  41. data/spec/models/test_category_history.rb +3 -0
  42. data/spec/models/test_user.rb +4 -0
  43. data/spec/models/test_user_history.rb +3 -0
  44. data/spec/models/test_website.rb +4 -0
  45. data/spec/models/test_website_history.rb +3 -0
  46. data/spec/rails_integration/historiographer_rails_integration_spec.rb +106 -0
  47. data/spec/spec_helper.rb +2 -3
  48. metadata +42 -4
  49. data/spec/foreign_key_spec.rb +0 -189
@@ -78,11 +78,24 @@ module Historiographer
78
78
  # "RetailerProductHistory."
79
79
  #
80
80
  foreign_class_name = base.name.gsub(/History$/) {} # e.g. "RetailerProductHistory" => "RetailerProduct"
81
- foreign_class = foreign_class_name.constantize
82
81
  association_name = foreign_class_name.split("::").last.underscore.to_sym # e.g. "RetailerProduct" => :retailer_product
83
82
 
84
- # Store the original class for method delegation
85
- class_variable_set(:@@original_class, foreign_class)
83
+ # Defer foreign class resolution to avoid load order issues
84
+ base.define_singleton_method :foreign_class do
85
+ return class_variable_get(:@@foreign_class) if class_variable_defined?(:@@foreign_class)
86
+ begin
87
+ foreign_class = foreign_class_name.constantize
88
+ class_variable_set(:@@foreign_class, foreign_class)
89
+ foreign_class
90
+ rescue NameError => e
91
+ # If the class isn't loaded yet, return nil and it will be retried later
92
+ nil
93
+ end
94
+ end
95
+
96
+ # Store the foreign class name for later use
97
+ class_variable_set(:@@foreign_class_name, foreign_class_name)
98
+ class_variable_set(:@@association_name, association_name)
86
99
 
87
100
  #
88
101
  # A History class will be linked to the user
@@ -94,12 +107,20 @@ module Historiographer
94
107
  #
95
108
  # To use histories, a user class must be defined.
96
109
  #
97
- unless foreign_class.ancestors.include?(Historiographer::Silent)
110
+ # Set up user association unless Silent module is included
111
+ # Defer this check until foreign_class is available
112
+ unless base.foreign_class && base.foreign_class.ancestors.include?(Historiographer::Silent)
98
113
  belongs_to :user, foreign_key: :history_user_id
99
114
  end
100
115
 
101
- # Add method_added hook to the original class
102
- foreign_class.singleton_class.class_eval do
116
+ # Add method_added hook to the original class when it's available
117
+ # This needs to be deferred until the foreign class is loaded
118
+ base.define_singleton_method :setup_method_delegation do
119
+ return unless foreign_class
120
+ return if class_variable_defined?(:@@method_delegation_setup) && class_variable_get(:@@method_delegation_setup)
121
+ class_variable_set(:@@method_delegation_setup, true)
122
+
123
+ foreign_class.singleton_class.class_eval do
103
124
  # Keep track of original method_added if it exists
104
125
  if method_defined?(:method_added)
105
126
  alias_method :original_method_added, :method_added
@@ -120,9 +141,9 @@ module Historiographer
120
141
  return unless method_obj.owner == self
121
142
 
122
143
  # Skip if we've already defined this method in the history class
123
- return if foreign_class.history_class.method_defined?(method_name)
144
+ return if self.history_class.method_defined?(method_name)
124
145
 
125
- foreign_class.history_class.class_eval do
146
+ self.history_class.class_eval do
126
147
  define_method(method_name) do |*args, **kwargs, &block|
127
148
  forward_method(method_name, *args, **kwargs, &block)
128
149
  end
@@ -133,16 +154,30 @@ module Historiographer
133
154
  end
134
155
  end
135
156
 
136
- (foreign_class.columns.map(&:name) - ["id"]).each do |method_name|
137
- define_method(method_name) do |*args, **kwargs, &block|
138
- forward_method(method_name, *args, **kwargs, &block)
157
+ begin
158
+ end
159
+ end
160
+
161
+ # Try to set up method delegation if foreign class is available
162
+ base.setup_method_delegation if base.foreign_class
163
+
164
+ # Also delegate existing methods from the foreign class
165
+ if base.foreign_class
166
+ begin
167
+ (base.foreign_class.columns.map(&:name) - ["id"]).each do |method_name|
168
+ define_method(method_name) do |*args, **kwargs, &block|
169
+ forward_method(method_name, *args, **kwargs, &block)
170
+ end
171
+ end
172
+ rescue ActiveRecord::StatementInvalid, ActiveRecord::ConnectionNotEstablished
173
+ # Table might not exist yet during setup
139
174
  end
140
175
  end
141
176
 
142
177
  # Add method_missing for any methods we might have missed
143
178
  def method_missing(method_name, *args, **kwargs, &block)
144
- original_class = self.class.class_variable_get(:@@original_class)
145
- if original_class.method_defined?(method_name)
179
+ original_class = self.class.foreign_class
180
+ if original_class && original_class.method_defined?(method_name)
146
181
  forward_method(method_name, *args, **kwargs, &block)
147
182
  else
148
183
  super
@@ -150,8 +185,8 @@ module Historiographer
150
185
  end
151
186
 
152
187
  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
188
+ original_class = self.class.foreign_class
189
+ (original_class && original_class.method_defined?(method_name)) || super
155
190
  end
156
191
 
157
192
  #
@@ -233,9 +268,59 @@ module Historiographer
233
268
  # Track custom association methods
234
269
  base.class_variable_set(:@@history_association_methods, [])
235
270
 
236
- # Dynamically define associations on the history class
237
- foreign_class.reflect_on_all_associations.each do |association|
238
- define_history_association(association)
271
+ # Register this history class to have its associations set up after initialization
272
+ history_classes = Thread.current[:historiographer_history_classes] ||= []
273
+ history_classes << base
274
+
275
+ # Always define the setup_history_associations method
276
+ base.define_singleton_method :setup_history_associations do |force = false|
277
+ return if !force && class_variable_defined?(:@@associations_set_up) && class_variable_get(:@@associations_set_up)
278
+ class_variable_set(:@@associations_set_up, true)
279
+
280
+ return unless foreign_class
281
+
282
+ # Also set up method delegation if not already done
283
+ setup_method_delegation if respond_to?(:setup_method_delegation)
284
+
285
+ foreign_class.reflect_on_all_associations.each do |association|
286
+ begin
287
+ define_history_association(association)
288
+ rescue => e
289
+ # Log but don't fail
290
+ puts "Warning: Could not define history association #{association.name}: #{e.message}" if ENV['DEBUG']
291
+ end
292
+ end
293
+ end
294
+
295
+ # Set up the after_initialize hook if we're in a Rails app
296
+ if defined?(Rails) && Rails.respond_to?(:application) && Rails.application && Rails.application.config.respond_to?(:after_initialize)
297
+ Rails.application.config.after_initialize do
298
+ history_classes.each do |history_class|
299
+ history_class.setup_method_delegation if history_class.respond_to?(:setup_method_delegation)
300
+ history_class.setup_history_associations
301
+ end
302
+ end
303
+ else
304
+ # For non-Rails environments, try to set up associations immediately
305
+
306
+ # Try to set up now if possible
307
+ begin
308
+ base.setup_history_associations
309
+ rescue => e
310
+ # Will retry later
311
+ end
312
+
313
+ # Override reflect_on_association to ensure associations are defined
314
+ base.define_singleton_method :reflect_on_association do |name|
315
+ setup_history_associations rescue nil
316
+ super(name)
317
+ end
318
+
319
+ # Override reflect_on_all_associations to ensure associations are defined
320
+ base.define_singleton_method :reflect_on_all_associations do |*args|
321
+ setup_history_associations rescue nil
322
+ super(*args)
323
+ end
239
324
  end
240
325
 
241
326
  def snapshot
@@ -254,93 +339,140 @@ module Historiographer
254
339
  end
255
340
 
256
341
  def original_class
257
- unless class_variable_defined?(:@@original_class)
258
- class_variable_set(:@@original_class, self.name.gsub(/History$/, '').constantize)
259
- end
260
-
261
- class_variable_get(:@@original_class)
342
+ # Use the foreign_class method we defined earlier
343
+ foreign_class
262
344
  end
263
345
 
264
346
  def define_history_association(association)
265
347
  if association.is_a?(Symbol) || association.is_a?(String)
266
348
  association = original_class.reflect_on_association(association)
349
+ # If the association doesn't exist on the original class, skip it
350
+ return unless association
267
351
  end
352
+
268
353
  assoc_name = association.name
269
- assoc_module = association.active_record.module_parent
270
- assoc_history_class_name = "#{association.class_name}History"
271
-
272
- begin
273
- assoc_module.const_get(assoc_history_class_name)
274
- assoc_history_class_name = "#{assoc_module}::#{assoc_history_class_name}" unless assoc_history_class_name.match?(Regexp.new("#{assoc_module}::"))
275
- rescue
276
- end
277
-
278
354
  assoc_foreign_key = association.foreign_key
279
355
 
280
356
  # Skip through associations to history classes to avoid infinite loops
281
357
  return if association.class_name.end_with?('History')
282
358
 
283
- # Always use the history class if it exists
284
- assoc_class = assoc_history_class_name.safe_constantize || OpenStruct.new(name: association.class_name)
285
- assoc_class_name = assoc_class.name
359
+ # Get the associated model's table name
360
+ original_assoc_class = association.class_name.safe_constantize
361
+ return unless original_assoc_class # Can't proceed without the class
362
+
363
+ assoc_table_name = original_assoc_class.table_name
364
+ history_table_name = "#{assoc_table_name.singularize}_histories"
365
+
366
+ # Check if a history table exists for this association
367
+ has_history_table = ActiveRecord::Base.connection.tables.include?(history_table_name)
368
+
369
+ if has_history_table
370
+ # This model has history tracking, use the history class
371
+ assoc_history_class_name = "#{association.class_name}History"
372
+ assoc_module = association.active_record.module_parent
373
+
374
+ begin
375
+ assoc_module.const_get(assoc_history_class_name)
376
+ assoc_history_class_name = "#{assoc_module}::#{assoc_history_class_name}" unless assoc_history_class_name.match?(Regexp.new("#{assoc_module}::"))
377
+ rescue
378
+ end
379
+
380
+ assoc_class = assoc_history_class_name.safe_constantize || OpenStruct.new(name: assoc_history_class_name)
381
+ assoc_class_name = assoc_class.name
382
+ else
383
+ # No history table, use the original model
384
+ assoc_class_name = association.class_name
385
+ end
286
386
 
287
387
  case association.macro
288
388
  when :belongs_to
289
- # For belongs_to associations, if the target is a history class, we need special handling
389
+ # Start with all original association options
390
+ options = association.options.dup
391
+
392
+ # Override the class name and foreign key
393
+ options[:class_name] = assoc_class_name
394
+ options[:foreign_key] = assoc_foreign_key
395
+
396
+ # For history associations, we need to handle snapshot filtering differently
397
+ # We'll create the association but override the accessor method
290
398
  if assoc_class_name.match?(/History/)
291
- # Override the association method to filter by snapshot_id
292
- # The history class uses <model>_id as the foreign key (e.g., author_id for AuthorHistory)
293
- history_fk = association.class_name.gsub(/History$/, '').underscore + '_id'
399
+ # Create the Rails association first
400
+ belongs_to assoc_name, **options
294
401
 
295
- # Track this custom method
296
- methods_list = class_variable_get(:@@history_association_methods) rescue []
297
- methods_list << assoc_name
298
- class_variable_set(:@@history_association_methods, methods_list)
402
+ # Then override the accessor to filter by snapshot_id
403
+ history_fk = association.class_name.gsub(/History$/, '').underscore + '_id'
299
404
 
300
- define_method(assoc_name) do
405
+ define_method("#{assoc_name}_with_snapshot") do
301
406
  return nil unless self[assoc_foreign_key]
302
407
  assoc_class.where(
303
408
  history_fk => self[assoc_foreign_key],
304
409
  snapshot_id: self.snapshot_id
305
410
  ).first
306
411
  end
412
+
413
+ # Alias the original method and replace it
414
+ alias_method "#{assoc_name}_without_snapshot", assoc_name
415
+ alias_method assoc_name, "#{assoc_name}_with_snapshot"
307
416
  else
308
- belongs_to assoc_name, class_name: assoc_class_name, foreign_key: assoc_foreign_key
417
+ belongs_to assoc_name, **options
309
418
  end
310
419
  when :has_one
420
+ # Start with all original association options
421
+ options = association.options.dup
422
+
423
+ # Override the class name and keys
424
+ options[:class_name] = assoc_class_name
425
+ options[:foreign_key] = assoc_foreign_key
426
+ options[:primary_key] = history_foreign_key
427
+
311
428
  if assoc_class_name.match?(/History/)
312
- hfk = history_foreign_key
429
+ # Create the Rails association first
430
+ has_one assoc_name, **options
313
431
 
314
- # Track this custom method
315
- methods_list = class_variable_get(:@@history_association_methods) rescue []
316
- methods_list << assoc_name
317
- class_variable_set(:@@history_association_methods, methods_list)
432
+ # Then override the accessor to filter by snapshot_id
433
+ hfk = history_foreign_key
318
434
 
319
- define_method(assoc_name) do
435
+ define_method("#{assoc_name}_with_snapshot") do
320
436
  assoc_class.where(
321
437
  assoc_foreign_key => self[hfk],
322
438
  snapshot_id: self.snapshot_id
323
439
  ).first
324
440
  end
441
+
442
+ # Alias the original method and replace it
443
+ alias_method "#{assoc_name}_without_snapshot", assoc_name
444
+ alias_method assoc_name, "#{assoc_name}_with_snapshot"
325
445
  else
326
- has_one assoc_name, class_name: assoc_class_name, foreign_key: assoc_foreign_key, primary_key: history_foreign_key
446
+ has_one assoc_name, **options
327
447
  end
328
448
  when :has_many
449
+ # Start with all original association options
450
+ options = association.options.dup
451
+
452
+ # Override the class name and keys
453
+ options[:class_name] = assoc_class_name
454
+ options[:foreign_key] = assoc_foreign_key
455
+ options[:primary_key] = history_foreign_key
456
+
329
457
  if assoc_class_name.match?(/History/)
458
+ # Create the Rails association first
459
+ has_many assoc_name, **options
460
+
461
+ # Then override the accessor to filter by snapshot_id
330
462
  hfk = history_foreign_key
331
- # Track this custom method
332
- methods_list = class_variable_get(:@@history_association_methods) rescue []
333
- methods_list << assoc_name
334
- class_variable_set(:@@history_association_methods, methods_list)
335
463
 
336
- define_method(assoc_name) do
464
+ define_method("#{assoc_name}_with_snapshot") do
337
465
  assoc_class.where(
338
466
  assoc_foreign_key => self[hfk],
339
467
  snapshot_id: self.snapshot_id
340
468
  )
341
469
  end
470
+
471
+ # Alias the original method and replace it
472
+ alias_method "#{assoc_name}_without_snapshot", assoc_name
473
+ alias_method assoc_name, "#{assoc_name}_with_snapshot"
342
474
  else
343
- has_many assoc_name, class_name: assoc_class_name, foreign_key: assoc_foreign_key, primary_key: history_foreign_key
475
+ has_many assoc_name, **options
344
476
  end
345
477
  end
346
478
  end
@@ -353,8 +485,9 @@ module Historiographer
353
485
  def history_foreign_key
354
486
  return @history_foreign_key if @history_foreign_key
355
487
 
356
- # CAN THIS BE TABLE OR MODEL?
357
- @history_foreign_key = original_class.base_class.name.singularize.foreign_key
488
+ # Use the table name to generate the foreign key to properly handle namespaced models
489
+ # E.g. EasyML::Column -> easy_ml_columns -> easy_ml_column_id
490
+ @history_foreign_key = original_class.base_class.table_name.singularize.foreign_key
358
491
  end
359
492
 
360
493
  end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ ENV['RAILS_ENV'] ||= 'test'
4
+
5
+ require 'bundler'
6
+ Bundler.require :default, :test
7
+
8
+ require 'historiographer'
9
+ require 'combustion'
10
+
11
+ Combustion.path = 'spec/internal'
12
+ Combustion.initialize! :active_record do
13
+ config.load_defaults Rails::VERSION::STRING.to_f
14
+ end
15
+
16
+ require 'rspec/rails'
17
+ require 'database_cleaner'
18
+
19
+ RSpec.configure do |config|
20
+ config.use_transactional_fixtures = false
21
+
22
+ config.before(:suite) do
23
+ DatabaseCleaner.strategy = :transaction
24
+ DatabaseCleaner.clean_with(:truncation)
25
+ end
26
+
27
+ config.before(:each) do
28
+ DatabaseCleaner.start
29
+ end
30
+
31
+ config.after(:each) do
32
+ DatabaseCleaner.clean
33
+ end
34
+ end
@@ -0,0 +1,26 @@
1
+ class CreateEasyMlColumns < ActiveRecord::Migration[7.0]
2
+ def change
3
+ create_table :easy_ml_columns do |t|
4
+ t.string :name
5
+ t.string :data_type
6
+ t.timestamps
7
+ end
8
+
9
+ create_table :easy_ml_column_histories do |t|
10
+ t.integer :easy_ml_column_id, null: false
11
+ t.string :name
12
+ t.string :data_type
13
+ t.timestamps
14
+
15
+ t.datetime :history_started_at, null: false
16
+ t.datetime :history_ended_at
17
+ t.integer :history_user_id
18
+ t.string :snapshot_id
19
+
20
+ t.index :easy_ml_column_id
21
+ t.index :history_started_at
22
+ t.index :history_ended_at
23
+ t.index :snapshot_id
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,26 @@
1
+ class CreateTestArticles < ActiveRecord::Migration[7.0]
2
+ def change
3
+ create_table :test_articles do |t|
4
+ t.string :title
5
+ t.integer :test_category_id
6
+ t.timestamps
7
+ end
8
+
9
+ create_table :test_article_histories do |t|
10
+ t.integer :test_article_id, null: false
11
+ t.string :title
12
+ t.integer :test_category_id
13
+ t.timestamps
14
+
15
+ t.datetime :history_started_at, null: false
16
+ t.datetime :history_ended_at
17
+ t.integer :history_user_id
18
+ t.string :snapshot_id
19
+
20
+ t.index :test_article_id
21
+ t.index :history_started_at
22
+ t.index :history_ended_at
23
+ t.index :snapshot_id
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,26 @@
1
+ class CreateTestCategories < ActiveRecord::Migration[7.0]
2
+ def change
3
+ create_table :test_categories do |t|
4
+ t.string :name
5
+ t.integer :test_articles_count, default: 0
6
+ t.timestamps
7
+ end
8
+
9
+ create_table :test_category_histories do |t|
10
+ t.integer :test_category_id, null: false
11
+ t.string :name
12
+ t.integer :test_articles_count, default: 0
13
+ t.timestamps
14
+
15
+ t.datetime :history_started_at, null: false
16
+ t.datetime :history_ended_at
17
+ t.integer :history_user_id
18
+ t.string :snapshot_id
19
+
20
+ t.index :test_category_id
21
+ t.index :history_started_at
22
+ t.index :history_ended_at
23
+ t.index :snapshot_id
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,11 @@
1
+ class CreateBylines < ActiveRecord::Migration[7.1]
2
+ def change
3
+ create_table :bylines do |t|
4
+ t.string :name, null: false
5
+ t.integer :author_id
6
+ t.timestamps
7
+ end
8
+
9
+ add_index :bylines, :author_id
10
+ end
11
+ end
@@ -0,0 +1,8 @@
1
+ class CreateTestUsers < ActiveRecord::Migration[7.0]
2
+ def change
3
+ create_table :test_users do |t|
4
+ t.string :name
5
+ t.timestamps
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,18 @@
1
+ class CreateTestUserHistories < ActiveRecord::Migration[7.0]
2
+ def change
3
+ create_table :test_user_histories do |t|
4
+ t.integer :test_user_id, null: false
5
+ t.string :name
6
+ t.timestamps
7
+ t.datetime :history_started_at, null: false
8
+ t.datetime :history_ended_at
9
+ t.integer :history_user_id
10
+ t.string :snapshot_id
11
+
12
+ t.index :test_user_id
13
+ t.index :history_started_at
14
+ t.index :history_ended_at
15
+ t.index :snapshot_id
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,9 @@
1
+ class CreateTestWebsites < ActiveRecord::Migration[7.0]
2
+ def change
3
+ create_table :test_websites do |t|
4
+ t.string :name
5
+ t.integer :user_id
6
+ t.timestamps
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,19 @@
1
+ class CreateTestWebsiteHistories < ActiveRecord::Migration[7.0]
2
+ def change
3
+ create_table :test_website_histories do |t|
4
+ t.integer :test_website_id, null: false
5
+ t.string :name
6
+ t.integer :user_id
7
+ t.timestamps
8
+ t.datetime :history_started_at, null: false
9
+ t.datetime :history_ended_at
10
+ t.integer :history_user_id
11
+ t.string :snapshot_id
12
+
13
+ t.index :test_website_id
14
+ t.index :history_started_at
15
+ t.index :history_ended_at
16
+ t.index :snapshot_id
17
+ end
18
+ end
19
+ end