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.
Files changed (56) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.rst +94 -0
  3. data/Gemfile +3 -0
  4. data/Gemfile.lock +12 -2
  5. data/README.md +1 -3
  6. data/docs/guides/feature-encrypted-fields.md +1 -1
  7. data/docs/guides/feature-expiration.md +1 -1
  8. data/docs/guides/feature-quantization.md +1 -1
  9. data/docs/guides/writing-migrations.md +345 -0
  10. data/docs/overview.md +7 -7
  11. data/docs/reference/api-technical.md +103 -7
  12. data/examples/migrations/v1_to_v2_serialization_migration.rb +374 -0
  13. data/examples/schemas/customer.json +33 -0
  14. data/examples/schemas/session.json +27 -0
  15. data/familia.gemspec +3 -2
  16. data/lib/familia/features/schema_validation.rb +139 -0
  17. data/lib/familia/migration/base.rb +447 -0
  18. data/lib/familia/migration/errors.rb +31 -0
  19. data/lib/familia/migration/model.rb +418 -0
  20. data/lib/familia/migration/pipeline.rb +226 -0
  21. data/lib/familia/migration/rake_tasks.rake +3 -0
  22. data/lib/familia/migration/rake_tasks.rb +160 -0
  23. data/lib/familia/migration/registry.rb +364 -0
  24. data/lib/familia/migration/runner.rb +311 -0
  25. data/lib/familia/migration/script.rb +234 -0
  26. data/lib/familia/migration.rb +43 -0
  27. data/lib/familia/schema_registry.rb +173 -0
  28. data/lib/familia/settings.rb +63 -1
  29. data/lib/familia/version.rb +1 -1
  30. data/lib/familia.rb +1 -0
  31. data/try/features/schema_registry_try.rb +193 -0
  32. data/try/features/schema_validation_feature_try.rb +218 -0
  33. data/try/migration/base_try.rb +226 -0
  34. data/try/migration/errors_try.rb +67 -0
  35. data/try/migration/integration_try.rb +451 -0
  36. data/try/migration/model_try.rb +431 -0
  37. data/try/migration/pipeline_try.rb +460 -0
  38. data/try/migration/rake_tasks_try.rb +61 -0
  39. data/try/migration/registry_try.rb +199 -0
  40. data/try/migration/runner_try.rb +311 -0
  41. data/try/migration/schema_validation_try.rb +201 -0
  42. data/try/migration/script_try.rb +192 -0
  43. data/try/migration/v1_to_v2_serialization_try.rb +513 -0
  44. data/try/performance/benchmarks_try.rb +11 -12
  45. metadata +45 -27
  46. data/docs/migrating/v2.0.0-pre.md +0 -84
  47. data/docs/migrating/v2.0.0-pre11.md +0 -253
  48. data/docs/migrating/v2.0.0-pre12.md +0 -306
  49. data/docs/migrating/v2.0.0-pre13.md +0 -95
  50. data/docs/migrating/v2.0.0-pre14.md +0 -37
  51. data/docs/migrating/v2.0.0-pre18.md +0 -58
  52. data/docs/migrating/v2.0.0-pre19.md +0 -197
  53. data/docs/migrating/v2.0.0-pre22.md +0 -241
  54. data/docs/migrating/v2.0.0-pre5.md +0 -131
  55. data/docs/migrating/v2.0.0-pre6.md +0 -154
  56. data/docs/migrating/v2.0.0-pre7.md +0 -222
