real_data_tests 0.4.1 → 0.5.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: afa2db23f090493e45cd9becca7395e36e2a6d2720b0cf2687d170d2c88d4455
4
- data.tar.gz: 267e784a01b732885aef2e00433044ab90ef6d2a9fb77516b55bebb7afdf1007
3
+ metadata.gz: 76313adb8becf99110aade7dae2741bb9fbb18426eab847ec46791dff93acec4
4
+ data.tar.gz: 2650573c1d3a7dc0d3e3866d0520c27696d4fe7d858b5d3b6380b3c88f640bd3
5
5
  SHA512:
6
- metadata.gz: 1f042a6324814776aa3f513b0301850e021f8032a5d0740d125ffa11b0b8e778cbdac2cdfb24c6f2364b5d4cde0eda420039eca9a4e264b2c9da6af2a7b03ad3
7
- data.tar.gz: ffbb471142b577bada70e151cb6cb06a6f460f6f6a4479f33278b98a459fbb3431ce5c55db5e8554682560a15dc355505d10fe3228c37bfd775bd0bcaa916d1d
6
+ metadata.gz: 6ef108ecbc7c30db555e8fb8792a08fce6af245763f524f0d6c1d8fe1bb59cff69d63e902eb46a785f8122fab502abb0dfd0803747348183f031cacdd6080ad0
7
+ data.tar.gz: 900f6e4f32dfbabae2a5492d7cc78bb8490f33b5b7610c9acd1b735e61e2837101ca0b7b8b74cd849d1cbc857805556935b821a87c56e7efa3005962a3179550
data/CHANGELOG.md CHANGED
@@ -1,3 +1,17 @@
1
+ ## [Unreleased]
2
+ ### Added
3
+ - **Load strategies**: SQL dump loading is now pluggable via `RealDataTests::LoadStrategies`
4
+ - `LoadStrategies::Native` — loads on the ActiveRecord connection (default only via `load_real_test_data_native`)
5
+ - `LoadStrategies::Psql` — the previous `psql` shell-out behavior, for dumps that require psql itself (meta-commands like `\set`, or dumps too large to read into memory)
6
+ - Inject via `load_real_test_data("dump", strategy: RealDataTests::LoadStrategies::Psql)`
7
+ - Custom strategies: subclass `LoadStrategies::Base` and implement `#call(dump_path)`
8
+
9
+ ### Changed
10
+ - Reworked SQL dump loading around the new strategies
11
+ - `load_real_test_data` behavior is unchanged (psql shell-out, `LoadStrategies::Psql` default); pass `strategy: LoadStrategies::Native` to load transactionally on the ActiveRecord connection so data participates in the caller's transaction (e.g. DatabaseCleaner `:transaction` strategy) and rolls back with it
12
+ - Native loader: dumps without `COPY ... FROM stdin` blocks are executed as a single multi-statement `execute` (one server round-trip) instead of parsed and executed block-by-block; block parsing is kept only as the COPY fallback
13
+ - Native loader: `COPY ... FROM stdin` blocks are streamed through `raw_connection.copy_data` on the same libpq session, so COPY data is transactional too (previously broken — the whole COPY block went through `execute`)
14
+
1
15
  ## [0.4.1] - 2026-04-09
2
16
  ### Fixed
3
17
  - Fixed `datetime` microsecond precision loss in `PgDumpGenerator`
data/Gemfile.lock CHANGED
@@ -1,8 +1,9 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- real_data_tests (0.2.1)
4
+ real_data_tests (0.5.0)
5
5
  activerecord (>= 5.0)
6
+ csv
6
7
  faker (~> 3.0)
7
8
  pg (>= 1.1)
8
9
  rails (>= 5.0)
@@ -11,66 +12,68 @@ PATH
11
12
  GEM
12
13
  remote: https://rubygems.org/
13
14
  specs:
14
- actioncable (7.2.2.1)
15
- actionpack (= 7.2.2.1)
16
- activesupport (= 7.2.2.1)
15
+ actioncable (7.2.3.1)
16
+ actionpack (= 7.2.3.1)
17
+ activesupport (= 7.2.3.1)
17
18
  nio4r (~> 2.0)
18
19
  websocket-driver (>= 0.6.1)
19
20
  zeitwerk (~> 2.6)
20
- actionmailbox (7.2.2.1)
21
- actionpack (= 7.2.2.1)
22
- activejob (= 7.2.2.1)
23
- activerecord (= 7.2.2.1)
24
- activestorage (= 7.2.2.1)
25
- activesupport (= 7.2.2.1)
21
+ actionmailbox (7.2.3.1)
22
+ actionpack (= 7.2.3.1)
23
+ activejob (= 7.2.3.1)
24
+ activerecord (= 7.2.3.1)
25
+ activestorage (= 7.2.3.1)
26
+ activesupport (= 7.2.3.1)
26
27
  mail (>= 2.8.0)
27
- actionmailer (7.2.2.1)
28
- actionpack (= 7.2.2.1)
29
- actionview (= 7.2.2.1)
30
- activejob (= 7.2.2.1)
31
- activesupport (= 7.2.2.1)
28
+ actionmailer (7.2.3.1)
29
+ actionpack (= 7.2.3.1)
30
+ actionview (= 7.2.3.1)
31
+ activejob (= 7.2.3.1)
32
+ activesupport (= 7.2.3.1)
32
33
  mail (>= 2.8.0)
33
34
  rails-dom-testing (~> 2.2)
34
- actionpack (7.2.2.1)
35
- actionview (= 7.2.2.1)
36
- activesupport (= 7.2.2.1)
35
+ actionpack (7.2.3.1)
36
+ actionview (= 7.2.3.1)
37
+ activesupport (= 7.2.3.1)
38
+ cgi
37
39
  nokogiri (>= 1.8.5)
38
40
  racc
39
- rack (>= 2.2.4, < 3.2)
41
+ rack (>= 2.2.4, < 3.3)
40
42
  rack-session (>= 1.0.1)
