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,173 @@
1
+ # lib/familia/schema_registry.rb
2
+ #
3
+ # frozen_string_literal: true
4
+
5
+ require 'json'
6
+
7
+ module Familia
8
+ # Registry for loading and caching external JSON schema files.
9
+ # Schemas are loaded at boot time based on configuration.
10
+ #
11
+ # @example Convention-based loading
12
+ # Familia.schema_path = 'schemas/models'
13
+ # SchemaRegistry.load!
14
+ # SchemaRegistry.schema_for('Customer') # loads schemas/models/customer.json
15
+ #
16
+ # @example Explicit mapping
17
+ # Familia.schemas = { 'Customer' => 'schemas/customer.json' }
18
+ # SchemaRegistry.load!
19
+ #
20
+ class SchemaRegistry
21
+ class << self
22
+ # Load schemas based on current configuration.
23
+ # Safe to call multiple times - only loads once.
24
+ def load!
25
+ return if @loaded
26
+
27
+ @schemas ||= {}
28
+ load_from_path if Familia.schema_path
29
+ load_from_hash if Familia.schemas&.any?
30
+ @loaded = true
31
+ end
32
+
33
+ # Check if schemas have been loaded
34
+ def loaded?
35
+ @loaded == true
36
+ end
37
+
38
+ # Get schema for a class by name or class reference
39
+ # @param klass_or_name [Class, String] the class or class name
40
+ # @return [Hash, nil] the parsed JSON schema or nil
41
+ def schema_for(klass_or_name)
42
+ load! unless loaded?
43
+ name = klass_or_name.is_a?(Class) ? klass_or_name.name : klass_or_name.to_s
44
+ @schemas[name]
45
+ end
46
+
47
+ # Check if a schema is defined for the given class
48
+ def schema_defined?(klass_or_name)
49
+ !schema_for(klass_or_name).nil?
50
+ end
51
+
52
+ # All registered schemas
53
+ def schemas
54
+ load! unless loaded?
55
+ @schemas.dup
56
+ end
57
+
58
+ # Validate data against a schema
59
+ # @param klass_or_name [Class, String] the class whose schema to use
60
+ # @param data [Hash] the data to validate
61
+ # @return [Hash] { valid: Boolean, errors: Array }
62
+ def validate(klass_or_name, data)
63
+ schema = schema_for(klass_or_name)
64
+ return { valid: true, errors: [] } unless schema
65
+
66
+ errors = validator.validate(schema, data).to_a
67
+ { valid: errors.empty?, errors: errors }
68
+ end
69
+
70
+ # Validate data or raise SchemaValidationError
71
+ # @raise [SchemaValidationError] if validation fails
72
+ def validate!(klass_or_name, data)
73
+ result = validate(klass_or_name, data)
74
+ raise SchemaValidationError.new(result[:errors]) unless result[:valid]
75
+
76
+ true
77
+ end
78
+
79
+ # Reset registry (primarily for testing)
80
+ def reset!
81
+ @schemas = {}
82
+ @loaded = false
83
+ @validator = nil
84
+ end
85
+
86
+ private
87
+
88
+ def load_from_path
89
+ path = Familia.schema_path
90
+ return unless path && File.directory?(path)
91
+
92
+ Dir.glob(File.join(path, '*.json')).each do |file|
93
+ # Convert filename to class name: customer.json -> Customer
94
+ # user_session.json -> UserSession
95
+ basename = File.basename(file, '.json')
96
+ class_name = basename.split('_').map(&:capitalize).join
97
+ @schemas[class_name] = load_schema_file(file)
98
+ end
99
+ end
100
+
101
+ def load_from_hash
102
+ Familia.schemas.each do |class_name, file_path|
103
+ @schemas[class_name.to_s] = load_schema_file(file_path)
104
+ end
105
+ end
106
+
107
+ def load_schema_file(path)
108
+ JSON.parse(File.read(path))
109
+ rescue JSON::ParserError => e
110
+ raise ArgumentError, "Failed to parse schema file #{path}: #{e.message}"
111
+ rescue Errno::ENOENT
112
+ raise ArgumentError, "Schema file not found: #{path}"
113
+ end
114
+
115
+ def validator
116
+ @validator ||= build_validator
117
+ end
118
+
119
+ def build_validator
120
+ case Familia.schema_validator
121
+ when :json_schemer
122
+ begin
123
+ require 'json_schemer'
124
+ JsonSchemerValidator.new
125
+ rescue LoadError
126
+ warn '[Familia] json_schemer gem not installed. Schema validation disabled.'
127
+ warn "[Familia] Add `gem 'json_schemer'` to your Gemfile to enable."
128
+ NullValidator.new
129
+ end
130
+ when :none, nil
131
+ NullValidator.new
132
+ else
133
+ # Custom validator instance provided
134
+ Familia.schema_validator
135
+ end
136
+ end
137
+ end
138
+ end
139
+
140
+ # Validator adapter for json_schemer gem
141
+ class JsonSchemerValidator
142
+ def validate(schema, data)
143
+ schemer = JSONSchemer.schema(schema)
144
+ schemer.validate(data)
145
+ end
146
+ end
147
+
148
+ # Null validator that always passes (for when validation is disabled)
149
+ class NullValidator
150
+ def validate(_schema, _data)
151
+ []
152
+ end
153
+ end
154
+
155
+ # Error raised when schema validation fails
156
+ class SchemaValidationError < HorreumError
157
+ attr_reader :errors
158
+
159
+ def initialize(errors)
160
+ @errors = errors
161
+ messages = errors.map { |e| format_error(e) }.first(3)
162
+ super("Schema validation failed: #{messages.join('; ')}")
163
+ end
164
+
165
+ private
166
+
167
+ def format_error(error)
168
+ path = error['data_pointer'] || error['schema_pointer'] || '/'
169
+ type = error['type'] || 'validation'
170
+ "#{type} at #{path}"
171
+ end
172
+ end
173
+ end
@@ -15,11 +15,17 @@ module Familia
15
15
  @encryption_personalization = 'FamilialMatters'.freeze
