familia 2.0.0 → 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 +45 -0
- data/Gemfile +2 -0
- data/Gemfile.lock +11 -1
- data/docs/guides/writing-migrations.md +345 -0
- 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 +2 -0
- data/lib/familia/data_type/types/hashkey.rb +0 -238
- data/lib/familia/data_type/types/listkey.rb +4 -110
- data/lib/familia/data_type/types/sorted_set.rb +0 -365
- data/lib/familia/data_type/types/stringkey.rb +0 -139
- data/lib/familia/data_type/types/unsorted_set.rb +2 -122
- 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 +44 -1
|
@@ -0,0 +1,513 @@
|
|
|
1
|
+
# try/migration/v1_to_v2_serialization_try.rb
|
|
2
|
+
#
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
# Tests for V1 to V2 Serialization Migration
|
|
6
|
+
#
|
|
7
|
+
# Demonstrates migrating Familia Horreum objects from v1.x serialization
|
|
8
|
+
# (plain strings via distinguisher) to v2.0 serialization (universal JSON).
|
|
9
|
+
|
|
10
|
+
require_relative '../support/helpers/test_helpers'
|
|
11
|
+
require_relative '../../lib/familia/migration'
|
|
12
|
+
require_relative '../../examples/migrations/v1_to_v2_serialization_migration'
|
|
13
|
+
|
|
14
|
+
Familia.debug = false
|
|
15
|
+
|
|
16
|
+
@redis = Familia.dbclient
|
|
17
|
+
@test_id = "#{Process.pid}_#{Time.now.to_i}"
|
|
18
|
+
@prefix = "familia:test:v1v2:#{@test_id}"
|
|
19
|
+
|
|
20
|
+
@initial_migrations = Familia::Migration.migrations.dup
|
|
21
|
+
|
|
22
|
+
# Test model for migration testing
|
|
23
|
+
class V1V2TestRecord < Familia::Horreum
|
|
24
|
+
identifier_field :record_id
|
|
25
|
+
field :record_id
|
|
26
|
+
field :name # String field
|
|
27
|
+
field :age # Integer field
|
|
28
|
+
field :balance # Float field
|
|
29
|
+
field :active # Boolean field
|
|
30
|
+
field :verified # Boolean field (false)
|
|
31
|
+
field :settings # Hash field
|
|
32
|
+
field :tags # Array field
|
|
33
|
+
field :notes # String or nil
|
|
34
|
+
field :created_at # Timestamp (integer)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Concrete migration for V1V2TestRecord
|
|
38
|
+
class V1V2TestMigration < V1ToV2SerializationMigration
|
|
39
|
+
self.migration_id = 'test_v1v2_migration'
|
|
40
|
+
self.description = 'Test migration for v1 to v2 serialization'
|
|
41
|
+
|
|
42
|
+
def prepare
|
|
43
|
+
@model_class = V1V2TestRecord
|
|
44
|
+
@batch_size = 10
|
|
45
|
+
super
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def field_types_for_model
|
|
49
|
+
{
|
|
50
|
+
record_id: :string,
|
|
51
|
+
name: :string,
|
|
52
|
+
age: :integer,
|
|
53
|
+
balance: :float,
|
|
54
|
+
active: :boolean,
|
|
55
|
+
verified: :boolean,
|
|
56
|
+
settings: :hash,
|
|
57
|
+
tags: :array,
|
|
58
|
+
notes: :string,
|
|
59
|
+
created_at: :timestamp
|
|
60
|
+
}
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Helper to create v1.x format data directly in Redis
|
|
65
|
+
# Simulates how v1.x Familia stored values
|
|
66
|
+
def create_v1_record(suffix, data = {})
|
|
67
|
+
id = "#{@test_id}_#{suffix}"
|
|
68
|
+
# Use the model's actual prefix to match what the migration scans for
|
|
69
|
+
prefix = V1V2TestRecord.prefix
|
|
70
|
+
dbkey = "#{prefix}:#{id}:object"
|
|
71
|
+
|
|
72
|
+
# v1.x serialization: plain strings, no JSON for simple types
|
|
73
|
+
v1_data = {
|
|
74
|
+
'record_id' => id,
|
|
75
|
+
'name' => data[:name] || 'Test User',
|
|
76
|
+
'age' => (data[:age] || 0).to_s, # v1: Integer as string
|
|
77
|
+
'balance' => (data[:balance] || 0.0).to_s, # v1: Float as string
|
|
78
|
+
'active' => (data[:active] || false).to_s, # v1: Boolean as "true"/"false"
|
|
79
|
+
'verified' => (data[:verified] || false).to_s, # v1: Boolean as "true"/"false"
|
|
80
|
+
'settings' => Familia::JsonSerializer.dump(data[:settings] || {}), # v1: Hash already JSON
|
|
81
|
+
'tags' => Familia::JsonSerializer.dump(data[:tags] || []), # v1: Array already JSON
|
|
82
|
+
'created_at' => (data[:created_at] || 0).to_s # v1: Timestamp as string
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
# Add notes only if present (v1 skipped nil values or stored as "")
|
|
86
|
+
v1_data['notes'] = data[:notes] || '' if data.key?(:notes)
|
|
87
|
+
|
|
88
|
+
@redis.hmset(dbkey, *v1_data.flatten)
|
|
89
|
+
|
|
90
|
+
# Register in instances sorted set (zset) for migration to find
|
|
91
|
+
# Familia uses zset for instances tracking with timestamp as score
|
|
92
|
+
@redis.zadd("#{prefix}:instances", Time.now.to_f, id)
|
|
93
|
+
|
|
94
|
+
{ dbkey: dbkey, id: id }
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Helper to read raw Redis values (bypass Familia)
|
|
98
|
+
def read_raw_redis(dbkey)
|
|
99
|
+
@redis.hgetall(dbkey)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Helper to load a record from the dbkey after migration
|
|
103
|
+
# Uses from_redis to load with v2 deserialization
|
|
104
|
+
def load_migrated_record(dbkey)
|
|
105
|
+
V1V2TestRecord.from_redis(dbkey)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Helper to cleanup test records
|
|
109
|
+
def cleanup_records
|
|
110
|
+
prefix = V1V2TestRecord.prefix
|
|
111
|
+
pattern = "#{prefix}:#{@test_id}_*"
|
|
112
|
+
@redis.keys(pattern).each { |k| @redis.del(k) }
|
|
113
|
+
# Clean up instances zset entries for our test ids
|
|
114
|
+
@redis.zremrangebylex("#{prefix}:instances", "[#{@test_id}_", "[#{@test_id}_\xff")
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
cleanup_records
|
|
118
|
+
|
|
119
|
+
## V1ToV2SerializationMigration is a subclass of Model
|
|
120
|
+
V1ToV2SerializationMigration < Familia::Migration::Model
|
|
121
|
+
#=> true
|
|
122
|
+
|
|
123
|
+
## Base migration_id is set
|
|
124
|
+
V1ToV2SerializationMigration.migration_id
|
|
125
|
+
#=> '20260201_000000_v1_to_v2_serialization_base'
|
|
126
|
+
|
|
127
|
+
## Test migration initializes correctly
|
|
128
|
+
migration = V1V2TestMigration.new
|
|
129
|
+
migration.is_a?(V1ToV2SerializationMigration)
|
|
130
|
+
#=> true
|
|
131
|
+
|
|
132
|
+
## Test migration prepares with field types
|
|
133
|
+
migration = V1V2TestMigration.new
|
|
134
|
+
migration.prepare
|
|
135
|
+
migration.instance_variable_get(:@field_types)[:age]
|
|
136
|
+
#=> :integer
|
|
137
|
+
|
|
138
|
+
## Test migration prepares with batch_size
|
|
139
|
+
migration = V1V2TestMigration.new
|
|
140
|
+
migration.prepare
|
|
141
|
+
migration.batch_size
|
|
142
|
+
#=> 10
|
|
143
|
+
|
|
144
|
+
## Detect type correctly identifies integers
|
|
145
|
+
migration = V1V2TestMigration.new
|
|
146
|
+
migration.send(:detect_type, '42')
|
|
147
|
+
#=> :integer
|
|
148
|
+
|
|
149
|
+
## Detect type correctly identifies negative integers
|
|
150
|
+
migration = V1V2TestMigration.new
|
|
151
|
+
migration.send(:detect_type, '-123')
|
|
152
|
+
#=> :integer
|
|
153
|
+
|
|
154
|
+
## Detect type correctly identifies floats
|
|
155
|
+
migration = V1V2TestMigration.new
|
|
156
|
+
migration.send(:detect_type, '3.14')
|
|
157
|
+
#=> :float
|
|
158
|
+
|
|
159
|
+
## Detect type correctly identifies booleans
|
|
160
|
+
migration = V1V2TestMigration.new
|
|
161
|
+
[migration.send(:detect_type, 'true'), migration.send(:detect_type, 'false')]
|
|
162
|
+
#=> [:boolean, :boolean]
|
|
163
|
+
|
|
164
|
+
## Detect type correctly identifies hashes
|
|
165
|
+
migration = V1V2TestMigration.new
|
|
166
|
+
migration.send(:detect_type, '{"key":"value"}')
|
|
167
|
+
#=> :hash
|
|
168
|
+
|
|
169
|
+
## Detect type correctly identifies arrays
|
|
170
|
+
migration = V1V2TestMigration.new
|
|
171
|
+
migration.send(:detect_type, '["a","b","c"]')
|
|
172
|
+
#=> :array
|
|
173
|
+
|
|
174
|
+
## Detect type defaults to string for plain text
|
|
175
|
+
migration = V1V2TestMigration.new
|
|
176
|
+
migration.send(:detect_type, 'hello world')
|
|
177
|
+
#=> :string
|
|
178
|
+
|
|
179
|
+
## Detect type recognizes v2.0 JSON-quoted strings
|
|
180
|
+
migration = V1V2TestMigration.new
|
|
181
|
+
migration.send(:detect_type, '"already quoted"')
|
|
182
|
+
#=> :string
|
|
183
|
+
|
|
184
|
+
## Parse v1 string value returns string as-is
|
|
185
|
+
migration = V1V2TestMigration.new
|
|
186
|
+
migration.send(:parse_v1_value, 'hello', :string)
|
|
187
|
+
#=> 'hello'
|
|
188
|
+
|
|
189
|
+
## Parse v1 integer value converts to Integer
|
|
190
|
+
migration = V1V2TestMigration.new
|
|
191
|
+
migration.send(:parse_v1_value, '42', :integer)
|
|
192
|
+
#=> 42
|
|
193
|
+
|
|
194
|
+
## Parse v1 float value converts to Float
|
|
195
|
+
migration = V1V2TestMigration.new
|
|
196
|
+
migration.send(:parse_v1_value, '3.14', :float)
|
|
197
|
+
#=> 3.14
|
|
198
|
+
|
|
199
|
+
## Parse v1 boolean true converts to TrueClass
|
|
200
|
+
migration = V1V2TestMigration.new
|
|
201
|
+
migration.send(:parse_v1_value, 'true', :boolean)
|
|
202
|
+
#=> true
|
|
203
|
+
|
|
204
|
+
## Parse v1 boolean false converts to FalseClass
|
|
205
|
+
migration = V1V2TestMigration.new
|
|
206
|
+
migration.send(:parse_v1_value, 'false', :boolean)
|
|
207
|
+
#=> false
|
|
208
|
+
|
|
209
|
+
## Parse v1 hash value returns Hash
|
|
210
|
+
migration = V1V2TestMigration.new
|
|
211
|
+
migration.send(:parse_v1_value, '{"theme":"dark"}', :hash)
|
|
212
|
+
#=> {"theme"=>"dark"}
|
|
213
|
+
|
|
214
|
+
## Parse v1 array value returns Array
|
|
215
|
+
migration = V1V2TestMigration.new
|
|
216
|
+
migration.send(:parse_v1_value, '["a","b"]', :array)
|
|
217
|
+
#=> ["a", "b"]
|
|
218
|
+
|
|
219
|
+
## Parse v1 timestamp value converts to Integer
|
|
220
|
+
migration = V1V2TestMigration.new
|
|
221
|
+
migration.send(:parse_v1_value, '1706745600', :timestamp)
|
|
222
|
+
#=> 1706745600
|
|
223
|
+
|
|
224
|
+
## Convert value transforms v1 string to v2 JSON-quoted string
|
|
225
|
+
migration = V1V2TestMigration.new
|
|
226
|
+
migration.send(:convert_value, 'hello', :string)
|
|
227
|
+
#=> '"hello"'
|
|
228
|
+
|
|
229
|
+
## Convert value transforms v1 integer string to v2 JSON integer
|
|
230
|
+
migration = V1V2TestMigration.new
|
|
231
|
+
migration.send(:convert_value, '42', :integer)
|
|
232
|
+
#=> '42'
|
|
233
|
+
|
|
234
|
+
## Convert value transforms v1 float string to v2 JSON float
|
|
235
|
+
migration = V1V2TestMigration.new
|
|
236
|
+
migration.send(:convert_value, '3.14', :float)
|
|
237
|
+
#=> '3.14'
|
|
238
|
+
|
|
239
|
+
## Convert value transforms v1 boolean string to v2 JSON boolean
|
|
240
|
+
migration = V1V2TestMigration.new
|
|
241
|
+
migration.send(:convert_value, 'true', :boolean)
|
|
242
|
+
#=> 'true'
|
|
243
|
+
|
|
244
|
+
## Convert value transforms empty string (v1 nil) to v2 null
|
|
245
|
+
migration = V1V2TestMigration.new
|
|
246
|
+
migration.send(:convert_value, '', :string)
|
|
247
|
+
#=> 'null'
|
|
248
|
+
|
|
249
|
+
## Already v2 format detects JSON-quoted strings
|
|
250
|
+
migration = V1V2TestMigration.new
|
|
251
|
+
migration.prepare
|
|
252
|
+
migration.send(:already_v2_format?, '"hello"', :string)
|
|
253
|
+
#=> true
|
|
254
|
+
|
|
255
|
+
## Already v2 format rejects plain strings
|
|
256
|
+
migration = V1V2TestMigration.new
|
|
257
|
+
migration.prepare
|
|
258
|
+
migration.send(:already_v2_format?, 'hello', :string)
|
|
259
|
+
#=> false
|
|
260
|
+
|
|
261
|
+
## Already v2 format accepts JSON hashes
|
|
262
|
+
migration = V1V2TestMigration.new
|
|
263
|
+
migration.prepare
|
|
264
|
+
migration.send(:already_v2_format?, '{"key":"value"}', :hash)
|
|
265
|
+
#=> true
|
|
266
|
+
|
|
267
|
+
## Already v2 format accepts JSON arrays
|
|
268
|
+
migration = V1V2TestMigration.new
|
|
269
|
+
migration.prepare
|
|
270
|
+
migration.send(:already_v2_format?, '["a","b"]', :array)
|
|
271
|
+
#=> true
|
|
272
|
+
|
|
273
|
+
## V1 data is created correctly for testing
|
|
274
|
+
cleanup_records
|
|
275
|
+
result = create_v1_record('basic', name: 'Alice', age: 30, balance: 99.99, active: true, verified: false)
|
|
276
|
+
raw = read_raw_redis(result[:dbkey])
|
|
277
|
+
raw['age']
|
|
278
|
+
#=> '30'
|
|
279
|
+
|
|
280
|
+
## V1 data stores boolean as string
|
|
281
|
+
cleanup_records
|
|
282
|
+
result = create_v1_record('bool', active: true, verified: false)
|
|
283
|
+
raw = read_raw_redis(result[:dbkey])
|
|
284
|
+
[raw['active'], raw['verified']]
|
|
285
|
+
#=> ['true', 'false']
|
|
286
|
+
|
|
287
|
+
## V1 data stores name as plain string (not JSON-quoted)
|
|
288
|
+
cleanup_records
|
|
289
|
+
result = create_v1_record('str', name: 'Bob Smith')
|
|
290
|
+
raw = read_raw_redis(result[:dbkey])
|
|
291
|
+
raw['name']
|
|
292
|
+
#=> 'Bob Smith'
|
|
293
|
+
|
|
294
|
+
## Migration converts v1 integer field to v2 format
|
|
295
|
+
cleanup_records
|
|
296
|
+
result = create_v1_record('int_test', age: 25)
|
|
297
|
+
migration = V1V2TestMigration.new(run: true)
|
|
298
|
+
migration.prepare
|
|
299
|
+
migration.migrate
|
|
300
|
+
raw = read_raw_redis(result[:dbkey])
|
|
301
|
+
# age stays '25' (JSON number, which is the same string representation)
|
|
302
|
+
# but now it will be parsed correctly by v2 deserializer
|
|
303
|
+
raw['age']
|
|
304
|
+
#=> '25'
|
|
305
|
+
|
|
306
|
+
## Migration converts v1 string field to v2 JSON-quoted format
|
|
307
|
+
cleanup_records
|
|
308
|
+
result = create_v1_record('str_test', name: 'Charlie')
|
|
309
|
+
migration = V1V2TestMigration.new(run: true)
|
|
310
|
+
migration.prepare
|
|
311
|
+
migration.migrate
|
|
312
|
+
raw = read_raw_redis(result[:dbkey])
|
|
313
|
+
raw['name']
|
|
314
|
+
#=> '"Charlie"'
|
|
315
|
+
|
|
316
|
+
## Migration converts v1 boolean field correctly
|
|
317
|
+
cleanup_records
|
|
318
|
+
result = create_v1_record('bool_test', active: true)
|
|
319
|
+
migration = V1V2TestMigration.new(run: true)
|
|
320
|
+
migration.prepare
|
|
321
|
+
migration.migrate
|
|
322
|
+
raw = read_raw_redis(result[:dbkey])
|
|
323
|
+
# Boolean fields stay as 'true'/'false' (same JSON representation)
|
|
324
|
+
raw['active']
|
|
325
|
+
#=> 'true'
|
|
326
|
+
|
|
327
|
+
## Migration converts v1 float field correctly
|
|
328
|
+
cleanup_records
|
|
329
|
+
result = create_v1_record('float_test', balance: 123.45)
|
|
330
|
+
migration = V1V2TestMigration.new(run: true)
|
|
331
|
+
migration.prepare
|
|
332
|
+
migration.migrate
|
|
333
|
+
raw = read_raw_redis(result[:dbkey])
|
|
334
|
+
# Float representation stays the same in JSON
|
|
335
|
+
raw['balance']
|
|
336
|
+
#=> '123.45'
|
|
337
|
+
|
|
338
|
+
## Migration converts v1 empty string (nil) to v2 null
|
|
339
|
+
cleanup_records
|
|
340
|
+
result = create_v1_record('nil_test', notes: nil)
|
|
341
|
+
migration = V1V2TestMigration.new(run: true)
|
|
342
|
+
migration.prepare
|
|
343
|
+
migration.migrate
|
|
344
|
+
raw = read_raw_redis(result[:dbkey])
|
|
345
|
+
raw['notes']
|
|
346
|
+
#=> 'null'
|
|
347
|
+
|
|
348
|
+
## Migration preserves v1 hash field (already JSON)
|
|
349
|
+
cleanup_records
|
|
350
|
+
settings = { 'theme' => 'dark', 'lang' => 'en' }
|
|
351
|
+
result = create_v1_record('hash_test', settings: settings)
|
|
352
|
+
migration = V1V2TestMigration.new(run: true)
|
|
353
|
+
migration.prepare
|
|
354
|
+
migration.migrate
|
|
355
|
+
raw = read_raw_redis(result[:dbkey])
|
|
356
|
+
# Hash stays as JSON object
|
|
357
|
+
Familia::JsonSerializer.parse(raw['settings'])
|
|
358
|
+
#=> {"theme"=>"dark", "lang"=>"en"}
|
|
359
|
+
|
|
360
|
+
## Migration preserves v1 array field (already JSON)
|
|
361
|
+
cleanup_records
|
|
362
|
+
tags = ['ruby', 'redis', 'orm']
|
|
363
|
+
result = create_v1_record('array_test', tags: tags)
|
|
364
|
+
migration = V1V2TestMigration.new(run: true)
|
|
365
|
+
migration.prepare
|
|
366
|
+
migration.migrate
|
|
367
|
+
raw = read_raw_redis(result[:dbkey])
|
|
368
|
+
# Array stays as JSON array
|
|
369
|
+
Familia::JsonSerializer.parse(raw['tags'])
|
|
370
|
+
#=> ["ruby", "redis", "orm"]
|
|
371
|
+
|
|
372
|
+
## Migrated data loads correctly with v2 deserializer
|
|
373
|
+
cleanup_records
|
|
374
|
+
result = create_v1_record('load_test', name: 'Diana', age: 28, active: true)
|
|
375
|
+
migration = V1V2TestMigration.new(run: true)
|
|
376
|
+
migration.prepare
|
|
377
|
+
migration.migrate
|
|
378
|
+
record = V1V2TestRecord.find_by_key(result[:dbkey])
|
|
379
|
+
[record.name, record.age, record.active]
|
|
380
|
+
#=> ['Diana', 28, true]
|
|
381
|
+
|
|
382
|
+
## Migrated integer field returns Integer class
|
|
383
|
+
cleanup_records
|
|
384
|
+
result = create_v1_record('type_int', age: 35)
|
|
385
|
+
migration = V1V2TestMigration.new(run: true)
|
|
386
|
+
migration.prepare
|
|
387
|
+
migration.migrate
|
|
388
|
+
record = V1V2TestRecord.find_by_key(result[:dbkey])
|
|
389
|
+
record.age.class
|
|
390
|
+
#=> Integer
|
|
391
|
+
|
|
392
|
+
## Migrated boolean field returns TrueClass
|
|
393
|
+
cleanup_records
|
|
394
|
+
result = create_v1_record('type_bool', active: true)
|
|
395
|
+
migration = V1V2TestMigration.new(run: true)
|
|
396
|
+
migration.prepare
|
|
397
|
+
migration.migrate
|
|
398
|
+
record = V1V2TestRecord.find_by_key(result[:dbkey])
|
|
399
|
+
record.active.class
|
|
400
|
+
#=> TrueClass
|
|
401
|
+
|
|
402
|
+
## Migrated boolean false field returns FalseClass
|
|
403
|
+
cleanup_records
|
|
404
|
+
result = create_v1_record('type_bool_false', verified: false)
|
|
405
|
+
migration = V1V2TestMigration.new(run: true)
|
|
406
|
+
migration.prepare
|
|
407
|
+
migration.migrate
|
|
408
|
+
record = V1V2TestRecord.find_by_key(result[:dbkey])
|
|
409
|
+
record.verified.class
|
|
410
|
+
#=> FalseClass
|
|
411
|
+
|
|
412
|
+
## Migrated float field returns Float class
|
|
413
|
+
cleanup_records
|
|
414
|
+
result = create_v1_record('type_float', balance: 99.99)
|
|
415
|
+
migration = V1V2TestMigration.new(run: true)
|
|
416
|
+
migration.prepare
|
|
417
|
+
migration.migrate
|
|
418
|
+
record = V1V2TestRecord.find_by_key(result[:dbkey])
|
|
419
|
+
record.balance.class
|
|
420
|
+
#=> Float
|
|
421
|
+
|
|
422
|
+
## Migrated nil field returns NilClass
|
|
423
|
+
cleanup_records
|
|
424
|
+
result = create_v1_record('type_nil', notes: nil)
|
|
425
|
+
migration = V1V2TestMigration.new(run: true)
|
|
426
|
+
migration.prepare
|
|
427
|
+
migration.migrate
|
|
428
|
+
record = V1V2TestRecord.find_by_key(result[:dbkey])
|
|
429
|
+
record.notes.class
|
|
430
|
+
#=> NilClass
|
|
431
|
+
|
|
432
|
+
## Migration respects dry_run mode (no changes made)
|
|
433
|
+
cleanup_records
|
|
434
|
+
result = create_v1_record('dry_run', name: 'Eve')
|
|
435
|
+
original_name = read_raw_redis(result[:dbkey])['name']
|
|
436
|
+
migration = V1V2TestMigration.new(run: false) # dry run
|
|
437
|
+
migration.prepare
|
|
438
|
+
migration.migrate
|
|
439
|
+
raw = read_raw_redis(result[:dbkey])
|
|
440
|
+
raw['name'] == original_name # Should be unchanged
|
|
441
|
+
#=> true
|
|
442
|
+
|
|
443
|
+
## Migration tracks records_updated statistic
|
|
444
|
+
cleanup_records
|
|
445
|
+
create_v1_record('stat1', name: 'F1')
|
|
446
|
+
create_v1_record('stat2', name: 'F2')
|
|
447
|
+
migration = V1V2TestMigration.new(run: true)
|
|
448
|
+
migration.prepare
|
|
449
|
+
migration.migrate
|
|
450
|
+
migration.records_updated >= 2
|
|
451
|
+
#=> true
|
|
452
|
+
|
|
453
|
+
## Migration tracks fields_converted statistic
|
|
454
|
+
# Note: Integer/float fields (age, balance) have same JSON representation in v1 and v2
|
|
455
|
+
# Only string fields (record_id, name) need actual conversion (adding JSON quotes)
|
|
456
|
+
cleanup_records
|
|
457
|
+
create_v1_record('conv1', name: 'G1', age: 20, balance: 10.5)
|
|
458
|
+
migration = V1V2TestMigration.new(run: true)
|
|
459
|
+
migration.prepare
|
|
460
|
+
migration.migrate
|
|
461
|
+
migration.stats[:fields_converted] >= 2
|
|
462
|
+
#=> true
|
|
463
|
+
|
|
464
|
+
## Migration processes multiple records correctly
|
|
465
|
+
cleanup_records
|
|
466
|
+
create_v1_record('multi1', name: 'H1', age: 21)
|
|
467
|
+
create_v1_record('multi2', name: 'H2', age: 22)
|
|
468
|
+
create_v1_record('multi3', name: 'H3', age: 23)
|
|
469
|
+
migration = V1V2TestMigration.new(run: true)
|
|
470
|
+
migration.prepare
|
|
471
|
+
migration.migrate
|
|
472
|
+
migration.total_scanned >= 3
|
|
473
|
+
#=> true
|
|
474
|
+
|
|
475
|
+
## Already migrated records are skipped on re-run
|
|
476
|
+
cleanup_records
|
|
477
|
+
result = create_v1_record('rerun', name: 'Iris', age: 30)
|
|
478
|
+
migration1 = V1V2TestMigration.new(run: true)
|
|
479
|
+
migration1.prepare
|
|
480
|
+
migration1.migrate
|
|
481
|
+
first_converted = migration1.stats[:fields_converted]
|
|
482
|
+
migration2 = V1V2TestMigration.new(run: true)
|
|
483
|
+
migration2.prepare
|
|
484
|
+
migration2.migrate
|
|
485
|
+
second_converted = migration2.stats[:fields_converted]
|
|
486
|
+
# Second run should convert zero fields (already v2 format)
|
|
487
|
+
second_converted
|
|
488
|
+
#=> 0
|
|
489
|
+
|
|
490
|
+
## Complete round-trip: create v1 data, migrate, load with v2, save, reload
|
|
491
|
+
cleanup_records
|
|
492
|
+
result = create_v1_record('roundtrip',
|
|
493
|
+
name: 'Jack',
|
|
494
|
+
age: 40,
|
|
495
|
+
balance: 500.00,
|
|
496
|
+
active: true,
|
|
497
|
+
verified: false,
|
|
498
|
+
settings: { 'pref' => 'value' },
|
|
499
|
+
tags: ['tag1', 'tag2'],
|
|
500
|
+
created_at: 1706745600
|
|
501
|
+
)
|
|
502
|
+
migration = V1V2TestMigration.new(run: true)
|
|
503
|
+
migration.prepare
|
|
504
|
+
migration.migrate
|
|
505
|
+
record = V1V2TestRecord.find_by_key(result[:dbkey])
|
|
506
|
+
record.name = 'Jack Updated'
|
|
507
|
+
record.save
|
|
508
|
+
reloaded = V1V2TestRecord.find_by_key(result[:dbkey])
|
|
509
|
+
[reloaded.name, reloaded.age.class, reloaded.active.class]
|
|
510
|
+
#=> ['Jack Updated', Integer, TrueClass]
|
|
511
|
+
|
|
512
|
+
cleanup_records
|
|
513
|
+
Familia::Migration.migrations.replace(@initial_migrations)
|
|
@@ -8,12 +8,6 @@ require_relative '../support/helpers/test_helpers'
|
|
|
8
8
|
require 'benchmark'
|
|
9
9
|
|
|
10
10
|
## serialization performance comparison
|
|
11
|
-
user_class = Class.new(Familia::Horreum) do
|
|
12
|
-
identifier_field :email
|
|
13
|
-
field :name
|
|
14
|
-
field :data
|
|
15
|
-
end
|
|
16
|
-
|
|
17
11
|
large_data = { metadata: "x" * 1000, items: (1..1000).to_a }
|
|
18
12
|
|
|
19
13
|
json_time = Benchmark.realtime do
|
|
@@ -25,11 +19,13 @@ familia_time = Benchmark.realtime do
|
|
|
25
19
|
end
|
|
26
20
|
|
|
27
21
|
json_time > 0 && familia_time > 0
|
|
28
|
-
|
|
22
|
+
#=> true
|
|
29
23
|
|
|
30
24
|
## bulk operations vs individual saves
|
|
31
25
|
user_class = Class.new(Familia::Horreum) do
|
|
26
|
+
prefix :benchuser1
|
|
32
27
|
identifier_field :email
|
|
28
|
+
field :email
|
|
33
29
|
field :name
|
|
34
30
|
field :data
|
|
35
31
|
end
|
|
@@ -46,23 +42,26 @@ end
|
|
|
46
42
|
users.each(&:delete!)
|
|
47
43
|
|
|
48
44
|
individual_time > 0
|
|
49
|
-
|
|
45
|
+
#=> true
|
|
50
46
|
|
|
51
47
|
## Valkey/Redis type access performance
|
|
52
|
-
|
|
48
|
+
user_class2 = Class.new(Familia::Horreum) do
|
|
49
|
+
prefix :benchuser2
|
|
53
50
|
identifier_field :email
|
|
51
|
+
field :email
|
|
54
52
|
field :name
|
|
55
53
|
field :data
|
|
54
|
+
set :tags
|
|
56
55
|
end
|
|
57
56
|
|
|
58
|
-
user =
|
|
57
|
+
user = user_class2.new(email: "perf@example.com")
|
|
59
58
|
user.save
|
|
60
59
|
|
|
61
60
|
access_time = Benchmark.realtime do
|
|
62
|
-
1000.times { user.
|
|
61
|
+
1000.times { user.tags }
|
|
63
62
|
end
|
|
64
63
|
|
|
65
64
|
result = access_time > 0
|
|
66
65
|
user.delete!
|
|
67
66
|
result
|
|
68
|
-
|
|
67
|
+
#=> true
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: familia
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 2.
|
|
4
|
+
version: 2.1.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Delano Mandelbaum
|
|
@@ -127,6 +127,20 @@ dependencies:
|
|
|
127
127
|
- - "~>"
|
|
128
128
|
- !ruby/object:Gem::Version
|
|
129
129
|
version: '1.4'
|
|
130
|
+
- !ruby/object:Gem::Dependency
|
|
131
|
+
name: json_schemer
|
|
132
|
+
requirement: !ruby/object:Gem::Requirement
|
|
133
|
+
requirements:
|
|
134
|
+
- - "~>"
|
|
135
|
+
- !ruby/object:Gem::Version
|
|
136
|
+
version: '2.0'
|
|
137
|
+
type: :development
|
|
138
|
+
prerelease: false
|
|
139
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
140
|
+
requirements:
|
|
141
|
+
- - "~>"
|
|
142
|
+
- !ruby/object:Gem::Version
|
|
143
|
+
version: '2.0'
|
|
130
144
|
description: 'Familia: An ORM for Valkey-compatible databases in Ruby.. Organize and
|
|
131
145
|
store ruby objects in Valkey/Redis'
|
|
132
146
|
email: gems@solutious.com
|
|
@@ -179,6 +193,7 @@ files:
|
|
|
179
193
|
- docs/guides/optimized-loading.md
|
|
180
194
|
- docs/guides/thread-safety-monitoring.md
|
|
181
195
|
- docs/guides/time-literals.md
|
|
196
|
+
- docs/guides/writing-migrations.md
|
|
182
197
|
- docs/migrating/.gitignore
|
|
183
198
|
- docs/overview.md
|
|
184
199
|
- docs/qodo-merge-compliance.md
|
|
@@ -189,9 +204,12 @@ files:
|
|
|
189
204
|
- examples/datatype_standalone.rb
|
|
190
205
|
- examples/encrypted_fields.rb
|
|
191
206
|
- examples/json_usage_patterns.rb
|
|
207
|
+
- examples/migrations/v1_to_v2_serialization_migration.rb
|
|
192
208
|
- examples/relationships.rb
|
|
193
209
|
- examples/safe_dump.rb
|
|
194
210
|
- examples/sampling_demo.rb
|
|
211
|
+
- examples/schemas/customer.json
|
|
212
|
+
- examples/schemas/session.json
|
|
195
213
|
- examples/single_connection_transaction_confusions.rb
|
|
196
214
|
- examples/through_relationships.rb
|
|
197
215
|
- familia.gemspec
|
|
@@ -257,6 +275,7 @@ files:
|
|
|
257
275
|
- lib/familia/features/relationships/participation_relationship.rb
|
|
258
276
|
- lib/familia/features/relationships/score_encoding.rb
|
|
259
277
|
- lib/familia/features/safe_dump.rb
|
|
278
|
+
- lib/familia/features/schema_validation.rb
|
|
260
279
|
- lib/familia/features/transient_fields.rb
|
|
261
280
|
- lib/familia/features/transient_fields/redacted_string.rb
|
|
262
281
|
- lib/familia/features/transient_fields/single_use_redacted_string.rb
|
|
@@ -276,10 +295,21 @@ files:
|
|
|
276
295
|
- lib/familia/instrumentation.rb
|
|
277
296
|
- lib/familia/json_serializer.rb
|
|
278
297
|
- lib/familia/logging.rb
|
|
298
|
+
- lib/familia/migration.rb
|
|
299
|
+
- lib/familia/migration/base.rb
|
|
300
|
+
- lib/familia/migration/errors.rb
|
|
301
|
+
- lib/familia/migration/model.rb
|
|
302
|
+
- lib/familia/migration/pipeline.rb
|
|
303
|
+
- lib/familia/migration/rake_tasks.rake
|
|
304
|
+
- lib/familia/migration/rake_tasks.rb
|
|
305
|
+
- lib/familia/migration/registry.rb
|
|
306
|
+
- lib/familia/migration/runner.rb
|
|
307
|
+
- lib/familia/migration/script.rb
|
|
279
308
|
- lib/familia/refinements.rb
|
|
280
309
|
- lib/familia/refinements/dear_json.rb
|
|
281
310
|
- lib/familia/refinements/stylize_words.rb
|
|
282
311
|
- lib/familia/refinements/time_literals.rb
|
|
312
|
+
- lib/familia/schema_registry.rb
|
|
283
313
|
- lib/familia/secure_identifier.rb
|
|
284
314
|
- lib/familia/settings.rb
|
|
285
315
|
- lib/familia/thread_safety/instrumented_mutex.rb
|
|
@@ -362,6 +392,8 @@ files:
|
|
|
362
392
|
- try/features/relationships/relationships_try.rb
|
|
363
393
|
- try/features/safe_dump/safe_dump_advanced_try.rb
|
|
364
394
|
- try/features/safe_dump/safe_dump_try.rb
|
|
395
|
+
- try/features/schema_registry_try.rb
|
|
396
|
+
- try/features/schema_validation_feature_try.rb
|
|
365
397
|
- try/features/transient_fields/redacted_string_try.rb
|
|
366
398
|
- try/features/transient_fields/refresh_reset_try.rb
|
|
367
399
|
- try/features/transient_fields/simple_refresh_test.rb
|
|
@@ -402,6 +434,17 @@ files:
|
|
|
402
434
|
- try/integration/transaction_safety_workflow_try.rb
|
|
403
435
|
- try/integration/verifiable_identifier_try.rb
|
|
404
436
|
- try/investigation/pipeline_routing/README.md
|
|
437
|
+
- try/migration/base_try.rb
|
|
438
|
+
- try/migration/errors_try.rb
|
|
439
|
+
- try/migration/integration_try.rb
|
|
440
|
+
- try/migration/model_try.rb
|
|
441
|
+
- try/migration/pipeline_try.rb
|
|
442
|
+
- try/migration/rake_tasks_try.rb
|
|
443
|
+
- try/migration/registry_try.rb
|
|
444
|
+
- try/migration/runner_try.rb
|
|
445
|
+
- try/migration/schema_validation_try.rb
|
|
446
|
+
- try/migration/script_try.rb
|
|
447
|
+
- try/migration/v1_to_v2_serialization_try.rb
|
|
405
448
|
- try/performance/benchmarks_try.rb
|
|
406
449
|
- try/performance/transaction_safety_benchmark_try.rb
|
|
407
450
|
- try/support/benchmarks/deserialization_benchmark.rb
|