41
43
  rack-test (>= 0.6.3)
42
44
  rails-dom-testing (~> 2.2)
43
45
  rails-html-sanitizer (~> 1.6)
44
46
  useragent (~> 0.16)
45
- actiontext (7.2.2.1)
46
- actionpack (= 7.2.2.1)
47
- activerecord (= 7.2.2.1)
48
- activestorage (= 7.2.2.1)
49
- activesupport (= 7.2.2.1)
47
+ actiontext (7.2.3.1)
48
+ actionpack (= 7.2.3.1)
49
+ activerecord (= 7.2.3.1)
50
+ activestorage (= 7.2.3.1)
51
+ activesupport (= 7.2.3.1)
50
52
  globalid (>= 0.6.0)
51
53
  nokogiri (>= 1.8.5)
52
- actionview (7.2.2.1)
53
- activesupport (= 7.2.2.1)
54
+ actionview (7.2.3.1)
55
+ activesupport (= 7.2.3.1)
54
56
  builder (~> 3.1)
57
+ cgi
55
58
  erubi (~> 1.11)
56
59
  rails-dom-testing (~> 2.2)
57
60
  rails-html-sanitizer (~> 1.6)
58
- activejob (7.2.2.1)
59
- activesupport (= 7.2.2.1)
61
+ activejob (7.2.3.1)
62
+ activesupport (= 7.2.3.1)
60
63
  globalid (>= 0.3.6)
61
- activemodel (7.2.2.1)
62
- activesupport (= 7.2.2.1)
63
- activerecord (7.2.2.1)
64
- activemodel (= 7.2.2.1)
65
- activesupport (= 7.2.2.1)
64
+ activemodel (7.2.3.1)
65
+ activesupport (= 7.2.3.1)
66
+ activerecord (7.2.3.1)
67
+ activemodel (= 7.2.3.1)
68
+ activesupport (= 7.2.3.1)
66
69
  timeout (>= 0.4.0)
67
- activestorage (7.2.2.1)
68
- actionpack (= 7.2.2.1)
69
- activejob (= 7.2.2.1)
70
- activerecord (= 7.2.2.1)
71
- activesupport (= 7.2.2.1)
70
+ activestorage (7.2.3.1)
71
+ actionpack (= 7.2.3.1)
72
+ activejob (= 7.2.3.1)
73
+ activerecord (= 7.2.3.1)
74
+ activesupport (= 7.2.3.1)
72
75
  marcel (~> 1.0)
73
- activesupport (7.2.2.1)
76
+ activesupport (7.2.3.1)
74
77
  base64
75
78
  benchmark (>= 0.3)
76
79
  bigdecimal
@@ -79,135 +82,159 @@ GEM
79
82
  drb
80
83
  i18n (>= 1.6, < 2)
81
84
  logger (>= 1.4.2)
82
- minitest (>= 5.1)
85
+ minitest (>= 5.1, < 6)
83
86
  securerandom (>= 0.3)
84
87
  tzinfo (~> 2.0, >= 2.0.5)
85
- base64 (0.2.0)
86
- benchmark (0.4.0)
87
- bigdecimal (3.1.9)
88
+ base64 (0.3.0)
89
+ benchmark (0.5.0)
90
+ bigdecimal (4.1.2)
88
91
  builder (3.3.0)
92
+ cgi (0.5.2)
89
93
  concurrent-ruby (1.3.4)
90
- connection_pool (2.5.0)
91
- crass (1.0.6)
94
+ connection_pool (2.5.5)
95
+ crass (1.0.7)
96
+ csv (3.3.6)
92
97
  database_cleaner (2.1.0)
93
98
  database_cleaner-active_record (>= 2, < 3)
94
- database_cleaner-active_record (2.2.0)
99
+ database_cleaner-active_record (2.2.2)
95
100
  activerecord (>= 5.a)
96
- database_cleaner-core (~> 2.0.0)
101
+ database_cleaner-core (~> 2.0)
97
102
  database_cleaner-core (2.0.1)
98
- date (3.4.1)
99
- diff-lcs (1.5.1)
100
- drb (2.2.1)
103
+ date (3.5.1)
104
+ diff-lcs (1.6.2)
105
+ drb (2.2.3)
106
+ erb (4.0.4.1)
107
+ cgi (>= 0.3.3)
101
108
  erubi (1.13.1)
102
- faker (3.5.1)
109
+ faker (3.6.1)
103
110
  i18n (>= 1.8.11, < 2)
104
- globalid (1.2.1)
111
+ globalid (1.4.0)
105
112
  activesupport (>= 6.1)
106
113
  i18n (1.14.6)
107
114
  concurrent-ruby (~> 1.0)
108
- io-console (0.8.0)
109
- irb (1.14.3)
115
+ io-console (0.8.2)
116
+ irb (1.18.0)
117
+ pp (>= 0.6.0)
118
+ prism (>= 1.3.0)
110
119
  rdoc (>= 4.0.0)
111
120
  reline (>= 0.4.2)
112
121
  logger (1.6.5)
113
- loofah (2.24.0)
122
+ loofah (2.25.2)
114
123
  crass (~> 1.0.2)
115
124
  nokogiri (>= 1.12.0)
116
- mail (2.8.1)
125
+ mail (2.9.1)
126
+ logger
117
127
  mini_mime (>= 0.1.1)
118
128
  net-imap
119
129
  net-pop
120
130
  net-smtp
121
- marcel (1.0.4)
131
+ marcel (1.2.1)
122
132
  mini_mime (1.1.5)
123
- minitest (5.25.4)
124
- net-imap (0.5.5)
133
+ minitest (5.27.0)
134
+ net-imap (0.5.15)
125
135
  date
126
136
  net-protocol
127
137
  net-pop (0.1.2)
128
138
  net-protocol
129
139
  net-protocol (0.2.2)
130
140
  timeout
131
- net-smtp (0.5.0)
141
+ net-smtp (0.5.1)
132
142
  net-protocol
