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
|
@@ -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
|
data/lib/familia/settings.rb
CHANGED
|
@@ -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
|
data/lib/familia/version.rb
CHANGED
data/lib/familia.rb
CHANGED
|
@@ -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)
|