verse-schema 1.0.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.
@@ -0,0 +1,172 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "./base"
4
+
5
+ module Verse
6
+ module Schema
7
+ class Selector < Base
8
+ attr_accessor :values
9
+
10
+ # Initialize a new selector schema.
11
+ # Selector schema will select a subset of the input based on the provided values.
12
+ #
13
+ # @param values [Hash<Symbol, Class|Array<Class>>] Selected values of the selector schema.
14
+ # @param post_processors [PostProcessor] The post processors to apply.
15
+ #
16
+ # @return [Selector] The new dictionary.
17
+ def initialize(values:, post_processors: nil)
18
+ super(post_processors:)
19
+
20
+ @values = values.transform_values{ |v| v.is_a?(Array) ? v : [v] }
21
+ end
22
+
23
+ def validate(input, error_builder: nil, locals: {})
24
+ locals = locals.dup # Ensure they are not modified
25
+
26
+ error_builder = \
27
+ case error_builder
28
+ when String
29
+ ErrorBuilder.new(error_builder)
30
+ when ErrorBuilder
31
+ error_builder
32
+ else
33
+ ErrorBuilder.new
34
+ end
35
+
36
+ locals[:__path__] ||= []
37
+
38
+ validate_selector(input, error_builder, locals)
39
+ end
40
+
41
+ def dup
42
+ Selector.new(
43
+ values: @values.dup,
44
+ post_processors: @post_processors&.dup
45
+ )
46
+ end
47
+
48
+ def inherit?(parent_schema)
49
+ # Check if parent_schema is a Base and if all parent fields are present in this schema
50
+ return false unless parent_schema.is_a?(Selector)
51
+
52
+ @values.all? do |key, value|
53
+ # Check if the key exists in the parent schema and if the value is a subclass of the parent value
54
+ parent_value = parent_schema.values[key]
55
+ next false unless parent_value
56
+
57
+ (parent_value & value).size == parent_schema.size
58
+ end
59
+ end
60
+
61
+ def <=(other)
62
+ other == self || inherit?(other)
63
+ end
64
+
65
+ def <(other)
66
+ other != self && inherit?(other)
67
+ end
68
+
69
+ # rubocop:disable Style/InverseMethods
70
+ def >(other)
71
+ !self.<=(other)
72
+ end
73
+ # rubocop:enable Style/InverseMethods
74
+
75
+ # Aggregation of two schemas.
76
+ def +(other)
77
+ raise ArgumentError, "aggregate must be a selector" unless other.is_a?(Selector)
78
+
79
+ new_classes = @values.merge(other.values) do |_key, old_value, new_value|
80
+ # Merge the arrays of classes
81
+ (old_value + new_value).uniq
82
+ end
83
+
84
+ new_post_processors = @post_processors&.dup
85
+
86
+ if other.post_processors
87
+ if new_post_processors
88
+ new_post_processors.attach(other.post_processors)
89
+ else
90
+ new_post_processors = other.post_processors.dup
91
+ end
92
+ end
93
+
94
+ Selector.new(
95
+ values: new_classes,
96
+ post_processors: new_post_processors
97
+ )
98
+ end
99
+
100
+ def dataclass_schema
101
+ return @dataclass_schema if @dataclass_schema
102
+
103
+ @dataclass_schema = dup
104
+
105
+ @dataclass_schema.values = @dataclass_schema.values.transform_values do |value|
106
+ if value.is_a?(Array)
107
+ value.map do |v|
108
+ if v.is_a?(Base)
109
+ v.dataclass_schema
110
+ else
111
+ v
112
+ end
113
+ end
114
+ elsif value.is_a?(Base)
115
+ value.dataclass_schema
116
+ else
117
+ value
118
+ end
119
+ end
120
+
121
+ @dataclass_schema
122
+ end
123
+
124
+ protected
125
+
126
+ def validate_selector(input, error_builder, locals)
127
+ output = {}
128
+
129
+ selector = locals.fetch(:selector) do
130
+ error_builder.add(
131
+ nil, "selector not provided for this schema", **locals
132
+ )
133
+
134
+ return Result.new(nil, error_builder.errors)
135
+ end
136
+
137
+ fetched_values = @values.fetch(selector) do
138
+ @values.fetch(:__else__) do
139
+ error_builder.add(
140
+ nil,
141
+ "selector `#{selector}` is not valid for this schema",
142
+ **locals
143
+ )
144
+
145
+ return Result.new(output, error_builder.errors)
146
+ end
147
+ end
148
+
149
+ coalesced_value = nil
150
+
151
+ begin
152
+ coalesced_value =
153
+ Coalescer.transform(
154
+ input,
155
+ fetched_values,
156
+ nil,
157
+ locals:
158
+ )
159
+
160
+ if coalesced_value.is_a?(Result)
161
+ error_builder.combine(nil, coalesced_value.errors)
162
+ coalesced_value = coalesced_value.value
163
+ end
164
+ rescue Coalescer::Error => e
165
+ error_builder.add(nil, e.message, **locals)
166
+ end
167
+
168
+ Result.new(coalesced_value, error_builder.errors)
169
+ end
170
+ end
171
+ end
172
+ end
@@ -0,0 +1,315 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "./field"
4
+ require_relative "./result"
5
+ require_relative "./error_builder"
6
+ require_relative "./post_processor"
7
+ require_relative "./invalid_schema_error"
8
+
9
+ module Verse
10
+ module Schema
11
+ class Struct < Base
12
+ attr_accessor :fields
13
+
14
+ # Initialize a new schema.
15
+ #
16
+ # @param fields [Array<Field>] The fields of the schema.
17
+ # @param post_processors [PostProcessor] The post processors to apply.
18
+ # @param extra_fields [Boolean] Whether to allow extra fields.
19
+ # @param block [Proc] The block to evaluate (DSL).
20
+ #
21
+ # @return [Struct] The new schema.
22
+ def initialize(
23
+ fields: [],
24
+ post_processors: nil,
25
+ extra_fields: false,
26
+
27
+ &block
28
+ )
29
+ super(post_processors:)
30
+ @fields = fields
31
+ @extra_fields = extra_fields
32
+
33
+ instance_eval(&block) if block_given?
34
+ end
35
+
36
+ # delegated method useful to write clean DSL
37
+ def define(from = nil, &block)
38
+ Verse::Schema.define(from, &block)
39
+ end
40
+
41
+ def field(field_name, type = Object, **opts, &block)
42
+ @cache_field_name = nil
43
+
44
+ if opts[:over] && @fields.none?{ |f| f.name == opts[:over] }
45
+ # Ensure the `over` field exists and is
46
+ # already defined.
47
+ # There is some dependencies in validation,
48
+ # and I think that's the best trade-off to
49
+ # raise error early during schema definition.
50
+ raise ArgumentError, "over field #{opts[:over]} must be defined before #{field_name}"
51
+ end
52
+
53
+ field = Field.new(field_name, type, opts, &block)
54
+ @fields << field
55
+ field
56
+ end
57
+
58
+ def field?(field_name, type = Object, **opts, &block)
59
+ field(field_name, type, **opts, &block).optional
60
+ end
61
+
62
+ # rubocop:disable Style/OptionalBooleanParameter
63
+ def extra_fields(value = true)
64
+ @extra_fields = !!value
65
+ end
66
+ # rubocop:enable Style/OptionalBooleanParameter
67
+
68
+ def extra_fields? = @extra_fields
69
+
70
+ def valid?(input) = validate(input).success?
71
+
72
+ def validate(input, error_builder: nil, locals: {})
73
+ error_builder = \
74
+ case error_builder
75
+ when String
76
+ ErrorBuilder.new(error_builder)
77
+ when ErrorBuilder
78
+ error_builder
79
+ else
80
+ ErrorBuilder.new
81
+ end
82
+
83
+ unless input.is_a?(Hash)
84
+ error_builder.add(nil, "must be a hash")
85
+ return Result.new({}, error_builder.errors)
86
+ end
87
+
88
+ locals = locals.dup # Ensure they are not modified
89
+
90
+ validate_hash(input, error_builder, locals)
91
+ end
92
+
93
+ def dup
94
+ Struct.new(
95
+ fields: @fields.map(&:dup),
96
+ extra_fields: @extra_fields,
97
+ post_processors: @post_processors&.dup
98
+ )
99
+ end
100
+
101
+ def inherit?(parent_schema)
102
+ # Check if parent_schema is a Struct and if all parent fields are present in this schema
103
+ parent_schema.is_a?(Struct) &&
104
+ parent_schema.fields.all? { |parent_field|
105
+ child_field = @fields.find { |f2| f2.name == parent_field.name }
106
+ child_field&.inherit?(parent_field)
107
+ }
108
+ end
109
+
110
+ def <=(other)
111
+ other == self || inherit?(other)
112
+ end
113
+
114
+ def <(other)
115
+ other != self && inherit?(other)
116
+ end
117
+
118
+ # rubocop:disable Style/InverseMethods
119
+ def >(other)
120
+ !self.<=(other)
121
+ end
122
+ # rubocop:enable Style/InverseMethods
123
+
124
+ # Aggregation of two schemas.
125
+ def +(other)
126
+ raise ArgumentError, "aggregate must be a schema" unless other.is_a?(Struct)
127
+
128
+ new_schema = dup
129
+
130
+ other.fields.each do |f|
131
+ field_index = new_schema.fields.find_index{ |f2| f2.name == f.name }
132
+
133
+ if field_index
134
+ field = new_schema.fields[field_index]
135
+
136
+ field_type = \
137
+ if field.type == f.type
138
+ field.type
139
+ else
140
+ [field.type, f.type].flatten.uniq
141
+ end
142
+
143
+ if f.post_processors
144
+ if field.post_processors
145
+ field.post_processors.attach(f.post_processors)
146
+ else
147
+ field.post_processors = f.post_processors
148
+ end
149
+ end
150
+
151
+ new_schema.fields[field_index] = Field.new(
152
+ field.name,
153
+ field_type,
154
+ field.opts.merge(f.opts),
155
+ post_processors: field.post_processors
156
+ )
157
+ else
158
+ new_schema.fields << f.dup
159
+ end
160
+ end
161
+
162
+ new_schema
163
+ end
164
+
165
+ def dataclass_schema
166
+ return @dataclass_schema if @dataclass_schema
167
+
168
+ @dataclass_schema = dup
169
+
170
+ @dataclass_schema.fields = @dataclass_schema.fields.map do |field|
171
+ type = field.type
172
+
173
+ if type.is_a?(Array)
174
+ Field.new(
175
+ field.name,
176
+ type.map do |t|
177
+ next t unless t.is_a?(Base)
178
+
179
+ t.dataclass_schema
180
+ end,
181
+ field.opts.dup,
182
+ post_processors: field.post_processors&.dup
183
+ )
184
+ elsif type.is_a?(Base)
185
+ Field.new(
186
+ field.name,
187
+ type.dataclass_schema,
188
+ field.opts.dup,
189
+ post_processors: field.post_processors&.dup
190
+ )
191
+ else
192
+ field.dup
193
+ end
194
+ end
195
+
196
+ this = self
197
+ fields_map = fields.map(&:name)
198
+
199
+ @dataclass_schema.transform do |value|
200
+ next value unless value.is_a?(Hash)
201
+
202
+ if this.extra_fields?
203
+ standard_fields = value.slice(*fields_map)
204
+ extra_fields = value.except(*fields_map)
205
+
206
+ this.dataclass.from_raw(**standard_fields, extra_fields:)
207
+ else
208
+ this.dataclass.from_raw(**value)
209
+ end
210
+ end
211
+ end
212
+
213
+ # Create a value object class from the schema.
214
+ def dataclass(&block)
215
+ return @dataclass if @dataclass
216
+
217
+ fields = @fields.map(&:name)
218
+
219
+ dataclass_schema = self.dataclass_schema
220
+
221
+ fields << :extra_fields if extra_fields?
222
+
223
+ # Special case for empty schema (yeah, I know, it happens in my production code...)
224
+ if fields.empty?
225
+ @dataclass = Class.new do
226
+ def self.from_raw(*)=new
227
+ def self.schema = dataclass_schema
228
+
229
+ class_eval(&block) if block
230
+ end
231
+ else
232
+ @dataclass = ::Struct.new(*fields, keyword_init: true) do
233
+ # Redefine new method
234
+ define_singleton_method(:from_raw, &method(:new))
235
+
236
+ define_singleton_method(:new) do |*args, **kwargs|
237
+ # Use the schema to generate the hash for our record
238
+ if args.size > 1
239
+ raise ArgumentError, "You cannot pass more than one argument"
240
+ end
241
+
242
+ if args.size == 1
243
+ if kwargs.any?
244
+ raise ArgumentError, "You cannot pass both a hash and keyword arguments"
245
+ end
246
+
247
+ kwargs = args.first
248
+ end
249
+
250
+ dataclass_schema.new(kwargs)
251
+ end
252
+
253
+ define_singleton_method(:schema){ dataclass_schema }
254
+
255
+ class_eval(&block) if block
256
+ end
257
+ end
258
+ end
259
+
260
+ def inspect
261
+ fields_string = @fields.map do |field|
262
+ if field.type.is_a?(Array)
263
+ type_str = field.type.map(&:inspect).join("|")
264
+ else
265
+ type_str = field.type.inspect
266
+ end
267
+
268
+ optional_marker = field.optional? ? "?" : ""
269
+ "#{field.name}#{optional_marker}: #{type_str}"
270
+ end.join(", ")
271
+
272
+ extra = @extra_fields ? ", ..." : ""
273
+
274
+ "#<struct{#{fields_string}#{extra}} 0x#{object_id.to_s(16)}>"
275
+ end
276
+
277
+ protected
278
+
279
+ def validate_hash(input, error_builder, locals)
280
+ locals[:__path__] ||= []
281
+
282
+ input = input.transform_keys(&:to_sym)
283
+ output = @extra_fields ? input : {}
284
+
285
+ @cache_field_name ||= @fields.map(&:key)
286
+
287
+ @fields.each do |field|
288
+ key_sym = field.key
289
+
290
+ exists = true
291
+ value = input.fetch(key_sym) { exists = false }
292
+
293
+ if (over = field.opts[:over])
294
+ locals[:selector] = output[over]
295
+ end
296
+
297
+ if exists
298
+ field.apply(value, output, error_builder, locals)
299
+ elsif field.default?
300
+ field.apply(field.default, output, error_builder, locals)
301
+ elsif field.required?
302
+ error_builder.add(field.key, "is required")
303
+ end
304
+ end
305
+
306
+ if @post_processors && error_builder.errors.empty?
307
+ output = @post_processors.call(output, nil, error_builder, **locals)
308
+ end
309
+
310
+ Result.new(output, error_builder.errors)
311
+ end
312
+
313
+ end
314
+ end
315
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Verse
4
+ module Schema
5
+ VERSION = "1.0.0"
6
+ end
7
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "schema/version"
4
+
5
+ module Verse
6
+ module Schema
7
+ module_function
8
+
9
+ require_relative "schema/base"
10
+ require_relative "schema/coalescer"
11
+ require_relative "schema/post_processor"
12
+
13
+ def define(from = nil, &block)
14
+ if from
15
+ schema = from.dup
16
+ schema.instance_eval(&block) if block_given?
17
+ schema
18
+ else
19
+ Struct.new(&block)
20
+ end
21
+ end
22
+
23
+ # Define the schema as an array of values
24
+ def array(*values, &block)
25
+ if block_given?
26
+ raise ArgumentError, "array of value cannot be used with a block" unless values.empty?
27
+
28
+ Collection.new(values: [define(&block)])
29
+ else
30
+ raise ArgumentError, "block or type is required" if values.empty?
31
+
32
+ Collection.new(values:)
33
+ end
34
+ end
35
+
36
+ def dictionary(*values, &block)
37
+ if block_given?
38
+ raise ArgumentError, "array of value cannot be used with a block" unless values.empty?
39
+
40
+ Dictionary.new(values: [define(&block)])
41
+ else
42
+ raise ArgumentError, "block or type is required" if values.empty?
43
+
44
+ Dictionary.new(values:)
45
+ end
46
+ end
47
+
48
+ def scalar(*values) = Scalar.new(values:)
49
+ def selector(**values) = Selector.new(values:)
50
+
51
+ def empty
52
+ @empty ||= begin
53
+ empty_schema = Verse::Schema.define
54
+ empty_schema.dataclass # Generate the dataclass
55
+ empty_schema.freeze # Freeze to avoid modification
56
+ empty_schema
57
+ end
58
+ end
59
+
60
+ def rule(message, &block)
61
+ PostProcessor.new do |value, error|
62
+ case block.arity
63
+ when 1, -1, -2
64
+ error.add(opts[:key], message) unless block.call(value)
65
+ when 2
66
+ error.add(opts[:key], message) unless block.call(value, error)
67
+ else
68
+ raise ArgumentError, "invalid block arity"
69
+ end
70
+
71
+ value
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,6 @@
1
+ module Verse
2
+ module Schema
3
+ VERSION: String
4
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
5
+ end
6
+ end
@@ -0,0 +1,73 @@
1
+ # Verse::Schema
2
+
3
+ ## Summary
4
+
5
+ Verse::Schema is a Ruby gem that provides a DSL for data validation and coercion.
6
+
7
+ It is designed to be used in a context where you need to validate and coerce data coming from external sources (e.g. HTTP requests, database, etc...).
8
+
9
+ Verse was initially using [dry-validation](https://dry-rb.org/gems/dry-validation/) for this purpose, but we found it too complex to use and to extend. Autodocumentation was almost impossible, and the different concepts (Schema, Params, Contract...) was not really clear in our opinion.
10
+
11
+ ## Installation
12
+
13
+ Add this line to your application's Gemfile:
14
+
15
+ ```ruby
16
+ gem 'verse-schema'
17
+ ```
18
+
19
+ ## Concept
20
+
21
+ Verse::Schema provides a flexible and opinionated way to define data structures, validate input, and coerce values. The core philosophy revolves around clear, explicit definitions and predictable transformations.
22
+
23
+ **Key Principles:**
24
+
25
+ * **Validation and Coercion:** The primary goal is to ensure incoming data conforms to a defined structure and type, automatically coercing values where possible (e.g., string "123" to integer 123).
26
+ * **Explicit Definitions:** Schemas are defined using a clear DSL, making the expected data structure easy to understand.
27
+ * **Symbolized Keys:** By design, all hash keys within validated data are converted to symbols for consistency.
28
+ * **Coalescing:** The library attempts to intelligently convert input values to the target type defined in the schema. This simplifies handling data from various sources (like JSON strings, form parameters, etc.).
29
+ * **Extensibility:** While opinionated, the library allows for custom rules, post-processing transformations, and schema inheritance.
30
+
31
+ **Schema Types (Wrappers):**
32
+
33
+ Verse::Schema offers several base schema types to handle different data structures:
34
+
35
+ * **`Verse::Schema::Struct`:** The most common type, used for defining hash-like structures with fixed keys and specific types for each value. This is the default when using `Verse::Schema.define { ... }`. It validates the presence, type, and rules for each defined field. It can optionally allow extra fields not explicitly defined.
36
+ * **`Verse::Schema::Collection`:** Used for defining arrays where each element must conform to a specific type or schema. Created using `Verse::Schema.array(TypeOrSchema)` or `field(:name, Array, of: TypeOrSchema)`.
37
+ * **`Verse::Schema::Dictionary`:** Defines hash-like structures where keys are symbols and values must conform to a specific type or schema. Useful for key-value stores or maps. Created using `Verse::Schema.dictionary(TypeOrSchema)` or `field(:name, Hash, of: TypeOrSchema)`.
38
+ * **`Verse::Schema::Scalar`:** Represents a single value that can be one of several specified scalar types (e.g., String, Integer, Boolean). Created using `Verse::Schema.scalar(Type1, Type2, ...)`.
39
+ * **`Verse::Schema::Selector`:** A powerful type that allows choosing which schema or type to apply based on the value of another field (the "selector" field) or a provided `selector` local variable. This enables handling polymorphic data structures. Created using `Verse::Schema.selector(key1: TypeOrSchema1, key2: TypeOrSchema2, ...)` or `field(:name, { key1: TypeOrSchema1, ... }, over: :selector_field_name)`.
40
+
41
+ These building blocks can be nested and combined to define complex data validation and coercion rules.
42
+
43
+
44
+ ## Usage
45
+
46
+ These examples are extracted directly from the gem's specs, ensuring they are accurate and up-to-date. You can run each example directly in IRB.
47
+
48
+ ### Table of Contents
49
+
50
+ <% chapters.each do |chapter_name, sections| %>
51
+ - [<%= chapter_name %>](#<%= chapter_name.downcase.gsub(/[^a-z0-9\s-]/, '').gsub(/\s+/, '-') %>)
52
+ <% sections.each do |section_name, _| %>
53
+ - [<%= section_name %>](#<%= section_name.downcase.gsub(/[^a-z0-9\s-]/, '').gsub(/\s+/, '-') %>)
54
+ <% end %>
55
+ <% end %>
56
+
57
+ <% chapters.each do |chapter_name, sections| %>
58
+ ## <%= chapter_name %>
59
+
60
+ <% sections.each do |section_name, section_examples| %>
61
+ ### <%= section_name %>
62
+
63
+ <% section_examples.each do |example| %>
64
+ ```ruby
65
+ <%= example %>
66
+ ```
67
+ <% end %>
68
+ <% end %>
69
+ <% end %>
70
+
71
+ ## Contributing
72
+
73
+ Bug reports and pull requests are welcome on GitHub at https://github.com/verse-rb/verse-schema.