133
- nio4r (2.7.4)
134
- nokogiri (1.18.1-x86_64-darwin)
143
+ nio4r (2.7.5)
144
+ nokogiri (1.18.10-arm64-darwin)
135
145
  racc (~> 1.4)
136
- pg (1.5.9)
137
- psych (5.2.2)
146
+ nokogiri (1.18.10-x86_64-darwin)
147
+ racc (~> 1.4)
148
+ nokogiri (1.18.10-x86_64-linux-gnu)
149
+ racc (~> 1.4)
150
+ pg (1.6.3-arm64-darwin)
151
+ pg (1.6.3-x86_64-darwin)
152
+ pg (1.6.3-x86_64-linux)
153
+ pp (0.6.4)
154
+ prettyprint
155
+ prettyprint (0.2.0)
156
+ prism (1.9.0)
157
+ psych (5.4.0)
138
158
  date
139
159
  stringio
140
160
  racc (1.8.1)
141
- rack (3.1.8)
142
- rack-session (2.1.0)
161
+ rack (3.2.6)
162
+ rack-session (2.1.2)
143
163
  base64 (>= 0.1.0)
144
164
  rack (>= 3.0.0)
145
165
  rack-test (2.2.0)
146
166
  rack (>= 1.3)
147
- rackup (2.2.1)
167
+ rackup (2.3.1)
148
168
  rack (>= 3)
149
- rails (7.2.2.1)
150
- actioncable (= 7.2.2.1)
151
- actionmailbox (= 7.2.2.1)
152
- actionmailer (= 7.2.2.1)
153
- actionpack (= 7.2.2.1)
154
- actiontext (= 7.2.2.1)
155
- actionview (= 7.2.2.1)
156
- activejob (= 7.2.2.1)
157
- activemodel (= 7.2.2.1)
158
- activerecord (= 7.2.2.1)
159
- activestorage (= 7.2.2.1)
160
- activesupport (= 7.2.2.1)
169
+ rails (7.2.3.1)
170
+ actioncable (= 7.2.3.1)
171
+ actionmailbox (= 7.2.3.1)
172
+ actionmailer (= 7.2.3.1)
173
+ actionpack (= 7.2.3.1)
174
+ actiontext (= 7.2.3.1)
175
+ actionview (= 7.2.3.1)
176
+ activejob (= 7.2.3.1)
177
+ activemodel (= 7.2.3.1)
178
+ activerecord (= 7.2.3.1)
179
+ activestorage (= 7.2.3.1)
180
+ activesupport (= 7.2.3.1)
161
181
  bundler (>= 1.15.0)
162
- railties (= 7.2.2.1)
163
- rails-dom-testing (2.2.0)
182
+ railties (= 7.2.3.1)
183
+ rails-dom-testing (2.3.0)
164
184
  activesupport (>= 5.0.0)
165
185
  minitest
166
186
  nokogiri (>= 1.6)
167
- rails-html-sanitizer (1.6.2)
168
- loofah (~> 2.21)
187
+ rails-html-sanitizer (1.7.1)
188
+ loofah (~> 2.25, >= 2.25.2)
169
189
  nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
170
- railties (7.2.2.1)
171
- actionpack (= 7.2.2.1)
172
- activesupport (= 7.2.2.1)
190
+ railties (7.2.3.1)
191
+ actionpack (= 7.2.3.1)
192
+ activesupport (= 7.2.3.1)
193
+ cgi
173
194
  irb (~> 1.13)
174
195
  rackup (>= 1.0.0)
175
196
  rake (>= 12.2)
176
197
  thor (~> 1.0, >= 1.2.2)
198
+ tsort (>= 0.2)
177
199
  zeitwerk (~> 2.6)
178
- rake (13.2.1)
179
- rdoc (6.10.0)
200
+ rake (13.4.2)
201
+ rdoc (7.2.0)
202
+ erb
180
203
  psych (>= 4.0.0)
181
- reline (0.6.0)
204
+ tsort
205
+ reline (0.6.3)
182
206
  io-console (~> 0.5)
183
- rspec (3.13.0)
207
+ rspec (3.13.2)
184
208
  rspec-core (~> 3.13.0)
185
209
  rspec-expectations (~> 3.13.0)
186
210
  rspec-mocks (~> 3.13.0)
187
- rspec-core (3.13.2)
211
+ rspec-core (3.13.6)
188
212
  rspec-support (~> 3.13.0)
189
- rspec-expectations (3.13.3)
213
+ rspec-expectations (3.13.5)
190
214
  diff-lcs (>= 1.2.0, < 2.0)
191
215
  rspec-support (~> 3.13.0)
192
- rspec-mocks (3.13.2)
216
+ rspec-mocks (3.13.8)
193
217
  diff-lcs (>= 1.2.0, < 2.0)
194
218
  rspec-support (~> 3.13.0)
195
219
  rspec-support (3.13.2)
196
220
  securerandom (0.4.1)
197
- stringio (3.1.2)
198
- thor (1.3.2)
199
- timeout (0.4.3)
221
+ stringio (3.2.0)
222
+ thor (1.5.0)
223
+ timeout (0.6.1)
224
+ tsort (0.2.0)
200
225
  tzinfo (2.0.6)
201
226
  concurrent-ruby (~> 1.0)
202
227
  useragent (0.16.11)
203
- websocket-driver (0.7.7)
228
+ websocket-driver (0.8.2)
204
229
  base64
205
230
  websocket-extensions (>= 0.1.0)
206
231
  websocket-extensions (0.1.5)
207
232
  zeitwerk (2.6.18)
208
233
 
209
234
  PLATFORMS
235
+ arm64-darwin-24
210
236
  x86_64-darwin-21
237
+ x86_64-linux
211
238
 
212
239
  DEPENDENCIES
213
240
  database_cleaner (~> 2.0)
data/README.md CHANGED
@@ -245,6 +245,36 @@ RSpec.describe "Blog" do
245
245
  end