@@ -0,0 +1,311 @@
1
+ # try/migration/runner_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:runner:#{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
+ # Store initial migration count
18
+ @initial_migrations = Familia::Migration.migrations.dup
19
+
20
+ # Define test migration classes
21
+ class RunnerTestMigrationA < Familia::Migration::Base
22
+ self.migration_id = 'runner_test_a'
23
+ self.description = 'First migration'
24
+ self.dependencies = []
25
+
26
+ def migration_needed?
27
+ true
28
+ end
29
+
30
+ def migrate
31
+ track_stat(:a_ran)
32
+ end
33
+ end
34
+
35
+ class RunnerTestMigrationB < Familia::Migration::Base
36
+ self.migration_id = 'runner_test_b'
37
+ self.description = 'Depends on A'
38
+ self.dependencies = ['runner_test_a']
39
+
40
+ def migration_needed?
41
+ true
42
+ end
43
+
44
+ def migrate
45
+ track_stat(:b_ran)
46
+ end
47
+ end
48
+
49
+ class RunnerTestReversible < Familia::Migration::Base
50
+ self.migration_id = 'runner_test_reversible'
51
+ self.description = 'Reversible migration'
52
+ self.dependencies = []
53
+
54
+ def migration_needed?
55
+ true
56
+ end
57
+
58
+ def migrate
59
+ track_stat(:forward)
60
+ end
61
+
62
+ def down
63
+ track_stat(:backward)
64
+ end
65
+ end
66
+
67
+ @test_migrations = [RunnerTestMigrationA, RunnerTestMigrationB, RunnerTestReversible]
68
+
69
+ # Circular dependency test migrations
70
+ class CircularMigrationA < Familia::Migration::Base
71
+ self.migration_id = 'circular_a'
72
+ self.dependencies = ['circular_b']
73
+ def migration_needed?; true; end
74
+ def migrate; end
75
+ end
76
+
77
+ class CircularMigrationB < Familia::Migration::Base
78
+ self.migration_id = 'circular_b'
79
+ self.dependencies = ['circular_a']
80
+ def migration_needed?; true; end
81
+ def migrate; end
82
+ end
83
+
84
+ class CircularMigrationC < Familia::Migration::Base
85
+ self.migration_id = 'circular_c'
86
+ self.dependencies = ['circular_d']
87
+ def migration_needed?; true; end
88
+ def migrate; end
89
+ end
90
+
91
+ class CircularMigrationD < Familia::Migration::Base
92
+ self.migration_id = 'circular_d'
93
+ self.dependencies = ['circular_e']
94
+ def migration_needed?; true; end
95
+ def migrate; end
96
+ end
97
+
98
+ class CircularMigrationE < Familia::Migration::Base
99
+ self.migration_id = 'circular_e'
100
+ self.dependencies = ['circular_c']
101
+ def migration_needed?; true; end
102
+ def migrate; end
103
+ end
104
+
105
+ class SelfRefMigration < Familia::Migration::Base
106
+ self.migration_id = 'self_ref'
107
+ self.dependencies = ['self_ref']
108
+ def migration_needed?; true; end
109
+ def migrate; end
110
+ end
111
+
112
+ ## Runner initializes with default values
113
+ runner = Familia::Migration::Runner.new(migrations: @test_migrations, registry: @registry)
114
+ runner.is_a?(Familia::Migration::Runner)
115
+ #=> true
116
+
117
+ ## status returns array of migration info
118
+ runner = Familia::Migration::Runner.new(migrations: @test_migrations, registry: @registry)
119
+ status = runner.status
120
+ status.is_a?(Array) && status.size == 3
121
+ #=> true
122
+
123
+ ## status shows all as pending initially
124
+ runner = Familia::Migration::Runner.new(migrations: @test_migrations, registry: @registry)
125
+ runner.status.all? { |s| s[:status] == :pending }
126
+ #=> true
127
+
128
+ ## pending returns all migrations initially
129
+ runner = Familia::Migration::Runner.new(migrations: @test_migrations, registry: @registry)
130
+ runner.pending.size
131
+ #=> 3
132
+
133
+ ## validate returns empty array when dependencies valid
134
+ runner = Familia::Migration::Runner.new(migrations: @test_migrations, registry: @registry)
135
+ runner.validate
136
+ #=> []
137
+
138
+ ## validate detects missing dependencies
139
+ class MissingDepMigration < Familia::Migration::Base
140
+ self.migration_id = 'missing_dep'
141
+ self.dependencies = ['nonexistent']
142
+ def migration_needed?; true; end
143
+ def migrate; end
144
+ end
145
+ runner = Familia::Migration::Runner.new(
146
+ migrations: [MissingDepMigration],
147
+ registry: @registry
148
+ )
149
+ issues = runner.validate
150
+ issues.any? { |i| i[:type] == :missing_dependency }
151
+ #=> true
152
+
153
+ ## run executes migrations respecting dependencies
154
+ @redis.keys("#{@prefix}:*").each { |k| @redis.del(k) }
155
+ runner = Familia::Migration::Runner.new(migrations: @test_migrations, registry: @registry)
156
+ results = runner.run(dry_run: false)
157
+ ids = results.map { |r| r[:migration_id] }
158
+ ids.index('runner_test_a') < ids.index('runner_test_b')
159
+ #=> true
160
+
161
+ ## run records applied migrations
162
+ runner = Familia::Migration::Runner.new(migrations: @test_migrations, registry: @registry)
163
+ @registry.applied?('runner_test_a')
164
+ #=> true
165
+
166
+ ## run_one with dry_run returns dry_run flag
167
+ @redis.keys("#{@prefix}:*").each { |k| @redis.del(k) }
168
+ runner = Familia::Migration::Runner.new(migrations: @test_migrations, registry: @registry)
169
+ result = runner.run_one(RunnerTestMigrationA, dry_run: true)
170
+ result[:dry_run]
171
+ #=> true
172
+
173
+ ## dry run does not mark as applied
174
+ @registry.applied?('runner_test_a')
175
+ #=> false
176
+
177
+ ## run with limit stops after N migrations
178
+ @redis.keys("#{@prefix}:*").each { |k| @redis.del(k) }
179
+ runner = Familia::Migration::Runner.new(migrations: @test_migrations, registry: @registry)
180
+ results = runner.run(dry_run: false, limit: 1)
181
+ results.size
182
+ #=> 1
183
+
184
+ ## run_one executes single migration by class
185
+ @redis.keys("#{@prefix}:*").each { |k| @redis.del(k) }
186
+ runner = Familia::Migration::Runner.new(migrations: @test_migrations, registry: @registry)
187
+ result = runner.run_one(RunnerTestMigrationA, dry_run: false)
188
+ result[:status]
189
+ #=> :success
190
+
191
+ ## run_one executes single migration by ID
192
+ @redis.keys("#{@prefix}:*").each { |k| @redis.del(k) }
193
+ runner = Familia::Migration::Runner.new(migrations: @test_migrations, registry: @registry)
194
+ result = runner.run_one('runner_test_reversible', dry_run: false)
195
+ result[:status]
196
+ #=> :success
197
+
198
+ ## run_one raises DependencyNotMet if dependencies not applied
199
+ @redis.keys("#{@prefix}:*").each { |k| @redis.del(k) }
200
+ runner = Familia::Migration::Runner.new(migrations: @test_migrations, registry: @registry)
201
+ begin
202
+ runner.run_one(RunnerTestMigrationB, dry_run: false)
203
+ false
204
+ rescue Familia::Migration::Errors::DependencyNotMet
205
+ true
206
+ end
207
+ #=> true
208
+
209
+ ## rollback calls down method
210
+ @redis.keys("#{@prefix}:*").each { |k| @redis.del(k) }
211
+ runner = Familia::Migration::Runner.new(migrations: @test_migrations, registry: @registry)
212
+ runner.run_one(RunnerTestReversible, dry_run: false)
213
+ result = runner.rollback('runner_test_reversible')
214
+ result[:status]
215
+ #=> :rolled_back
216
+
217
+ ## rollback raises NotApplied if not applied
218
+ runner = Familia::Migration::Runner.new(migrations: @test_migrations, registry: @registry)
219
+ begin
220
+ runner.rollback('runner_test_reversible')
221
+ false
222
+ rescue Familia::Migration::Errors::NotApplied
223
+ true
224
+ end
225
+ #=> true
226
+
227
+ ## rollback raises HasDependents if others depend on it
228
+ @redis.keys("#{@prefix}:*").each { |k| @redis.del(k) }
229
+ runner = Familia::Migration::Runner.new(migrations: @test_migrations, registry: @registry)
230
+ runner.run(dry_run: false)
231
+ begin
232
+ runner.rollback('runner_test_a')
233
+ false
234
+ rescue Familia::Migration::Errors::HasDependents
235
+ true
236
+ end
237
+ #=> true
238
+
239
+ ## rollback raises NotReversible if no down method
240
+ @redis.keys("#{@prefix}:*").each { |k| @redis.del(k) }
241
+ runner = Familia::Migration::Runner.new(migrations: @test_migrations, registry: @registry)
242
+ runner.run_one(RunnerTestMigrationA, dry_run: false)
243
+ begin
244
+ runner.rollback('runner_test_a')
245
+ false
246
+ rescue Familia::Migration::Errors::NotReversible
247
+ true
248
+ end
249
+ #=> true
250
+
251
+ ## NotFound raised for unknown migration ID
252
+ runner = Familia::Migration::Runner.new(migrations: @test_migrations, registry: @registry)
253
+ begin
254
+ runner.run_one('nonexistent_migration', dry_run: false)
255
+ false
256
+ rescue Familia::Migration::Errors::NotFound
257
+ true
258
+ end
259
+ #=> true
260
+
261
+ ## validate detects simple circular dependency (A -> B -> A)
262
+ runner = Familia::Migration::Runner.new(
263
+ migrations: [CircularMigrationA, CircularMigrationB],
264
+ registry: @registry
265
+ )
266
+ issues = runner.validate
267
+ issues.any? { |i| i[:type] == :circular_dependency }
268
+ #=> true
269
+
270
+ ## validate detects complex circular dependency (C -> D -> E -> C)
271
+ runner = Familia::Migration::Runner.new(
272
+ migrations: [CircularMigrationC, CircularMigrationD, CircularMigrationE],
273
+ registry: @registry
274
+ )
275
+ issues = runner.validate
276
+ issues.any? { |i| i[:type] == :circular_dependency }
277
+ #=> true
278
+
279
+ ## run raises CircularDependency for circular dependencies
280
+ @redis.keys("#{@prefix}:*").each { |k| @redis.del(k) }
281
+ runner = Familia::Migration::Runner.new(
282
+ migrations: [CircularMigrationA, CircularMigrationB],
283
+ registry: @registry
284
+ )
285
+ begin
286
+ runner.run(dry_run: false)
287
+ false
288
+ rescue Familia::Migration::Errors::CircularDependency
289
+ true
290
+ end
291
+ #=> true
292
+
293
+ ## Self-referencing dependency is detected
294
+ runner = Familia::Migration::Runner.new(
295
+ migrations: [SelfRefMigration],
296
+ registry: @registry
297
+ )
298
+ issues = runner.validate
299
+ issues.any? { |i| i[:type] == :circular_dependency }
300
+ #=> true
301
+
302
+ # Teardown
303
+ @redis.keys("#{@prefix}:*").each { |k| @redis.del(k) }
304
+ Familia::Migration.migrations.replace(@initial_migrations)
305
+ Familia::Migration.migrations.delete(MissingDepMigration)
306
+ Familia::Migration.migrations.delete(CircularMigrationA)
307
+ Familia::Migration.migrations.delete(CircularMigrationB)
308
+ Familia::Migration.migrations.delete(CircularMigrationC)
309
+ Familia::Migration.migrations.delete(CircularMigrationD)
310
+ Familia::Migration.migrations.delete(CircularMigrationE)
311
+ Familia::Migration.migrations.delete(SelfRefMigration)
@@ -0,0 +1,201 @@
1
+ # try/migration/schema_validation_try.rb
2
+ #
3
+ # frozen_string_literal: true
4
+
5
+ require_relative '../support/helpers/test_helpers'
6
+ require_relative '../../lib/familia/migration'
7
+ require 'json'
8
+ require 'tmpdir'
9
+ require 'fileutils'
10
+
11
+ Familia.debug = false
12
+
13
+ # Setup
14
+ @schema_dir = Dir.mktmpdir('familia_migration_schemas')
15
+
16
+ # Store and configure
17
+ @original_schema_path = Familia.schema_path
18
+ @original_schemas = Familia.schemas
19
+ @original_validator = Familia.schema_validator
20
+ @initial_migrations = Familia::Migration.migrations.dup
21
+
22
+ # Test model - define at top level to avoid Tryouts class scoping issue
23
+ # The class will have a simple name 'MigrationSchemaModel' instead of
24
+ # '#<Class:0x...>::MigrationSchemaModel' which happens when defined inside
25
+ # Tryouts' evaluation context
26
+ Object.const_set(:MigrationSchemaModel, Class.new(Familia::Horreum) do
27
+ feature :schema_validation
28
+ identifier_field :custid
29
+ field :custid
30
+ field :email
31
+ field :status
32
+ end) unless defined?(::MigrationSchemaModel)
33
+
34
+ # Custom migration for testing validation hooks
35
+ Object.const_set(:TestValidationMigration, Class.new(Familia::Migration::Model) do
36
+ self.migration_id = 'test_validation_hooks'
37
+
38
+ define_method(:validate_before_transform?) { true }
39
+ define_method(:validate_after_transform?) { true }
40
+ define_method(:migration_needed?) { false }
41
+ define_method(:prepare) { @model_class = MigrationSchemaModel }
42
+ define_method(:process_record) { |obj, key| }
43
+ end) unless defined?(::TestValidationMigration)
44
+
45
+ # Create test schema after class is defined so we can use the actual name
46
+ File.write(File.join(@schema_dir, 'migration_schema_model.json'), JSON.generate({
47
+ type: 'object',
48
+ properties: {
49
+ email: { type: 'string', format: 'email' },
50
+ status: { type: 'string', enum: ['active', 'inactive', 'pending'] }
51
+ },
52
+ required: ['email']
53
+ }))
54
+
55
+ Familia.schema_path = @schema_dir
56
+ Familia.schema_validator = :json_schemer
57
+ Familia::SchemaRegistry.reset!
58
+ Familia::SchemaRegistry.load!
59
+
60
+ ## Base migration has validate_schema method
61
+ migration = Familia::Migration::Base.new
62
+ migration.respond_to?(:validate_schema)
63
+ #=> true
64
+
65
+ ## Base migration has validate_schema! method
66
+ migration = Familia::Migration::Base.new
67
+ migration.respond_to?(:validate_schema!)
68
+ #=> true
69
+
70
+ ## validate_schema returns valid result for conforming data
71
+ migration = Familia::Migration::Base.new
72
+ obj = MigrationSchemaModel.new(custid: 'c1', email: 'test@example.com', status: 'active')
73
+ result = migration.validate_schema(obj)
74
+ result[:valid]
75
+ #=> true
76
+
77
+ ## validate_schema returns errors for non-conforming data
78
+ migration = Familia::Migration::Base.new
79
+ obj = MigrationSchemaModel.new(custid: 'c2', email: 'invalid-email', status: 'unknown')
80
+ result = migration.validate_schema(obj)
81
+ result[:valid]
82
+ #=> false
83
+
84
+ ## validate_schema errors array has content for invalid data
85
+ migration = Familia::Migration::Base.new
86
+ obj = MigrationSchemaModel.new(custid: 'c3', email: 'bad')
87
+ result = migration.validate_schema(obj)
88
+ result[:errors].size > 0
89
+ #=> true
90
+
91
+ ## validate_schema! raises for invalid data
92
+ migration = Familia::Migration::Base.new
93
+ obj = MigrationSchemaModel.new(custid: 'c4', status: 'active')
94
+ begin
95
+ migration.validate_schema!(obj)
96
+ false
97
+ rescue Familia::SchemaValidationError
98
+ true
99
+ end
100
+ #=> true
101
+
102
+ ## validate_schema! returns true for valid data
103
+ migration = Familia::Migration::Base.new
104
+ obj = MigrationSchemaModel.new(custid: 'c5', email: 'valid@example.com', status: 'pending')
105
+ migration.validate_schema!(obj)
106
+ #=> true
107
+
108
+ ## schema_validation_enabled? returns true by default
109
+ migration = Familia::Migration::Base.new
110
+ migration.schema_validation_enabled?
111
+ #=> true
112
+
113
+ ## skip_schema_validation! disables validation
114
+ migration = Familia::Migration::Base.new
115
+ migration.skip_schema_validation!
116
+ migration.schema_validation_enabled?
117
+ #=> false
118
+
119
+ ## validate_schema returns valid when validation disabled
120
+ migration = Familia::Migration::Base.new
121
+ migration.skip_schema_validation!
122
+ obj = MigrationSchemaModel.new(custid: 'c6', email: 'invalid')
123
+ result = migration.validate_schema(obj)
124
+ result[:valid]
125
+ #=> true
126
+
127
+ ## Model migration has validate_before_transform? protected method
128
+ migration = Familia::Migration::Model.new
129
+ migration.respond_to?(:validate_before_transform?, true)
130
+ #=> true
131
+
132
+ ## Model migration has validate_after_transform? protected method
133
+ migration = Familia::Migration::Model.new
134
+ migration.respond_to?(:validate_after_transform?, true)
135
+ #=> true
136
+
137
+ ## validate_before_transform? defaults to false via send
138
+ migration = Familia::Migration::Model.new
139
+ migration.send(:validate_before_transform?)
140
+ #=> false
141
+
142
+ ## validate_after_transform? defaults to false via send
143
+ migration = Familia::Migration::Model.new
144
+ migration.send(:validate_after_transform?)
145
+ #=> false
146
+
147
+ ## Custom migration has before validation hook enabled
148
+ migration = TestValidationMigration.new
149
+ migration.send(:validate_before_transform?)
150
+ #=> true
151
+
152
+ ## Custom migration has after validation hook enabled
153
+ migration = TestValidationMigration.new
154
+ migration.send(:validate_after_transform?)
155
+ #=> true
156
+
157
+ ## process_record_with_validation is a protected method
158
+ migration = TestValidationMigration.new
159
+ migration.respond_to?(:process_record_with_validation, true)
160
+ #=> true
161
+
162
+ ## validate_schema with context returns valid for good data
163
+ migration = Familia::Migration::Base.new
164
+ obj = MigrationSchemaModel.new(custid: 'c7', email: 'valid@example.com', status: 'active')
165
+ result = migration.validate_schema(obj, context: 'before transform')
166
+ result[:valid]
167
+ #=> true
168
+
169
+ ## validate_schema! with context raises for invalid data
170
+ migration = Familia::Migration::Base.new
171
+ obj = MigrationSchemaModel.new(custid: 'c8', status: 'active')
172
+ begin
173
+ migration.validate_schema!(obj, context: 'after transform')
174
+ false
175
+ rescue Familia::SchemaValidationError
176
+ true
177
+ end
178
+ #=> true
179
+
180
+ ## Model migration inherits schema validation from Base
181
+ Familia::Migration::Model.ancestors.include?(Familia::Migration::Base)
182
+ #=> true
183
+
184
+ ## Model migration responds to all schema validation methods
185
+ migration = Familia::Migration::Model.new
186
+ [:validate_schema, :validate_schema!, :schema_validation_enabled?, :skip_schema_validation!].all? do |m|
187
+ migration.respond_to?(m)
188
+ end
189
+ #=> true
190
+
191
+ # Teardown
192
+ FileUtils.rm_rf(@schema_dir)
193
+ Familia.schema_path = @original_schema_path
194
+ Familia.schemas = @original_schemas || {}
195
+ Familia.schema_validator = @original_validator || :json_schemer
196
+ Familia::SchemaRegistry.reset!
197
+ Familia::Migration.migrations.replace(@initial_migrations)
198
+ Familia::Migration.migrations.delete(TestValidationMigration) if defined?(::TestValidationMigration)
199
+ # Clean up the top-level constants
200
+ Object.send(:remove_const, :MigrationSchemaModel) if defined?(::MigrationSchemaModel)
201
+ Object.send(:remove_const, :TestValidationMigration) if defined?(::TestValidationMigration)
@@ -0,0 +1,192 @@
1
+ # try/migration/script_try.rb
2
+ #
3
+ # frozen_string_literal: true
4
+
5
+ require_relative '../support/helpers/test_helpers'
6
+ require_relative '../../lib/familia/migration/errors'
7
+ require_relative '../../lib/familia/migration/script'
8
+
9
+ Familia.debug = false
10
+
11
+ @redis = Familia.dbclient
12
+ @test_prefix = "familia:test:script:#{Process.pid}"
13
+
14
+ ## Script class exists
15
+ Familia::Migration::Script.is_a?(Class)
16
+ #=> true
17
+
18
+ ## Built-in scripts are registered
19
+ Familia::Migration::Script.scripts.keys.sort
20
+ #=> [:backup_and_modify_field, :copy_field, :delete_field, :rename_field, :rename_key_preserve_ttl]
21
+
22
+ ## registered? returns true for built-in scripts
23
+ Familia::Migration::Script.registered?(:rename_field)
24
+ #=> true
25
+
26
+ ## registered? returns false for unknown scripts
27
+ Familia::Migration::Script.registered?(:nonexistent_script)
28
+ #=> false
29
+
30
+ ## sha_for returns SHA1 hash for registered script
31
+ sha = Familia::Migration::Script.sha_for(:rename_field)
32
+ sha.is_a?(String) && sha.length == 40
33
+ #=> true
34
+
35
+ ## sha_for returns nil for unregistered script
36
+ Familia::Migration::Script.sha_for(:no_such_script)
37
+ #=> nil
38
+
39
+ ## Custom script can be registered
40
+ Familia::Migration::Script.register(:test_script, "return 'hello'")
41
+ Familia::Migration::Script.registered?(:test_script)
42
+ #=> true
43
+
44
+ ## register raises ArgumentError for non-Symbol name
45
+ begin
46
+ Familia::Migration::Script.register("string_name", "return 1")
47
+ false
48
+ rescue ArgumentError => e
49
+ e.message.include?('Symbol')
50
+ end
51
+ #=> true
52
+
53
+ ## register raises ArgumentError for empty source
54
+ begin
55
+ Familia::Migration::Script.register(:empty_script, " ")
56
+ false
57
+ rescue ArgumentError => e
58
+ e.message.include?('empty')
59
+ end
60
+ #=> true
61
+
62
+ ## rename_field script works correctly
63
+ @redis.del("#{@test_prefix}:hash1")
64
+ @redis.hset("#{@test_prefix}:hash1", "old_name", "test_value")
65
+ result = Familia::Migration::Script.execute(
66
+ @redis,
67
+ :rename_field,
68
+ keys: ["#{@test_prefix}:hash1"],
69
+ argv: ["old_name", "new_name"]
70
+ )
71
+ [@redis.hexists("#{@test_prefix}:hash1", "old_name"),
72
+ @redis.hget("#{@test_prefix}:hash1", "new_name"),
73
+ result]
74
+ #=> [false, "test_value", 1]
75
+
76
+ ## rename_field returns 0 when source field does not exist
77
+ @redis.del("#{@test_prefix}:hash1a")
78
+ @redis.hset("#{@test_prefix}:hash1a", "other_field", "value")
79
+ result = Familia::Migration::Script.execute(
80
+ @redis,
81
+ :rename_field,
82
+ keys: ["#{@test_prefix}:hash1a"],
83
+ argv: ["missing_field", "new_field"]
84
+ )
85
+ result
86
+ #=> 0
87
+
88
+ ## copy_field script works correctly
89
+ @redis.del("#{@test_prefix}:hash2")
90
+ @redis.hset("#{@test_prefix}:hash2", "source", "copied_value")
91
+ Familia::Migration::Script.execute(
92
+ @redis,
93
+ :copy_field,
94
+ keys: ["#{@test_prefix}:hash2"],
95
+ argv: ["source", "destination"]
96
+ )
97
+ [@redis.hget("#{@test_prefix}:hash2", "source"),
98
+ @redis.hget("#{@test_prefix}:hash2", "destination")]
99
+ #=> ["copied_value", "copied_value"]
100
+
101
+ ## copy_field returns 0 when source field does not exist
102
+ @redis.del("#{@test_prefix}:hash2a")
103
+ result = Familia::Migration::Script.execute(
104
+ @redis,
105
+ :copy_field,
106
+ keys: ["#{@test_prefix}:hash2a"],
107
+ argv: ["missing", "destination"]
108
+ )
109
+ result
110
+ #=> 0
111
+
112
+ ## delete_field script works correctly
113
+ @redis.del("#{@test_prefix}:hash3")
114
+ @redis.hset("#{@test_prefix}:hash3", "to_delete", "value")
115
+ result = Familia::Migration::Script.execute(
116
+ @redis,
117
+ :delete_field,
118
+ keys: ["#{@test_prefix}:hash3"],
119
+ argv: ["to_delete"]
120
+ )
121
+ [result, @redis.hexists("#{@test_prefix}:hash3", "to_delete")]
122
+ #=> [1, false]
123
+
124
+ ## delete_field returns 0 when field does not exist
125
+ @redis.del("#{@test_prefix}:hash3a")
126
+ result = Familia::Migration::Script.execute(
127
+ @redis,
128
+ :delete_field,
129
+ keys: ["#{@test_prefix}:hash3a"],
130
+ argv: ["nonexistent"]
131
+ )
132
+ result
133
+ #=> 0
134
+
135
+ ## rename_key_preserve_ttl works and preserves TTL
136
+ @redis.del("#{@test_prefix}:src_key", "#{@test_prefix}:dst_key")
137
+ @redis.set("#{@test_prefix}:src_key", "value")
138
+ @redis.expire("#{@test_prefix}:src_key", 3600)
139
+ Familia::Migration::Script.execute(
140
+ @redis,
141
+ :rename_key_preserve_ttl,
142
+ keys: ["#{@test_prefix}:src_key", "#{@test_prefix}:dst_key"],
143
+ argv: []
144
+ )
145
+ ttl = @redis.ttl("#{@test_prefix}:dst_key")
146
+ [@redis.exists("#{@test_prefix}:src_key"),
147
+ @redis.get("#{@test_prefix}:dst_key"),
148
+ ttl > 3500]
149
+ #=> [0, "value", true]
150
+
151
+ ## backup_and_modify_field backs up and modifies correctly
152
+ @redis.del("#{@test_prefix}:hash4", "#{@test_prefix}:backup")
153
+ @redis.hset("#{@test_prefix}:hash4", "myfield", "original_value")
154
+ old_val = Familia::Migration::Script.execute(
155
+ @redis,
156
+ :backup_and_modify_field,
157
+ keys: ["#{@test_prefix}:hash4", "#{@test_prefix}:backup"],
158
+ argv: ["myfield", "new_value", "3600"]
159
+ )
160
+ [@redis.hget("#{@test_prefix}:hash4", "myfield"),
161
+ @redis.hget("#{@test_prefix}:backup", "#{@test_prefix}:hash4:myfield"),
162
+ old_val]
163
+ #=> ["new_value", "original_value", "original_value"]
164
+
165
+ ## ScriptNotFound raised for unregistered script
166
+ begin
167
+ Familia::Migration::Script.execute(@redis, :no_such_script, keys: [], argv: [])
168
+ false
169
+ rescue Familia::Migration::Script::ScriptNotFound
170
+ true
171
+ end
172
+ #=> true
173
+
174
+ ## ScriptEntry is immutable (frozen)
175
+ entry = Familia::Migration::Script.scripts[:rename_field]
176
+ [entry.source.frozen?, entry.sha.frozen?]
177
+ #=> [true, true]
178
+
179
+ ## preload_all returns map of script names to SHAs
180
+ result = Familia::Migration::Script.preload_all(@redis)
181
+ result.is_a?(Hash) && result.key?(:rename_field) && result[:rename_field].length == 40
182
+ #=> true
183
+
184
+ ## reset! restores only built-in scripts
185
+ Familia::Migration::Script.register(:temp_script, "return 1")
186
+ Familia::Migration::Script.reset!
187
+ [Familia::Migration::Script.registered?(:temp_script),
188
+ Familia::Migration::Script.registered?(:rename_field)]
189
+ #=> [false, true]
190
+
191
+ # Teardown
192
+ @redis.keys("#{@test_prefix}:*").each { |k| @redis.del(k) }