real_data_tests 0.2.1 → 0.3.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/CHANGELOG.md +20 -0
- data/Gemfile.lock +1 -1
- data/README.md +47 -0
- data/lib/real_data_tests/configuration.rb +30 -2
- data/lib/real_data_tests/record_collector.rb +143 -38
- data/lib/real_data_tests/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 1c5ba3c2d2b4bb4f9ec9a6c539ddd809b6840d3eb7e6cbfb33d02f524177b3ff
|
4
|
+
data.tar.gz: e64f18ddd2a0bab9c0f3aad9ab628fb7bd86097644807c5d99596aa3c3af55c4
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 14a8d12cb5fd01582b2bf22d47d7fa518f2f0be2258e15c477f42c29c1c95b9d362fc20f486fe583f655eeac5b15ceba48c888eb49e9bc47444ab3ffcf79feb6
|
7
|
+
data.tar.gz: 1c2a99d52fa1b740c07152bf12d6802711bba8af8af38d94924d66ddda06d2eab76e6f459b532af3c29e2a0767bfd4ee7977aa23293b3a616225a5b340010a89
|
data/CHANGELOG.md
CHANGED
@@ -1,5 +1,25 @@
|
|
1
1
|
## [Unreleased]
|
2
2
|
|
3
|
+
## [0.3.1] - 2025-01-14
|
4
|
+
### Fixed
|
5
|
+
- Fixed circular dependency handling in RecordCollector to correctly limit record collection
|
6
|
+
- Moved prevention logic earlier in the collection process to stop circular dependencies before record collection
|
7
|
+
- Improved tracking of visited associations for more accurate prevention
|
8
|
+
- Added better logging for dependency prevention decisions
|
9
|
+
- Fixed test case for circular dependency prevention in nested associations
|
10
|
+
|
11
|
+
## [0.3.0] - 2025-01-13
|
12
|
+
### Added
|
13
|
+
- **Polymorphic Association Support**:
|
14
|
+
- RecordCollector now supports tracking and collecting records from polymorphic associations.
|
15
|
+
- Polymorphic `belongs_to`, `has_many`, and `has_one` associations are automatically detected and processed during data collection.
|
16
|
+
- Added tracking for polymorphic types in `@collection_stats` to provide detailed insights into polymorphic relationships.
|
17
|
+
- Graceful handling of missing records in polymorphic associations using error logging.
|
18
|
+
|
19
|
+
### Fixed
|
20
|
+
- Improved error handling for `ActiveRecord::RecordNotFound` exceptions when loading polymorphic associations.
|
21
|
+
- Correctly initializes and updates association statistics for polymorphic associations in `@collection_stats`.
|
22
|
+
|
3
23
|
## [0.2.1] - 2025-01-13
|
4
24
|
### Fixed
|
5
25
|
- Fixed JSONB field handling to output '{}' instead of empty string for blank values
|
data/Gemfile.lock
CHANGED
data/README.md
CHANGED
@@ -90,6 +90,37 @@ Rails.application.config.after_initialize do
|
|
90
90
|
end
|
91
91
|
```
|
92
92
|
|
93
|
+
## Polymorphic Association Support
|
94
|
+
|
95
|
+
Real Data Tests supports collecting records through polymorphic associations. This feature allows you to:
|
96
|
+
- Automatically detect and collect records for polymorphic `belongs_to`, `has_many`, and `has_one` associations.
|
97
|
+
- Track and report the types of records collected through polymorphic associations in detailed collection statistics.
|
98
|
+
|
99
|
+
### Example
|
100
|
+
If your model includes a polymorphic association like this:
|
101
|
+
|
102
|
+
```ruby
|
103
|
+
class Payment < ApplicationRecord
|
104
|
+
belongs_to :billable, polymorphic: true
|
105
|
+
end
|
106
|
+
```
|
107
|
+
|
108
|
+
Real Data Tests will:
|
109
|
+
1. Collect the associated `billable` records regardless of their type (e.g., `InsuranceCompany`, `Patient`).
|
110
|
+
2. Include the `billable_type` in the collection statistics for transparency and reporting.
|
111
|
+
|
112
|
+
### Configuration for Polymorphic Associations
|
113
|
+
Polymorphic associations are automatically handled based on your existing configuration. You can also explicitly include or limit polymorphic associations, like so:
|
114
|
+
|
115
|
+
```ruby
|
116
|
+
RealDataTests.configure do |config|
|
117
|
+
config.include_associations_for 'Payment', :billable
|
118
|
+
config.limit_association 'Payment.billable', 5
|
119
|
+
end
|
120
|
+
```
|
121
|
+
|
122
|
+
This ensures a robust and flexible way to handle even the most complex relationships in your data.
|
123
|
+
|
93
124
|
## Using Presets
|
94
125
|
|
95
126
|
Real Data Tests allows you to define multiple configuration presets for different data extraction needs. This is particularly useful when you need different association rules and anonymization settings for different testing scenarios.
|
@@ -268,6 +299,22 @@ This is particularly useful when:
|
|
268
299
|
- You want to collect an association from one model but not another
|
269
300
|
- You need to maintain a clean separation of concerns in your test data
|
270
301
|
|
302
|
+
### Polymorphic Associations
|
303
|
+
Polymorphic associations are fully supported. Include and configure them as needed:
|
304
|
+
|
305
|
+
```ruby
|
306
|
+
RealDataTests.configure do |config|
|
307
|
+
config.include_associations_for 'Payment', :billable
|
308
|
+
end
|
309
|
+
```
|
310
|
+
|
311
|
+
You can also limit or prevent reciprocal loading for polymorphic associations:
|
312
|
+
|
313
|
+
```ruby
|
314
|
+
config.limit_association 'Payment.billable', 10
|
315
|
+
config.prevent_reciprocal 'Payment.billable'
|
316
|
+
```
|
317
|
+
|
271
318
|
### Association Loading Control
|
272
319
|
|
273
320
|
You can further refine how associations are loaded using limits and reciprocal prevention:
|
@@ -61,7 +61,10 @@ module RealDataTests
|
|
61
61
|
class PresetConfig
|
62
62
|
attr_reader :association_filter_mode, :association_filter_list,
|
63
63
|
:model_specific_associations, :association_limits,
|
64
|
-
:prevent_reciprocal_loading, :anonymization_rules
|
64
|
+
:prevent_reciprocal_loading, :anonymization_rules,
|
65
|
+
:prevented_reciprocals
|
66
|
+
|
67
|
+
attr_accessor :max_depth
|
65
68
|
|
66
69
|
def initialize
|
67
70
|
@association_filter_mode = nil
|
@@ -70,6 +73,26 @@ module RealDataTests
|
|
70
73
|
@association_limits = {}
|
71
74
|
@prevent_reciprocal_loading = {}
|
72
75
|
@anonymization_rules = {}
|
76
|
+
@prevented_reciprocals = Set.new
|
77
|
+
@max_depth = 10
|
78
|
+
end
|
79
|
+
|
80
|
+
def prevent_circular_dependency(klass, association_name)
|
81
|
+
key = if klass.is_a?(String)
|
82
|
+
"#{klass}:#{association_name}"
|
83
|
+
else
|
84
|
+
"#{klass.name}:#{association_name}"
|
85
|
+
end
|
86
|
+
@prevented_reciprocals << key
|
87
|
+
end
|
88
|
+
|
89
|
+
def has_circular_dependency?(klass, association_name)
|
90
|
+
key = if klass.is_a?(String)
|
91
|
+
"#{klass}:#{association_name}"
|
92
|
+
else
|
93
|
+
"#{klass.name}:#{association_name}"
|
94
|
+
end
|
95
|
+
@prevented_reciprocals.include?(key)
|
73
96
|
end
|
74
97
|
|
75
98
|
def include_associations(*associations)
|
@@ -102,9 +125,14 @@ module RealDataTests
|
|
102
125
|
@association_limits[path]
|
103
126
|
end
|
104
127
|
|
128
|
+
def set_association_limit(model_name, association_name, limit)
|
129
|
+
path = "#{model_name}.#{association_name}"
|
130
|
+
@association_limits[path] = limit
|
131
|
+
end
|
132
|
+
|
105
133
|
def prevent_reciprocal?(record_class, association_name)
|
106
134
|
path = "#{record_class.name}.#{association_name}"
|
107
|
-
@prevent_reciprocal_loading[path]
|
135
|
+
@prevent_reciprocal_loading[path] || has_circular_dependency?(record_class, association_name)
|
108
136
|
end
|
109
137
|
|
110
138
|
def prevent_reciprocal(path)
|
@@ -1,84 +1,182 @@
|
|
1
1
|
module RealDataTests
|
2
2
|
class RecordCollector
|
3
|
+
attr_reader :collection_stats, :collected_records
|
4
|
+
|
3
5
|
def initialize(record)
|
4
6
|
@record = record
|
5
7
|
@collected_records = Set.new
|
6
|
-
@collection_stats =
|
8
|
+
@collection_stats = {}
|
7
9
|
@processed_associations = Set.new
|
8
10
|
@association_path = []
|
11
|
+
@current_depth = 0
|
12
|
+
@visited_associations = {}
|
13
|
+
|
14
|
+
# Initialize stats for the record's class
|
15
|
+
@collection_stats[record.class.name] = {
|
16
|
+
count: 0,
|
17
|
+
associations: Hash.new(0),
|
18
|
+
polymorphic_types: {}
|
19
|
+
}
|
20
|
+
|
21
|
+
record.class.reflect_on_all_associations(:belongs_to).each do |assoc|
|
22
|
+
if assoc.polymorphic?
|
23
|
+
@collection_stats[record.class.name][:polymorphic_types][assoc.name.to_s] ||= Set.new
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
puts "\nInitializing RecordCollector for #{record.class.name}##{record.id}"
|
28
|
+
record.class.reflect_on_all_associations(:belongs_to).each do |assoc|
|
29
|
+
if assoc.polymorphic?
|
30
|
+
type = record.public_send("#{assoc.name}_type")
|
31
|
+
id = record.public_send("#{assoc.name}_id")
|
32
|
+
puts "Found polymorphic belongs_to '#{assoc.name}' with type: #{type}, id: #{id}"
|
33
|
+
end
|
34
|
+
end
|
9
35
|
end
|
10
36
|
|
11
37
|
def collect
|
12
38
|
puts "\nStarting record collection from: #{@record.class.name}##{@record.id}"
|
13
|
-
filter_mode = RealDataTests.configuration.association_filter_mode
|
14
|
-
filter_list = RealDataTests.configuration.association_filter_list
|
39
|
+
filter_mode = RealDataTests.configuration.current_preset.association_filter_mode
|
40
|
+
filter_list = RealDataTests.configuration.current_preset.association_filter_list
|
15
41
|
puts "Using #{filter_mode || 'no'} filter with #{filter_list.any? ? filter_list.join(', ') : 'no associations'}"
|
16
|
-
collect_record(@record)
|
42
|
+
collect_record(@record, 0)
|
17
43
|
print_collection_stats
|
18
44
|
@collected_records.to_a
|
19
45
|
end
|
20
46
|
|
21
47
|
private
|
22
48
|
|
23
|
-
def
|
24
|
-
association_key = "#{record.class.name}##{record.id}:#{association.name}"
|
25
|
-
return false if @processed_associations.include?(association_key)
|
26
|
-
@processed_associations.add(association_key)
|
27
|
-
|
28
|
-
# Use the enhanced should_process_association? method
|
29
|
-
return false unless RealDataTests.configuration.should_process_association?(record, association.name)
|
30
|
-
|
31
|
-
# Check for prevented reciprocal loading
|
32
|
-
if RealDataTests.configuration.prevent_reciprocal?(record.class, association.name)
|
33
|
-
puts " Skipping prevented reciprocal association: #{association.name} on #{record.class.name}"
|
34
|
-
return false
|
35
|
-
end
|
36
|
-
|
37
|
-
true
|
38
|
-
end
|
39
|
-
|
40
|
-
def collect_record(record)
|
49
|
+
def collect_record(record, depth)
|
41
50
|
return if @collected_records.include?(record)
|
42
51
|
return unless record # Guard against nil records
|
52
|
+
return if depth > RealDataTests.configuration.current_preset.max_depth
|
43
53
|
|
54
|
+
puts "\nCollecting record: #{record.class.name}##{record.id}"
|
44
55
|
@collected_records.add(record)
|
56
|
+
|
57
|
+
# Initialize stats structure
|
58
|
+
@collection_stats[record.class.name] ||= {
|
59
|
+
count: 0,
|
60
|
+
associations: {},
|
61
|
+
polymorphic_types: {}
|
62
|
+
}
|
45
63
|
@collection_stats[record.class.name][:count] += 1
|
46
|
-
|
64
|
+
|
65
|
+
# Track types for polymorphic belongs_to associations
|
66
|
+
record.class.reflect_on_all_associations(:belongs_to).each do |assoc|
|
67
|
+
next unless assoc.polymorphic?
|
68
|
+
|
69
|
+
type = record.public_send("#{assoc.name}_type")
|
70
|
+
@collection_stats[record.class.name][:polymorphic_types][assoc.name.to_sym] ||= Set.new
|
71
|
+
|
72
|
+
begin
|
73
|
+
associated_record = record.public_send(assoc.name)
|
74
|
+
if associated_record
|
75
|
+
puts " Adding polymorphic type '#{type}' for #{assoc.name}"
|
76
|
+
@collection_stats[record.class.name][:polymorphic_types][assoc.name.to_sym] << associated_record.class.name
|
77
|
+
else
|
78
|
+
puts " Skipping polymorphic type for #{assoc.name} due to missing associated record"
|
79
|
+
end
|
80
|
+
rescue StandardError => e
|
81
|
+
puts " Error loading polymorphic association #{assoc.name}: #{e.message}"
|
82
|
+
end
|
83
|
+
end
|
84
|
+
|
85
|
+
collect_associations(record, depth)
|
47
86
|
end
|
48
87
|
|
49
|
-
def collect_associations(record)
|
88
|
+
def collect_associations(record, depth)
|
50
89
|
return unless record.class.respond_to?(:reflect_on_all_associations)
|
90
|
+
return if depth >= RealDataTests.configuration.current_preset.max_depth
|
51
91
|
|
52
92
|
associations = record.class.reflect_on_all_associations
|
53
93
|
puts "\nProcessing associations for: #{record.class.name}##{record.id}"
|
54
94
|
puts "Found #{associations.length} associations"
|
55
95
|
|
56
96
|
associations.each do |association|
|
57
|
-
|
97
|
+
association_key = "#{record.class.name}##{record.id}:#{association.name}"
|
98
|
+
puts " Checking if should process: #{association_key}"
|
58
99
|
|
59
|
-
|
60
|
-
|
61
|
-
|
100
|
+
if RealDataTests.configuration.current_preset.prevent_reciprocal?(record.class, association.name)
|
101
|
+
track_key = "#{record.class.name}:#{association.name}"
|
102
|
+
@visited_associations[track_key] ||= Set.new
|
103
|
+
|
104
|
+
# Skip if we've already processed this association type for this class
|
105
|
+
if @visited_associations[track_key].any?
|
106
|
+
puts " Skipping prevented reciprocal association: #{track_key}"
|
107
|
+
next
|
108
|
+
end
|
109
|
+
@visited_associations[track_key].add(record.id)
|
62
110
|
end
|
63
111
|
|
64
|
-
|
65
|
-
|
112
|
+
next unless should_process_association?(record, association, depth)
|
113
|
+
|
114
|
+
puts " Processing #{association.macro} #{association.polymorphic? ? 'polymorphic ' : ''}association: #{association.name}"
|
115
|
+
process_association(record, association, depth)
|
66
116
|
end
|
67
117
|
end
|
68
118
|
|
69
|
-
def
|
119
|
+
def should_process_association?(record, association, depth = 0)
|
120
|
+
return false if depth >= RealDataTests.configuration.current_preset.max_depth
|
121
|
+
|
122
|
+
association_key = "#{record.class.name}##{record.id}:#{association.name}"
|
123
|
+
return false if @processed_associations.include?(association_key)
|
124
|
+
|
125
|
+
# Check if the association is allowed by configuration
|
126
|
+
should_process = RealDataTests.configuration.current_preset.should_process_association?(record, association.name)
|
127
|
+
puts " Configuration says: #{should_process}"
|
128
|
+
|
129
|
+
if should_process
|
130
|
+
@processed_associations.add(association_key)
|
131
|
+
true
|
132
|
+
else
|
133
|
+
false
|
134
|
+
end
|
135
|
+
end
|
136
|
+
|
137
|
+
def process_association(record, association, depth)
|
138
|
+
@association_path.push(association.name)
|
139
|
+
|
70
140
|
begin
|
141
|
+
if detect_circular_dependency?(record, association)
|
142
|
+
puts " Skipping circular dependency for #{association.name} on #{record.class.name}##{record.id}"
|
143
|
+
return
|
144
|
+
end
|
145
|
+
|
71
146
|
related_records = fetch_related_records(record, association)
|
72
147
|
count = related_records.length
|
73
148
|
puts " Found #{count} related #{association.name} records"
|
74
|
-
@collection_stats[record.class.name][:associations][association.name] += count
|
75
149
|
|
76
|
-
|
150
|
+
@collection_stats[record.class.name][:associations][association.name.to_s] ||= 0
|
151
|
+
@collection_stats[record.class.name][:associations][association.name.to_s] += count
|
152
|
+
|
153
|
+
related_records.each { |related_record| collect_record(related_record, depth + 1) }
|
77
154
|
rescue => e
|
78
155
|
puts " Error processing association #{association.name}: #{e.message}"
|
156
|
+
ensure
|
157
|
+
@association_path.pop
|
79
158
|
end
|
80
159
|
end
|
81
160
|
|
161
|
+
def self_referential_association?(klass, association)
|
162
|
+
return false unless association.options[:class_name]
|
163
|
+
return false if association.polymorphic?
|
164
|
+
association.options[:class_name] == klass.name
|
165
|
+
end
|
166
|
+
|
167
|
+
def detect_circular_dependency?(record, association)
|
168
|
+
return false unless association.belongs_to?
|
169
|
+
return false if association.polymorphic?
|
170
|
+
|
171
|
+
target_class = association.klass
|
172
|
+
return false unless target_class
|
173
|
+
|
174
|
+
path_key = "#{target_class.name}:#{association.name}"
|
175
|
+
visited_count = @association_path.count { |assoc| "#{target_class.name}:#{assoc}" == path_key }
|
176
|
+
|
177
|
+
visited_count > 1
|
178
|
+
end
|
179
|
+
|
82
180
|
def fetch_related_records(record, association)
|
83
181
|
case association.macro
|
84
182
|
when :belongs_to, :has_one
|
@@ -86,13 +184,12 @@ module RealDataTests
|
|
86
184
|
when :has_many, :has_and_belongs_to_many
|
87
185
|
relation = record.public_send(association.name)
|
88
186
|
|
89
|
-
|
90
|
-
|
91
|
-
|
92
|
-
relation = relation.limit(limit)
|
187
|
+
if limit = RealDataTests.configuration.current_preset.get_association_limit(record.class, association.name)
|
188
|
+
puts " Applying configured limit of #{limit} records for #{record.class.name}.#{association.name}"
|
189
|
+
relation = relation[0...limit]
|
93
190
|
end
|
94
191
|
|
95
|
-
relation
|
192
|
+
relation
|
96
193
|
else
|
97
194
|
[]
|
98
195
|
end
|
@@ -103,12 +200,20 @@ module RealDataTests
|
|
103
200
|
@collection_stats.each do |model, stats|
|
104
201
|
puts "\n#{model}:"
|
105
202
|
puts " Total records: #{stats[:count]}"
|
203
|
+
|
106
204
|
if stats[:associations].any?
|
107
205
|
puts " Associations:"
|
108
206
|
stats[:associations].each do |assoc_name, count|
|
109
207
|
puts " #{assoc_name}: #{count} records"
|
110
208
|
end
|
111
209
|
end
|
210
|
+
|
211
|
+
if stats[:polymorphic_types].any?
|
212
|
+
puts " Polymorphic Types:"
|
213
|
+
stats[:polymorphic_types].each do |assoc_name, types|
|
214
|
+
puts " #{assoc_name}: #{types.to_a.join(', ')}"
|
215
|
+
end
|
216
|
+
end
|
112
217
|
end
|
113
218
|
puts "\nTotal unique records collected: #{@collected_records.size}"
|
114
219
|
puts "==============================\n"
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: real_data_tests
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.3.1
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Kevin Dias
|
8
8
|
autorequire:
|
9
9
|
bindir: exe
|
10
10
|
cert_chain: []
|
11
|
-
date: 2025-01-
|
11
|
+
date: 2025-01-14 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: rails
|