246
246
  ```
247
247
 
248
+ ### 3. Load Strategies
249
+
250
+ `load_real_test_data` accepts a strategy that controls how the dump is executed:
251
+
252
+ ```ruby
253
+ # Default: shells out to psql. Data commits on a separate connection,
254
+ # outside any test transaction. Supports psql meta-commands (\set, \connect).
255
+ load_real_test_data("active_user_with_posts")
256
+
257
+ # Native: executes on the ActiveRecord connection, so the data joins the
258
+ # caller's transaction (e.g. DatabaseCleaner's :transaction strategy) and
259
+ # rolls back with it.
260
+ load_real_test_data("active_user_with_posts", strategy: RealDataTests::LoadStrategies::Native)
261
+
262
+ # Shorthand for the Native strategy:
263
+ load_real_test_data_native("active_user_with_posts")
264
+ ```
265
+
266
+ Custom strategies subclass `RealDataTests::LoadStrategies::Base` and implement `#call(dump_path)`:
267
+
268
+ ```ruby
269
+ class MyStrategy < RealDataTests::LoadStrategies::Base
270
+ def call(dump_path)
271
+ # execute the dump however you like
272
+ end
273
+ end
274
+
275
+ load_real_test_data("active_user_with_posts", strategy: MyStrategy)
276
+ ```
277
+
248
278
  ## Association Control
249
279
 
250
280
  Real Data Tests provides several ways to control how associations are collected and loaded.
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RealDataTests
4
+ module LoadStrategies
5
+ # Base class for dump loading strategies. Subclasses implement #call,
6
+ # which receives the absolute path to the SQL dump file and executes it.
7
+ #
8
+ # class MyStrategy < RealDataTests::LoadStrategies::Base
9
+ # def call(dump_path)
10
+ # # load the dump
11
+ # end
12
+ # end
13
+ #
14
+ # load_real_test_data("dump", strategy: MyStrategy)
15
+ class Base
16
+ def self.call(dump_path)
17
+ new.call(dump_path)
18
+ end
19
+
20
+ def call(dump_path)
21
+ raise NotImplementedError, "#{self.class.name} must implement #call"
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RealDataTests
4
+ module LoadStrategies
5
+ # Loads a SQL dump on the ActiveRecord connection, so the data
6
+ # participates in the caller's transaction (e.g. DatabaseCleaner's
7
+ # :transaction strategy) and rolls back with it.
8
+ #
9
+ # Dumps without COPY ... FROM stdin blocks are sent as a single
10
+ # multi-statement execute (one server round-trip). Dumps containing COPY
11
+ # blocks fall back to block-by-block execution, streaming COPY data
12
+ # through raw_connection.copy_data on the same libpq session.
13
+ class Native < Base
14
+ def call(dump_path)
15
+ sql_content = File.read(dump_path)
16
+
17
+ ActiveRecord::Base.transaction do
18
+ connection = ActiveRecord::Base.connection
19
+
20
+ # Disable foreign key checks
21
+ connection.execute('SET session_replication_role = replica;')
22
+
23
+ begin
24
+ if sql_content.match?(/^COPY .* FROM stdin/i)
25
+ blocks = SqlDumpParser.parse(sql_content)
26
+ blocks.each_with_index do |block, index|
27
+ execute_block(block, index + 1, blocks.length)
28
+ end
29
+ else
30
+ # No COPY blocks: send the whole dump in one round-trip. The
31
+ # server parses the multi-statement string, so semicolons inside
32
+ # string literals are handled correctly without client-side splitting.
33
+ connection.execute(sql_content)
34
+ end
35
+ ensure
36
+ connection.execute('SET session_replication_role = DEFAULT;')
37
+ end
38
+ end
39
+ end
40
+
41
+ private
42
+
43
+ def execute_block(block, index, total)
44
+ case block.type
45
+ when :insert
46
+ execute_insert_block(block, index, total)
47
+ when :copy
48
+ execute_copy_block(block, index, total)
49
+ else
50
+ execute_regular_block(block, index, total)
51
+ end
52
+ end
53
+
54
+ def execute_insert_block(block, index, total)
55
+ # Don't modify statements that already end with semicolon
56
+ statement = if block.content.strip.end_with?(';')
57
+ block.content
58
+ else
59
+ "#{block.content};"
60
+ end
61
+
62
+ begin
63
+ ActiveRecord::Base.connection.execute(statement)
64
+ rescue ActiveRecord::StatementInvalid => e
65
+ if e.message.include?('syntax error at or near "ON"')
66
+ # Try alternative formatting for ON CONFLICT
67
+ modified_statement = statement.gsub(/\)\s+ON\s+CONFLICT/, ') ON CONFLICT')
68
+ ActiveRecord::Base.connection.execute(modified_statement)
69
+ else
70
+ raise
71
+ end
72
+ end
73
+ end
74
+
75
+ def execute_copy_block(block, index, total)
76
+ lines = block.content.lines.map(&:chomp)
77
+ copy_statement = lines.shift
78
+ data_lines = lines.take_while { |line| line != '\\.' }
79
+
80
+ # raw_connection is the same libpq session as the AR connection, so the
81
+ # COPY participates in the surrounding transaction.
82
+ raw = ActiveRecord::Base.connection.raw_connection
83
+ raw.copy_data(copy_statement) do
84
+ data_lines.each { |line| raw.put_copy_data("#{line}\n") }
85
+ end
86
+ end
87
+
88
+ def execute_regular_block(block, index, total)
89
+ ActiveRecord::Base.connection.execute(block.content)
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RealDataTests
4
+ module LoadStrategies
5
+ # Loads a SQL dump by shelling out to psql. The psql subprocess uses its
6
+ # own Postgres connection, so the loaded data commits immediately and does
7
+ # NOT participate in the caller's transaction (a transaction is
8
+ # per-connection; a child process can never join it).
9
+ #
10
+ # This is the default strategy of load_real_test_data. Prefer the Native
11
+ # strategy (load_real_test_data_native) unless the dump requires psql
12
+ # itself — e.g. psql meta-commands (\set, \connect) or dumps too large to
13
+ # read into memory.
14
+ class Psql < Base
15
+ def call(dump_path)
16
+ # Load the SQL dump quietly. Invoked without a shell (argv array +
17
+ # :in redirect), so paths and connection values need no escaping.
18
+ # Note: no transaction or session_replication_role handling here —
19
+ # psql runs on its own Postgres session, so nothing set on the
20
+ # ActiveRecord connection can affect the load.
21
+ result = system('psql', *connection_args, in: dump_path)
22
+ raise Error, "Failed to load test data: #{dump_path}" unless result
23
+ end
24
+
25
+ private
26
+
27
+ def connection_args
28
+ config = if ActiveRecord::Base.respond_to?(:connection_db_config)
29
+ ActiveRecord::Base.connection_db_config.configuration_hash
30
+ else
31
+ ActiveRecord::Base.connection_config
32
+ end
33
+ args = []
34
+ args += ['-h', config[:host].to_s] if config[:host]
35
+ args += ['-p', config[:port].to_s] if config[:port]
36
+ args += ['-U', config[:username].to_s] if config[:username]
37
+ args += ['-d', config[:database].to_s]
38
+ args << '-q'
39
+ args
40
+ end
41
+ end
42
+ end
43
+ end
@@ -297,21 +297,5 @@ module RealDataTests
297
297
  def sanitize_string(str)
