activerecord-graph-extractor 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,212 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecordGraphExtractor
4
+ class RelationshipAnalyzer
5
+ attr_reader :config, :visited_models, :circular_paths
6
+
7
+ def initialize(config = ActiveRecordGraphExtractor.configuration)
8
+ @config = config
9
+ @visited_models = Set.new
10
+ @circular_paths = []
11
+ end
12
+
13
+ def analyze_model(model_class)
14
+ # Validate that this is an ActiveRecord model
15
+ unless model_class.respond_to?(:reflect_on_all_associations)
16
+ raise ExtractionError, "#{model_class} is not an ActiveRecord model"
17
+ end
18
+
19
+ relationships = {}
20
+
21
+ model_class.reflect_on_all_associations.each do |association|
22
+ begin
23
+ next if association.klass.nil?
24
+
25
+ relationship_name = association.name.to_s
26
+ next unless config.relationship_included?(relationship_name)
27
+
28
+ model_name = association.klass.name
29
+ next unless config.model_included?(model_name)
30
+
31
+ relationships[relationship_name] = {
32
+ 'type' => association.macro.to_s,
33
+ 'model_class' => association.klass,
34
+ 'model_name' => model_name,
35
+ 'foreign_key' => association.foreign_key,
36
+ 'polymorphic' => association.options[:polymorphic] || false,
37
+ 'optional' => association.options[:optional] || false
38
+ }
39
+ rescue NameError => e
40
+ if config.skip_missing_models
41
+ next
42
+ else
43
+ raise e
44
+ end
45
+ end
46
+ end
47
+
48
+ filter_relationships(relationships)
49
+ end
50
+
51
+ def analyze_models(model_classes)
52
+ # Validate input
53
+ unless model_classes.is_a?(Array)
54
+ raise ExtractionError, "Expected an array of model classes"
55
+ end
56
+
57
+ # Handle empty array
58
+ return {} if model_classes.empty?
59
+
60
+ # Validate each model class
61
+ model_classes.each do |model_class|
62
+ unless model_class.respond_to?(:reflect_on_all_associations)
63
+ raise ExtractionError, "#{model_class} is not an ActiveRecord model"
64
+ end
65
+ end
66
+
67
+ relationships = {}
68
+
69
+ model_classes.each do |model_class|
70
+ model_name = model_class.name
71
+ relationships[model_name] = analyze_model(model_class)
72
+ end
73
+
74
+ relationships
75
+ end
76
+
77
+ def get_relationship_info(record, relationship_name)
78
+ relationships = analyze_model(record.class)
79
+ relationship = relationships[relationship_name.to_s]
80
+
81
+ return nil unless relationship
82
+
83
+ # Convert to symbols for consistency with expected API
84
+ {
85
+ model_class: relationship['model_class'],
86
+ model_name: relationship['model_name'],
87
+ type: relationship['type'].to_sym
88
+ }
89
+ end
90
+
91
+ def build_dependency_graph(models)
92
+ dependency_graph = {}
93
+
94
+ models.each do |model_class|
95
+ dependencies = []
96
+ relationships = analyze_model(model_class)
97
+
98
+ relationships.each do |name, info|
99
+ if info['type'] == 'belongs_to' && !info['optional']
100
+ dependencies << info['model_class'] unless dependencies.include?(info['model_class'])
101
+ end
102
+ end
103
+
104
+ dependency_graph[model_class] = dependencies
105
+ end
106
+
107
+ dependency_graph
108
+ end
109
+
110
+ def circular_reference?(model_name, visited)
111
+ visited.include?(model_name)
112
+ end
113
+
114
+ private
115
+
116
+ def filter_relationships(relationships)
117
+ relationships.select do |name, info|
118
+ config.model_included?(info['model_name']) &&
119
+ config.relationship_included?(name)
120
+ end
121
+ end
122
+
123
+ def should_include_association?(association)
124
+ return false unless config.relationship_included?(association.name.to_s)
125
+
126
+ # Skip associations that don't have a klass (can happen with polymorphic or broken associations)
127
+ return false unless association.klass
128
+
129
+ return false unless config.model_included?(association.klass.name)
130
+
131
+ # Skip polymorphic associations that can't be resolved
132
+ return false if association.polymorphic? && association.foreign_type.nil?
133
+
134
+ true
135
+ rescue NameError
136
+ # Skip associations that reference non-existent models
137
+ false
138
+ end
139
+
140
+ def build_relationship_info(association)
141
+ {
142
+ type: association.macro,
143
+ class_name: association.klass&.name,
144
+ foreign_key: association.foreign_key,
145
+ primary_key: association.association_primary_key,
146
+ polymorphic: association.polymorphic?,
147
+ through: association.through_reflection&.name,
148
+ source: association.source_reflection&.name,
149
+ dependent: association.options[:dependent],
150
+ inverse_of: association.inverse_of&.name
151
+ }
152
+ end
153
+
154
+ def traverse_for_dependencies(model_class, dependency_graph, path)
155
+ model_name = model_class.name
156
+
157
+ # Detect circular references
158
+ if path.include?(model_name)
159
+ handle_circular_reference(model_name, path)
160
+ return
161
+ end
162
+
163
+ return if dependency_graph.key?(model_name)
164
+
165
+ dependencies = []
166
+ current_path = path + [model_name]
167
+
168
+ model_class.reflect_on_all_associations.each do |association|
169
+ next unless should_include_association?(association)
170
+ next if association.macro == :has_many || association.macro == :has_one
171
+
172
+ # Only belongs_to creates dependencies
173
+ if association.macro == :belongs_to
174
+ # Skip if klass is nil (can happen with polymorphic associations)
175
+ next unless association.klass
176
+
177
+ associated_class = association.klass
178
+ dependencies << associated_class.name
179
+
180
+ # Recursively analyze dependencies
181
+ traverse_for_dependencies(associated_class, dependency_graph, current_path)
182
+ end
183
+ end
184
+
185
+ dependency_graph[model_name] = dependencies.uniq
186
+ end
187
+
188
+ def handle_circular_reference(model_name, path)
189
+ cycle_start = path.index(model_name)
190
+ cycle = path[cycle_start..-1] + [model_name]
191
+
192
+ @circular_paths << cycle
193
+
194
+ case config.circular_reference_strategy
195
+ when :error
196
+ raise CircularReferenceError.new(
197
+ "Circular reference detected: #{cycle.join(' -> ')}",
198
+ model: model_name
199
+ )
200
+ when :skip
201
+ # Simply skip this path
202
+ return
203
+ when :break_at_depth
204
+ return if path.length >= config.max_circular_depth
205
+ end
206
+ end
207
+
208
+ def get_custom_rules(from_model, to_model)
209
+ config.custom_traversal_rules.dig(from_model, to_model) || {}
210
+ end
211
+ end
212
+ end
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'aws-sdk-s3'
4
+
5
+ module ActiveRecordGraphExtractor
6
+ class S3Client
7
+ attr_reader :bucket_name, :region, :s3_client
8
+
9
+ def initialize(bucket_name:, region: 'us-east-1', **options)
10
+ @bucket_name = bucket_name
11
+ @region = region
12
+
13
+ # Initialize AWS S3 client with optional credentials
14
+ s3_options = { region: region }
15
+ s3_options.merge!(options) if options.any?
16
+
17
+ @s3_client = Aws::S3::Client.new(s3_options)
18
+
19
+ validate_bucket_access!
20
+ end
21
+
22
+ # Upload a file to S3
23
+ def upload_file(local_file_path, s3_key = nil, **options)
24
+ raise FileError, "File not found: #{local_file_path}" unless File.exist?(local_file_path)
25
+
26
+ s3_key ||= generate_s3_key(local_file_path)
27
+
28
+ begin
29
+ File.open(local_file_path, 'rb') do |file|
30
+ s3_client.put_object(
31
+ bucket: bucket_name,
32
+ key: s3_key,
33
+ body: file,
34
+ content_type: 'application/json',
35
+ **options
36
+ )
37
+ end
38
+
39
+ {
40
+ bucket: bucket_name,
41
+ key: s3_key,
42
+ url: s3_url(s3_key),
43
+ size: File.size(local_file_path)
44
+ }
45
+ rescue Aws::S3::Errors::ServiceError => e
46
+ raise S3Error, "Failed to upload file to S3: #{e.message}"
47
+ end
48
+ end
49
+
50
+ # Download a file from S3
51
+ def download_file(s3_key, local_file_path = nil)
52
+ local_file_path ||= File.basename(s3_key)
53
+
54
+ begin
55
+ s3_client.get_object(
56
+ bucket: bucket_name,
57
+ key: s3_key,
58
+ response_target: local_file_path
59
+ )
60
+
61
+ {
62
+ bucket: bucket_name,
63
+ key: s3_key,
64
+ local_path: local_file_path,
65
+ size: File.size(local_file_path)
66
+ }
67
+ rescue Aws::S3::Errors::NoSuchKey
68
+ raise S3Error, "File not found in S3: s3://#{bucket_name}/#{s3_key}"
69
+ rescue Aws::S3::Errors::ServiceError => e
70
+ raise S3Error, "Failed to download file from S3: #{e.message}"
71
+ end
72
+ end
73
+
74
+ # Check if a file exists in S3
75
+ def file_exists?(s3_key)
76
+ s3_client.head_object(bucket: bucket_name, key: s3_key)
77
+ true
78
+ rescue Aws::S3::Errors::NotFound
79
+ false
80
+ rescue Aws::S3::Errors::ServiceError => e
81
+ raise S3Error, "Failed to check file existence: #{e.message}"
82
+ end
83
+
84
+ # List files in S3 with optional prefix
85
+ def list_files(prefix: nil, max_keys: 1000)
86
+ options = {
87
+ bucket: bucket_name,
88
+ max_keys: max_keys
89
+ }
90
+ options[:prefix] = prefix if prefix
91
+
92
+ begin
93
+ response = s3_client.list_objects_v2(options)
94
+
95
+ response.contents.map do |object|
96
+ {
97
+ key: object.key,
98
+ size: object.size,
99
+ last_modified: object.last_modified,
100
+ url: s3_url(object.key)
101
+ }
102
+ end
103
+ rescue Aws::S3::Errors::ServiceError => e
104
+ raise S3Error, "Failed to list files: #{e.message}"
105
+ end
106
+ end
107
+
108
+ # Delete a file from S3
109
+ def delete_file(s3_key)
110
+ begin
111
+ s3_client.delete_object(bucket: bucket_name, key: s3_key)
112
+ true
113
+ rescue Aws::S3::Errors::ServiceError => e
114
+ raise S3Error, "Failed to delete file: #{e.message}"
115
+ end
116
+ end
117
+
118
+ # Generate a presigned URL for downloading
119
+ def presigned_url(s3_key, expires_in: 3600)
120
+ begin
121
+ presigner = Aws::S3::Presigner.new(client: s3_client)
122
+ presigner.presigned_url(:get_object, bucket: bucket_name, key: s3_key, expires_in: expires_in)
123
+ rescue Aws::S3::Errors::ServiceError => e
124
+ raise S3Error, "Failed to generate presigned URL: #{e.message}"
125
+ end
126
+ end
127
+
128
+ # Get file metadata
129
+ def file_metadata(s3_key)
130
+ begin
131
+ response = s3_client.head_object(bucket: bucket_name, key: s3_key)
132
+
133
+ {
134
+ key: s3_key,
135
+ size: response.content_length,
136
+ last_modified: response.last_modified,
137
+ content_type: response.content_type,
138
+ etag: response.etag,
139
+ metadata: response.metadata
140
+ }
141
+ rescue Aws::S3::Errors::NotFound
142
+ raise S3Error, "File not found: s3://#{bucket_name}/#{s3_key}"
143
+ rescue Aws::S3::Errors::ServiceError => e
144
+ raise S3Error, "Failed to get file metadata: #{e.message}"
145
+ end
146
+ end
147
+
148
+ private
149
+
150
+ def validate_bucket_access!
151
+ s3_client.head_bucket(bucket: bucket_name)
152
+ rescue Aws::S3::Errors::NotFound
153
+ raise S3Error, "Bucket not found: #{bucket_name}"
154
+ rescue Aws::S3::Errors::Forbidden
155
+ raise S3Error, "Access denied to bucket: #{bucket_name}"
156
+ rescue Aws::S3::Errors::ServiceError => e
157
+ raise S3Error, "Failed to access bucket: #{e.message}"
158
+ end
159
+
160
+ def generate_s3_key(local_file_path)
161
+ filename = File.basename(local_file_path)
162
+ timestamp = Time.now.strftime('%Y/%m/%d')
163
+ "activerecord-graph-extractor/#{timestamp}/#{filename}"
164
+ end
165
+
166
+ def s3_url(s3_key)
167
+ "s3://#{bucket_name}/#{s3_key}"
168
+ end
169
+ end
170
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecordGraphExtractor
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record"
4
+ require "active_support"
5
+ require "yajl"
6
+
7
+ require_relative "activerecord_graph_extractor/version"
8
+ require_relative "activerecord_graph_extractor/errors"
9
+ require_relative "activerecord_graph_extractor/configuration"
10
+ require_relative "activerecord_graph_extractor/relationship_analyzer"
11
+ require_relative "activerecord_graph_extractor/dependency_resolver"
12
+ require_relative "activerecord_graph_extractor/json_serializer"
13
+ require_relative "activerecord_graph_extractor/primary_key_mapper"
14
+ require_relative "activerecord_graph_extractor/progress_tracker"
15
+ require_relative "activerecord_graph_extractor/s3_client"
16
+ require_relative "activerecord_graph_extractor/dry_run_analyzer"
17
+ require_relative "activerecord_graph_extractor/extractor"
18
+ require_relative "activerecord_graph_extractor/importer"
19
+
20
+ module ActiveRecordGraphExtractor
21
+ class << self
22
+ def configure
23
+ yield(configuration)
24
+ end
25
+
26
+ def configuration
27
+ @configuration ||= Configuration.new
28
+ end
29
+
30
+ def reset_configuration!
31
+ @configuration = Configuration.new
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,192 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Installation Verification Script for ActiveRecord Graph Extractor CLI
5
+ # Run this script to verify your installation is working correctly
6
+
7
+ puts "🔍 ActiveRecord Graph Extractor CLI Installation Verification"
8
+ puts "=" * 60
9
+
10
+ # Check if the arge command is available
11
+ def check_command_availability
12
+ print "Checking if 'arge' command is available... "
13
+
14
+ result = `which arge 2>/dev/null`.strip
15
+ if result.empty?
16
+ puts "❌ NOT FOUND"
17
+ puts
18
+ puts "The 'arge' command is not available in your PATH."
19
+ puts "This could mean:"
20
+ puts " 1. The gem is not installed globally"
21
+ puts " 2. You need to use 'bundle exec arge' instead"
22
+ puts " 3. You need to run 'rbenv rehash' (if using rbenv)"
23
+ puts
24
+ return false
25
+ else
26
+ puts "✅ FOUND at #{result}"
27
+ return true
28
+ end
29
+ end
30
+
31
+ # Check gem installation
32
+ def check_gem_installation
33
+ print "Checking gem installation... "
34
+
35
+ begin
36
+ require 'activerecord_graph_extractor'
37
+ version = ActiveRecordGraphExtractor::VERSION
38
+ puts "✅ INSTALLED (version #{version})"
39
+ return true
40
+ rescue LoadError
41
+ puts "❌ NOT FOUND"
42
+ puts
43
+ puts "The activerecord-graph-extractor gem is not installed or not in your load path."
44
+ puts "Try: gem install activerecord-graph-extractor"
45
+ puts
46
+ return false
47
+ end
48
+ end
49
+
50
+ # Test CLI functionality
51
+ def test_cli_functionality
52
+ print "Testing CLI functionality... "
53
+
54
+ # Try to run the version command
55
+ output = `arge version 2>&1`
56
+ exit_code = $?.exitstatus
57
+
58
+ if exit_code == 0 && output.include?("ActiveRecord Graph Extractor")
59
+ puts "✅ WORKING"
60
+ puts " Output: #{output.strip}"
61
+ return true
62
+ else
63
+ puts "❌ FAILED"
64
+ puts " Exit code: #{exit_code}"
65
+ puts " Output: #{output}"
66
+ return false
67
+ end
68
+ end
69
+
70
+ # Test CLI help
71
+ def test_cli_help
72
+ print "Testing CLI help... "
73
+
74
+ output = `arge help 2>&1`
75
+ exit_code = $?.exitstatus
76
+
77
+ expected_commands = ['extract', 'import', 'extract_to_s3', 's3_list', 's3_download', 'analyze', 'dry_run']
78
+
79
+ if exit_code == 0 && expected_commands.all? { |cmd| output.include?(cmd) }
80
+ puts "✅ ALL COMMANDS AVAILABLE"
81
+ puts " Available commands: #{expected_commands.join(', ')}"
82
+ return true
83
+ else
84
+ puts "❌ MISSING COMMANDS"
85
+ puts " Expected: #{expected_commands.join(', ')}"
86
+ puts " Output: #{output}"
87
+ return false
88
+ end
89
+ end
90
+
91
+ # Check Ruby version
92
+ def check_ruby_version
93
+ print "Checking Ruby version... "
94
+
95
+ ruby_version = RUBY_VERSION
96
+ required_version = Gem::Version.new('2.7.0')
97
+ current_version = Gem::Version.new(ruby_version)
98
+
99
+ if current_version >= required_version
100
+ puts "✅ COMPATIBLE (#{ruby_version})"
101
+ return true
102
+ else
103
+ puts "❌ TOO OLD (#{ruby_version}, requires >= 2.7.0)"
104
+ puts
105
+ puts "Please upgrade your Ruby version to 2.7.0 or higher."
106
+ puts "Consider using rbenv or RVM to manage Ruby versions."
107
+ puts
108
+ return false
109
+ end
110
+ end
111
+
112
+ # Check if running in Rails environment
113
+ def check_rails_environment
114
+ print "Checking Rails environment... "
115
+
116
+ if defined?(Rails)
117
+ puts "✅ RAILS DETECTED (#{Rails.version})"
118
+ puts " You can use: bundle exec arge [command]"
119
+ return true
120
+ else
121
+ puts "ℹ️ NO RAILS DETECTED"
122
+ puts " This is fine for standalone usage"
123
+ return true
124
+ end
125
+ end
126
+
127
+ # Main verification process
128
+ def main
129
+ puts
130
+
131
+ all_checks_passed = true
132
+
133
+ # Run all checks
134
+ all_checks_passed &= check_ruby_version
135
+ all_checks_passed &= check_gem_installation
136
+ all_checks_passed &= check_rails_environment
137
+ all_checks_passed &= check_command_availability
138
+
139
+ # Only test CLI if command is available
140
+ if check_command_availability
141
+ all_checks_passed &= test_cli_functionality
142
+ all_checks_passed &= test_cli_help
143
+ else
144
+ # Try with bundle exec if direct command failed
145
+ puts
146
+ puts "Trying with 'bundle exec'..."
147
+
148
+ bundle_output = `bundle exec arge version 2>&1`
149
+ bundle_exit_code = $?.exitstatus
150
+
151
+ if bundle_exit_code == 0
152
+ puts "✅ 'bundle exec arge' works!"
153
+ puts " Use: bundle exec arge [command]"
154
+ puts " This is normal when the gem is installed via Gemfile"
155
+ else
156
+ puts "❌ 'bundle exec arge' also failed"
157
+ puts " Output: #{bundle_output}"
158
+ all_checks_passed = false
159
+ end
160
+ end
161
+
162
+ puts
163
+ puts "=" * 60
164
+
165
+ if all_checks_passed
166
+ puts "🎉 INSTALLATION VERIFICATION SUCCESSFUL!"
167
+ puts
168
+ puts "Your ActiveRecord Graph Extractor CLI is properly installed and working."
169
+ puts
170
+ puts "Quick start:"
171
+ puts " arge version # Check version"
172
+ puts " arge help # See all commands"
173
+ puts " arge extract Order 123 # Extract a record (requires Rails)"
174
+ puts " arge extract_to_s3 Order 123 # Extract to S3 (requires AWS config)"
175
+ puts
176
+ else
177
+ puts "❌ INSTALLATION VERIFICATION FAILED!"
178
+ puts
179
+ puts "Some checks failed. Please review the errors above and:"
180
+ puts " 1. Make sure the gem is properly installed"
181
+ puts " 2. Check your Ruby version (>= 2.7.0 required)"
182
+ puts " 3. Try 'rbenv rehash' if using rbenv"
183
+ puts " 4. Use 'bundle exec arge' if installed via Gemfile"
184
+ puts
185
+ puts "For more help, see the installation guide in the README."
186
+ end
187
+
188
+ puts
189
+ end
190
+
191
+ # Run the verification
192
+ main if __FILE__ == $0