data_migration_for_rails 0.1.1

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 (94) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +17 -0
  3. data/README.md +196 -0
  4. data/Rakefile +8 -0
  5. data/app/assets/config/manifest.js +2 -0
  6. data/app/assets/stylesheets/application.css +15 -0
  7. data/app/channels/application_cable/channel.rb +6 -0
  8. data/app/channels/application_cable/connection.rb +6 -0
  9. data/app/controllers/concerns/data_migration/pundit_authorization.rb +12 -0
  10. data/app/controllers/data_migration/application_controller.rb +63 -0
  11. data/app/controllers/data_migration/exports_controller.rb +68 -0
  12. data/app/controllers/data_migration/imports_controller.rb +78 -0
  13. data/app/controllers/data_migration/migration_executions_controller.rb +75 -0
  14. data/app/controllers/data_migration/migration_plans_controller.rb +103 -0
  15. data/app/controllers/data_migration/migration_steps_controller.rb +164 -0
  16. data/app/controllers/data_migration/users_controller.rb +71 -0
  17. data/app/controllers/users/sessions_controller.rb +30 -0
  18. data/app/helpers/data_migration/application_helper.rb +24 -0
  19. data/app/jobs/application_job.rb +9 -0
  20. data/app/jobs/export_job.rb +27 -0
  21. data/app/jobs/import_job.rb +28 -0
  22. data/app/mailers/application_mailer.rb +6 -0
  23. data/app/models/application_record.rb +5 -0
  24. data/app/models/data_migration_user.rb +43 -0
  25. data/app/models/migration_execution.rb +93 -0
  26. data/app/models/migration_plan.rb +23 -0
  27. data/app/models/migration_record.rb +60 -0
  28. data/app/models/migration_step.rb +150 -0
  29. data/app/policies/application_policy.rb +53 -0
  30. data/app/policies/data_migration/user_policy.rb +27 -0
  31. data/app/policies/data_migration_user_policy.rb +37 -0
  32. data/app/policies/migration_execution_policy.rb +33 -0
  33. data/app/policies/migration_plan_policy.rb +41 -0
  34. data/app/policies/migration_step_policy.rb +29 -0
  35. data/app/services/data_migration/model_registry.rb +95 -0
  36. data/app/services/exports/generator_service.rb +444 -0
  37. data/app/services/imports/processor_service.rb +457 -0
  38. data/app/services/migration_plans/export_config_service.rb +41 -0
  39. data/app/services/migration_plans/import_config_service.rb +158 -0
  40. data/app/views/data_migration/devise/registrations/edit.html.erb +41 -0
  41. data/app/views/data_migration/devise/sessions/new.html.erb +35 -0
  42. data/app/views/data_migration/devise/shared/_error_messages.html.erb +13 -0
  43. data/app/views/data_migration/devise/shared/_links.html.erb +21 -0
  44. data/app/views/data_migration/exports/new.html.erb +85 -0
  45. data/app/views/data_migration/imports/new.html.erb +70 -0
  46. data/app/views/data_migration/migration_executions/index.html.erb +78 -0
  47. data/app/views/data_migration/migration_executions/show.html.erb +338 -0
  48. data/app/views/data_migration/migration_plans/_form.html.erb +28 -0
  49. data/app/views/data_migration/migration_plans/edit.html.erb +12 -0
  50. data/app/views/data_migration/migration_plans/index.html.erb +118 -0
  51. data/app/views/data_migration/migration_plans/new.html.erb +9 -0
  52. data/app/views/data_migration/migration_plans/show.html.erb +105 -0
  53. data/app/views/data_migration/migration_steps/_form.html.erb +473 -0
  54. data/app/views/data_migration/migration_steps/edit.html.erb +12 -0
  55. data/app/views/data_migration/migration_steps/new.html.erb +9 -0
  56. data/app/views/data_migration/users/_form.html.erb +49 -0
  57. data/app/views/data_migration/users/edit.html.erb +2 -0
  58. data/app/views/data_migration/users/index.html.erb +41 -0
  59. data/app/views/data_migration/users/new.html.erb +2 -0
  60. data/app/views/data_migration/users/show.html.erb +133 -0
  61. data/app/views/layouts/_navbar.html.erb +38 -0
  62. data/app/views/layouts/data_migration.html.erb +37 -0
  63. data/app/views/layouts/mailer.html.erb +13 -0
  64. data/app/views/layouts/mailer.text.erb +1 -0
  65. data/app/views/users/registrations/edit.html.erb +41 -0
  66. data/app/views/users/sessions/new.html.erb +35 -0
  67. data/app/views/users/shared/_error_messages.html.erb +13 -0
  68. data/app/views/users/shared/_links.html.erb +21 -0
  69. data/config/initializers/assets.rb +14 -0
  70. data/config/initializers/content_security_policy.rb +27 -0
  71. data/config/initializers/devise.rb +313 -0
  72. data/config/initializers/filter_parameter_logging.rb +10 -0
  73. data/config/initializers/inflections.rb +18 -0
  74. data/config/initializers/permissions_policy.rb +15 -0
  75. data/config/initializers/warden.rb +14 -0
  76. data/config/locales/devise.en.yml +65 -0
  77. data/config/locales/en.yml +31 -0
  78. data/config/routes.rb +62 -0
  79. data/db/migrate/20251102121659_create_migration_plans.rb +13 -0
  80. data/db/migrate/20251102122012_create_migration_steps.rb +24 -0
  81. data/db/migrate/20251105215702_create_migration_executions.rb +23 -0
  82. data/db/migrate/20251105215853_create_migration_records.rb +16 -0
  83. data/db/migrate/20251115154000_remove_unused_attributes.rb +17 -0
  84. data/db/migrate/20251116120000_add_filter_params_to_migration_executions.rb +7 -0
  85. data/db/migrate/20251118140000_create_data_migration_users.rb +27 -0
  86. data/db/migrate/20251118200641_add_user_foreign_keys.rb +15 -0
  87. data/db/migrate/20251124140000_add_attachment_export_mode_to_migration_steps.rb +9 -0
  88. data/db/schema.rb +102 -0
  89. data/db/seeds.rb +19 -0
  90. data/lib/data_migration/engine.rb +28 -0
  91. data/lib/data_migration/version.rb +5 -0
  92. data/lib/data_migration.rb +8 -0
  93. data/lib/tasks/data_migration_tasks.rake +40 -0
  94. metadata +279 -0