16
16
  @pipelined_mode = :warn
17
17
 
18
+ # Schema validation configuration
19
+ @schema_path = nil # Directory containing schema files (String or Pathname)
20
+ @schemas = {} # Hash mapping class names to schema file paths
21
+ @schema_validator = :json_schemer # Validator type (:json_schemer, :none, or custom)
22
+
18
23
  # Familia::Settings
19
24
  #
20
25
  module Settings
21
26
  attr_writer :delim, :suffix, :default_expiration, :logical_database, :prefix, :encryption_keys,
22
- :current_key_version, :encryption_personalization, :transaction_mode
27
+ :current_key_version, :encryption_personalization, :transaction_mode,
28
+ :schema_path, :schemas, :schema_validator
23
29
 
24
30
  def delim(val = nil)
25
31
  @delim = val if val
@@ -140,6 +146,62 @@ module Familia
140
146
  @pipelined_mode = val
141
147
  end
142
148
 
149
+ # Directory containing schema files for JSON Schema validation.
150
+ # When set, schema files are discovered by convention using the
151
+ # underscored class name (e.g., Customer -> customer.json).
152
+ #
153
+ # @param val [String, Pathname, nil] The schema directory path, or nil to get current value
154
+ # @return [String, Pathname, nil] Current schema path
155
+ #
156
+ # @example Convention-based schema discovery
157
+ # Familia.configure do |config|
158
+ # config.schema_path = 'schemas/models'
159
+ # end
160
+ #
161
+ def schema_path(val = nil)
162
+ @schema_path = val if val
163
+ @schema_path
164
+ end
165
+
166
+ # Hash mapping class names to their schema file paths.
167
+ # Takes precedence over convention-based discovery via schema_path.
168
+ #
169
+ # @param val [Hash, nil] A hash of class name => schema path mappings, or nil to get current
170
+ # @return [Hash] Current schema mappings
171
+ #
172
+ # @example Explicit schema mapping
173
+ # Familia.configure do |config|
174
+ # config.schemas = {
175
+ # 'Customer' => 'schemas/customer.json',
176
+ # 'Session' => 'schemas/session.json'
177
+ # }
178
+ # end
179
+ #
180
+ def schemas(val = nil)
181
+ @schemas = val if val
182
+ @schemas || {}
183
+ end
184
+
185
+ # Validator type for JSON Schema validation.
186
+ #
187
+ # @param val [Symbol, Object, nil] The validator type or instance, or nil to get current
188
+ # @return [Symbol, Object] Current validator setting
189
+ #
190
+ # Available options:
191
+ # - :json_schemer (default): Use the json_schemer gem for validation
192
+ # - :none: Disable schema validation entirely
193
+ # - Custom instance: Any object responding to #validate
194
+ #
195
+ # @example Disable validation
196
+ # Familia.configure do |config|
197
+ # config.schema_validator = :none
198
+ # end
199
+ #
200
+ def schema_validator(val = nil)
201
+ @schema_validator = val if val
202
+ @schema_validator || :json_schemer
203
+ end
204
+
143
205
  # Configure Familia settings