298
298
  "'#{str.gsub("'", "''")}'"
299
299
  end
300
-
301
- def connection_options
302
- config = if ActiveRecord::Base.respond_to?(:connection_db_config)
303
- ActiveRecord::Base.connection_db_config.configuration_hash
304
- else
305
- ActiveRecord::Base.connection_config
306
- end
307
-
308
- options = []
309
- options << "-h #{config[:host]}" if config[:host]
310
- options << "-p #{config[:port]}" if config[:port]
311
- options << "-U #{config[:username]}" if config[:username]
312
- options << "-d #{config[:database]}"
313
- options << "-q" # Run quietly
314
- options.join(" ")
315
- end
316
300
  end
317
301
  end
@@ -1,376 +1,30 @@
1
1
  module RealDataTests
2
2
  module RSpecHelper
3
- class SqlBlock
4
- attr_reader :type, :content, :table_name
5
-
6
- def initialize(content)
7
- @content = content.strip
8
- @type = determine_block_type
9
- @table_name = extract_table_name if @type == :insert
10
- end
11
-
12
- private
13
-
14
- def determine_block_type
15
- case @content
16
- when /\AINSERT INTO/i
17
- :insert
18
- when /\ACOPY.*FROM stdin/i
19
- :copy
20
- when /\AALTER TABLE/i
21
- :alter
22
- when /\ASET/i
23
- :set
24
- else
25
- :other
26
- end
27
- end
28
-
29
- def extract_table_name
30
- if @content =~ /INSERT INTO\s+"?([^\s"(]+)"?\s/i
31
- $1
32
- end
33
- end
34
- end
35
-
36
- def load_real_test_data(name)
3
+ # Loads a SQL dump using the given strategy (default: psql shell-out,
4
+ # matching pre-0.5 behavior — data commits outside the caller's
5
+ # transaction). Pass a different strategy to change how the dump is
6
+ # executed, e.g. transactional loading on the ActiveRecord connection:
7
+ #
8
+ # load_real_test_data("dump", strategy: RealDataTests::LoadStrategies::Native)
9
+ def load_real_test_data(name, strategy: LoadStrategies::Psql)
37
10
  dump_path = File.join(RealDataTests.configuration.dump_path, "#{name}.sql")
38
11
  raise Error, "Test data file not found: #{dump_path}" unless File.exist?(dump_path)
39
- ActiveRecord::Base.transaction do
40
- # Disable foreign key checks
41
- ActiveRecord::Base.connection.execute('SET session_replication_role = replica;')
42
- begin
43
- # Load the SQL dump quietly
44
- result = system("psql #{connection_options} -q < #{dump_path}")
45
- raise Error, "Failed to load test data: #{dump_path}" unless result
46
- ensure
47
- # Re-enable foreign key checks
48
- ActiveRecord::Base.connection.execute('SET session_replication_role = DEFAULT;')
49
- end
50
- end
51
- end
52
-
53
- def load_real_test_data_native(name)
54
- dump_path = File.join(RealDataTests.configuration.dump_path, "#{name}.sql")
55
- raise Error, "Test data file not found: #{dump_path}" unless File.exist?(dump_path)
56
-
57
- sql_content = File.read(dump_path)
58
- blocks = parse_sql_blocks(sql_content)
59
-
60
- ActiveRecord::Base.transaction do
61
- connection = ActiveRecord::Base.connection
62
-
63
- # Disable foreign key checks
64
- connection.execute('SET session_replication_role = replica;')
65
-
66
- begin
67
- blocks.each_with_index do |block, index|
68
- execute_block(block, index + 1, blocks.length)
69
- end
70
- ensure
71
- connection.execute('SET session_replication_role = DEFAULT;')
72
- end
73
- end
74
- end
75
-
76
- private
77
-
78
- def connection_options
79
- config = if ActiveRecord::Base.respond_to?(:connection_db_config)
80
- ActiveRecord::Base.connection_db_config.configuration_hash
81
- else
82
- ActiveRecord::Base.connection_config
83
- end
84
- options = []
85
- options << "-h #{config[:host]}" if config[:host]
86
- options << "-p #{config[:port]}" if config[:port]
87
- options << "-U #{config[:username]}" if config[:username]
88
- options << "-d #{config[:database]}"
89
- options << "-q"
90
- options.join(" ")
91
- end
92
-
93
- class SqlBlock
94
- attr_reader :type, :content, :table_name
95
-
96
- def initialize(content)
97
- @content = content.strip
98
- @type = determine_block_type
99
- @table_name = extract_table_name if @type == :insert
100
- end
101
-
102
- private
103
-
104
- def determine_block_type
105
- if @content.match?(/\AINSERT INTO/i)
106
- :insert
107
- elsif @content.match?(/\ACOPY.*FROM stdin/i)
108
- :copy
109
- elsif @content.match?(/\AALTER TABLE/i)
110
- :alter
111
- elsif @content.match?(/\ASET/i)
112
- :set
113
- else
114
- :other
115
- end
116
- end
117
-
118
- def extract_table_name
119
- if @content =~ /INSERT INTO\s+"?([^\s"(]+)"?\s/i
120
- $1
121
- end
122
- end
123
- end
124
-
125
- def parse_sql_blocks(content)
126
- blocks = []
127
- current_block = []
128
- in_copy_block = false
129
-
130
- content.each_line do |line|
131
- line = line.chomp
132
-
133
- # Skip empty lines and comments unless in COPY block
134
- next if !in_copy_block && (line.empty? || line.start_with?('--'))
135
-
136
- # Handle start of COPY block
137
- if !in_copy_block && line.upcase.match?(/\ACOPY.*FROM stdin/i)
138
- current_block = [line]
139
- in_copy_block = true
140
- next
141
- end
142
-
143
- # Handle end of COPY block
144
- if in_copy_block && line == '\\.'
145
- current_block << line
146
- blocks << SqlBlock.new(current_block.join("\n"))
147
- current_block = []
148
- in_copy_block = false
149
- next
150
- end
151
-
152
- # Accumulate lines in COPY block
153
- if in_copy_block
154
- current_block << line
155
- next
156
- end
157
-
158
- # Handle regular SQL statements
159
- current_block << line
160
- if line.end_with?(';')
161
- blocks << SqlBlock.new(current_block.join("\n"))
162
- current_block = []
163
- end
164
- end
165
-
166
- # Handle any remaining block
167
- blocks << SqlBlock.new(current_block.join("\n")) unless current_block.empty?
168
- blocks
169
- end
170
12
 
