separate_history 0.1.0
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 +7 -0
- data/.rspec +3 -0
- data/.rubocop.yml +8 -0
- data/Appraisals +19 -0
- data/CHANGELOG.md +5 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/LICENSE.txt +21 -0
- data/README.md +350 -0
- data/Rakefile +18 -0
- data/gemfiles/rails_7.0.gemfile +8 -0
- data/gemfiles/rails_7.1.gemfile +7 -0
- data/gemfiles/rails_7.2.gemfile +7 -0
- data/gemfiles/rails_8.0.gemfile +8 -0
- data/lib/generators/separate_history/install/install_generator.rb +15 -0
- data/lib/generators/separate_history/install/templates/separate_history.rb +7 -0
- data/lib/generators/separate_history/migration_generator.rb +54 -0
- data/lib/generators/separate_history/model/model_generator.rb +39 -0
- data/lib/generators/separate_history/model/templates/migration.rb.erb +13 -0
- data/lib/generators/separate_history/model/templates/model.rb.erb +13 -0
- data/lib/generators/separate_history/scan/scan_generator.rb +32 -0
- data/lib/generators/separate_history/sync/sync_generator.rb +57 -0
- data/lib/generators/separate_history/sync/templates/migration.rb.erb +18 -0
- data/lib/generators/separate_history/templates/migration.rb.erb +37 -0
- data/lib/separate_history/core.rb +127 -0
- data/lib/separate_history/history.rb +15 -0
- data/lib/separate_history/model.rb +124 -0
- data/lib/separate_history/railtie.rb +13 -0
- data/lib/separate_history/version.rb +5 -0
- data/lib/separate_history.rb +20 -0
- data/sig/separate_history.rbs +4 -0
- metadata +203 -0
@@ -0,0 +1,39 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "rails/generators/active_record"
|
4
|
+
|
5
|
+
module SeparateHistory
|
6
|
+
module Generators
|
7
|
+
class ModelGenerator < ActiveRecord::Generators::Base
|
8
|
+
source_root File.expand_path("templates", __dir__)
|
9
|
+
argument :name, type: :string
|
10
|
+
|
11
|
+
def create_migration_file
|
12
|
+
# if exists history class, skip migration and tell run seperate_histaor:migration name
|
13
|
+
if Object.const_defined?(history_class_name)
|
14
|
+
say_status :skipped, "History class #{history_class_name} already exists. "\
|
15
|
+
"Run `rails g separate_history:migration #{name}` to create a migration for it.", :yellow
|
16
|
+
nil
|
17
|
+
else
|
18
|
+
migration_template "migration.rb.erb", "db/migrate/create_#{file_name}_history.rb"
|
19
|
+
say_status :created, "Migration for #{history_class_name} created successfully.", :green
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
def create_model_file
|
24
|
+
@original_class = name.classify.constantize
|
25
|
+
template "model.rb.erb", "app/models/#{file_name}_history.rb"
|
26
|
+
end
|
27
|
+
|
28
|
+
private
|
29
|
+
|
30
|
+
def file_name
|
31
|
+
name.underscore
|
32
|
+
end
|
33
|
+
|
34
|
+
def history_class_name
|
35
|
+
"#{name}History"
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
@@ -0,0 +1,13 @@
|
|
1
|
+
class Create<%= class_name %>History < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
|
2
|
+
def change
|
3
|
+
create_table :<%= table_name.pluralize %>_histories do |t|
|
4
|
+
t.bigint :original_id
|
5
|
+
# Add columns from the original table here
|
6
|
+
t.string :event
|
7
|
+
t.datetime :history_created_at
|
8
|
+
t.datetime :history_updated_at
|
9
|
+
end
|
10
|
+
|
11
|
+
add_index :<%= table_name.pluralize %>_histories, :original_id
|
12
|
+
end
|
13
|
+
end
|
@@ -0,0 +1,13 @@
|
|
1
|
+
class <%= class_name%>History < ApplicationRecord
|
2
|
+
include SeparateHistory::History
|
3
|
+
belongs_to :original, class_name: '<%= @original_class.name %>', foreign_key: 'original_id'
|
4
|
+
|
5
|
+
# Add any additional associations or validations here
|
6
|
+
|
7
|
+
# Example validation
|
8
|
+
validates :event, presence: true
|
9
|
+
validates :history_created_at, presence: true
|
10
|
+
validates :history_updated_at, presence: true
|
11
|
+
|
12
|
+
# Add any custom methods or scopes here
|
13
|
+
end
|
@@ -0,0 +1,32 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "rails/generators"
|
4
|
+
|
5
|
+
module SeparateHistory
|
6
|
+
module Generators
|
7
|
+
class ScanGenerator < Rails::Generators::Base
|
8
|
+
def scan_models
|
9
|
+
Rails.application.eager_load!
|
10
|
+
models = ActiveRecord::Base.descendants.select do |model|
|
11
|
+
model.respond_to?(:separate_history_options)
|
12
|
+
end
|
13
|
+
|
14
|
+
if models.any?
|
15
|
+
say "Models with has_separate_history:", :green
|
16
|
+
models.each do |m|
|
17
|
+
say "- #{m.name}", :cyan
|
18
|
+
options = m.separate_history_options
|
19
|
+
events = options[:events] || []
|
20
|
+
if events.any?
|
21
|
+
say " Supported events: #{events.join(", ")}", :magenta
|
22
|
+
else
|
23
|
+
say " Supported events: (none specified) [default is all]", :yellow
|
24
|
+
end
|
25
|
+
end
|
26
|
+
else
|
27
|
+
say "No models found with has_separate_history.", :yellow
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
@@ -0,0 +1,57 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "rails/generators/active_record"
|
4
|
+
|
5
|
+
module SeparateHistory
|
6
|
+
module Generators
|
7
|
+
class SyncGenerator < ActiveRecord::Generators::Base
|
8
|
+
source_root File.expand_path("templates", __dir__)
|
9
|
+
argument :name, type: :string
|
10
|
+
|
11
|
+
def create_migration_file
|
12
|
+
@model_class = name.classify.constantize
|
13
|
+
@history_table_name = "#{name.underscore}_histories"
|
14
|
+
|
15
|
+
check_for_mismatched_columns
|
16
|
+
|
17
|
+
@missing_columns = find_missing_columns
|
18
|
+
|
19
|
+
if @missing_columns.any?
|
20
|
+
migration_template "migration.rb.erb", "db/migrate/sync_#{file_name}_history.rb"
|
21
|
+
else
|
22
|
+
say_status :skipped, "No new columns to add for #{name}", :green
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
private
|
27
|
+
|
28
|
+
def check_for_mismatched_columns
|
29
|
+
original_cols = @model_class.columns.index_by(&:name)
|
30
|
+
history_cols = ActiveRecord::Base.connection.columns(@history_table_name).index_by(&:name)
|
31
|
+
|
32
|
+
mismatched = []
|
33
|
+
(original_cols.keys & history_cols.keys).each do |col|
|
34
|
+
if original_cols[col].type != history_cols[col].type
|
35
|
+
mismatched << "- '#{col}' is '#{original_cols[col].type}' in original table but '#{history_cols[col].type}' in history table."
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
return unless mismatched.any?
|
40
|
+
|
41
|
+
say_status :warning, "Mismatched column types detected for #{name}:", :yellow
|
42
|
+
mismatched.each { |m| say m, :yellow }
|
43
|
+
end
|
44
|
+
|
45
|
+
def find_missing_columns
|
46
|
+
original_columns = @model_class.column_names
|
47
|
+
history_columns = ActiveRecord::Base.connection.columns(@history_table_name).map(&:name)
|
48
|
+
|
49
|
+
(original_columns - history_columns) - ["id"]
|
50
|
+
end
|
51
|
+
|
52
|
+
def file_name
|
53
|
+
name.underscore
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
@@ -0,0 +1,18 @@
|
|
1
|
+
class Sync<%= class_name %>History < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
|
2
|
+
def change
|
3
|
+
<% @missing_columns.each do |column_name| %>
|
4
|
+
<% column = @model_class.columns.find { |c| c.name == column_name } %>
|
5
|
+
<%
|
6
|
+
# Build column options hash manually for Rails 6+ compatibility
|
7
|
+
options = {}
|
8
|
+
options[:limit] = column.limit if column.limit
|
9
|
+
options[:precision] = column.precision if column.precision
|
10
|
+
options[:scale] = column.scale if column.scale
|
11
|
+
options[:null] = column.null unless column.null
|
12
|
+
options[:default] = column.default if column.default
|
13
|
+
options_str = options.any? ? ", #{options.map{|k,v| "#{k}: #{v.inspect}"}.join(', ')}" : ""
|
14
|
+
%>
|
15
|
+
add_column :<%= @history_table_name %>, :<%= column.name %>, :<%= column.type %><%= options_str %>
|
16
|
+
<% end %>
|
17
|
+
end
|
18
|
+
end
|
@@ -0,0 +1,37 @@
|
|
1
|
+
class Create<%= history_class_name %> < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
|
2
|
+
def change
|
3
|
+
create_table :<%= history_table_name %> do |t|
|
4
|
+
<% original_columns.each do |column| -%>
|
5
|
+
<%-
|
6
|
+
# Handle both ActiveRecord column objects and hash format
|
7
|
+
if column.respond_to?(:type)
|
8
|
+
# ActiveRecord column object
|
9
|
+
column_type = column.type
|
10
|
+
column_name = column.name
|
11
|
+
options = {
|
12
|
+
limit: column.limit,
|
13
|
+
precision: column.precision,
|
14
|
+
scale: column.scale,
|
15
|
+
null: true, # Force null to be true for all columns
|
16
|
+
default: column.default
|
17
|
+
}.compact
|
18
|
+
options[:null] = true
|
19
|
+
options_string = options.map { |k, v| "#{k}: #{v.inspect}" }.join(', ')
|
20
|
+
else
|
21
|
+
# Hash format
|
22
|
+
column_type = column[:type]
|
23
|
+
column_name = column[:name]
|
24
|
+
options_string = "null: true"
|
25
|
+
end
|
26
|
+
-%>
|
27
|
+
t.<%= column_type %> :<%= column_name %><%= ", #{options_string}" if options_string.present? %>
|
28
|
+
<% end -%>
|
29
|
+
t.integer :original_id, null: false
|
30
|
+
t.string :event, null: false
|
31
|
+
t.datetime :history_created_at, null: false
|
32
|
+
t.datetime :history_updated_at, null: false
|
33
|
+
|
34
|
+
t.index :original_id
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
@@ -0,0 +1,127 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "active_support/concern"
|
4
|
+
require "separate_history/model"
|
5
|
+
require "separate_history/history"
|
6
|
+
module SeparateHistory
|
7
|
+
module Core
|
8
|
+
extend ActiveSupport::Concern
|
9
|
+
|
10
|
+
def has_separate_history(options = {})
|
11
|
+
raise ArgumentError, "has_separate_history can not be called on an abstract class" if abstract_class?
|
12
|
+
raise ArgumentError, "Options :only and :except can not be used together" if options[:only] && options[:except]
|
13
|
+
|
14
|
+
valid_options = %i[only except history_class_name events track_changes]
|
15
|
+
invalid_options = options.keys - valid_options
|
16
|
+
raise ArgumentError, "Invalid options: #{invalid_options.join(", ")}" if invalid_options.any?
|
17
|
+
|
18
|
+
options[:track_changes] = false if options[:track_changes].nil?
|
19
|
+
unless options[:track_changes].is_a?(TrueClass) || options[:track_changes].is_a?(FalseClass)
|
20
|
+
raise ArgumentError, "track_changes must be true or false"
|
21
|
+
end
|
22
|
+
|
23
|
+
supported_events = %i[create update destroy]
|
24
|
+
if options[:events]
|
25
|
+
events = Array(options[:events])
|
26
|
+
invalid_events = events - supported_events
|
27
|
+
raise ArgumentError, "Invalid events: #{invalid_events.join(", ")}" if invalid_events.any?
|
28
|
+
end
|
29
|
+
|
30
|
+
cattr_accessor :separate_history_options
|
31
|
+
self.separate_history_options = options
|
32
|
+
|
33
|
+
class << self
|
34
|
+
# Returns the history class (e.g., UserHistory for User)
|
35
|
+
def history_class
|
36
|
+
history_class_name = separate_history_options.fetch(:history_class_name, "#{name}History")
|
37
|
+
history_class_name.safe_constantize
|
38
|
+
end
|
39
|
+
|
40
|
+
# Returns the snapshot of the record as it was at or before the given timestamp
|
41
|
+
def history_for(id, timestamp = Time.current)
|
42
|
+
history_class.where(original_id: id)
|
43
|
+
.where(history_updated_at: ..timestamp)
|
44
|
+
.order(history_updated_at: :desc, id: :desc)
|
45
|
+
.first
|
46
|
+
end
|
47
|
+
|
48
|
+
# Alias for readability: get the state of a record as of a point in time
|
49
|
+
def history_as_of(id, timestamp)
|
50
|
+
history_for(id, timestamp)
|
51
|
+
end
|
52
|
+
|
53
|
+
# Returns true if any history exists for the given record ID
|
54
|
+
def history_exists_for?(id)
|
55
|
+
history_class.where(original_id: id).exists?
|
56
|
+
end
|
57
|
+
|
58
|
+
# Returns all history records for the given record ID, ordered by update time
|
59
|
+
def all_history_for(id)
|
60
|
+
history_class.where(original_id: id)
|
61
|
+
.order(history_updated_at: :asc, id: :asc)
|
62
|
+
end
|
63
|
+
|
64
|
+
# Returns the most recent history record for the given record ID
|
65
|
+
def latest_history_for(id)
|
66
|
+
history_class.where(original_id: id)
|
67
|
+
.order(history_updated_at: :desc, id: :desc)
|
68
|
+
.first
|
69
|
+
end
|
70
|
+
|
71
|
+
# Deletes all history for the given record ID.
|
72
|
+
# Requires force: true to prevent accidental destruction.
|
73
|
+
def clear_history_for(id, force:)
|
74
|
+
raise ArgumentError, "Force must be true to clear history." unless force
|
75
|
+
|
76
|
+
history_class.where(original_id: id).delete_all
|
77
|
+
end
|
78
|
+
|
79
|
+
# clear all history for all records of this model
|
80
|
+
def clear_all_history(force:)
|
81
|
+
raise ArgumentError, "Force must be true to clear all history." unless force
|
82
|
+
|
83
|
+
history_class.delete_all
|
84
|
+
end
|
85
|
+
end
|
86
|
+
|
87
|
+
history_class_name = options.fetch(:history_class_name, "#{name}History")
|
88
|
+
history_table_name = history_class_name.tableize
|
89
|
+
association_name = name.demodulize.underscore.to_sym
|
90
|
+
|
91
|
+
# Main association
|
92
|
+
# Main association for accessing all history records
|
93
|
+
has_many history_table_name.to_sym,
|
94
|
+
class_name: history_class_name,
|
95
|
+
foreign_key: :original_id,
|
96
|
+
inverse_of: association_name
|
97
|
+
|
98
|
+
# Alias association for convenience (points to the same records)
|
99
|
+
alias_method :separate_histories, history_table_name.to_sym
|
100
|
+
|
101
|
+
history_class = history_class_name.safe_constantize
|
102
|
+
if history_class
|
103
|
+
# Set up the belongs_to association on the history class
|
104
|
+
unless history_class.reflect_on_association(association_name)
|
105
|
+
history_class.belongs_to association_name,
|
106
|
+
class_name: name,
|
107
|
+
foreign_key: :original_id,
|
108
|
+
inverse_of: history_table_name.to_sym,
|
109
|
+
optional: true
|
110
|
+
end
|
111
|
+
|
112
|
+
history_class.include SeparateHistory::History
|
113
|
+
end
|
114
|
+
|
115
|
+
# Model-level events support
|
116
|
+
events = Array(options[:events] || supported_events)
|
117
|
+
events.each do |event|
|
118
|
+
next unless supported_events.include?(event)
|
119
|
+
|
120
|
+
after_commit :"record_history_#{event}", on: event
|
121
|
+
end
|
122
|
+
|
123
|
+
include SeparateHistory::Model
|
124
|
+
SeparateHistory.track_model(self)
|
125
|
+
end
|
126
|
+
end
|
127
|
+
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "active_support/concern"
|
4
|
+
|
5
|
+
module SeparateHistory
|
6
|
+
module History
|
7
|
+
extend ActiveSupport::Concern
|
8
|
+
|
9
|
+
def manipulated?
|
10
|
+
return false unless respond_to?(:history_created_at) && respond_to?(:history_updated_at)
|
11
|
+
|
12
|
+
history_updated_at.to_i != history_created_at.to_i
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
@@ -0,0 +1,124 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "active_support/concern"
|
4
|
+
|
5
|
+
module SeparateHistory
|
6
|
+
module Model
|
7
|
+
extend ActiveSupport::Concern
|
8
|
+
|
9
|
+
# Manually create a history snapshot for this record
|
10
|
+
def snapshot_history
|
11
|
+
_create_history_record("snapshot")
|
12
|
+
end
|
13
|
+
|
14
|
+
# Check if any history exists for this instance
|
15
|
+
def history?
|
16
|
+
self.class.history_class.where(original_id: id).exists?
|
17
|
+
end
|
18
|
+
|
19
|
+
# Get the snapshot of this record at or before the given timestamp
|
20
|
+
def history_as_of(timestamp)
|
21
|
+
self.class.history_for(id, timestamp)
|
22
|
+
end
|
23
|
+
|
24
|
+
# Get all historical versions of this record
|
25
|
+
def all_history
|
26
|
+
self.class.all_history_for(id)
|
27
|
+
end
|
28
|
+
|
29
|
+
# Get the latest snapshot of this record
|
30
|
+
def latest_history
|
31
|
+
self.class.latest_history_for(id)
|
32
|
+
end
|
33
|
+
|
34
|
+
# Delete this record’s history (requires force: true)
|
35
|
+
def clear_history(force:)
|
36
|
+
raise ArgumentError, "Force must be true to clear history." unless force
|
37
|
+
|
38
|
+
self.class.history_class.where(original_id: id).delete_all
|
39
|
+
end
|
40
|
+
|
41
|
+
private
|
42
|
+
|
43
|
+
def record_history_create
|
44
|
+
_create_history_record("create")
|
45
|
+
end
|
46
|
+
|
47
|
+
def record_history_update
|
48
|
+
_create_history_record("update") if _tracked_attributes_changed?
|
49
|
+
end
|
50
|
+
|
51
|
+
def record_history_destroy
|
52
|
+
_create_history_record("destroy")
|
53
|
+
end
|
54
|
+
|
55
|
+
def _create_history_record(event)
|
56
|
+
attrs = attributes_for_history(event)
|
57
|
+
history_class.create!(attrs)
|
58
|
+
rescue ActiveRecord::StatementInvalid => e
|
59
|
+
raise unless e.message.match?(/Table '.*' doesn't exist/)
|
60
|
+
|
61
|
+
raise "History table `#{history_class.table_name}` is missing. " \
|
62
|
+
"Run `rails g separate_history:model #{self.class.name}` to create it."
|
63
|
+
end
|
64
|
+
|
65
|
+
def attributes_for_history(event)
|
66
|
+
options = self.class.separate_history_options
|
67
|
+
|
68
|
+
attrs = if options[:track_changes] && event == "update"
|
69
|
+
saved_changes.transform_values(&:last).with_indifferent_access
|
70
|
+
else
|
71
|
+
attributes.dup
|
72
|
+
end
|
73
|
+
|
74
|
+
# For track_changes, we need to ensure original_id is set properly
|
75
|
+
# since saved_changes doesn't include the id attribute
|
76
|
+
attrs["original_id"] = if options[:track_changes] && event == "update"
|
77
|
+
id
|
78
|
+
else
|
79
|
+
attrs.delete("id")
|
80
|
+
end
|
81
|
+
attrs["event"] = event.to_s
|
82
|
+
|
83
|
+
# attrs["history_created_at"] = attrs.delete("created_at") if attrs.key?("created_at")
|
84
|
+
# attrs["history_updated_at"] = attrs.delete("updated_at") if attrs.key?("updated_at")
|
85
|
+
|
86
|
+
attrs["history_created_at"] = Time.now
|
87
|
+
attrs["history_updated_at"] = Time.now
|
88
|
+
|
89
|
+
if options[:only]
|
90
|
+
allowed_keys = options[:only].map(&:to_s) + %w[original_id event history_created_at history_updated_at]
|
91
|
+
attrs.slice!(*allowed_keys)
|
92
|
+
elsif options[:except]
|
93
|
+
options[:except].each { |key| attrs.delete(key.to_s) }
|
94
|
+
end
|
95
|
+
|
96
|
+
# remove columns that are not present in the history table
|
97
|
+
history_columns = history_class.column_names
|
98
|
+
attrs.select! { |key, _| history_columns.include?(key) }
|
99
|
+
attrs
|
100
|
+
end
|
101
|
+
|
102
|
+
def _tracked_attributes_changed?
|
103
|
+
# If track_changes is true, we always want to record the update
|
104
|
+
# because the point is to track only the changed attributes
|
105
|
+
return true if self.class.separate_history_options[:track_changes]
|
106
|
+
|
107
|
+
if self.class.separate_history_options[:only].nil? && self.class.separate_history_options[:except].nil?
|
108
|
+
return true
|
109
|
+
end
|
110
|
+
|
111
|
+
tracked_columns = if self.class.separate_history_options[:only]
|
112
|
+
self.class.separate_history_options[:only].map(&:to_s)
|
113
|
+
else
|
114
|
+
self.class.column_names - self.class.separate_history_options[:except].map(&:to_s)
|
115
|
+
end
|
116
|
+
(saved_changes.keys & tracked_columns).any?
|
117
|
+
end
|
118
|
+
|
119
|
+
def history_class
|
120
|
+
history_class_name = self.class.separate_history_options.fetch(:history_class_name, "#{self.class.name}History")
|
121
|
+
@history_class ||= history_class_name.constantize
|
122
|
+
end
|
123
|
+
end
|
124
|
+
end
|
@@ -0,0 +1,13 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "rails/railtie"
|
4
|
+
|
5
|
+
module SeparateHistory
|
6
|
+
class Railtie < ::Rails::Railtie
|
7
|
+
initializer "separate_history.initialize" do
|
8
|
+
ActiveSupport.on_load(:active_record) do
|
9
|
+
extend SeparateHistory::Core
|
10
|
+
end
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|
@@ -0,0 +1,20 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "separate_history/version"
|
4
|
+
require "separate_history/core"
|
5
|
+
|
6
|
+
module SeparateHistory
|
7
|
+
class Error < StandardError; end
|
8
|
+
|
9
|
+
@tracked_models = []
|
10
|
+
|
11
|
+
def self.track_model(model)
|
12
|
+
@tracked_models << model unless @tracked_models.include?(model)
|
13
|
+
end
|
14
|
+
|
15
|
+
def self.tracked_models
|
16
|
+
@tracked_models.sort_by(&:name)
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
require "separate_history/railtie" if defined?(Rails::Railtie)
|