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,139 @@
1
+ # lib/familia/features/schema_validation.rb
2
+ #
3
+ # frozen_string_literal: true
4
+
5
+ module Familia
6
+ module Features
7
+ # Adds JSON schema validation methods to Horreum models.
8
+ # Schemas are loaded from external files via SchemaRegistry.
9
+ #
10
+ # This feature provides instance-level validation against JSON schemas,
11
+ # enabling data integrity checks before persistence or during API operations.
12
+ #
13
+ # Example:
14
+ #
15
+ # class Customer < Familia::Horreum
16
+ # feature :schema_validation
17
+ # identifier_field :custid
18
+ # field :custid
19
+ # field :email
20
+ # end
21
+ #
22
+ # # With schema at schemas/customer.json
23
+ # customer = Customer.new(custid: 'c1', email: 'invalid')
24
+ # customer.valid_against_schema? # => false
25
+ # customer.schema_validation_errors # => [...]
26
+ # customer.validate_against_schema! # raises SchemaValidationError
27
+ #
28
+ # Schema Loading:
29
+ #
30
+ # Schemas are loaded by SchemaRegistry based on class names. Configure
31
+ # schema loading before enabling this feature:
32
+ #
33
+ # # Convention-based loading
34
+ # Familia.schema_path = 'schemas/models'
35
+ # # Loads schemas/models/customer.json for Customer class
36
+ #
37
+ # # Explicit mapping
38
+ # Familia.schemas = { 'Customer' => 'schemas/customer.json' }
39
+ #
40
+ # Validation Behavior:
41
+ #
42
+ # - Returns true/valid if no schema is defined for the class
43
+ # - Uses json_schemer gem for validation when available
44
+ # - Falls back to null validation (always passes) if gem not installed
45
+ #
46
+ # Integration Patterns:
47
+ #
48
+ # # Validate before save
49
+ # class Order < Familia::Horreum
50
+ # feature :schema_validation
51
+ #
52
+ # def save
53
+ # validate_against_schema!
54
+ # super
55
+ # end
56
+ # end
57
+ #
58
+ # # Conditional validation
59
+ # class User < Familia::Horreum
60
+ # feature :schema_validation
61
+ #
62
+ # def save
63
+ # if self.class.schema_defined?
64
+ # return false unless valid_against_schema?
65
+ # end
66
+ # super
67
+ # end
68
+ # end
69
+ #
70
+ # Error Handling:
71
+ #
72
+ # The validate_against_schema! method raises SchemaValidationError with
73
+ # detailed error information:
74
+ #
75
+ # begin
76
+ # customer.validate_against_schema!
77
+ # rescue Familia::SchemaValidationError => e
78
+ # e.errors # => [{ 'data_pointer' => '/email', 'type' => 'format', ... }]
79
+ # end
80
+ #
81
+ # @see Familia::SchemaRegistry for schema loading and configuration
82
+ # @see Familia::SchemaValidationError for error details
83
+ #
84
+ module SchemaValidation
85
+ Familia::Base.add_feature self, :schema_validation
86
+
87
+ def self.included(base)
88
+ Familia.trace :LOADED, self, base if Familia.debug?
89
+ base.extend(ClassMethods)
90
+ end
91
+
92
+ # Class-level schema access methods
93
+ module ClassMethods
94
+ # Get the JSON schema for this class
95
+ # @return [Hash, nil] the parsed schema or nil if not defined
96
+ def schema
97
+ Familia::SchemaRegistry.schema_for(name)
98
+ end
99
+
100
+ # Check if a schema is defined for this class
101
+ # @return [Boolean]
102
+ def schema_defined?
103
+ Familia::SchemaRegistry.schema_defined?(name)
104
+ end
105
+ end
106
+
107
+ # Get the schema for this instance's class
108
+ # @return [Hash, nil]
109
+ def schema
110
+ self.class.schema
111
+ end
112
+
113
+ # Check if the current state validates against the schema
114
+ # @return [Boolean] true if valid or no schema defined
115
+ def valid_against_schema?
116
+ return true unless self.class.schema_defined?
117
+
118
+ Familia::SchemaRegistry.validate(self.class.name, to_h)[:valid]
119
+ end
120
+
121
+ # Get validation errors for the current state
122
+ # @return [Array<Hash>] array of error objects (empty if valid)
123
+ def schema_validation_errors
124
+ return [] unless self.class.schema_defined?
125
+
126
+ Familia::SchemaRegistry.validate(self.class.name, to_h)[:errors]
127
+ end
128
+
129
+ # Validate current state or raise SchemaValidationError
130
+ # @return [true] if valid
131
+ # @raise [SchemaValidationError] if validation fails
132
+ def validate_against_schema!
133
+ return true unless self.class.schema_defined?
134
+
135
+ Familia::SchemaRegistry.validate!(self.class.name, to_h)
136
+ end
137
+ end
138
+ end
139
+ end
@@ -0,0 +1,447 @@
1
+ # lib/familia/migration/base.rb
2
+ #
3
+ # frozen_string_literal: true
4
+
5
+ module Familia
6
+ module Migration
7
+ # Base class for Familia data migrations providing common infrastructure
8
+ # for idempotent data transformations and configuration updates.
9
+ #
10
+ # Unlike traditional database migrations, these migrations:
11
+ # - Don't track execution state in a migrations table
12
+ # - Use {#migration_needed?} to detect if changes are required
13
+ # - Support both dry-run and actual execution modes
14
+ # - Provide built-in statistics tracking and logging
15
+ #
16
+ # ## Subclassing Requirements
17
+ #
18
+ # Subclasses must implement these methods:
19
+ # - {#migration_needed?} - Detect if migration should run
20
+ # - {#migrate} - Perform the actual migration work
21
+ #
22
+ # Subclasses may override:
23
+ # - {#prepare} - Initialize and validate migration parameters
24
+ # - {#down} - Rollback logic for reversible migrations
25
+ #
26
+ # ## Usage Patterns
27
+ #
28
+ # For simple data migrations, extend Base directly:
29
+ #
30
+ # class ConfigurationMigration < Familia::Migration::Base
31
+ # self.migration_id = '20260131_120000_config_update'
32
+ #
33
+ # def migration_needed?
34
+ # !redis.exists('config:new_feature_flag')
35
+ # end
36
+ #
37
+ # def migrate
38
+ # for_realsies_this_time? do
39
+ # redis.set('config:new_feature_flag', 'true')
40
+ # end
41
+ # track_stat(:settings_updated)
42
+ # end
43
+ # end
44
+ #
45
+ # For record-by-record processing, use {Model}.
46
+ # For bulk updates with Redis pipelining, use {Pipeline}.
47
+ #
48
+ # ## CLI Usage
49
+ #
50
+ # ConfigurationMigration.cli_run # Dry run (preview)
51
+ # ConfigurationMigration.cli_run(['--run']) # Actual execution
52
+ #
53
+ # @abstract Subclass and implement {#migration_needed?} and {#migrate}
54
+ # @see Model For individual record processing
55
+ # @see Pipeline For bulk record processing with pipelining
56
+ class Base
57
+ class << self
58
+ # Unique identifier for this migration
59
+ # @return [String] format: {timestamp}_{snake_case_name}
60
+ attr_accessor :migration_id
61
+
62
+ # Human-readable description of what this migration does
63
+ # @return [String]
64
+ attr_accessor :description
65
+
66
+ # List of migration IDs that must run before this one
67
+ # @return [Array<String>]
68
+ attr_accessor :dependencies
69
+
70
+ # Auto-registration hook called when a subclass is defined.
71
+ # Registers the migration with Familia::Migration.migrations.
72
+ #
73
+ # @param subclass [Class] The inheriting class
74
+ def inherited(subclass)
75
+ super
76
+ subclass.dependencies ||= []
77
+
78
+ # Only register named classes (skip anonymous classes)
79
+ # Use respond_to? to handle load-order edge cases where migrations
80
+ # array may not yet be defined (e.g., Model class loading during require)
81
+ return if subclass.name.nil?
82
+ return unless Familia::Migration.respond_to?(:migrations)
83
+
84
+ Familia::Migration.migrations << subclass
85
+ end
86
+
87
+ # CLI entry point for migration execution
88
+ #
89
+ # Handles command-line argument parsing and returns appropriate exit codes.
90
+ # This is the recommended entry point for migration scripts.
91
+ #
92
+ # @param argv [Array<String>] command-line arguments (default: ARGV)
93
+ # @return [Integer] exit code (0 = success, 1 = error/action required)
94
+ #
95
+ # @example In migration script
96
+ # if __FILE__ == $0
97
+ # exit(MyMigration.cli_run)
98
+ # end
99
+ def cli_run(argv = ARGV)
100
+ if argv.include?('--check')
101
+ check_only
102
+ else
103
+ result = run(run: argv.include?('--run'))
104
+ # nil (not needed) and true (success) both return 0
105
+ # only false (failure) returns 1
106
+ result == false ? 1 : 0
107
+ end
108
+ end
109
+
110
+ # Check-only mode for programmatic use
111
+ #
112
+ # Returns exit code indicating whether migration is needed.
113
+ # Does not perform any migration work.
114
+ #
115
+ # @return [Integer] 0 if no migration needed, 1 if migration needed
116
+ def check_only
117
+ migration = new
118
+ migration.prepare
119
+ migration.migration_needed? ? 1 : 0
120
+ end
121
+
122
+ # Main entry point for migration execution
123
+ #
124
+ # Orchestrates the full migration process including preparation,
125
+ # conditional execution based on {#migration_needed?}, and cleanup.
126
+ #
127
+ # @param options [Hash] CLI options, typically { run: true/false }
128
+ # @return [Boolean, nil] true if migration completed successfully,
129
+ # nil if not needed, false if failed
130
+ def run(options = {})
131
+ migration = new
132
+ migration.options = options
133
+ migration.prepare
134
+
135
+ return migration.handle_migration_not_needed unless migration.migration_needed?
136
+
137
+ migration.migrate
138
+ end
139
+ end
140
+
141
+ # CLI options passed to migration, typically { run: true/false }
142
+ # @return [Hash] the options hash
143
+ attr_accessor :options
144
+
145
+ # Migration statistics for tracking operations performed
146
+ # @return [Hash] auto-incrementing counters for named statistics
147
+ attr_reader :stats
148
+
149
+ # Initialize new migration instance with default state
150
+ def initialize(options = {})
151
+ @options = options
152
+ @stats = Hash.new(0) # Auto-incrementing counter for tracking migration stats
153
+ end
154
+
155
+ # Hook for subclass initialization and validation
156
+ #
157
+ # Override this method to:
158
+ # - Set instance variables needed by the migration
159
+ # - Validate prerequisites and configuration
160
+ # - Initialize connections or external dependencies
161
+ #
162
+ # @return [void]
163
+ def prepare
164
+ debug('Preparing migration - default implementation')
165
+ end
166
+
167
+ # Perform actual migration work
168
+ #
169
+ # This is the core migration logic that subclasses must implement.
170
+ # Use {#for_realsies_this_time?} to wrap actual changes and
171
+ # {#track_stat} to record operations performed.
172
+ #
173
+ # @abstract Subclasses must implement this method
174
+ # @return [Boolean] true if migration succeeded
175
+ # @raise [NotImplementedError] if not implemented by subclass
176
+ def migrate
177
+ raise NotImplementedError, "#{self.class} must implement #migrate"
178
+ end
179
+
180
+ # Detect if migration needs to run
181
+ #
182
+ # This method should implement idempotency logic by checking
183
+ # current system state and returning false if migration has
184
+ # already been applied or is not needed.
185
+ #
186
+ # @abstract Subclasses must implement this method
187
+ # @return [Boolean] true if migration should proceed
188
+ # @raise [NotImplementedError] if not implemented by subclass
189
+ def migration_needed?
190
+ raise NotImplementedError, "#{self.class} must implement #migration_needed?"
191
+ end
192
+
193
+ # Optional rollback logic.
194
+ # Override in subclass to support reversible migrations.
195
+ def down
196
+ # Override in subclass for rollback support
197
+ end
198
+
199
+ # Check if this migration has rollback support.
200
+ #
201
+ # @return [Boolean] true if down method is overridden
202
+ def reversible?
203
+ method(:down).owner != Familia::Migration::Base
204
+ end
205
+
206
+ # === Run Mode Control ===
207
+
208
+ # Check if migration is running in dry-run mode
209
+ # @return [Boolean] true if no changes should be made
210
+ def dry_run?
211
+ !options[:run]
212
+ end
213
+
214
+ # Check if migration is running in actual execution mode
215
+ # @return [Boolean] true if changes will be applied
216
+ def actual_run?
217
+ options[:run]
218
+ end
219
+
220
+ # Display run mode banner with appropriate warnings
221
+ # @return [void]
222
+ def run_mode_banner
223
+ header("Running in #{dry_run? ? 'DRY RUN' : 'ACTUAL RUN'} mode")
224
+ info(dry_run? ? 'No changes will be made' : 'Changes WILL be applied to the database')
225
+ info(separator)
226
+ end
227
+
228
+ # Execute block only in actual run mode
229
+ #
230
+ # Use this to wrap code that makes actual changes to the system.
231
+ # In dry-run mode, the block will not be executed.
232
+ #
233
+ # @yield Block to execute if in actual run mode
234
+ # @return [Boolean] true if block was executed, false if skipped
235
+ def for_realsies_this_time?
236
+ return false unless actual_run?
237
+
238
+ yield if block_given?
239
+ true
240
+ end
241
+
242
+ # Execute block only in dry run mode
243
+ #
244
+ # Use this for dry-run specific logging or validation.
245
+ #
246
+ # @yield Block to execute if in dry run mode
247
+ # @return [Boolean] true if block was executed, false if skipped
248
+ def dry_run_only?
249
+ return false unless dry_run?
250
+
251
+ yield if block_given?
252
+ true
253
+ end
254
+
255
+ # === Statistics Tracking ===
256
+
257
+ # Increment named counter for migration statistics
258
+ #
259
+ # Use this to track operations, errors, skipped records, etc.
260
+ # Statistics are automatically displayed in migration summaries.
261
+ #
262
+ # @param key [Symbol] stat name to increment
263
+ # @param increment [Integer] amount to add (default 1)
264
+ # @return [nil]
265
+ def track_stat(key, increment = 1)
266
+ @stats[key] += increment
267
+ nil
268
+ end
269
+
270
+ # === Logging Interface ===
271
+
272
+ # Print formatted header with separator lines
273
+ # @param message [String] header text to display
274
+ # @return [void]
275
+ def header(message)
276
+ info ''
277
+ info separator
278
+ info(message.upcase)
279
+ end
280
+
281
+ # Log informational message
282
+ # @param message [String] message to log
283
+ # @return [void]
284
+ def info(message = nil)
285
+ Familia.logger.info { message } if message
286
+ end
287
+
288
+ # Log debug message
289
+ # @param message [String] message to log
290
+ # @return [void]
291
+ def debug(message = nil)
292
+ Familia.logger.debug { message } if message
293
+ end
294
+
295
+ # Log warning message
296
+ # @param message [String] message to log
297
+ # @return [void]
298
+ def warn(message = nil)
299
+ Familia.logger.warn { message } if message
300
+ end
301
+
302
+ # Log error message
303
+ # @param message [String] message to log
304
+ # @return [void]
305
+ def error(message = nil)
306
+ Familia.logger.error { message } if message
307
+ end
308
+
309
+ # Generate separator line for visual formatting
310
+ # @return [String] dash separator line
311
+ def separator
312
+ '-' * 60
313
+ end
314
+
315
+ # Progress indicator for long operations
316
+ #
317
+ # Displays progress updates at specified intervals to avoid
318
+ # overwhelming the log output during bulk operations.
319
+ #
320
+ # @param current [Integer] current item number
321
+ # @param total [Integer] total items to process
322
+ # @param message [String] operation description
323
+ # @param step [Integer] progress reporting frequency (default 100)
324
+ # @return [void]
325
+ def progress(current, total, message = 'Processing', step = 100)
326
+ return unless current % step == 0 || current == total
327
+
328
+ info "#{message} #{current}/#{total}..."
329
+ end
330
+
331
+ # Display migration summary with custom content block
332
+ #
333
+ # Automatically adjusts header based on run mode and yields
334
+ # the current mode to the block for conditional content.
335
+ #
336
+ # @param title [String, nil] custom summary title
337
+ # @yield [Symbol] :dry_run or :actual_run for conditional content
338
+ # @return [void]
339
+ def print_summary(title = nil)
340
+ if dry_run?
341
+ header(title || 'DRY RUN SUMMARY')
342
+ yield(:dry_run) if block_given?
343
+ else
344
+ header(title || 'ACTUAL RUN SUMMARY')
345
+ yield(:actual_run) if block_given?
346
+ end
347
+ end
348
+
349
+ # Handle case where migration is not needed
350
+ #
351
+ # Called automatically when {#migration_needed?} returns false.
352
+ # Provides standard messaging about migration state.
353
+ #
354
+ # @return [nil]
355
+ def handle_migration_not_needed
356
+ info('')
357
+ info('Migration needed? false.')
358
+ info('')
359
+ info('This usually means that the migration has already been applied.')
360
+ nil
361
+ end
362
+
363
+ # === Schema Validation ===
364
+
365
+ # Validate an object against its schema
366
+ #
367
+ # Uses the SchemaRegistry to validate an object's data against
368
+ # its registered JSON schema. Returns validation results without
369
+ # raising exceptions.
370
+ #
371
+ # @param obj [Object] object with to_h method
372
+ # @param context [String, nil] context for error messages (e.g., 'before transform')
373
+ # @return [Hash] { valid: Boolean, errors: Array }
374
+ def validate_schema(obj, context: nil)
375
+ return { valid: true, errors: [] } unless schema_validation_enabled?
376
+
377
+ klass_name = obj.class.name
378
+ data = obj.respond_to?(:to_h) ? obj.to_h : obj
379
+
380
+ result = Familia::SchemaRegistry.validate(klass_name, data)
381
+
382
+ unless result[:valid]
383
+ context_msg = context ? " (#{context})" : ''
384
+ warn "Schema validation failed for #{klass_name}#{context_msg}: #{result[:errors].size} error(s)"
385
+ result[:errors].first(3).each do |e|
386
+ debug " - #{e['type'] || 'error'}: #{e['data_pointer'] || '/'}"
387
+ end
388
+ end
389
+
390
+ result
391
+ end
392
+
393
+ # Validate an object or raise SchemaValidationError
394
+ #
395
+ # Uses the SchemaRegistry to validate an object's data against
396
+ # its registered JSON schema. Raises an exception if validation fails.
397
+ #
398
+ # @param obj [Object] object with to_h method
399
+ # @param context [String, nil] context for error messages
400
+ # @return [true] if valid
401
+ # @raise [Familia::SchemaValidationError] if validation fails
402
+ def validate_schema!(obj, context: nil)
403
+ result = validate_schema(obj, context: context)
404
+ unless result[:valid]
405
+ raise Familia::SchemaValidationError.new(result[:errors])
406
+ end
407
+
408
+ true
409
+ end
410
+
411
+ # Check if schema validation is enabled for this migration
412
+ #
413
+ # Schema validation is enabled by default when SchemaRegistry is loaded.
414
+ # Use {#skip_schema_validation!} to disable for this migration instance.
415
+ #
416
+ # @return [Boolean]
417
+ def schema_validation_enabled?
418
+ @schema_validation != false && Familia::SchemaRegistry.loaded?
419
+ end
420
+
421
+ # Disable schema validation for this migration
422
+ #
423
+ # Call this in {#prepare} or at any point before validation to
424
+ # skip all schema validation for this migration run.
425
+ #
426
+ # @return [void]
427
+ def skip_schema_validation!
428
+ @schema_validation = false
429
+ end
430
+
431
+ protected
432
+
433
+ # Access to database client
434
+ #
435
+ # Provides a database connection for migrations
436
+ # that need to access data outside of Familia models.
437
+ #
438
+ # @return [Redis] configured Redis connection
439
+ def dbclient
440
+ @dbclient ||= Familia.dbclient
441
+ end
442
+
443
+ # Alias for dbclient for convenience
444
+ alias redis dbclient
445
+ end
446
+ end
447
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Familia
4
+ module Migration
5
+ module Errors
6
+ # Base class for all migration errors
7
+ class MigrationError < StandardError; end
8
+
9
+ # Raised when attempting to rollback a migration without a down method
10
+ class NotReversible < MigrationError; end
11
+
12
+ # Raised when attempting to rollback a migration that hasn't been applied
13
+ class NotApplied < MigrationError; end
14
+
15
+ # Raised when a migration ID cannot be found
16
+ class NotFound < MigrationError; end
17
+
18
+ # Raised when a migration's dependencies haven't been applied
19
+ class DependencyNotMet < MigrationError; end
20
+
21
+ # Raised when attempting to rollback a migration that other migrations depend on
22
+ class HasDependents < MigrationError; end
23
+
24
+ # Raised when migration dependencies form a cycle
25
+ class CircularDependency < MigrationError; end
26
+
27
+ # Raised when migration preconditions are not met
28
+ class PreconditionFailed < MigrationError; end
29
+ end
30
+ end
31
+ end