171
- def execute_block(block, index, total)
172
- case block.type
173
- when :insert
174
- execute_insert_block(block, index, total)
175
- when :copy
176
- execute_copy_block(block, index, total)
177
- else
178
- execute_regular_block(block, index, total)
179
- end
13
+ strategy.call(dump_path)
180
14
  end
181
15
 
182
- def execute_insert_block(block, index, total)
183
- # puts "Executing INSERT block #{index}/#{total} for table: #{block.table_name}"
184
- # Don't modify statements that already end with semicolon
185
- statement = if block.content.strip.end_with?(';')
186
- block.content
187
- else
188
- "#{block.content};"
189
- end
190
-
191
- begin
192
- ActiveRecord::Base.connection.execute(statement)
193
- rescue ActiveRecord::StatementInvalid => e
194
- if e.message.include?('syntax error at or near "ON"')
195
- # Try alternative formatting for ON CONFLICT
196
- modified_statement = statement.gsub(/\)\s+ON\s+CONFLICT/, ') ON CONFLICT')
197
- ActiveRecord::Base.connection.execute(modified_statement)
198
- else
199
- raise
200
- end
201
- end
202
- end
203
-
204
- def execute_copy_block(block, index, total)
205
- # puts "Executing COPY block #{index}/#{total}"
206
- ActiveRecord::Base.connection.execute(block.content)
207
- end
208
-
209
- def execute_regular_block(block, index, total)
210
- # puts "Executing block #{index}/#{total} of type: #{block.type}"
211
- ActiveRecord::Base.connection.execute(block.content)
212
- end
213
-
214
- def normalize_insert_statement(statement)
215
- # First clean up any excess whitespace around parentheses
216
- statement = statement.gsub(/\(\s+/, '(')
217
- .gsub(/\s+\)/, ')')
218
- .gsub(/\)\s+ON\s+CONFLICT/, ') ON CONFLICT')
219
-
220
- # Ensure proper spacing around ON CONFLICT
221
- if statement =~ /(.*?)\s*ON\s+CONFLICT\s+(.*?)\s*(?:DO\s+.*?)?\s*;\s*\z/i
222
- base = $1.strip
223
- conflict_part = $2.strip
224
- action_part = $3&.strip || 'DO NOTHING'
225
-
226
- # Rebuild the statement with consistent formatting
227
- "#{base} ON CONFLICT #{conflict_part} #{action_part};"
228
- else
229
- # If no ON CONFLICT clause, just clean up the spacing
230
- statement.strip.sub(/;?\s*$/, ';')
231
- end
232
- end
233
-
234
- def split_sql_statements(sql)
235
- statements = []
236
- current_statement = ''
237
- in_string = false
238
- escaped = false
239
-
240
- sql.each_char do |char|
241
- if char == '\\'
242
- escaped = !escaped
243
- elsif char == "'" && !escaped
244
- in_string = !in_string
245
- elsif char == ';' && !in_string
246
- # Add the completed statement
247
- statements << current_statement.strip unless current_statement.strip.empty?
248
- current_statement = ''
249
- next
250
- end
251
- escaped = false
252
- current_statement << char
253
- end
254
-
255
- # Add the last statement if it exists
256
- statements << current_statement.strip unless current_statement.strip.empty?
257
-
258
- # Normalize `ON CONFLICT` clauses
259
- statements = statements.each_with_object([]) do |stmt, result|
260
- if stmt.strip.upcase.start_with?('ON CONFLICT')
261
- result[-1] = "#{result.last.strip} #{stmt.strip}"
262
- else
263
- result << stmt.strip
264
- end
265
- end
266
-
267
- # Ensure semicolons and spacing
268
- statements.map! do |stmt|
269
- stmt = stmt.gsub(/\)\s*ON CONFLICT/, ') ON CONFLICT') # Normalize spacing
270
- stmt.strip.end_with?(';') ? stmt.strip : "#{stmt.strip};"
271
- end
272
-
273
- statements
274
- end
275
-
276
- def extract_conflict_clause(statement)
277
- # Use a more precise regex that handles multiple closing parentheses
278
- if statement =~ /(.+?\))\s*(ON\s+CONFLICT\s+.*?)(?:;?\s*$)/i
279
- [$1, $2.strip]
280
- else
281
- [statement.sub(/;?\s*$/, ''), nil]
282
- end
283
- end
284
-
285
- def clean_sql_statement(statement)
286
- # Match either INSERT INTO...VALUES or just VALUES
287
- if statement =~ /(?:INSERT INTO.*)?VALUES\s*\(/i
288
- # Split the statement into parts, being careful with the ending
289
- if statement =~ /(.*?VALUES\s*\()(.*)(\)\s*(?:ON CONFLICT.*)?;?\s*$)/i
290
- pre_values = $1
291
- values_content = $2
292
- post_values = $3
293
-
294
- # Clean the values content while preserving complex JSON
295
- cleaned_values = clean_complex_values(values_content)
296
-
297
- # Reassemble the statement, ensuring exactly one semicolon at the end
298
- statement = "#{pre_values}#{cleaned_values}#{post_values}"
299
- statement = statement.gsub(/;*\s*$/, '') # Remove any trailing semicolons and whitespace
300
- statement += ";"
301
- end
302
- end
303
- statement
304
- end
305
-
306
- def clean_complex_values(values_str)
307
- current_value = ''
308
- values = []
309
- in_quotes = false
310
- in_json = false
311
- json_brace_count = 0
312
- escaped = false
313
-
314
- chars = values_str.chars
315
- i = 0
316
- while i < chars.length
317
- char = chars[i]
318
-
319
- case char
320
- when '\\'
321
- current_value << char
322
- escaped = !escaped
323
- when "'"
324
- if !escaped
325
- in_quotes = !in_quotes
326
- end
327
- escaped = false
328
- current_value << char
329
- when '{'
330
- if !in_quotes
331
- in_json = true
332
- json_brace_count += 1
333
- end
334
- current_value << char
335
- when '}'
336
- if !in_quotes
337
- json_brace_count -= 1
338
- in_json = json_brace_count > 0
339
- end
340
- current_value << char
341
- when ','
342
- if !in_quotes && !in_json
343
- values << clean_value(current_value.strip)
344
- current_value = ''
345
- else
346
- current_value << char
347
- end
348
- else
349
- escaped = false
350
- current_value << char
351
- end
352
- i += 1
353
- end
354
-
355
- # Add the last value
356
- values << clean_value(current_value.strip) unless current_value.strip.empty?
357
-
358
- values.join(', ')
359
- end
360
-
361
- def clean_value(value)
362
- return value if value.start_with?("'") # Already quoted
363
- return value if value.start_with?("'{") # JSON object
364
- return 'NULL' if value.upcase == 'NULL'
365
- return value.downcase if ['true', 'false'].include?(value.downcase)
366
- return value if value.match?(/^\d+$/) # Numbers
367
-
368
- if value.match?(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i)
369
- "'#{value}'" # UUID
370
- else
371
- # Handle any other string value, including those with commas
372
- "'#{value}'" # Other strings
373
- end
16
+ # Loads a SQL dump on the ActiveRecord connection
17
+ # (LoadStrategies::Native), so the data participates in the caller's
18
+ # transaction (e.g. DatabaseCleaner's :transaction strategy) and rolls
19
+ # back with it. Shorthand for:
20
+ #
21
+ # load_real_test_data(name, strategy: RealDataTests::LoadStrategies::Native)
22
+ #
23
+ # Unlike the default psql strategy, this never commits outside the test
24
+ # transaction, but it cannot process psql meta-commands (e.g. \set) and
25
+ # reads the whole dump into memory.
26
+ def load_real_test_data_native(name)
27
+ load_real_test_data(name, strategy: LoadStrategies::Native)
374
28
  end
375
29
  end
376
- end
30
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RealDataTests
4
+ # Splits a SQL dump into executable blocks. Regular statements become one
5
+ # block each (terminated by ";"); COPY ... FROM stdin blocks span from the
6
+ # COPY line through the "\." terminator, preserving the data lines between.
7
+ class SqlDumpParser
8
+ # Returns an array of SqlBlock in dump order.
9
+ def self.parse(content)
10
+ blocks = []
11
+ current_block = []
12
+ in_copy_block = false
13
+
14
+ content.each_line do |line|
15
+ line = line.chomp
16
+
17
+ # Skip empty lines and comments unless in COPY block
18
+ next if !in_copy_block && (line.empty? || line.start_with?('--'))
19
+
20
+ # Handle start of COPY block
21
+ if !in_copy_block && line.upcase.match?(/\ACOPY.*FROM stdin/i)
22
+ current_block = [line]
23
+ in_copy_block = true
24
+ next
25
+ end
26
+
27
+ # Handle end of COPY block
28
+ if in_copy_block && line == '\\.'
29
+ current_block << line
30
+ blocks << SqlBlock.new(current_block.join("\n"))
31
+ current_block = []
32
+ in_copy_block = false
33
+ next
34
+ end
35
+
36
+ # Accumulate lines in COPY block
37
+ if in_copy_block
38
+ current_block << line
39
+ next
40
+ end
41
+
42
+ # Handle regular SQL statements
43
+ current_block << line
44
+ if line.end_with?(';')
45
+ blocks << SqlBlock.new(current_block.join("\n"))
46
+ current_block = []
47
+ end
48
+ end
49
+
50
+ # Handle any remaining block
51
+ blocks << SqlBlock.new(current_block.join("\n")) unless current_block.empty?
52
+ blocks
53
+ end
54
+
55
+ # A single parsed block of the dump, tagged with its statement type.
56
+ class SqlBlock
57
+ attr_reader :type, :content, :table_name
58
+
59
+ def initialize(content)
60
+ @content = content.strip
61
+ @type = determine_block_type
62
+ @table_name = extract_table_name if @type == :insert
63
+ end
64
+
65
+ private
66
+
67
+ def determine_block_type
68
+ case @content
69
+ when /\AINSERT INTO/i
70
+ :insert
71
+ when /\ACOPY.*FROM stdin/i
72
+ :copy
73
+ when /\AALTER TABLE/i
74
+ :alter
75
+ when /\ASET/i
76
+ :set
77
+ else
78
+ :other
79
+ end
80
+ end
81
+
82
+ def extract_table_name
83
+ if @content =~ /INSERT INTO\s+"?([^\s"(]+)"?\s/i
84
+ $1
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RealDataTests
4
- VERSION = "0.4.1"
4
+ VERSION = "0.5.0"
5
5
  end