@@ -0,0 +1,164 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DataMigration
4
+ class MigrationStepsController < ApplicationController
5
+ include DataMigration::PunditAuthorization
6
+
7
+ before_action :set_migration_plan
8
+ before_action :set_migration_step, only: %i[show edit update destroy]
9
+
10
+ def index
11
+ @migration_steps = policy_scope(MigrationStep).where(migration_plan_id: params[:migration_plan_id]).order(:sequence)
12
+ end
13
+
14
+ def show
15
+ authorize @migration_step
16
+ end
17
+
18
+ def new
19
+ @migration_step = @migration_plan.migration_steps.new
20
+ authorize @migration_step
21
+ end
22
+
23
+ def create
24
+ @migration_step = @migration_plan.migration_steps.new(migration_step_params)
25
+ authorize @migration_step
26
+
27
+ # Validate JSON fields before saving
28
+ validation_errors = validate_json_fields(@migration_step)
29
+ if validation_errors.any?
30
+ validation_errors.each { |error| @migration_step.errors.add(:base, error) }
31
+ render :new, status: :unprocessable_entity
32
+ return
33
+ end
34
+
35
+ if @migration_step.save
36
+ redirect_to "/data_migration/migration_plans/#{@migration_plan.id}",
37
+ notice: 'Migration step was successfully created.'
38
+ else
39
+ render :new, status: :unprocessable_entity
40
+ end
41
+ end
42
+
43
+ def edit
44
+ authorize @migration_step
45
+ end
46
+
47
+ def update
48
+ authorize @migration_step
49
+
50
+ # Assign attributes first
51
+ @migration_step.assign_attributes(migration_step_params)
52
+
53
+ # Validate JSON fields before saving
54
+ validation_errors = validate_json_fields(@migration_step)
55
+ if validation_errors.any?
56
+ validation_errors.each { |error| @migration_step.errors.add(:base, error) }
57
+ render :edit, status: :unprocessable_entity
58
+ return
59
+ end
60
+
61
+ if @migration_step.save
62
+ redirect_to "/data_migration/migration_plans/#{@migration_plan.id}",
63
+ notice: 'Migration step was successfully updated.'
64
+ else
65
+ render :edit, status: :unprocessable_entity
66
+ end
67
+ end
68
+
69
+ def destroy
70
+ authorize @migration_step
71
+
72
+ @migration_step.destroy
73
+ redirect_to "/data_migration/migration_plans/#{@migration_plan.id}",
74
+ notice: 'Migration step was successfully destroyed.'
75
+ end
76
+
77
+ private
78
+
79
+ def set_migration_plan
80
+ @migration_plan = MigrationPlan.find(params[:migration_plan_id])
81
+ end
82
+
83
+ def set_migration_step
84
+ @migration_step = @migration_plan.migration_steps.find(params[:id])
85
+ end
86
+
87
+ def migration_step_params
88
+ params.require(:migration_step).permit(
89
+ :source_model_name, :sequence, :filter_query, :dependee_id, :migration_plan_id,
90
+ :dependee_attribute_mapping, :column_overrides, :association_overrides,
91
+ :attachment_export_mode, :attachment_fields
92
+ )
93
+ end
94
+
95
+ def validate_json_fields(step)
96
+ errors = []
97
+
98
+ # Validate column_overrides
99
+ if step.column_overrides.present?
100
+ begin
101
+ parsed = step.column_overrides.is_a?(String) ? JSON.parse(step.column_overrides) : step.column_overrides
102
+ errors << 'Column Overrides must be a JSON object/hash' unless parsed.is_a?(Hash)
103
+ rescue JSON::ParserError
104
+ errors << 'Column Overrides contains invalid JSON'
105
+ end
106
+ end
107
+
108
+ # Validate association_overrides
109
+ if step.association_overrides.present?
110
+ begin
111
+ parsed = step.association_overrides.is_a?(String) ? JSON.parse(step.association_overrides) : step.association_overrides
112
+ if parsed.is_a?(Hash)
113
+ # Validate structure: each value should be a hash with proper keys
114
+ parsed.each do |key, value|
115
+ unless value.is_a?(Hash)
116
+ errors << "Association Overrides: '#{key}' must be a hash"
117
+ next
118
+ end
119
+
120
+ # Check for polymorphic associations
121
+ if value['polymorphic'] == true
122
+ # Polymorphic requires type_column and lookup_attributes hash
123
+ if value['type_column'].blank?
124
+ errors << "Association Overrides: '#{key}' (polymorphic) must have a 'type_column' key"
125
+ end
126
+
127
+ if value['lookup_attributes'].blank?
128
+ errors << "Association Overrides: '#{key}' (polymorphic) must have 'lookup_attributes' key"
129
+ elsif !value['lookup_attributes'].is_a?(Hash)
130
+ errors << "Association Overrides: '#{key}' (polymorphic) 'lookup_attributes' must be a hash with model types as keys"
131
+ end
132
+ else
133
+ # Non-polymorphic requires model and lookup_attributes
134
+ errors << "Association Overrides: '#{key}' must have a 'model' key" if value['model'].blank?
135
+
136
+ if value['lookup_attributes'].blank?
137
+ errors << "Association Overrides: '#{key}' must have 'lookup_attributes' key"
138
+ elsif !value['lookup_attributes'].is_a?(Array)
139
+ errors << "Association Overrides: '#{key}' 'lookup_attributes' must be an array"
140
+ end
141
+ end
142
+ end
143
+ else
144
+ errors << 'Association Overrides must be a JSON object/hash'
145
+ end
146
+ rescue JSON::ParserError
147
+ errors << 'Association Overrides contains invalid JSON'
148
+ end
149
+ end
150
+
151
+ # Validate dependee_attribute_mapping
152
+ if step.dependee_attribute_mapping.present?
153
+ begin
154
+ parsed = step.dependee_attribute_mapping.is_a?(String) ? JSON.parse(step.dependee_attribute_mapping) : step.dependee_attribute_mapping
155
+ errors << 'Dependee Attribute Mapping must be a JSON object/hash' unless parsed.is_a?(Hash)
156
+ rescue JSON::ParserError
157
+ errors << 'Dependee Attribute Mapping contains invalid JSON'
158
+ end
159
+ end
160
+
161
+ errors
162
+ end
163
+ end
164
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DataMigration
4
+ class UsersController < ApplicationController
5
+ include DataMigration::PunditAuthorization
6
+
7
+ before_action :set_user, only: %i[edit update destroy]
8
+
9
+ def index
10
+ authorize DataMigrationUser
11
+ @users = policy_scope(DataMigrationUser).ordered_by_email
12
+ end
13
+
14
+ def new
15
+ authorize DataMigrationUser
16
+ @user = DataMigrationUser.new
17
+ end
18
+
19
+ def create
20
+ authorize DataMigrationUser
21
+ @user = DataMigrationUser.new(user_params)
22
+
23
+ if @user.save
24
+ redirect_to users_path, notice: 'User created successfully.'
25
+ else
26
+ render :new, status: :unprocessable_entity
27
+ end
28
+ end
29
+
30
+ def edit
31
+ authorize @user
32
+ end
33
+
34
+ def update
35
+ authorize @user
36
+
37
+ # Remove password if blank
38
+ params_to_update = if user_params[:password].blank?
39
+ user_params.except(:password, :password_confirmation)
40
+ else
41
+ user_params
42
+ end
43
+
44
+ if @user.update(params_to_update)
45
+ redirect_to users_path, notice: 'User updated successfully.'
46
+ else
47
+ render :edit, status: :unprocessable_entity
48
+ end
49
+ end
50
+
51
+ def destroy
52
+ authorize @user
53
+
54
+ if @user.destroy
55
+ redirect_to users_path, notice: 'User deleted successfully.'
56
+ else
57
+ redirect_to users_path, alert: @user.errors.full_messages.join(', ')
58
+ end
59
+ end
60
+
61
+ private
62
+
63
+ def set_user
64
+ @user = DataMigrationUser.find(params[:id])
65
+ end
66
+
67
+ def user_params
68
+ params.require(:user).permit(:name, :email, :password, :password_confirmation, :role)
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Users
4
+ class SessionsController < Devise::SessionsController
5
+ # Skip CSRF verification for the new action to handle Devise FailureApp redirects
6
+ skip_before_action :verify_authenticity_token, only: [:new]
7
+
8
+ # GET /resource/sign_in
9
+ # def new
10
+ # super
11
+ # end
12
+
13
+ # POST /resource/sign_in
14
+ # def create
15
+ # super
16
+ # end
17
+
18
+ # DELETE /resource/sign_out
19
+ # def destroy
20
+ # super
21
+ # end
22
+
23
+ # protected
24
+
25
+ # If you have extra params to permit, append them to the sanitizer.
26
+ # def configure_sign_in_params
27
+ # devise_parameter_sanitizer.permit(:sign_in, keys: [:attribute])
28
+ # end
29
+ end
30
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DataMigration
4
+ module ApplicationHelper
5
+ def execution_status_color(status)
6
+ case status.to_sym
7
+ when :pending
8
+ 'secondary'
9
+ when :running
10
+ 'primary'
11
+ when :completed
12
+ 'success'
13
+ when :failed
14
+ 'danger'
15
+ else
16
+ 'secondary'
17
+ end
18
+ end
19
+
20
+ def execution_type_icon(type)
21
+ type.to_sym == :export ? '📤' : '📥'
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ class ApplicationJob < ActiveJob::Base
4
+ # Automatically retry jobs that encountered a deadlock
5
+ # retry_on ActiveRecord::Deadlocked
6
+
7
+ # Most jobs are safe to ignore if the underlying records are no longer available
8
+ # discard_on ActiveJob::DeserializationError
9
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ class ExportJob < ApplicationJob
4
+ queue_as :default
5
+
6
+ def perform(execution_id)
7
+ execution = MigrationExecution.find(execution_id)
8
+ migration_plan = execution.migration_plan
9
+
10
+ Exports::GeneratorService.new(migration_plan, execution).call
11
+ rescue StandardError => e
12
+ Rails.logger.error("ExportJob failed for execution #{execution_id}: #{e.message}")
13
+ Rails.logger.error(e.backtrace.join("\n"))
14
+
15
+ # Only update if the service didn't already mark it as failed
16
+ if execution.reload.status != 'failed'
17
+ execution.update!(
18
+ status: :failed,
19
+ completed_at: Time.current,
20
+ error_log: e.full_message
21
+ )
22
+ end
23
+
24
+ # Don't re-raise - allow the job to complete so Sidekiq doesn't retry
25
+ # The error is already logged and saved to the execution record
26
+ end
27
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ class ImportJob < ApplicationJob
4
+ queue_as :default
5
+
6
+ def perform(execution_id)
7
+ execution = MigrationExecution.find(execution_id)
8
+ migration_plan = execution.migration_plan
9
+ uploaded_file_path = execution.file_path
10
+
11
+ Imports::ProcessorService.new(migration_plan, execution, uploaded_file_path).call
12
+ rescue StandardError => e
13
+ Rails.logger.error("ImportJob failed for execution #{execution_id}: #{e.message}")
14
+ Rails.logger.error(e.backtrace.join("\n"))
15
+
16
+ # Only update if the service didn't already mark it as failed
17
+ if execution.reload.status != 'failed'
18
+ execution.update!(
19
+ status: :failed,
20
+ completed_at: Time.current,
21
+ error_log: e.full_message
22
+ )
23
+ end
24
+
25
+ # Don't re-raise - allow the job to complete so Sidekiq doesn't retry
26
+ # The error is already logged and saved to the execution record
27
+ end
28
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ class ApplicationMailer < ActionMailer::Base
4
+ default from: 'from@example.com'
5
+ layout 'mailer'
6
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ class ApplicationRecord < ActiveRecord::Base
4
+ primary_abstract_class
5
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ class DataMigrationUser < ApplicationRecord
4
+ self.table_name = 'data_migration_users'
5
+
6
+ # Devise modules
7
+ devise :database_authenticatable, :registerable,
8
+ :recoverable, :rememberable, :validatable
9
+
10
+ # Associations
11
+ has_many :migration_plans, dependent: :restrict_with_error
12
+ has_many :migration_executions, dependent: :restrict_with_error
13
+
14
+ # Validations
15
+ validates :name, presence: true
16
+
17
+ # Role enum
18
+ enum role: { viewer: 0, operator: 1, admin: 2 }
19
+
20
+ # Callbacks
21
+ after_initialize :set_default_role, if: :new_record?
22
+
23
+ # Scopes
24
+ scope :ordered_by_email, -> { order(:email) }
25
+ scope :admins, -> { where(role: :admin) }
26
+ scope :operators, -> { where(role: :operator) }
27
+ scope :viewers, -> { where(role: :viewer) }
28
+
29
+ # Permission methods
30
+ def can_execute_migrations?
31
+ operator? || admin?
32
+ end
33
+
34
+ def can_manage_users?
35
+ admin?
36
+ end
37
+
38
+ private
39
+
40
+ def set_default_role
41
+ self.role ||= :viewer
42
+ end
43
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ class MigrationExecution < ApplicationRecord
4
+ # Associations
5
+ belongs_to :migration_plan
6
+ belongs_to :user, class_name: 'DataMigrationUser'
7
+ has_many :migration_records, dependent: :destroy
8
+
9
+ # Enums
10
+ enum execution_type: { export: 0, import: 1 }
11
+ enum status: { pending: 0, running: 1, completed: 2, failed: 3 }
12
+
13
+ # Validations
14
+ validates :execution_type, presence: true
15
+ validates :status, presence: true
16
+
17
+ # Callbacks to set defaults
18
+ after_initialize :set_defaults, if: :new_record?
19
+
20
+ # Scopes
21
+ scope :recent, -> { order(created_at: :desc) }
22
+ scope :by_user, ->(user) { where(user: user) }
23
+ scope :by_plan, ->(plan) { where(migration_plan: plan) }
24
+
25
+ # Instance methods
26
+ def duration
27
+ return nil unless started_at && completed_at
28
+
29
+ completed_at - started_at
30
+ end
31
+
32
+ def progress_percentage
33
+ return 0 if stats.blank? || stats['total'].to_i.zero?
34
+
35
+ ((stats['processed'].to_f / stats['total']) * 100).round(2)
36
+ end
37
+
38
+ def display_name
39
+ "#{execution_type.titleize} - #{migration_plan.name}"
40
+ end
41
+
42
+ # Custom getter for filter_params
43
+ def filter_params
44
+ value = read_attribute(:filter_params)
45
+ return {} if value.blank?
46
+ return value if value.is_a?(Hash)
47
+
48
+ JSON.parse(value)
49
+ rescue JSON::ParserError => e
50
+ Rails.logger.error "Failed to parse filter_params for MigrationExecution #{id}: #{e.message}"
51
+ {}
52
+ end
53
+
54
+ # Custom setter for filter_params
55
+ def filter_params=(value)
56
+ if value.is_a?(String)
57
+ write_attribute(:filter_params, value)
58
+ elsif value.is_a?(Hash)
59
+ write_attribute(:filter_params, value.to_json)
60
+ else
61
+ write_attribute(:filter_params, {}.to_json)
62
+ end
63
+ end
64
+
65
+ # Custom getter for stats
66
+ def stats
67
+ value = read_attribute(:stats)
68
+ return {} if value.blank?
69
+ return value if value.is_a?(Hash)
70
+
71
+ JSON.parse(value)
72
+ rescue JSON::ParserError => e
73
+ Rails.logger.error "Failed to parse stats for MigrationExecution #{id}: #{e.message}"
74
+ {}
75
+ end
76
+
77
+ # Custom setter for stats
78
+ def stats=(value)
79
+ if value.is_a?(String)
80
+ write_attribute(:stats, value)
81
+ elsif value.is_a?(Hash)
82
+ write_attribute(:stats, value.to_json)
83
+ else
84
+ write_attribute(:stats, {}.to_json)
85
+ end
86
+ end
87
+
88
+ private
89
+
90
+ def set_defaults
91
+ # Defaults are handled by getters
92
+ end
93
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ class MigrationPlan < ApplicationRecord
4
+ # Associations
5
+ belongs_to :user, class_name: 'DataMigrationUser'
6
+ has_many :migration_steps, dependent: :destroy
7
+ has_many :migration_executions, dependent: :restrict_with_error
8
+
9
+ # Validations
10
+ validates :name, presence: true, uniqueness: true
11
+
12
+ # Scopes
13
+ scope :ordered_by_name, -> { order(:name) }
14
+
15
+ # Instance methods
16
+ def can_be_deleted?
17
+ migration_executions.empty?
18
+ end
19
+
20
+ def last_execution
21
+ migration_executions.recent.first
22
+ end
23
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ class MigrationRecord < ApplicationRecord
4
+ # Associations
5
+ belongs_to :migration_execution
6
+
7
+ # Enums
8
+ enum action: { created: 0, updated: 1, skipped: 2, failed: 3 }
9
+
10
+ # Validations
11
+ validates :migrated_model_name, presence: true
12
+ validates :record_identifier, presence: true
13
+ validates :action, presence: true
14
+
15
+ # Callbacks to set defaults
16
+ after_initialize :set_defaults, if: :new_record?
17
+
18
+ # Scopes
19
+ scope :by_action, ->(action) { where(action: action) }
20
+ scope :by_model, ->(model_name) { where(migrated_model_name: model_name) }
21
+ scope :errors_only, -> { where(action: :failed) }
22
+
23
+ # Custom getter for record_changes
24
+ def record_changes
25
+ value = read_attribute(:record_changes)
26
+ return {} if value.blank?
27
+ return value if value.is_a?(Hash)
28
+
29
+ JSON.parse(value)
30
+ rescue JSON::ParserError => e
31
+ Rails.logger.error "Failed to parse record_changes for MigrationRecord #{id}: #{e.message}"
32
+ {}
33
+ end
34
+
35
+ # Custom setter for record_changes
36
+ def record_changes=(value)
37
+ if value.is_a?(String)
38
+ write_attribute(:record_changes, value)
39
+ elsif value.is_a?(Hash)
40
+ write_attribute(:record_changes, value.to_json)
41
+ else
42
+ write_attribute(:record_changes, {}.to_json)
43
+ end
44
+ end
45
+
46
+ # Instance methods
47
+ def display_name
48
+ "#{migrated_model_name} #{record_identifier}"
49
+ end
50
+
51
+ def success?
52
+ !failed?
53
+ end
54
+
55
+ private
56
+
57
+ def set_defaults
58
+ # Defaults are handled by getters
59
+ end
60
+ end