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
|
@@ -48,7 +48,7 @@ Base class for Valkey/Redis data type implementations.
|
|
|
48
48
|
|
|
49
49
|
---
|
|
50
50
|
|
|
51
|
-
## Feature System
|
|
51
|
+
## Feature System
|
|
52
52
|
|
|
53
53
|
### Feature Architecture
|
|
54
54
|
Modular system for extending Horreum classes with reusable functionality.
|
|
@@ -500,6 +500,39 @@ Familia::Base.add_feature ExternalIdentifier, :external_identifier, depends_on:
|
|
|
500
500
|
end
|
|
501
501
|
```
|
|
502
502
|
|
|
503
|
+
### Per-Class Feature Registration
|
|
504
|
+
|
|
505
|
+
Register custom features for specific model classes with ancestry chain lookup.
|
|
506
|
+
|
|
507
|
+
```ruby
|
|
508
|
+
# Define a custom feature module
|
|
509
|
+
module CustomerAnalytics
|
|
510
|
+
def track_purchase(amount)
|
|
511
|
+
purchases.increment(amount)
|
|
512
|
+
end
|
|
513
|
+
end
|
|
514
|
+
|
|
515
|
+
# Register feature only for Customer and its subclasses
|
|
516
|
+
Customer.add_feature CustomerAnalytics, :customer_analytics
|
|
517
|
+
|
|
518
|
+
class Customer < Familia::Horreum
|
|
519
|
+
feature :customer_analytics # Available via Customer's registry
|
|
520
|
+
end
|
|
521
|
+
|
|
522
|
+
class PremiumCustomer < Customer
|
|
523
|
+
feature :customer_analytics # Inherited via ancestry chain
|
|
524
|
+
end
|
|
525
|
+
|
|
526
|
+
class Session < Familia::Horreum
|
|
527
|
+
# feature :customer_analytics # Not available - would raise error
|
|
528
|
+
end
|
|
529
|
+
```
|
|
530
|
+
|
|
531
|
+
**Benefits:**
|
|
532
|
+
- Features can have the same name across different model hierarchies
|
|
533
|
+
- Natural inheritance through Ruby's class hierarchy
|
|
534
|
+
- Better namespace management for large applications
|
|
535
|
+
|
|
503
536
|
### Per-Class Feature Configuration Isolation
|
|
504
537
|
Each class maintains independent feature options.
|
|
505
538
|
|
|
@@ -970,6 +1003,43 @@ end
|
|
|
970
1003
|
|
|
971
1004
|
## Performance Optimization
|
|
972
1005
|
|
|
1006
|
+
### Pipelined Bulk Loading
|
|
1007
|
+
|
|
1008
|
+
Load multiple objects efficiently with a single pipelined Redis batch.
|
|
1009
|
+
|
|
1010
|
+
```ruby
|
|
1011
|
+
# Before: N×2 commands (EXISTS + HGETALL per object)
|
|
1012
|
+
users = ids.map { |id| User.find_by_id(id) }
|
|
1013
|
+
# For 14 objects: 28 Redis commands
|
|
1014
|
+
|
|
1015
|
+
# After: 1 pipelined batch
|
|
1016
|
+
users = User.load_multi(ids)
|
|
1017
|
+
# For 14 objects: 1 batch with 14 HGETALL commands (2× faster)
|
|
1018
|
+
|
|
1019
|
+
# Load by full dbkeys
|
|
1020
|
+
users = User.load_multi_by_keys(['user:123:object', 'user:456:object'])
|
|
1021
|
+
|
|
1022
|
+
# Filter out nils for missing objects
|
|
1023
|
+
existing_users = User.load_multi(ids).compact
|
|
1024
|
+
```
|
|
1025
|
+
|
|
1026
|
+
### Optional EXISTS Check Optimization
|
|
1027
|
+
|
|
1028
|
+
Skip the EXISTS check for 50% reduction in Redis commands when keys are known to exist.
|
|
1029
|
+
|
|
1030
|
+
```ruby
|
|
1031
|
+
# Default behavior (2 commands: EXISTS + HGETALL)
|
|
1032
|
+
user = User.find_by_id(123)
|
|
1033
|
+
|
|
1034
|
+
# Optimized (1 command: HGETALL only)
|
|
1035
|
+
user = User.find_by_id(123, check_exists: false)
|
|
1036
|
+
```
|
|
1037
|
+
|
|
1038
|
+
**When to use `check_exists: false`:**
|
|
1039
|
+
- Loading from sorted set results (keys guaranteed to exist)
|
|
1040
|
+
- High-throughput API endpoints
|
|
1041
|
+
- Bulk operations with known-existing keys
|
|
1042
|
+
|
|
973
1043
|
### Batch Operations
|
|
974
1044
|
Minimize Valkey/Redis round trips with batch operations.
|
|
975
1045
|
|
|
@@ -990,6 +1060,33 @@ User.pipelined do
|
|
|
990
1060
|
end
|
|
991
1061
|
```
|
|
992
1062
|
|
|
1063
|
+
### Index Rebuilding
|
|
1064
|
+
|
|
1065
|
+
Auto-generated rebuild methods for unique and multi indexes with zero downtime.
|
|
1066
|
+
|
|
1067
|
+
```ruby
|
|
1068
|
+
class User < Familia::Horreum
|
|
1069
|
+
feature :relationships
|
|
1070
|
+
unique_index :email, :email_lookup
|
|
1071
|
+
end
|
|
1072
|
+
|
|
1073
|
+
# Rebuild class-level unique index
|
|
1074
|
+
User.rebuild_email_lookup
|
|
1075
|
+
|
|
1076
|
+
# With progress tracking
|
|
1077
|
+
User.rebuild_email_lookup(batch_size: 100) do |progress|
|
|
1078
|
+
puts "#{progress[:completed]}/#{progress[:total]}"
|
|
1079
|
+
end
|
|
1080
|
+
|
|
1081
|
+
# Instance-scoped index rebuild
|
|
1082
|
+
company.rebuild_badge_index
|
|
1083
|
+
```
|
|
1084
|
+
|
|
1085
|
+
**When to use:**
|
|
1086
|
+
- After data migrations or bulk imports
|
|
1087
|
+
- Recovering from index corruption
|
|
1088
|
+
- Adding indexes to existing data
|
|
1089
|
+
|
|
993
1090
|
### Memory Optimization
|
|
994
1091
|
Efficient memory usage patterns.
|
|
995
1092
|
|
|
@@ -1040,7 +1137,7 @@ end
|
|
|
1040
1137
|
|
|
1041
1138
|
## Migration and Upgrading
|
|
1042
1139
|
|
|
1043
|
-
### From v1.x to v2.0
|
|
1140
|
+
### From v1.x to v2.0
|
|
1044
1141
|
Key changes and migration steps.
|
|
1045
1142
|
|
|
1046
1143
|
```ruby
|
|
@@ -1051,7 +1148,7 @@ class User < Familia
|
|
|
1051
1148
|
list :sessions
|
|
1052
1149
|
end
|
|
1053
1150
|
|
|
1054
|
-
# NEW v2.0
|
|
1151
|
+
# NEW v2.0 syntax
|
|
1055
1152
|
class User < Familia::Horreum
|
|
1056
1153
|
identifier_field :email # Updated method name
|
|
1057
1154
|
field :name # Generic field method
|
|
@@ -1304,9 +1401,8 @@ end
|
|
|
1304
1401
|
- [Connection Pooling Guide](../guides/Connection-Pooling-Guide.md)
|
|
1305
1402
|
|
|
1306
1403
|
### Version Information
|
|
1307
|
-
- **Current Version**: v2.0.0
|
|
1308
|
-
- **
|
|
1309
|
-
- **Ruby Compatibility**: 3.0+ (3.4+ recommended for optimal threading)
|
|
1404
|
+
- **Current Version**: v2.0.0
|
|
1405
|
+
- **Ruby Compatibility**: 3.2+
|
|
1310
1406
|
- **Redis Compatibility**: 6.0+ (Valkey compatible)
|
|
1311
1407
|
|
|
1312
|
-
This technical reference covers the major components and usage patterns available in Familia v2.0.
|
|
1408
|
+
This technical reference covers the major components and usage patterns available in Familia v2.0. For complete API documentation, see the generated YARD docs and wiki guides.
|
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
# examples/migrations/v1_to_v2_serialization_migration.rb
|
|
2
|
+
#
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
# V1 to V2 Serialization Migration
|
|
6
|
+
#
|
|
7
|
+
# This migration demonstrates how to upgrade Familia Horreum objects from
|
|
8
|
+
# v1.x serialization format (where values were stored as plain strings via
|
|
9
|
+
# distinguisher logic) to v2.0 format (where ALL values are JSON-encoded
|
|
10
|
+
# for type preservation).
|
|
11
|
+
#
|
|
12
|
+
# == Background
|
|
13
|
+
#
|
|
14
|
+
# In Familia v1.x, serialization was selective:
|
|
15
|
+
# - Simple types (String, Integer, Float, Symbol) → stored as plain strings via `to_s`
|
|
16
|
+
# - Booleans → stored as "true"/"false" strings (type info lost)
|
|
17
|
+
# - nil → stored as "" (empty string) or field not present
|
|
18
|
+
# - Hash/Array → JSON encoded
|
|
19
|
+
#
|
|
20
|
+
# In Familia v2.0, serialization is universal:
|
|
21
|
+
# - ALL values → JSON encoded for type preservation
|
|
22
|
+
# - Strings: "hello" → "\"hello\"" (JSON string with quotes)
|
|
23
|
+
# - Integers: 42 → "42" (JSON number, decoded as Integer)
|
|
24
|
+
# - Booleans: true → "true" (JSON boolean, decoded as TrueClass)
|
|
25
|
+
# - nil → "null" (JSON null, decoded as nil)
|
|
26
|
+
#
|
|
27
|
+
# == Migration Strategy
|
|
28
|
+
#
|
|
29
|
+
# This migration:
|
|
30
|
+
# 1. Scans all Horreum object keys for the specified model
|
|
31
|
+
# 2. Reads raw Redis values (bypassing Familia's deserializer)
|
|
32
|
+
# 3. Detects v1.x format values using heuristics
|
|
33
|
+
# 4. Re-serializes values using v2.0 JSON encoding
|
|
34
|
+
# 5. Writes updated values back to Redis
|
|
35
|
+
#
|
|
36
|
+
# == Usage
|
|
37
|
+
#
|
|
38
|
+
# # Create a subclass for your specific model:
|
|
39
|
+
# class CustomerSerializationMigration < V1ToV2SerializationMigration
|
|
40
|
+
# self.migration_id = '20260201_120000_customer_serialization'
|
|
41
|
+
# self.description = 'Migrate Customer model from v1.x to v2.0 serialization'
|
|
42
|
+
#
|
|
43
|
+
# def prepare
|
|
44
|
+
# @model_class = Customer
|
|
45
|
+
# @batch_size = 100
|
|
46
|
+
# super # Important: calls V1ToV2SerializationMigration's prepare
|
|
47
|
+
# end
|
|
48
|
+
# end
|
|
49
|
+
#
|
|
50
|
+
# # Run dry-run first:
|
|
51
|
+
# CustomerSerializationMigration.cli_run
|
|
52
|
+
#
|
|
53
|
+
# # Run actual migration:
|
|
54
|
+
# CustomerSerializationMigration.cli_run(['--run'])
|
|
55
|
+
#
|
|
56
|
+
# == Field Type Declarations
|
|
57
|
+
#
|
|
58
|
+
# For accurate type detection and conversion, override `field_types_for_model`:
|
|
59
|
+
#
|
|
60
|
+
# def field_types_for_model
|
|
61
|
+
# {
|
|
62
|
+
# email: :string,
|
|
63
|
+
# name: :string,
|
|
64
|
+
# age: :integer,
|
|
65
|
+
# balance: :float,
|
|
66
|
+
# active: :boolean,
|
|
67
|
+
# settings: :hash,
|
|
68
|
+
# tags: :array,
|
|
69
|
+
# deleted_at: :timestamp # Integer timestamp
|
|
70
|
+
# }
|
|
71
|
+
# end
|
|
72
|
+
#
|
|
73
|
+
# This helps the migration correctly interpret v1.x values like:
|
|
74
|
+
# - "true" as boolean (not string)
|
|
75
|
+
# - "42" as integer (not string)
|
|
76
|
+
# - "{}" as hash (already JSON, no change needed)
|
|
77
|
+
#
|
|
78
|
+
require_relative '../../lib/familia'
|
|
79
|
+
require_relative '../../lib/familia/migration'
|
|
80
|
+
|
|
81
|
+
class V1ToV2SerializationMigration < Familia::Migration::Model
|
|
82
|
+
self.migration_id = '20260201_000000_v1_to_v2_serialization_base'
|
|
83
|
+
self.description = 'Base migration for v1.x to v2.0 serialization format'
|
|
84
|
+
|
|
85
|
+
# Type mapping for v1.x → v2.0 conversions
|
|
86
|
+
SUPPORTED_TYPES = %i[string integer float boolean hash array timestamp].freeze
|
|
87
|
+
|
|
88
|
+
def prepare
|
|
89
|
+
raise NotImplementedError, "Subclass must set @model_class in #prepare" unless @model_class
|
|
90
|
+
|
|
91
|
+
@batch_size ||= 100
|
|
92
|
+
@field_types = field_types_for_model
|
|
93
|
+
|
|
94
|
+
info "Migrating #{@model_class.name} with field types: #{@field_types.keys.join(', ')}"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Override in subclass to specify field types for your model
|
|
98
|
+
#
|
|
99
|
+
# @return [Hash<Symbol, Symbol>] field_name => type mapping
|
|
100
|
+
# Supported types: :string, :integer, :float, :boolean, :hash, :array, :timestamp
|
|
101
|
+
def field_types_for_model
|
|
102
|
+
# Default: treat all fields as strings (safest, no-op for most)
|
|
103
|
+
# Override in subclass for type-aware conversion
|
|
104
|
+
{}
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Override load_from_key to skip Familia's deserialization.
|
|
108
|
+
# For v1→v2 migration, we work directly with raw Redis data.
|
|
109
|
+
# The 'obj' returned is actually just the key itself (a String).
|
|
110
|
+
def load_from_key(key)
|
|
111
|
+
key # Return the key directly, we'll read raw values in process_record
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def process_record(dbkey, _original_key)
|
|
115
|
+
# Note: dbkey is the key string (not an object) because we override load_from_key
|
|
116
|
+
# Read raw Redis values (bypass Familia deserialization)
|
|
117
|
+
raw_values = read_raw_values(dbkey)
|
|
118
|
+
|
|
119
|
+
return track_stat(:empty_records) if raw_values.empty?
|
|
120
|
+
|
|
121
|
+
# Detect and convert v1.x values to v2.0 format
|
|
122
|
+
converted = convert_v1_to_v2(raw_values)
|
|
123
|
+
|
|
124
|
+
if converted.empty?
|
|
125
|
+
debug "No fields need conversion for #{dbkey}"
|
|
126
|
+
track_stat(:already_v2_format)
|
|
127
|
+
return
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
debug "Converting #{converted.size} fields for #{dbkey}: #{converted.keys.join(', ')}"
|
|
131
|
+
|
|
132
|
+
for_realsies_this_time? do
|
|
133
|
+
write_converted_values(dbkey, converted)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
track_stat(:records_updated)
|
|
137
|
+
track_stat(:fields_converted, converted.size)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
protected
|
|
141
|
+
|
|
142
|
+
# Read raw string values from Redis, bypassing Familia's deserializer
|
|
143
|
+
def read_raw_values(dbkey)
|
|
144
|
+
redis.hgetall(dbkey)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Write converted values back to Redis using HMSET
|
|
148
|
+
def write_converted_values(dbkey, converted)
|
|
149
|
+
redis.hmset(dbkey, *converted.flatten) if converted.any?
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Convert v1.x format values to v2.0 JSON-encoded format
|
|
153
|
+
#
|
|
154
|
+
# @param raw_values [Hash<String, String>] field_name => raw Redis value
|
|
155
|
+
# @return [Hash<String, String>] field_name => converted JSON value
|
|
156
|
+
def convert_v1_to_v2(raw_values)
|
|
157
|
+
converted = {}
|
|
158
|
+
|
|
159
|
+
raw_values.each do |field_name, raw_value|
|
|
160
|
+
field_sym = field_name.to_sym
|
|
161
|
+
field_type = @field_types[field_sym] || detect_type(raw_value)
|
|
162
|
+
|
|
163
|
+
# Skip if already in v2.0 format
|
|
164
|
+
next if already_v2_format?(raw_value, field_type)
|
|
165
|
+
|
|
166
|
+
# Convert v1.x value to v2.0 format
|
|
167
|
+
v2_value = convert_value(raw_value, field_type)
|
|
168
|
+
|
|
169
|
+
if v2_value != raw_value
|
|
170
|
+
converted[field_name] = v2_value
|
|
171
|
+
track_stat("converted_#{field_type}".to_sym)
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
converted
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Detect if a value is already in v2.0 JSON format
|
|
179
|
+
#
|
|
180
|
+
# v2.0 format characteristics:
|
|
181
|
+
# - Strings are JSON-quoted: "\"hello\""
|
|
182
|
+
# - Numbers, booleans are valid JSON: "42", "true", "false"
|
|
183
|
+
# - null is explicit: "null"
|
|
184
|
+
# - Hashes/Arrays are JSON objects/arrays: "{...}", "[...]"
|
|
185
|
+
#
|
|
186
|
+
# v1.x format characteristics:
|
|
187
|
+
# - Strings are plain: "hello" (no wrapping quotes)
|
|
188
|
+
# - Numbers stored as string but parsed same as JSON
|
|
189
|
+
# - Booleans same as JSON but interpreted as strings
|
|
190
|
+
# - Empty string "" for nil (v2 uses "null")
|
|
191
|
+
def already_v2_format?(value, expected_type)
|
|
192
|
+
# nil values in Ruby don't need conversion (handled elsewhere)
|
|
193
|
+
return true if value.nil?
|
|
194
|
+
|
|
195
|
+
# Empty strings in v1.x represent nil, which should be "null" in v2.0
|
|
196
|
+
# So empty strings are NOT already in v2.0 format
|
|
197
|
+
return false if value.empty?
|
|
198
|
+
|
|
199
|
+
case expected_type
|
|
200
|
+
when :string
|
|
201
|
+
# v2.0 strings start and end with escaped quotes
|
|
202
|
+
value.start_with?('"') && value.end_with?('"')
|
|
203
|
+
|
|
204
|
+
when :integer, :float
|
|
205
|
+
# Numbers look the same in both formats, but v2 JSON parses correctly
|
|
206
|
+
# Can't reliably detect, so we'll skip if parseable as JSON number
|
|
207
|
+
begin
|
|
208
|
+
parsed = Familia::JsonSerializer.parse(value)
|
|
209
|
+
parsed.is_a?(Integer) || parsed.is_a?(Float)
|
|
210
|
+
rescue Familia::SerializerError
|
|
211
|
+
false
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
when :boolean
|
|
215
|
+
# Both formats store "true"/"false", but v1 parses as string
|
|
216
|
+
# v2 parses as actual boolean - can't detect from storage alone
|
|
217
|
+
# We need to re-serialize to ensure correct JSON format
|
|
218
|
+
value == 'true' || value == 'false'
|
|
219
|
+
|
|
220
|
+
when :hash, :array
|
|
221
|
+
# Both v1 and v2 store as JSON, already compatible
|
|
222
|
+
begin
|
|
223
|
+
parsed = Familia::JsonSerializer.parse(value)
|
|
224
|
+
(expected_type == :hash && parsed.is_a?(Hash)) ||
|
|
225
|
+
(expected_type == :array && parsed.is_a?(Array))
|
|
226
|
+
rescue Familia::SerializerError
|
|
227
|
+
false
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
when :timestamp
|
|
231
|
+
# Timestamps are integers, same handling as :integer
|
|
232
|
+
already_v2_format?(value, :integer)
|
|
233
|
+
|
|
234
|
+
else
|
|
235
|
+
false
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# Convert a v1.x value to v2.0 JSON-encoded format
|
|
240
|
+
#
|
|
241
|
+
# @param raw_value [String] The raw Redis string value
|
|
242
|
+
# @param field_type [Symbol] Expected field type
|
|
243
|
+
# @return [String] JSON-encoded value for v2.0 storage
|
|
244
|
+
def convert_value(raw_value, field_type)
|
|
245
|
+
# Handle empty string (v1.x nil representation)
|
|
246
|
+
return 'null' if raw_value == ''
|
|
247
|
+
|
|
248
|
+
ruby_value = parse_v1_value(raw_value, field_type)
|
|
249
|
+
Familia::JsonSerializer.dump(ruby_value)
|
|
250
|
+
rescue StandardError => e
|
|
251
|
+
warn "Failed to convert value '#{raw_value}' as #{field_type}: #{e.message}"
|
|
252
|
+
track_stat(:conversion_errors)
|
|
253
|
+
raw_value # Return original on error
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
# Parse a v1.x stored value to its Ruby type
|
|
257
|
+
#
|
|
258
|
+
# @param raw_value [String] The raw Redis string value
|
|
259
|
+
# @param field_type [Symbol] Expected field type
|
|
260
|
+
# @return [Object] The parsed Ruby value
|
|
261
|
+
def parse_v1_value(raw_value, field_type)
|
|
262
|
+
case field_type
|
|
263
|
+
when :string
|
|
264
|
+
# v1 strings are stored as-is, already correct Ruby type
|
|
265
|
+
raw_value
|
|
266
|
+
|
|
267
|
+
when :integer, :timestamp
|
|
268
|
+
# v1 integers stored as string "42"
|
|
269
|
+
raw_value.to_i
|
|
270
|
+
|
|
271
|
+
when :float
|
|
272
|
+
# v1 floats stored as string "3.14"
|
|
273
|
+
raw_value.to_f
|
|
274
|
+
|
|
275
|
+
when :boolean
|
|
276
|
+
# v1 booleans stored as "true"/"false" strings
|
|
277
|
+
raw_value == 'true'
|
|
278
|
+
|
|
279
|
+
when :hash, :array
|
|
280
|
+
# v1 complex types already JSON-encoded, parse them
|
|
281
|
+
begin
|
|
282
|
+
Familia::JsonSerializer.parse(raw_value)
|
|
283
|
+
rescue Familia::SerializerError
|
|
284
|
+
# Corrupted JSON, return empty structure
|
|
285
|
+
field_type == :hash ? {} : []
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
else
|
|
289
|
+
# Unknown type, treat as string
|
|
290
|
+
raw_value
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
# Attempt to detect the type of a value from its format
|
|
295
|
+
#
|
|
296
|
+
# Used when field_types_for_model doesn't specify a field type.
|
|
297
|
+
# This is a heuristic and may not always be accurate.
|
|
298
|
+
#
|
|
299
|
+
# @param value [String] The raw Redis value
|
|
300
|
+
# @return [Symbol] Detected type (defaults to :string)
|
|
301
|
+
def detect_type(value)
|
|
302
|
+
return :string if value.nil? || value.empty?
|
|
303
|
+
|
|
304
|
+
# JSON object (hash)
|
|
305
|
+
return :hash if value.start_with?('{') && value.end_with?('}')
|
|
306
|
+
|
|
307
|
+
# JSON array
|
|
308
|
+
return :array if value.start_with?('[') && value.end_with?(']')
|
|
309
|
+
|
|
310
|
+
# Potential boolean
|
|
311
|
+
return :boolean if %w[true false].include?(value)
|
|
312
|
+
|
|
313
|
+
# JSON null (v2.0 format for nil)
|
|
314
|
+
return :string if value == 'null'
|
|
315
|
+
|
|
316
|
+
# Potential integer
|
|
317
|
+
return :integer if value.match?(/\A-?\d+\z/)
|
|
318
|
+
|
|
319
|
+
# Potential float
|
|
320
|
+
return :float if value.match?(/\A-?\d+\.\d+\z/)
|
|
321
|
+
|
|
322
|
+
# Already JSON-quoted string (v2.0 format)
|
|
323
|
+
return :string if value.start_with?('"') && value.end_with?('"')
|
|
324
|
+
|
|
325
|
+
# Default: plain string (v1.x format)
|
|
326
|
+
:string
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
# Example: Concrete migration for a User model
|
|
331
|
+
#
|
|
332
|
+
# Uncomment and customize for your application:
|
|
333
|
+
#
|
|
334
|
+
# class UserSerializationMigration < V1ToV2SerializationMigration
|
|
335
|
+
# self.migration_id = '20260201_120000_user_serialization'
|
|
336
|
+
# self.description = 'Migrate User model from v1.x to v2.0 serialization'
|
|
337
|
+
#
|
|
338
|
+
# def prepare
|
|
339
|
+
# @model_class = User
|
|
340
|
+
# @batch_size = 100
|
|
341
|
+
# super
|
|
342
|
+
# end
|
|
343
|
+
#
|
|
344
|
+
# def field_types_for_model
|
|
345
|
+
# {
|
|
346
|
+
# email: :string,
|
|
347
|
+
# name: :string,
|
|
348
|
+
# age: :integer,
|
|
349
|
+
# balance: :float,
|
|
350
|
+
# active: :boolean,
|
|
351
|
+
# verified: :boolean,
|
|
352
|
+
# login_count: :integer,
|
|
353
|
+
# last_login_at: :timestamp,
|
|
354
|
+
# settings: :hash,
|
|
355
|
+
# roles: :array
|
|
356
|
+
# }
|
|
357
|
+
# end
|
|
358
|
+
# end
|
|
359
|
+
|
|
360
|
+
if $PROGRAM_NAME == __FILE__
|
|
361
|
+
puts "V1ToV2SerializationMigration is a base class."
|
|
362
|
+
puts "Create a subclass for your specific model and run that."
|
|
363
|
+
puts
|
|
364
|
+
puts "Example:"
|
|
365
|
+
puts " class CustomerMigration < V1ToV2SerializationMigration"
|
|
366
|
+
puts " self.migration_id = '20260201_customer_v2'"
|
|
367
|
+
puts " def prepare"
|
|
368
|
+
puts " @model_class = Customer"
|
|
369
|
+
puts " super"
|
|
370
|
+
puts " end"
|
|
371
|
+
puts " end"
|
|
372
|
+
puts
|
|
373
|
+
puts " CustomerMigration.cli_run"
|
|
374
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://example.com/schemas/customer.json",
|
|
4
|
+
"title": "Customer",
|
|
5
|
+
"description": "Customer model schema for Familia ORM",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"properties": {
|
|
8
|
+
"custid": {
|
|
9
|
+
"type": "string",
|
|
10
|
+
"description": "Unique customer identifier"
|
|
11
|
+
},
|
|
12
|
+
"email": {
|
|
13
|
+
"type": "string",
|
|
14
|
+
"format": "email",
|
|
15
|
+
"description": "Customer email address"
|
|
16
|
+
},
|
|
17
|
+
"name": {
|
|
18
|
+
"type": "string",
|
|
19
|
+
"minLength": 1,
|
|
20
|
+
"maxLength": 255
|
|
21
|
+
},
|
|
22
|
+
"status": {
|
|
23
|
+
"type": "string",
|
|
24
|
+
"enum": ["active", "inactive", "pending"],
|
|
25
|
+
"default": "pending"
|
|
26
|
+
},
|
|
27
|
+
"created_at": {
|
|
28
|
+
"type": "number",
|
|
29
|
+
"description": "Unix timestamp"
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
"required": ["custid", "email"]
|
|
33
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://example.com/schemas/session.json",
|
|
4
|
+
"title": "Session",
|
|
5
|
+
"description": "User session model schema",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"properties": {
|
|
8
|
+
"sessid": {
|
|
9
|
+
"type": "string",
|
|
10
|
+
"pattern": "^[a-f0-9]{32}$",
|
|
11
|
+
"description": "32-character hex session ID"
|
|
12
|
+
},
|
|
13
|
+
"customer_id": {
|
|
14
|
+
"type": "string"
|
|
15
|
+
},
|
|
16
|
+
"expires_at": {
|
|
17
|
+
"type": "number",
|
|
18
|
+
"description": "Unix timestamp when session expires"
|
|
19
|
+
},
|
|
20
|
+
"data": {
|
|
21
|
+
"type": "object",
|
|
22
|
+
"additionalProperties": true,
|
|
23
|
+
"description": "Arbitrary session data"
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
"required": ["sessid"]
|
|
27
|
+
}
|
data/familia.gemspec
CHANGED
|
@@ -17,9 +17,8 @@ Gem::Specification.new do |spec|
|
|
|
17
17
|
spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
|
|
18
18
|
spec.require_paths = ['lib']
|
|
19
19
|
|
|
20
|
-
spec.required_ruby_version = Gem::Requirement.new('>= 3.
|
|
20
|
+
spec.required_ruby_version = Gem::Requirement.new('>= 3.2')
|
|
21
21
|
|
|
22
|
-
spec.add_dependency 'benchmark', '~> 0.4'
|
|
23
22
|
spec.add_dependency 'concurrent-ruby', '~> 1.3'
|
|
24
23
|
spec.add_dependency 'connection_pool', '~> 2.5'
|
|
25
24
|
spec.add_dependency 'csv', '~> 3.3'
|
|
@@ -29,5 +28,7 @@ Gem::Specification.new do |spec|
|
|
|
29
28
|
spec.add_dependency 'stringio', '~> 3.1.1'
|
|
30
29
|
spec.add_dependency 'uri-valkey', '~> 1.4'
|
|
31
30
|
|
|
31
|
+
spec.add_development_dependency 'json_schemer', '~> 2.0'
|
|
32
|
+
|
|
32
33
|
spec.metadata['rubygems_mfa_required'] = 'true'
|
|
33
34
|
end
|