@@ -7,6 +7,10 @@ require_relative 'real_data_tests/data_anonymizer'
7
7
  require_relative 'real_data_tests/engine' if defined?(Rails)
8
8
  require_relative 'real_data_tests/pg_dump_generator'
9
9
  require_relative 'real_data_tests/record_collector'
10
+ require_relative 'real_data_tests/sql_dump_parser'
11
+ require_relative 'real_data_tests/load_strategies/base'
12
+ require_relative 'real_data_tests/load_strategies/native'
13
+ require_relative 'real_data_tests/load_strategies/psql'
10
14
  require_relative 'real_data_tests/rspec_helper'
11
15
  require_relative 'real_data_tests/test_data_builder'
12
16
 
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/real_data_tests/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "real_data_tests"
7
+ spec.version = RealDataTests::VERSION
8
+ spec.authors = ["Kevin Dias"]
9
+ spec.email = ["diasks2@gmail.com"]
10
+
11
+ spec.summary = "Create realistic test data from local db records"
12
+ spec.description = "A Ruby gem that helps create test data by analyzing and extracting real records and their associations from your database."
13
+ spec.homepage = "https://github.com/diasks2/real_data_tests"
14
+ spec.license = "MIT"
15
+ spec.required_ruby_version = ">= 2.6.0"
16
+
17
+ spec.metadata["homepage_uri"] = spec.homepage
18
+ spec.metadata["source_code_uri"] = spec.homepage
19
+ spec.metadata["changelog_uri"] = spec.homepage
20
+
21
+ # Specify which files should be added to the gem when it is released.
22
+ # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
23
+ spec.files = Dir.chdir(__dir__) do
24
+ `git ls-files -z`.split("\x0").reject do |f|
25
+ (f == __FILE__) || f.match(%r{\A(?:(?:bin|test|spec|features)/|\.(?:git|travis|circleci)|appveyor)})
26
+ end
27
+ end
28
+ spec.bindir = "exe"
29
+ spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
30
+ spec.require_paths = ["lib"]
31
+
32
+ spec.add_dependency "rails", ">= 5.0"
33
+ spec.add_dependency "activerecord", ">= 5.0"
34
+ spec.add_dependency "thor", "~> 1.0"
35
+ spec.add_dependency "pg", ">= 1.1"
36
+ spec.add_dependency "csv"
37
+ spec.add_development_dependency "rake", "~> 13.0"
38
+ spec.add_development_dependency "rspec", "~> 3.0"
39
+ spec.add_development_dependency "database_cleaner", "~> 2.0"
40
+ spec.add_development_dependency "database_cleaner-active_record"
41
+ spec.add_dependency "faker", "~> 3.0"
42
+ end
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: real_data_tests
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.1
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kevin Dias
8
- autorequire:
9
8
  bindir: exe