144
206
  #
145
207
  # @yield [Settings] self for block-based configuration
@@ -4,5 +4,5 @@
4
4
 
5
5
  module Familia
6
6
  # Version information for the Familia
7
- VERSION = '2.0.0.pre26' unless defined?(Familia::VERSION)
7
+ VERSION = '2.1.0' unless defined?(Familia::VERSION)
8
8
  end
data/lib/familia.rb CHANGED
@@ -181,3 +181,4 @@ require_relative 'familia/features'
181
181
  require_relative 'familia/data_type'
182
182
  require_relative 'familia/horreum'
183
183
  require_relative 'familia/encryption'
184
+ require_relative 'familia/schema_registry'
@@ -0,0 +1,193 @@
1
+ # try/features/schema_registry_try.rb
2
+ #
3
+ # frozen_string_literal: true
4
+
5
+ require_relative '../support/helpers/test_helpers'
6
+ require 'json'
7
+ require 'tmpdir'
8
+ require 'fileutils'
9
+
10
+ Familia.debug = false
11
+
12
+ # Setup - create temp schema directory
13
+ @schema_dir = Dir.mktmpdir('familia_schemas')
14
+
15
+ # Create test schema files
16
+ File.write(File.join(@schema_dir, 'customer.json'), JSON.generate({
17
+ type: 'object',
18
+ properties: {
19
+ email: { type: 'string', format: 'email' },
20
+ age: { type: 'integer', minimum: 0 }
21
+ },
22
+ required: ['email']
23
+ }))
24
+
25
+ File.write(File.join(@schema_dir, 'user_session.json'), JSON.generate({
26
+ type: 'object',
27
+ properties: {
28
+ token: { type: 'string', minLength: 32 }
29
+ }
30
+ }))
31
+
32
+ # Store original settings
33
+ @original_schema_path = Familia.schema_path
34
+ @original_schemas = Familia.schemas
35
+ @original_validator = Familia.schema_validator
36
+
37
+ # Ensure json_schemer is used for validation
38
+ Familia.schema_validator = :json_schemer
39
+
40
+ ## SchemaRegistry class exists
41
+ Familia::SchemaRegistry.is_a?(Class)
42
+ #=> true
43
+
44
+ ## SchemaRegistry starts unloaded after reset
45
+ Familia::SchemaRegistry.reset!
46
+ Familia::SchemaRegistry.loaded?
47
+ #=> false
48
+
49
+ ## schema_for returns nil before loading when no config
50
+ Familia::SchemaRegistry.reset!
51
+ Familia.schema_path = nil
52
+ Familia.schemas = {}
53
+ result = Familia::SchemaRegistry.schema_for('Customer')
54
+ result.nil?
55
+ #=> true
56
+
57
+ ## load! loads schemas from schema_path
58
+ Familia.schema_path = @schema_dir
59
+ Familia::SchemaRegistry.reset!
60
+ Familia::SchemaRegistry.load!
61
+ Familia::SchemaRegistry.loaded?
62
+ #=> true
63
+
64
+ ## schema_for returns parsed schema after loading
65
+ Familia.schema_path = @schema_dir
66
+ Familia::SchemaRegistry.reset!
67
+ Familia::SchemaRegistry.load!
68
+ schema = Familia::SchemaRegistry.schema_for('Customer')
69
+ schema['type']
70
+ #=> 'object'
71
+
72
+ ## schema_defined? returns true for loaded schemas
73
+ Familia.schema_path = @schema_dir
74
+ Familia::SchemaRegistry.reset!
75
+ Familia::SchemaRegistry.load!
76
+ Familia::SchemaRegistry.schema_defined?('Customer')
77
+ #=> true
78
+
79
+ ## schema_defined? returns false for unknown schemas
80
+ Familia::SchemaRegistry.schema_defined?('NonExistent')
81
+ #=> false
82
+
83
+ ## Underscore filenames convert to CamelCase class names
84
+ Familia.schema_path = @schema_dir
85
+ Familia::SchemaRegistry.reset!
86
+ Familia::SchemaRegistry.load!
87
+ Familia::SchemaRegistry.schema_defined?('UserSession')
88
+ #=> true
89
+
90
+ ## validate returns valid for conforming data
91
+ Familia.schema_path = @schema_dir
92
+ Familia::SchemaRegistry.reset!
93
+ Familia::SchemaRegistry.load!
94
+ result = Familia::SchemaRegistry.validate('Customer', { 'email' => 'test@example.com', 'age' => 25 })
95
+ result[:valid]
96
+ #=> true
97
+
98
+ ## validate returns errors for missing required field
99
+ result = Familia::SchemaRegistry.validate('Customer', { 'age' => 25 })
100
+ result[:valid]
101
+ #=> false
102
+
103
+ ## validate errors array is non-empty for invalid data
104
+ result = Familia::SchemaRegistry.validate('Customer', { 'age' => 25 })
105
+ result[:errors].size > 0
106
+ #=> true
107
+
108
+ ## validate returns valid for undefined schemas (no-op)
109
+ result = Familia::SchemaRegistry.validate('NonExistent', { 'anything' => 'goes' })
110
+ result[:valid]
111
+ #=> true
112
+
113
+ ## validate! raises SchemaValidationError for invalid data
114
+ begin
115
+ Familia::SchemaRegistry.validate!('Customer', { 'age' => 'not a number' })
116
+ false
117
+ rescue Familia::SchemaValidationError => e
118
+ e.errors.size > 0
119
+ end
120
+ #=> true
121
+
122
+ ## validate! returns true for valid data
123
+ result = Familia::SchemaRegistry.validate!('Customer', { 'email' => 'valid@example.com' })
124
+ result
125
+ #=> true
126
+
127
+ ## Explicit schemas hash loads correctly
128
+ Familia.schema_path = nil
129
+ Familia.schemas = { 'ExplicitModel' => File.join(@schema_dir, 'customer.json') }
130
+ Familia::SchemaRegistry.reset!
131
+ Familia::SchemaRegistry.load!
132
+ Familia::SchemaRegistry.schema_defined?('ExplicitModel')
133
+ #=> true
134
+
135
+ ## schemas returns copy of all loaded schemas
136
+ Familia.schema_path = @schema_dir
137
+ Familia.schemas = {}
138
+ Familia::SchemaRegistry.reset!
139
+ Familia::SchemaRegistry.load!
140
+ schemas = Familia::SchemaRegistry.schemas
141
+ schemas.keys.sort
142
+ #=> ['Customer', 'UserSession']
143
+
144
+ ## reset! clears loaded state
145
+ Familia::SchemaRegistry.reset!
146
+ Familia::SchemaRegistry.loaded?
147
+ #=> false
148
+
149
+ ## SchemaValidationError has errors accessor
150
+ begin
151
+ Familia.schema_path = @schema_dir
152
+ Familia::SchemaRegistry.reset!
153
+ Familia::SchemaRegistry.load!
154
+ Familia::SchemaRegistry.validate!('Customer', {})
155
+ rescue Familia::SchemaValidationError => e
156
+ e.respond_to?(:errors) && e.errors.is_a?(Array)
157
+ end
158
+ #=> true
159
+
160
+ ## load! is idempotent - calling multiple times does not reload
161
+ Familia.schema_path = @schema_dir
162
+ Familia::SchemaRegistry.reset!
163
+ Familia::SchemaRegistry.load!
164
+ first_schema = Familia::SchemaRegistry.schema_for('Customer')
165
+ Familia::SchemaRegistry.load!
166
+ second_schema = Familia::SchemaRegistry.schema_for('Customer')
167
+ first_schema.equal?(second_schema)
168
+ #=> true
169
+
170
+ ## schema_for auto-loads when not yet loaded
171
+ Familia.schema_path = @schema_dir
172
+ Familia.schemas = {}
173
+ Familia::SchemaRegistry.reset!
174
+ schema = Familia::SchemaRegistry.schema_for('Customer')
175
+ schema['type']
176
+ #=> 'object'
177
+
178
+ ## NullValidator always returns valid when validation disabled
179
+ Familia.schema_path = @schema_dir
180
+ Familia.schema_validator = :none
181
+ Familia::SchemaRegistry.reset!
182
+ Familia::SchemaRegistry.load!
183
+ result = Familia::SchemaRegistry.validate('Customer', {})
184
+ Familia.schema_validator = :json_schemer
185
+ result[:valid]
186
+ #=> true
187
+
188
+ # Teardown
189
+ FileUtils.rm_rf(@schema_dir)
190
+ Familia.schema_path = @original_schema_path
191
+ Familia.schemas = @original_schemas || {}
192
+ Familia.schema_validator = @original_validator || :json_schemer
193
+ Familia::SchemaRegistry.reset!
@@ -0,0 +1,218 @@
1
+ # try/features/schema_validation_feature_try.rb
2
+ #
3
+ # frozen_string_literal: true
4
+
5
+ require_relative '../support/helpers/test_helpers'
6
+ require 'json'
7
+ require 'tmpdir'
8
+ require 'fileutils'
9
+
10
+ Familia.debug = false
11
+
12
+ # Store original config for teardown
13
+ @original_schema_path = Familia.schema_path
14
+ @original_schemas = Familia.schemas
15
+ @original_validator = Familia.schema_validator
16
+
17
+ # Setup - create temp schema directory and configure before defining classes
18
+ @schema_dir = Dir.mktmpdir('familia_schema_feature')
19
+ @prefix = "familia:test:schemafeature:#{Process.pid}"
20
+
21
+ # Create test schema for SchemaValidatedModel class
22
+ # Note: snake_case filename converts to CamelCase class name
23
+ # Using type arrays to allow null for optional fields (age is not required)
24
+ File.write(File.join(@schema_dir, 'schema_validated_model.json'), JSON.generate({
25
+ 'type' => 'object',
26
+ 'properties' => {
27
+ 'email' => { 'type' => 'string', 'format' => 'email' },
28
+ 'name' => { 'type' => 'string', 'minLength' => 1 },
29
+ 'age' => { 'type' => ['integer', 'null'], 'minimum' => 0, 'maximum' => 150 }
30
+ },
31
+ 'required' => ['email', 'name']
32
+ }))
33
+
34
+ Familia.schema_path = @schema_dir
35
+ Familia.schema_validator = :json_schemer
36
+ Familia::SchemaRegistry.reset!
37
+ Familia::SchemaRegistry.load!
38
+
39
+ # Define test model with schema validation feature dynamically
40
+ # Uses Object.const_set to define at runtime after schema is loaded
41
+ Object.const_set(:SchemaValidatedModel, Class.new(Familia::Horreum) do
42
+ feature :schema_validation
43
+
44
+ identifier_field :modelid
45
+ field :modelid
46
+ field :email
47
+ field :name
48
+ field :age
49
+ end)
50
+
51
+ # Define model without schema validation feature for comparison
52
+ Object.const_set(:NoSchemaFeatureModel, Class.new(Familia::Horreum) do
53
+ identifier_field :id
54
+ field :id
55
+ field :data
56
+ end) unless defined?(NoSchemaFeatureModel)
57
+
58
+ # Define model with feature but no schema file
59
+ Object.const_set(:UnschemaedFeatureModel, Class.new(Familia::Horreum) do
60
+ feature :schema_validation
61
+
62
+ identifier_field :uid
63
+ field :uid
64
+ field :stuff
65
+ end)
66
+
67
+ ## Model class has schema class method
68
+ SchemaValidatedModel.respond_to?(:schema)
69
+ #=> true
70
+
71
+ ## Model class schema returns Hash
72
+ SchemaValidatedModel.schema.is_a?(Hash)
73
+ #=> true
74
+
75
+ ## Model class has schema_defined? method
76
+ SchemaValidatedModel.respond_to?(:schema_defined?)
77
+ #=> true
78
+
79
+ ## Model class schema_defined? returns true when schema exists
80
+ SchemaValidatedModel.schema_defined?
81
+ #=> true
82
+
83
+ ## Instance can access schema via instance method
84
+ instance = SchemaValidatedModel.new(modelid: 't1', email: 'test@example.com', name: 'Test')
85
+ instance.respond_to?(:schema)
86
+ #=> true
87
+
88
+ ## Instance schema returns same Hash as class schema
89
+ instance = SchemaValidatedModel.new(modelid: 't1', email: 'test@example.com', name: 'Test')
90
+ instance.schema == SchemaValidatedModel.schema
91
+ #=> true
92
+
93
+ ## valid_against_schema? returns true for valid data
94
+ instance = SchemaValidatedModel.new(modelid: 't2', email: 'test@example.com', name: 'Test', age: 25)
95
+ instance.valid_against_schema?
96
+ #=> true
97
+
98
+ ## valid_against_schema? returns false for invalid email format
99
+ instance = SchemaValidatedModel.new(modelid: 't3', email: 'not-an-email', name: 'Test')
100
+ instance.valid_against_schema?
101
+ #=> false
102
+
103
+ ## valid_against_schema? returns false for missing required name field
104
+ instance = SchemaValidatedModel.new(modelid: 't4', email: 'test@example.com')
105
+ instance.valid_against_schema?
106
+ #=> false
107
+
108
+ ## valid_against_schema? returns false for age out of range
109
+ instance = SchemaValidatedModel.new(modelid: 't5', email: 'test@example.com', name: 'Test', age: 200)
110
+ instance.valid_against_schema?
111
+ #=> false
112
+
113
+ ## schema_validation_errors returns empty array for valid data
114
+ instance = SchemaValidatedModel.new(modelid: 't6', email: 'test@example.com', name: 'Valid')
115
+ instance.schema_validation_errors.empty?
116
+ #=> true
117
+
118
+ ## schema_validation_errors returns non-empty array for invalid data
119
+ instance = SchemaValidatedModel.new(modelid: 't7', email: 'not-valid', name: 'Test')
120
+ instance.schema_validation_errors.size > 0
121
+ #=> true
122
+
123
+ ## schema_validation_errors returns array type
124
+ instance = SchemaValidatedModel.new(modelid: 't8', email: 'test@example.com', name: '')
125
+ errors = instance.schema_validation_errors
126
+ errors.is_a?(Array)
127
+ #=> true
128
+
129
+ ## validate_against_schema! returns true for valid data
130
+ instance = SchemaValidatedModel.new(modelid: 't9', email: 'valid@example.com', name: 'Valid')
131
+ instance.validate_against_schema!
132
+ #=> true
133
+
134
+ ## validate_against_schema! raises SchemaValidationError for invalid email
135
+ instance = SchemaValidatedModel.new(modelid: 't10', email: 'bad-email', name: 'Test')
136
+ begin
137
+ instance.validate_against_schema!
138
+ false
139
+ rescue Familia::SchemaValidationError
140
+ true
141
+ end
142
+ #=> true
143
+
144
+ ## validate_against_schema! raises SchemaValidationError for missing required field
145
+ instance = SchemaValidatedModel.new(modelid: 't11', email: 'test@example.com')
146
+ begin
147
+ instance.validate_against_schema!
148
+ false
149
+ rescue Familia::SchemaValidationError
150
+ true
151
+ end
152
+ #=> true
153
+
154
+ ## SchemaValidationError includes errors accessor
155
+ instance = SchemaValidatedModel.new(modelid: 't12', email: 'bad')
156
+ begin
157
+ instance.validate_against_schema!
158
+ nil
159
+ rescue Familia::SchemaValidationError => e
160
+ e.respond_to?(:errors) && e.errors.is_a?(Array) && e.errors.size > 0
161
+ end
162
+ #=> true
163
+
164
+ ## Model without feature does not have valid_against_schema? method
165
+ instance = NoSchemaFeatureModel.new(id: 'x', data: 'anything')
166
+ instance.respond_to?(:valid_against_schema?)
167
+ #=> false
168
+
169
+ ## Model without feature does not have validate_against_schema! method
170
+ instance = NoSchemaFeatureModel.new(id: 'x', data: 'anything')
171
+ instance.respond_to?(:validate_against_schema!)
172
+ #=> false
173
+
174
+ ## Model without feature does not have schema_validation_errors method
175
+ instance = NoSchemaFeatureModel.new(id: 'x', data: 'anything')
176
+ instance.respond_to?(:schema_validation_errors)
177
+ #=> false
178
+
179
+ ## Model with feature but no schema file still works - valid_against_schema returns true
180
+ instance = UnschemaedFeatureModel.new(uid: 'u1', stuff: 'whatever')
181
+ instance.valid_against_schema?
182
+ #=> true
183
+
184
+ ## Model with feature but no schema file - schema_validation_errors returns empty array
185
+ instance = UnschemaedFeatureModel.new(uid: 'u2', stuff: 123)
186
+ instance.schema_validation_errors
187
+ #=> []
188
+
189
+ ## Model with feature but no schema file - validate_against_schema! returns true
190
+ instance = UnschemaedFeatureModel.new(uid: 'u3', stuff: nil)
191
+ instance.validate_against_schema!
192
+ #=> true
193
+
194
+ ## Model with feature but no schema - schema_defined? returns false
195
+ UnschemaedFeatureModel.schema_defined?
196
+ #=> false
197
+
198
+ ## Model with feature but no schema - schema returns nil
199
+ UnschemaedFeatureModel.schema.nil?
200
+ #=> true
201
+
202
+ ## Feature is properly registered in enabled features
203
+ SchemaValidatedModel.features_enabled.include?(:schema_validation)
204
+ #=> true
205
+
206
+ ## schema_validation feature is available in Familia
207
+ Familia::Base.features_available.key?(:schema_validation)
208
+ #=> true
209
+
210
+ # Teardown
211
+ FileUtils.rm_rf(@schema_dir)
212
+ Familia.schema_path = @original_schema_path
213
+ Familia.schemas = @original_schemas || {}
214
+ Familia.schema_validator = @original_validator || :json_schemer
215
+ Familia::SchemaRegistry.reset!
216
+ Object.send(:remove_const, :SchemaValidatedModel) if defined?(SchemaValidatedModel)
217
+ Object.send(:remove_const, :NoSchemaFeatureModel) if defined?(NoSchemaFeatureModel)
218
+ Object.send(:remove_const, :UnschemaedFeatureModel) if defined?(UnschemaedFeatureModel)