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.
@@ -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,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SeparateHistory
4
+ VERSION = "0.1.0"
5
+ 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)
@@ -0,0 +1,4 @@
1
+ module SeparateHistory
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end