10
9
  cert_chain: []
11
- date: 2026-04-09 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: rails
@@ -66,6 +65,20 @@ dependencies:
66
65
  - - ">="
67
66
  - !ruby/object:Gem::Version
68
67
  version: '1.1'
68
+ - !ruby/object:Gem::Dependency
69
+ name: csv
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - ">="
73
+ - !ruby/object:Gem::Version
74
+ version: '0'
75
+ type: :runtime
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - ">="
80
+ - !ruby/object:Gem::Version
81
+ version: '0'
69
82
  - !ruby/object:Gem::Dependency
70
83
  name: rake
71
84
  requirement: !ruby/object:Gem::Requirement
@@ -156,11 +169,16 @@ files:
156
169
  - lib/real_data_tests/configuration.rb
157
170
  - lib/real_data_tests/data_anonymizer.rb
158
171
  - lib/real_data_tests/engine.rb
172
+ - lib/real_data_tests/load_strategies/base.rb
173
+ - lib/real_data_tests/load_strategies/native.rb
174
+ - lib/real_data_tests/load_strategies/psql.rb
159
175
  - lib/real_data_tests/pg_dump_generator.rb
160
176
  - lib/real_data_tests/record_collector.rb
161
177
  - lib/real_data_tests/rspec_helper.rb
178
+ - lib/real_data_tests/sql_dump_parser.rb
162
179
  - lib/real_data_tests/test_data_builder.rb
163
180
  - lib/real_data_tests/version.rb
181
+ - real_data_tests.gemspec
164
182
  homepage: https://github.com/diasks2/real_data_tests
165
183
  licenses:
166
184
  - MIT
@@ -168,7 +186,6 @@ metadata:
168
186
  homepage_uri: https://github.com/diasks2/real_data_tests
169
187
  source_code_uri: https://github.com/diasks2/real_data_tests
170
188
  changelog_uri: https://github.com/diasks2/real_data_tests
171
- post_install_message:
172
189
  rdoc_options: []
173
190
  require_paths:
174
191
  - lib
@@ -183,8 +200,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
183
200
  - !ruby/object:Gem::Version
184
201
  version: '0'
185
202
  requirements: []
186
- rubygems_version: 3.3.26
187
- signing_key:
203
+ rubygems_version: 3.7.2
188
204
  specification_version: 4
189
205
  summary: Create realistic test data from local db records
190
206
  test_files: []