familia 2.0.0.pre26 → 2.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.
- checksums.yaml +4 -4
- data/CHANGELOG.rst +94 -0
- data/Gemfile +3 -0
- data/Gemfile.lock +12 -2
- data/README.md +1 -3
- data/docs/guides/feature-encrypted-fields.md +1 -1
- data/docs/guides/feature-expiration.md +1 -1
- data/docs/guides/feature-quantization.md +1 -1
- data/docs/guides/writing-migrations.md +345 -0
- data/docs/overview.md +7 -7
- data/docs/reference/api-technical.md +103 -7
- data/examples/migrations/v1_to_v2_serialization_migration.rb +374 -0
- data/examples/schemas/customer.json +33 -0
- data/examples/schemas/session.json +27 -0
- data/familia.gemspec +3 -2
- data/lib/familia/features/schema_validation.rb +139 -0
- data/lib/familia/migration/base.rb +447 -0
- data/lib/familia/migration/errors.rb +31 -0
- data/lib/familia/migration/model.rb +418 -0
- data/lib/familia/migration/pipeline.rb +226 -0
- data/lib/familia/migration/rake_tasks.rake +3 -0
- data/lib/familia/migration/rake_tasks.rb +160 -0
- data/lib/familia/migration/registry.rb +364 -0
- data/lib/familia/migration/runner.rb +311 -0
- data/lib/familia/migration/script.rb +234 -0
- data/lib/familia/migration.rb +43 -0
- data/lib/familia/schema_registry.rb +173 -0
- data/lib/familia/settings.rb +63 -1
- data/lib/familia/version.rb +1 -1
- data/lib/familia.rb +1 -0
- data/try/features/schema_registry_try.rb +193 -0
- data/try/features/schema_validation_feature_try.rb +218 -0
- data/try/migration/base_try.rb +226 -0
- data/try/migration/errors_try.rb +67 -0
- data/try/migration/integration_try.rb +451 -0
- data/try/migration/model_try.rb +431 -0
- data/try/migration/pipeline_try.rb +460 -0
- data/try/migration/rake_tasks_try.rb +61 -0
- data/try/migration/registry_try.rb +199 -0
- data/try/migration/runner_try.rb +311 -0
- data/try/migration/schema_validation_try.rb +201 -0
- data/try/migration/script_try.rb +192 -0
- data/try/migration/v1_to_v2_serialization_try.rb +513 -0
- data/try/performance/benchmarks_try.rb +11 -12
- metadata +45 -27
- data/docs/migrating/v2.0.0-pre.md +0 -84
- data/docs/migrating/v2.0.0-pre11.md +0 -253
- data/docs/migrating/v2.0.0-pre12.md +0 -306
- data/docs/migrating/v2.0.0-pre13.md +0 -95
- data/docs/migrating/v2.0.0-pre14.md +0 -37
- data/docs/migrating/v2.0.0-pre18.md +0 -58
- data/docs/migrating/v2.0.0-pre19.md +0 -197
- data/docs/migrating/v2.0.0-pre22.md +0 -241
- data/docs/migrating/v2.0.0-pre5.md +0 -131
- data/docs/migrating/v2.0.0-pre6.md +0 -154
- data/docs/migrating/v2.0.0-pre7.md +0 -222
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
# try/migration/pipeline_try.rb
|
|
2
|
+
#
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
require_relative '../support/helpers/test_helpers'
|
|
6
|
+
require_relative '../../lib/familia/migration'
|
|
7
|
+
|
|
8
|
+
Familia.debug = false
|
|
9
|
+
|
|
10
|
+
@redis = Familia.dbclient
|
|
11
|
+
@test_id = "#{Process.pid}_#{Time.now.to_i}"
|
|
12
|
+
@prefix = "familia:test:pipeline:#{@test_id}"
|
|
13
|
+
|
|
14
|
+
@initial_migrations = Familia::Migration.migrations.dup
|
|
15
|
+
|
|
16
|
+
# Test model class for pipeline migrations
|
|
17
|
+
class PipelineTestRecord < Familia::Horreum
|
|
18
|
+
identifier_field :record_id
|
|
19
|
+
field :record_id
|
|
20
|
+
field :name
|
|
21
|
+
field :status
|
|
22
|
+
field :old_field
|
|
23
|
+
field :new_field
|
|
24
|
+
field :migrated_at
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Simple Pipeline migration
|
|
28
|
+
class SimplePipelineMigration < Familia::Migration::Pipeline
|
|
29
|
+
self.migration_id = 'pipeline_test_simple'
|
|
30
|
+
self.description = 'Simple pipeline migration test'
|
|
31
|
+
|
|
32
|
+
class << self
|
|
33
|
+
attr_accessor :processed_count
|
|
34
|
+
end
|
|
35
|
+
self.processed_count = 0
|
|
36
|
+
|
|
37
|
+
def prepare
|
|
38
|
+
@model_class = PipelineTestRecord
|
|
39
|
+
@batch_size = 10
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def should_process?(obj)
|
|
43
|
+
true
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def build_update_fields(obj)
|
|
47
|
+
self.class.processed_count += 1
|
|
48
|
+
{ new_field: 'pipeline_updated' }
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Pipeline migration that filters records
|
|
53
|
+
class FilteringPipelineMigration < Familia::Migration::Pipeline
|
|
54
|
+
self.migration_id = 'pipeline_test_filtering'
|
|
55
|
+
|
|
56
|
+
class << self
|
|
57
|
+
attr_accessor :skipped_count, :processed_count
|
|
58
|
+
end
|
|
59
|
+
self.skipped_count = 0
|
|
60
|
+
self.processed_count = 0
|
|
61
|
+
|
|
62
|
+
def prepare
|
|
63
|
+
@model_class = PipelineTestRecord
|
|
64
|
+
@batch_size = 10
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def should_process?(obj)
|
|
68
|
+
if obj.status == 'skip_me'
|
|
69
|
+
self.class.skipped_count += 1
|
|
70
|
+
track_stat(:skipped)
|
|
71
|
+
return false
|
|
72
|
+
end
|
|
73
|
+
self.class.processed_count += 1
|
|
74
|
+
true
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def build_update_fields(obj)
|
|
78
|
+
{ new_field: 'filtered_update', migrated_at: Time.now.to_i.to_s }
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Pipeline migration with empty fields (should skip HMSET)
|
|
83
|
+
class EmptyFieldsPipelineMigration < Familia::Migration::Pipeline
|
|
84
|
+
self.migration_id = 'pipeline_test_empty'
|
|
85
|
+
|
|
86
|
+
class << self
|
|
87
|
+
attr_accessor :build_called_count
|
|
88
|
+
end
|
|
89
|
+
self.build_called_count = 0
|
|
90
|
+
|
|
91
|
+
def prepare
|
|
92
|
+
@model_class = PipelineTestRecord
|
|
93
|
+
@batch_size = 10
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def should_process?(obj)
|
|
97
|
+
true
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def build_update_fields(obj)
|
|
101
|
+
self.class.build_called_count += 1
|
|
102
|
+
{} # Return empty hash
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Pipeline migration with nil fields
|
|
107
|
+
class NilFieldsPipelineMigration < Familia::Migration::Pipeline
|
|
108
|
+
self.migration_id = 'pipeline_test_nil'
|
|
109
|
+
|
|
110
|
+
def prepare
|
|
111
|
+
@model_class = PipelineTestRecord
|
|
112
|
+
@batch_size = 10
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def should_process?(obj)
|
|
116
|
+
true
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def build_update_fields(obj)
|
|
120
|
+
nil # Return nil
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Pipeline migration with custom execute_update
|
|
125
|
+
class CustomExecutePipelineMigration < Familia::Migration::Pipeline
|
|
126
|
+
self.migration_id = 'pipeline_test_custom_execute'
|
|
127
|
+
|
|
128
|
+
class << self
|
|
129
|
+
attr_accessor :custom_execute_called, :original_keys
|
|
130
|
+
end
|
|
131
|
+
self.custom_execute_called = 0
|
|
132
|
+
self.original_keys = []
|
|
133
|
+
|
|
134
|
+
def prepare
|
|
135
|
+
@model_class = PipelineTestRecord
|
|
136
|
+
@batch_size = 10
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def should_process?(obj)
|
|
140
|
+
true
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def build_update_fields(obj)
|
|
144
|
+
{ custom_field: 'custom_value' }
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def execute_update(pipe, obj, fields, original_key = nil)
|
|
148
|
+
self.class.custom_execute_called += 1
|
|
149
|
+
self.class.original_keys << original_key
|
|
150
|
+
super(pipe, obj, fields, original_key)
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Pipeline migration that errors during batch
|
|
155
|
+
class ErrorPipelineMigration < Familia::Migration::Pipeline
|
|
156
|
+
self.migration_id = 'pipeline_test_error'
|
|
157
|
+
|
|
158
|
+
class << self
|
|
159
|
+
attr_accessor :error_triggered
|
|
160
|
+
end
|
|
161
|
+
self.error_triggered = false
|
|
162
|
+
|
|
163
|
+
def prepare
|
|
164
|
+
@model_class = PipelineTestRecord
|
|
165
|
+
@batch_size = 10
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def should_process?(obj)
|
|
169
|
+
if obj.name == 'trigger_error'
|
|
170
|
+
self.class.error_triggered = true
|
|
171
|
+
raise 'Intentional pipeline error'
|
|
172
|
+
end
|
|
173
|
+
true
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def build_update_fields(obj)
|
|
177
|
+
{ new_field: 'updated' }
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Pipeline without should_process? - should raise
|
|
182
|
+
class MissingShouldProcessPipeline < Familia::Migration::Pipeline
|
|
183
|
+
self.migration_id = 'pipeline_test_missing_should'
|
|
184
|
+
|
|
185
|
+
def prepare
|
|
186
|
+
@model_class = PipelineTestRecord
|
|
187
|
+
@batch_size = 10
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def build_update_fields(obj)
|
|
191
|
+
{ field: 'value' }
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Pipeline without build_update_fields - should raise
|
|
196
|
+
class MissingBuildFieldsPipeline < Familia::Migration::Pipeline
|
|
197
|
+
self.migration_id = 'pipeline_test_missing_build'
|
|
198
|
+
|
|
199
|
+
def prepare
|
|
200
|
+
@model_class = PipelineTestRecord
|
|
201
|
+
@batch_size = 10
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def should_process?(obj)
|
|
205
|
+
true
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# Helper to create test records with unique prefix
|
|
210
|
+
def create_pipeline_record(suffix, name: 'Test', status: 'active')
|
|
211
|
+
id = "#{@test_id}_#{suffix}"
|
|
212
|
+
record = PipelineTestRecord.new(record_id: id, name: name, status: status)
|
|
213
|
+
record.save
|
|
214
|
+
record
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# Helper to cleanup all test records
|
|
218
|
+
def cleanup_records
|
|
219
|
+
pattern = "pipeline_test_record:#{@test_id}_*"
|
|
220
|
+
@redis.keys(pattern).each { |k| @redis.del(k) }
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
cleanup_records
|
|
224
|
+
|
|
225
|
+
## Pipeline is a subclass of Model
|
|
226
|
+
Familia::Migration::Pipeline < Familia::Migration::Model
|
|
227
|
+
#=> true
|
|
228
|
+
|
|
229
|
+
## Pipeline initializes correctly
|
|
230
|
+
migration = SimplePipelineMigration.new
|
|
231
|
+
migration.is_a?(Familia::Migration::Pipeline)
|
|
232
|
+
#=> true
|
|
233
|
+
|
|
234
|
+
## Pipeline prepare sets model_class
|
|
235
|
+
SimplePipelineMigration.processed_count = 0
|
|
236
|
+
migration = SimplePipelineMigration.new
|
|
237
|
+
migration.prepare
|
|
238
|
+
migration.model_class == PipelineTestRecord
|
|
239
|
+
#=> true
|
|
240
|
+
|
|
241
|
+
## Pipeline should_process? raises NotImplementedError for base class
|
|
242
|
+
begin
|
|
243
|
+
Familia::Migration::Pipeline.new.send(:should_process?, nil)
|
|
244
|
+
false
|
|
245
|
+
rescue NotImplementedError
|
|
246
|
+
true
|
|
247
|
+
end
|
|
248
|
+
#=> true
|
|
249
|
+
|
|
250
|
+
## Pipeline build_update_fields raises NotImplementedError for base class
|
|
251
|
+
begin
|
|
252
|
+
Familia::Migration::Pipeline.new.send(:build_update_fields, nil)
|
|
253
|
+
false
|
|
254
|
+
rescue NotImplementedError
|
|
255
|
+
true
|
|
256
|
+
end
|
|
257
|
+
#=> true
|
|
258
|
+
|
|
259
|
+
## Pipeline process_record is a no-op
|
|
260
|
+
migration = SimplePipelineMigration.new
|
|
261
|
+
migration.send(:process_record, nil, 'key')
|
|
262
|
+
true
|
|
263
|
+
#=> true
|
|
264
|
+
|
|
265
|
+
## Pipeline processes records in batches
|
|
266
|
+
cleanup_records
|
|
267
|
+
create_pipeline_record('p1', name: 'P1')
|
|
268
|
+
create_pipeline_record('p2', name: 'P2')
|
|
269
|
+
create_pipeline_record('p3', name: 'P3')
|
|
270
|
+
SimplePipelineMigration.processed_count = 0
|
|
271
|
+
migration = SimplePipelineMigration.new(run: true)
|
|
272
|
+
migration.prepare
|
|
273
|
+
migration.migrate
|
|
274
|
+
SimplePipelineMigration.processed_count >= 3
|
|
275
|
+
#=> true
|
|
276
|
+
|
|
277
|
+
## Pipeline updates are applied in actual_run mode
|
|
278
|
+
cleanup_records
|
|
279
|
+
record = create_pipeline_record('q1', name: 'Q1')
|
|
280
|
+
dbkey = record.dbkey
|
|
281
|
+
SimplePipelineMigration.processed_count = 0
|
|
282
|
+
migration = SimplePipelineMigration.new(run: true)
|
|
283
|
+
migration.prepare
|
|
284
|
+
migration.migrate
|
|
285
|
+
@redis.hget(dbkey, 'new_field')
|
|
286
|
+
#=> "pipeline_updated"
|
|
287
|
+
|
|
288
|
+
## Pipeline respects dry_run mode
|
|
289
|
+
cleanup_records
|
|
290
|
+
record = create_pipeline_record('r1', name: 'R1')
|
|
291
|
+
SimplePipelineMigration.processed_count = 0
|
|
292
|
+
migration = SimplePipelineMigration.new(run: false)
|
|
293
|
+
migration.prepare
|
|
294
|
+
migration.migrate
|
|
295
|
+
reloaded = PipelineTestRecord.find_by_key(record.dbkey)
|
|
296
|
+
reloaded.new_field.nil?
|
|
297
|
+
#=> true
|
|
298
|
+
|
|
299
|
+
## Pipeline filtering works correctly
|
|
300
|
+
cleanup_records
|
|
301
|
+
create_pipeline_record('s1', status: 'active')
|
|
302
|
+
create_pipeline_record('s2', status: 'skip_me')
|
|
303
|
+
create_pipeline_record('s3', status: 'active')
|
|
304
|
+
FilteringPipelineMigration.skipped_count = 0
|
|
305
|
+
FilteringPipelineMigration.processed_count = 0
|
|
306
|
+
migration = FilteringPipelineMigration.new(run: true)
|
|
307
|
+
migration.prepare
|
|
308
|
+
migration.migrate
|
|
309
|
+
FilteringPipelineMigration.skipped_count >= 1 && FilteringPipelineMigration.processed_count >= 2
|
|
310
|
+
#=> true
|
|
311
|
+
|
|
312
|
+
## Pipeline tracks records_updated correctly
|
|
313
|
+
cleanup_records
|
|
314
|
+
create_pipeline_record('t1')
|
|
315
|
+
create_pipeline_record('t2')
|
|
316
|
+
SimplePipelineMigration.processed_count = 0
|
|
317
|
+
migration = SimplePipelineMigration.new(run: true)
|
|
318
|
+
migration.prepare
|
|
319
|
+
migration.migrate
|
|
320
|
+
migration.records_updated >= 2
|
|
321
|
+
#=> true
|
|
322
|
+
|
|
323
|
+
## Pipeline with empty fields skips HMSET
|
|
324
|
+
cleanup_records
|
|
325
|
+
record = create_pipeline_record('u1', name: 'Original')
|
|
326
|
+
EmptyFieldsPipelineMigration.build_called_count = 0
|
|
327
|
+
migration = EmptyFieldsPipelineMigration.new(run: true)
|
|
328
|
+
migration.prepare
|
|
329
|
+
migration.migrate
|
|
330
|
+
EmptyFieldsPipelineMigration.build_called_count >= 1
|
|
331
|
+
#=> true
|
|
332
|
+
|
|
333
|
+
## Pipeline with nil fields skips HMSET
|
|
334
|
+
cleanup_records
|
|
335
|
+
record = create_pipeline_record('v1', name: 'Original')
|
|
336
|
+
migration = NilFieldsPipelineMigration.new(run: true)
|
|
337
|
+
migration.prepare
|
|
338
|
+
migration.migrate
|
|
339
|
+
record.name
|
|
340
|
+
#=> 'Original'
|
|
341
|
+
|
|
342
|
+
## Pipeline custom execute_update is called
|
|
343
|
+
cleanup_records
|
|
344
|
+
record = create_pipeline_record('w1')
|
|
345
|
+
CustomExecutePipelineMigration.custom_execute_called = 0
|
|
346
|
+
CustomExecutePipelineMigration.original_keys = []
|
|
347
|
+
migration = CustomExecutePipelineMigration.new(run: true)
|
|
348
|
+
migration.prepare
|
|
349
|
+
migration.migrate
|
|
350
|
+
CustomExecutePipelineMigration.custom_execute_called >= 1
|
|
351
|
+
#=> true
|
|
352
|
+
|
|
353
|
+
## Pipeline custom execute_update receives original_key
|
|
354
|
+
cleanup_records
|
|
355
|
+
create_pipeline_record('x1')
|
|
356
|
+
CustomExecutePipelineMigration.custom_execute_called = 0
|
|
357
|
+
CustomExecutePipelineMigration.original_keys = []
|
|
358
|
+
migration = CustomExecutePipelineMigration.new(run: true)
|
|
359
|
+
migration.prepare
|
|
360
|
+
migration.migrate
|
|
361
|
+
CustomExecutePipelineMigration.original_keys.first.include?('object')
|
|
362
|
+
#=> true
|
|
363
|
+
|
|
364
|
+
## Pipeline handles batch errors gracefully
|
|
365
|
+
cleanup_records
|
|
366
|
+
create_pipeline_record('y1', name: 'trigger_error')
|
|
367
|
+
ErrorPipelineMigration.error_triggered = false
|
|
368
|
+
migration = ErrorPipelineMigration.new(run: true)
|
|
369
|
+
migration.prepare
|
|
370
|
+
migration.migrate
|
|
371
|
+
ErrorPipelineMigration.error_triggered && migration.error_count >= 1
|
|
372
|
+
#=> true
|
|
373
|
+
|
|
374
|
+
## Pipeline tracks errors per batch size
|
|
375
|
+
cleanup_records
|
|
376
|
+
create_pipeline_record('z1', name: 'trigger_error')
|
|
377
|
+
create_pipeline_record('z2', name: 'normal')
|
|
378
|
+
ErrorPipelineMigration.error_triggered = false
|
|
379
|
+
migration = ErrorPipelineMigration.new(run: true)
|
|
380
|
+
migration.prepare
|
|
381
|
+
migration.migrate
|
|
382
|
+
migration.error_count >= 1
|
|
383
|
+
#=> true
|
|
384
|
+
|
|
385
|
+
## process_batch calls should_process? and build_update_fields
|
|
386
|
+
cleanup_records
|
|
387
|
+
create_pipeline_record('aa1')
|
|
388
|
+
SimplePipelineMigration.processed_count = 0
|
|
389
|
+
migration = SimplePipelineMigration.new(run: true)
|
|
390
|
+
migration.prepare
|
|
391
|
+
migration.migrate
|
|
392
|
+
SimplePipelineMigration.processed_count >= 1
|
|
393
|
+
#=> true
|
|
394
|
+
|
|
395
|
+
## Pipeline tracks total_scanned
|
|
396
|
+
cleanup_records
|
|
397
|
+
create_pipeline_record('bb1')
|
|
398
|
+
create_pipeline_record('bb2')
|
|
399
|
+
migration = SimplePipelineMigration.new(run: true)
|
|
400
|
+
migration.prepare
|
|
401
|
+
migration.migrate
|
|
402
|
+
migration.total_scanned >= 2
|
|
403
|
+
#=> true
|
|
404
|
+
|
|
405
|
+
## Pipeline tracks records_needing_update
|
|
406
|
+
cleanup_records
|
|
407
|
+
create_pipeline_record('cc1')
|
|
408
|
+
create_pipeline_record('cc2')
|
|
409
|
+
create_pipeline_record('cc3')
|
|
410
|
+
migration = SimplePipelineMigration.new(run: true)
|
|
411
|
+
migration.prepare
|
|
412
|
+
migration.migrate
|
|
413
|
+
migration.records_needing_update >= 3
|
|
414
|
+
#=> true
|
|
415
|
+
|
|
416
|
+
## Pipeline returns true on success
|
|
417
|
+
cleanup_records
|
|
418
|
+
create_pipeline_record('dd1')
|
|
419
|
+
migration = SimplePipelineMigration.new(run: true)
|
|
420
|
+
migration.prepare
|
|
421
|
+
migration.migrate
|
|
422
|
+
#=> true
|
|
423
|
+
|
|
424
|
+
## Pipeline returns false when errors
|
|
425
|
+
cleanup_records
|
|
426
|
+
create_pipeline_record('ee1', name: 'trigger_error')
|
|
427
|
+
ErrorPipelineMigration.error_triggered = false
|
|
428
|
+
migration = ErrorPipelineMigration.new(run: true)
|
|
429
|
+
migration.prepare
|
|
430
|
+
migration.migrate
|
|
431
|
+
#=> false
|
|
432
|
+
|
|
433
|
+
## MissingShouldProcessPipeline raises NotImplementedError
|
|
434
|
+
cleanup_records
|
|
435
|
+
create_pipeline_record('ff1')
|
|
436
|
+
migration = MissingShouldProcessPipeline.new(run: true)
|
|
437
|
+
migration.prepare
|
|
438
|
+
begin
|
|
439
|
+
migration.migrate
|
|
440
|
+
false
|
|
441
|
+
rescue NotImplementedError => e
|
|
442
|
+
e.message.include?('should_process?')
|
|
443
|
+
end
|
|
444
|
+
#=> true
|
|
445
|
+
|
|
446
|
+
## MissingBuildFieldsPipeline raises NotImplementedError
|
|
447
|
+
cleanup_records
|
|
448
|
+
create_pipeline_record('gg1')
|
|
449
|
+
migration = MissingBuildFieldsPipeline.new(run: true)
|
|
450
|
+
migration.prepare
|
|
451
|
+
begin
|
|
452
|
+
migration.migrate
|
|
453
|
+
false
|
|
454
|
+
rescue NotImplementedError => e
|
|
455
|
+
e.message.include?('build_update_fields')
|
|
456
|
+
end
|
|
457
|
+
#=> true
|
|
458
|
+
|
|
459
|
+
cleanup_records
|
|
460
|
+
Familia::Migration.migrations.replace(@initial_migrations)
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rake'
|
|
4
|
+
require_relative '../../lib/familia/migration/rake_tasks'
|
|
5
|
+
|
|
6
|
+
# Enable metadata recording so descriptions are available
|
|
7
|
+
Rake::TaskManager.record_task_metadata = true
|
|
8
|
+
|
|
9
|
+
# Clear existing tasks and reload to ensure fresh state
|
|
10
|
+
Rake::Task.clear
|
|
11
|
+
Familia::Migration::RakeTasks.new
|
|
12
|
+
|
|
13
|
+
## All expected tasks are defined
|
|
14
|
+
expected_tasks = %w[
|
|
15
|
+
familia:migrate
|
|
16
|
+
familia:migrate:run
|
|
17
|
+
familia:migrate:status
|
|
18
|
+
familia:migrate:dry_run
|
|
19
|
+
familia:migrate:rollback
|
|
20
|
+
familia:migrate:validate
|
|
21
|
+
familia:migrate:schema_drift
|
|
22
|
+
]
|
|
23
|
+
expected_tasks.all? { |name| Rake::Task.task_defined?(name) }
|
|
24
|
+
#=> true
|
|
25
|
+
|
|
26
|
+
## familia:migrate:run has correct description
|
|
27
|
+
Rake::Task['familia:migrate:run'].comment
|
|
28
|
+
#=> 'Run all pending migrations'
|
|
29
|
+
|
|
30
|
+
## familia:migrate:status has correct description
|
|
31
|
+
Rake::Task['familia:migrate:status'].comment
|
|
32
|
+
#=> 'Show migration status table'
|
|
33
|
+
|
|
34
|
+
## familia:migrate:dry_run has correct description
|
|
35
|
+
Rake::Task['familia:migrate:dry_run'].comment
|
|
36
|
+
#=> 'Preview pending migrations (dry run)'
|
|
37
|
+
|
|
38
|
+
## familia:migrate:rollback has correct description
|
|
39
|
+
Rake::Task['familia:migrate:rollback'].comment
|
|
40
|
+
#=> 'Rollback a specific migration'
|
|
41
|
+
|
|
42
|
+
## familia:migrate:validate has correct description
|
|
43
|
+
Rake::Task['familia:migrate:validate'].comment
|
|
44
|
+
#=> 'Validate migration dependencies'
|
|
45
|
+
|
|
46
|
+
## familia:migrate:schema_drift has correct description
|
|
47
|
+
Rake::Task['familia:migrate:schema_drift'].comment
|
|
48
|
+
#=> 'List models with schema drift'
|
|
49
|
+
|
|
50
|
+
## familia:migrate shortcut task has correct description
|
|
51
|
+
Rake::Task['familia:migrate'].comment
|
|
52
|
+
#=> 'Run all pending migrations'
|
|
53
|
+
|
|
54
|
+
## familia:migrate shortcut has familia:migrate:run as prerequisite
|
|
55
|
+
Rake::Task['familia:migrate'].prerequisites
|
|
56
|
+
#=> ['migrate:run']
|
|
57
|
+
|
|
58
|
+
## familia:migrate:rollback accepts an argument
|
|
59
|
+
task = Rake::Task['familia:migrate:rollback']
|
|
60
|
+
task.arg_names
|
|
61
|
+
#=> [:id]
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
# try/migration/registry_try.rb
|
|
2
|
+
#
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
require_relative '../support/helpers/test_helpers'
|
|
6
|
+
require_relative '../../lib/familia/migration'
|
|
7
|
+
|
|
8
|
+
Familia.debug = false
|
|
9
|
+
|
|
10
|
+
@redis = Familia.dbclient
|
|
11
|
+
@prefix = "familia:test:registry:#{Process.pid}:#{Time.now.to_i}"
|
|
12
|
+
@registry = Familia::Migration::Registry.new(redis: @redis, prefix: @prefix)
|
|
13
|
+
|
|
14
|
+
# Clean any existing test keys
|
|
15
|
+
@redis.keys("#{@prefix}:*").each { |k| @redis.del(k) }
|
|
16
|
+
|
|
17
|
+
# Mock migration classes for pending and status tests
|
|
18
|
+
# Using instance variables so they persist across test cases
|
|
19
|
+
@applied_migration = Class.new do
|
|
20
|
+
def self.migration_id; 'test_migration_1'; end
|
|
21
|
+
end
|
|
22
|
+
@pending_migration = Class.new do
|
|
23
|
+
def self.migration_id; 'test_migration_2'; end
|
|
24
|
+
end
|
|
25
|
+
@also_pending_migration = Class.new do
|
|
26
|
+
def self.migration_id; 'test_migration_3'; end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
## Registry class exists
|
|
30
|
+
Familia::Migration::Registry.is_a?(Class)
|
|
31
|
+
#=> true
|
|
32
|
+
|
|
33
|
+
## Registry initializes with correct prefix
|
|
34
|
+
@registry.prefix
|
|
35
|
+
#=> @prefix
|
|
36
|
+
|
|
37
|
+
## Registry initializes with provided redis client
|
|
38
|
+
@registry.redis == @redis
|
|
39
|
+
#=> true
|
|
40
|
+
|
|
41
|
+
## client method returns the redis client
|
|
42
|
+
@registry.client == @redis
|
|
43
|
+
#=> true
|
|
44
|
+
|
|
45
|
+
## applied? returns false for new migration
|
|
46
|
+
@registry.applied?('test_migration_1')
|
|
47
|
+
#=> false
|
|
48
|
+
|
|
49
|
+
## record_applied adds migration to applied set
|
|
50
|
+
@registry.record_applied('test_migration_1', { records_updated: 100 })
|
|
51
|
+
@registry.applied?('test_migration_1')
|
|
52
|
+
#=> true
|
|
53
|
+
|
|
54
|
+
## applied_at returns Time when applied
|
|
55
|
+
result = @registry.applied_at('test_migration_1')
|
|
56
|
+
result.is_a?(Time) && (Time.now - result) < 5
|
|
57
|
+
#=> true
|
|
58
|
+
|
|
59
|
+
## applied_at returns nil when not applied
|
|
60
|
+
@registry.applied_at('nonexistent_migration')
|
|
61
|
+
#=> nil
|
|
62
|
+
|
|
63
|
+
## all_applied returns array with applied migrations
|
|
64
|
+
applied = @registry.all_applied
|
|
65
|
+
applied.is_a?(Array) && applied.any? { |h| h[:migration_id] == 'test_migration_1' }
|
|
66
|
+
#=> true
|
|
67
|
+
|
|
68
|
+
## all_applied entry contains applied_at timestamp
|
|
69
|
+
applied = @registry.all_applied
|
|
70
|
+
entry = applied.find { |h| h[:migration_id] == 'test_migration_1' }
|
|
71
|
+
entry[:applied_at].is_a?(Time)
|
|
72
|
+
#=> true
|
|
73
|
+
|
|
74
|
+
## metadata returns hash with correct status
|
|
75
|
+
meta = @registry.metadata('test_migration_1')
|
|
76
|
+
meta.is_a?(Hash) && meta[:status] == 'applied'
|
|
77
|
+
#=> true
|
|
78
|
+
|
|
79
|
+
## metadata contains keys_scanned from stats
|
|
80
|
+
meta = @registry.metadata('test_migration_1')
|
|
81
|
+
meta.key?(:keys_scanned)
|
|
82
|
+
#=> true
|
|
83
|
+
|
|
84
|
+
## metadata returns nil for unapplied migration
|
|
85
|
+
@registry.metadata('nonexistent_migration')
|
|
86
|
+
#=> nil
|
|
87
|
+
|
|
88
|
+
## pending filters out applied migrations
|
|
89
|
+
pending = @registry.pending([@applied_migration, @pending_migration])
|
|
90
|
+
pending.map(&:migration_id)
|
|
91
|
+
#=> ['test_migration_2']
|
|
92
|
+
|
|
93
|
+
## pending returns empty array when all are applied
|
|
94
|
+
@registry.record_applied('test_migration_2', {})
|
|
95
|
+
pending = @registry.pending([@applied_migration, @pending_migration])
|
|
96
|
+
pending
|
|
97
|
+
#=> []
|
|
98
|
+
|
|
99
|
+
## pending returns empty array for nil input
|
|
100
|
+
@registry.pending(nil)
|
|
101
|
+
#=> []
|
|
102
|
+
|
|
103
|
+
## pending returns empty array for empty input
|
|
104
|
+
@registry.pending([])
|
|
105
|
+
#=> []
|
|
106
|
+
|
|
107
|
+
## status returns combined info for all migrations
|
|
108
|
+
status = @registry.status([@applied_migration, @pending_migration, @also_pending_migration])
|
|
109
|
+
statuses = status.map { |s| [s[:migration_id], s[:status]] }.to_h
|
|
110
|
+
[statuses['test_migration_1'], statuses['test_migration_3']]
|
|
111
|
+
#=> [:applied, :pending]
|
|
112
|
+
|
|
113
|
+
## status entry includes applied_at for applied migrations
|
|
114
|
+
status = @registry.status([@applied_migration])
|
|
115
|
+
entry = status.first
|
|
116
|
+
entry[:applied_at].is_a?(Time)
|
|
117
|
+
#=> true
|
|
118
|
+
|
|
119
|
+
## status entry has nil applied_at for pending migrations
|
|
120
|
+
status = @registry.status([@also_pending_migration])
|
|
121
|
+
entry = status.first
|
|
122
|
+
entry[:applied_at]
|
|
123
|
+
#=> nil
|
|
124
|
+
|
|
125
|
+
## status returns empty array for nil input
|
|
126
|
+
@registry.status(nil)
|
|
127
|
+
#=> []
|
|
128
|
+
|
|
129
|
+
## record_rollback removes from applied set
|
|
130
|
+
@registry.record_rollback('test_migration_1')
|
|
131
|
+
@registry.applied?('test_migration_1')
|
|
132
|
+
#=> false
|
|
133
|
+
|
|
134
|
+
## record_rollback updates metadata to rolled_back status
|
|
135
|
+
meta = @registry.metadata('test_migration_1')
|
|
136
|
+
meta[:status]
|
|
137
|
+
#=> 'rolled_back'
|
|
138
|
+
|
|
139
|
+
## record_rollback adds rolled_back_at timestamp
|
|
140
|
+
meta = @registry.metadata('test_migration_1')
|
|
141
|
+
meta.key?(:rolled_back_at)
|
|
142
|
+
#=> true
|
|
143
|
+
|
|
144
|
+
## backup_field stores value in backup hash
|
|
145
|
+
@registry.backup_field('backup_test', 'some:key', 'field1', 'original_value')
|
|
146
|
+
backup_key = "#{@prefix}:backup:backup_test"
|
|
147
|
+
@redis.hget(backup_key, 'some:key:field1')
|
|
148
|
+
#=> 'original_value'
|
|
149
|
+
|
|
150
|
+
## backup key has TTL set
|
|
151
|
+
ttl = @redis.ttl("#{@prefix}:backup:backup_test")
|
|
152
|
+
ttl > 0 && ttl <= Familia::Migration.config.backup_ttl
|
|
153
|
+
#=> true
|
|
154
|
+
|
|
155
|
+
## restore_backup returns count of restored fields
|
|
156
|
+
@redis.del('some:key')
|
|
157
|
+
@redis.hset('some:key', 'field1', 'modified_value')
|
|
158
|
+
count = @registry.restore_backup('backup_test')
|
|
159
|
+
count >= 1
|
|
160
|
+
#=> true
|
|
161
|
+
|
|
162
|
+
## restore_backup restores correct value
|
|
163
|
+
@redis.hget('some:key', 'field1')
|
|
164
|
+
#=> 'original_value'
|
|
165
|
+
|
|
166
|
+
## restore_backup returns 0 when no backup exists
|
|
167
|
+
@registry.restore_backup('nonexistent_backup')
|
|
168
|
+
#=> 0
|
|
169
|
+
|
|
170
|
+
## clear_backup removes backup data
|
|
171
|
+
@registry.clear_backup('backup_test')
|
|
172
|
+
@redis.exists("#{@prefix}:backup:backup_test")
|
|
173
|
+
#=> 0
|
|
174
|
+
|
|
175
|
+
## record_applied works with class that has migration_id
|
|
176
|
+
@class_migration = Class.new do
|
|
177
|
+
def self.migration_id; 'class_based_migration'; end
|
|
178
|
+
end
|
|
179
|
+
@registry.record_applied(@class_migration, {})
|
|
180
|
+
@registry.applied?('class_based_migration')
|
|
181
|
+
#=> true
|
|
182
|
+
|
|
183
|
+
## record_applied works with instance whose class has migration_id
|
|
184
|
+
@instance_migration_class = Class.new do
|
|
185
|
+
def self.migration_id; 'instance_based_migration'; end
|
|
186
|
+
end
|
|
187
|
+
instance = @instance_migration_class.new
|
|
188
|
+
@registry.record_applied(instance, { duration_ms: 150 })
|
|
189
|
+
@registry.applied?('instance_based_migration')
|
|
190
|
+
#=> true
|
|
191
|
+
|
|
192
|
+
## record_applied stores duration_ms in metadata
|
|
193
|
+
meta = @registry.metadata('instance_based_migration')
|
|
194
|
+
meta[:duration_ms]
|
|
195
|
+
#=> 150
|
|
196
|
+
|
|
197
|
+
# Teardown
|
|
198
|
+
@redis.keys("#{@prefix}:*").each { |k| @redis.del(k) }
|
|
199
|
+
@redis.del('some:key')
|