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,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ class MigrationStep < ApplicationRecord
4
+ # Associations
5
+ belongs_to :migration_plan
6
+ belongs_to :dependee, class_name: 'MigrationStep', optional: true
7
+ has_many :dependents, class_name: 'MigrationStep', foreign_key: :dependee_id
8
+
9
+ # Enums
10
+ enum attachment_export_mode: {
11
+ ignore: 0,
12
+ url: 1,
13
+ raw_data: 2
14
+ }
15
+
16
+ # Validations
17
+ validates :source_model_name, presence: true
18
+ validates :sequence, presence: true, numericality: { only_integer: true }
19
+ validate :validate_json_field_types
20
+
21
+ # Callbacks to set defaults
22
+ after_initialize :set_defaults, if: :new_record?
23
+ before_validation :parse_json_fields
24
+
25
+ # Scopes
26
+ scope :ordered_by_sequence, -> { order(:sequence) }
27
+
28
+ # Custom getter for dependee_attribute_mapping
29
+ def dependee_attribute_mapping
30
+ value = read_attribute(:dependee_attribute_mapping)
31
+ return {} if value.blank?
32
+ return value if value.is_a?(Hash)
33
+
34
+ JSON.parse(value)
35
+ rescue JSON::ParserError => e
36
+ Rails.logger.error "Failed to parse dependee_attribute_mapping for MigrationStep #{id}: #{e.message}"
37
+ {}
38
+ end
39
+
40
+ # Custom setter for dependee_attribute_mapping
41
+ def dependee_attribute_mapping=(value)
42
+ if value.is_a?(String)
43
+ write_attribute(:dependee_attribute_mapping, value)
44
+ elsif value.is_a?(Hash)
45
+ write_attribute(:dependee_attribute_mapping, value.to_json)
46
+ else
47
+ write_attribute(:dependee_attribute_mapping, {}.to_json)
48
+ end
49
+ end
50
+
51
+ # Custom getter for column_overrides
52
+ def column_overrides
53
+ value = read_attribute(:column_overrides)
54
+ return {} if value.blank?
55
+ return value if value.is_a?(Hash)
56
+
57
+ JSON.parse(value)
58
+ rescue JSON::ParserError => e
59
+ Rails.logger.error "Failed to parse column_overrides for MigrationStep #{id}: #{e.message}"
60
+ {}
61
+ end
62
+
63
+ # Custom setter for column_overrides
64
+ def column_overrides=(value)
65
+ if value.is_a?(String)
66
+ write_attribute(:column_overrides, value)
67
+ elsif value.is_a?(Hash)
68
+ write_attribute(:column_overrides, value.to_json)
69
+ else
70
+ write_attribute(:column_overrides, {}.to_json)
71
+ end
72
+ end
73
+
74
+ # Custom getter for association_overrides
75
+ def association_overrides
76
+ value = read_attribute(:association_overrides)
77
+ return {} if value.blank?
78
+ return value if value.is_a?(Hash)
79
+
80
+ JSON.parse(value)
81
+ rescue JSON::ParserError => e
82
+ Rails.logger.error "Failed to parse association_overrides for MigrationStep #{id}: #{e.message}"
83
+ {}
84
+ end
85
+
86
+ # Custom setter for association_overrides
87
+ def association_overrides=(value)
88
+ if value.is_a?(String)
89
+ write_attribute(:association_overrides, value)
90
+ elsif value.is_a?(Hash)
91
+ write_attribute(:association_overrides, value.to_json)
92
+ else
93
+ write_attribute(:association_overrides, {}.to_json)
94
+ end
95
+ end
96
+
97
+ private
98
+
99
+ def set_defaults
100
+ self.dependee_attribute_mapping ||= {}
101
+ self.column_overrides ||= {}
102
+ self.association_overrides ||= {}
103
+ end
104
+
105
+ def parse_json_fields
106
+ # Parse JSON string fields into proper objects
107
+ json_fields = %i[
108
+ dependee_attribute_mapping
109
+ column_overrides
110
+ association_overrides
111
+ ]
112
+
113
+ json_fields.each do |field|
114
+ value = send(field)
115
+ next if value.blank?
116
+
117
+ # If it's a string, try to parse it as JSON
118
+ next unless value.is_a?(String)
119
+
120
+ begin
121
+ parsed_value = JSON.parse(value)
122
+ send("#{field}=", parsed_value)
123
+ rescue JSON::ParserError => e
124
+ # Add validation error for invalid JSON
125
+ errors.add(field, "contains invalid JSON: #{e.message}")
126
+ end
127
+ end
128
+ end
129
+
130
+ def validate_json_field_types
131
+ # Ensure JSON fields are objects (Hash), not arrays
132
+ json_fields = {
133
+ dependee_attribute_mapping: 'Dependee Attribute Mapping',
134
+ column_overrides: 'Column Overrides',
135
+ association_overrides: 'Association ID Mappings'
136
+ }
137
+
138
+ json_fields.each do |field, label|
139
+ value = send(field)
140
+ next if value.blank? || value == {}
141
+
142
+ # Check if it's a Hash (JSON object)
143
+ if value.is_a?(Array)
144
+ errors.add(field, "#{label} must be a JSON object {}, not an array []")
145
+ elsif !value.is_a?(Hash)
146
+ errors.add(field, "#{label} must be a valid JSON object")
147
+ end
148
+ end
149
+ end
150
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ class ApplicationPolicy
4
+ attr_reader :user, :record
5
+
6
+ def initialize(user, record)
7
+ @user = user
8
+ @record = record
9
+ end
10
+
11
+ def index?
12
+ false
13
+ end
14
+
15
+ def show?
16
+ false
17
+ end
18
+
19
+ def create?
20
+ false
21
+ end
22
+
23
+ def new?
24
+ create?
25
+ end
26
+
27
+ def update?
28
+ false
29
+ end
30
+
31
+ def edit?
32
+ update?
33
+ end
34
+
35
+ def destroy?
36
+ false
37
+ end
38
+
39
+ class Scope
40
+ def initialize(user, scope)
41
+ @user = user
42
+ @scope = scope
43
+ end
44
+
45
+ def resolve
46
+ raise NoMethodError, "You must define #resolve in #{self.class}"
47
+ end
48
+
49
+ private
50
+
51
+ attr_reader :user, :scope
52
+ end
53
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DataMigration
4
+ class UserPolicy < ApplicationPolicy
5
+ class Scope < Scope
6
+ def resolve
7
+ scope.all
8
+ end
9
+ end
10
+
11
+ def index?
12
+ user.admin?
13
+ end
14
+
15
+ def create?
16
+ user.admin?
17
+ end
18
+
19
+ def update?
20
+ user.admin?
21
+ end
22
+
23
+ def destroy?
24
+ user.admin? && record != user # Can't delete yourself
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ class DataMigrationUserPolicy < ApplicationPolicy
4
+ def index?
5
+ user.can_manage_users?
6
+ end
7
+
8
+ def show?
9
+ user.can_manage_users? || record.id == user.id # Users can view their own profile
10
+ end
11
+
12
+ def create?
13
+ user.can_manage_users?
14
+ end
15
+
16
+ def update?
17
+ user.can_manage_users? || record.id == user.id # Users can update their own profile
18
+ end
19
+
20
+ def destroy?
21
+ user.can_manage_users? && record.id != user.id # Can't delete yourself
22
+ end
23
+
24
+ def change_role?
25
+ user.can_manage_users? && record.id != user.id # Can't change your own role
26
+ end
27
+
28
+ class Scope < Scope
29
+ def resolve
30
+ if user.can_manage_users?
31
+ scope.all # Admins see all users
32
+ else
33
+ scope.where(id: user.id) # Others only see themselves
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ class MigrationExecutionPolicy < ApplicationPolicy
4
+ def index?
5
+ true # All authenticated users can view execution history
6
+ end
7
+
8
+ def show?
9
+ true # All authenticated users can view execution details
10
+ end
11
+
12
+ def create?
13
+ user.can_execute_migrations?
14
+ end
15
+
16
+ def download?
17
+ true # All authenticated users can download exports
18
+ end
19
+
20
+ def cancel?
21
+ user.can_execute_migrations? && record.running?
22
+ end
23
+
24
+ class Scope < Scope
25
+ def resolve
26
+ if user.viewer?
27
+ scope.where(status: %i[completed failed]) # Viewers only see finished executions
28
+ else
29
+ scope.all # Operators and admins see everything including running
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ class MigrationPlanPolicy < ApplicationPolicy
4
+ def index?
5
+ true # All authenticated users can view plans
6
+ end
7
+
8
+ def show?
9
+ true # All authenticated users can view a plan
10
+ end
11
+
12
+ def create?
13
+ user.admin?
14
+ end
15
+
16
+ def update?
17
+ user.admin?
18
+ end
19
+
20
+ def destroy?
21
+ user.admin? && record.can_be_deleted?
22
+ end
23
+
24
+ def execute?
25
+ user.can_execute_migrations?
26
+ end
27
+
28
+ def export_config?
29
+ true # All authenticated users can export plan configuration
30
+ end
31
+
32
+ def import_config?
33
+ user.admin? # Only admins can import plan configurations
34
+ end
35
+
36
+ class Scope < Scope
37
+ def resolve
38
+ scope.all # All users can see all plans
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ class MigrationStepPolicy < ApplicationPolicy
4
+ def index?
5
+ true # All authenticated users can view steps
6
+ end
7
+
8
+ def show?
9
+ true
10
+ end
11
+
12
+ def create?
13
+ user.admin?
14
+ end
15
+
16
+ def update?
17
+ user.admin?
18
+ end
19
+
20
+ def destroy?
21
+ user.admin?
22
+ end
23
+
24
+ class Scope < Scope
25
+ def resolve
26
+ scope.all
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DataMigration
4
+ class ModelRegistry
5
+ CACHE_KEY = 'data_migration_model_registry'
6
+ CACHE_EXPIRY = 1.hour
7
+
8
+ class << self
9
+ def all_models
10
+ Rails.cache.fetch(CACHE_KEY, expires_in: CACHE_EXPIRY) do
11
+ load_models
12
+ end
13
+ end
14
+
15
+ def model_metadata(model_name)
16
+ all_models[model_name]
17
+ end
18
+
19
+ def model_names
20
+ all_models.keys.sort
21
+ end
22
+
23
+ def refresh!
24
+ Rails.cache.delete(CACHE_KEY)
25
+ all_models
26
+ end
27
+
28
+ private
29
+
30
+ def load_models
31
+ models_data = {}
32
+
33
+ # Ensure all models are loaded
34
+ Rails.application.eager_load! unless Rails.application.config.eager_load
35
+
36
+ ActiveRecord::Base.descendants.each do |model|
37
+ # Skip engine models, abstract classes, and internal Rails models
38
+ next if model.name.nil?
39
+ next if model.name.start_with?('DataMigration', 'ActiveStorage', 'ApplicationRecord')
40
+ next if model.abstract_class?
41
+
42
+ begin
43
+ models_data[model.name] = extract_model_metadata(model)
44
+ rescue StandardError => e
45
+ Rails.logger.warn "Failed to extract metadata for #{model.name}: #{e.message}"
46
+ end
47
+ end
48
+
49
+ models_data
50
+ end
51
+
52
+ def extract_model_metadata(model)
53
+ metadata = {
54
+ name: model.name,
55
+ table_name: model.table_name,
56
+ columns: [],
57
+ attachments: [],
58
+ associations: {}
59
+ }
60
+
61
+ # Extract column information
62
+ model.columns.each do |column|
63
+ metadata[:columns] << {
64
+ name: column.name,
65
+ type: column.type.to_s,
66
+ sql_type: column.sql_type
67
+ }
68
+ end
69
+
70
+ # Extract Active Storage attachments
71
+ if model.respond_to?(:reflect_on_all_attachments)
72
+ model.reflect_on_all_attachments.each do |attachment|
73
+ metadata[:attachments] << {
74
+ name: attachment.name.to_s,
75
+ type: attachment.macro.to_s # :has_one_attached or :has_many_attached
76
+ }
77
+ end
78
+ end
79
+
80
+ # Extract associations
81
+ model.reflect_on_all_associations.each do |association|
82
+ next if association.class_name.start_with?('ActiveStorage', 'DataMigration')
83
+
84
+ metadata[:associations][association.name.to_s] = {
85
+ type: association.macro.to_s,
86
+ class_name: association.class_name,
87
+ foreign_key: association.foreign_key
88
+ }
89
+ end
90
+
91
+ metadata
92
+ end
93
+ end
94
+ end
95
+ end