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.
- checksums.yaml +7 -0
- data/.rspec +3 -0
- data/.rubocop-https---relaxed-ruby-style-rubocop-yml +153 -0
- data/.rubocop.yml +32 -0
- data/Gemfile +23 -0
- data/Gemfile.lock +87 -0
- data/README.md +1591 -0
- data/Rakefile +15 -0
- data/benchmarks/troubleshoot.rb +73 -0
- data/lib/tasks/readme.rake +94 -0
- data/lib/tasks/readme_doc_extractor.rb +158 -0
- data/lib/verse/schema/base.rb +80 -0
- data/lib/verse/schema/coalescer/register.rb +132 -0
- data/lib/verse/schema/coalescer.rb +81 -0
- data/lib/verse/schema/collection.rb +175 -0
- data/lib/verse/schema/dictionary.rb +160 -0
- data/lib/verse/schema/error_builder.rb +43 -0
- data/lib/verse/schema/field/ext.rb +24 -0
- data/lib/verse/schema/field.rb +394 -0
- data/lib/verse/schema/invalid_schema_error.rb +18 -0
- data/lib/verse/schema/optionable.rb +47 -0
- data/lib/verse/schema/post_processor.rb +56 -0
- data/lib/verse/schema/result.rb +30 -0
- data/lib/verse/schema/scalar.rb +154 -0
- data/lib/verse/schema/selector.rb +172 -0
- data/lib/verse/schema/struct.rb +315 -0
- data/lib/verse/schema/version.rb +7 -0
- data/lib/verse/schema.rb +75 -0
- data/sig/verse/schema.rbs +6 -0
- data/templates/README.md.erb +73 -0
- metadata +83 -0
data/README.md
ADDED
@@ -0,0 +1,1591 @@
|
|
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
|
+
|
51
|
+
- [1. Basic Usage](#1-basic-usage)
|
52
|
+
|
53
|
+
- [Simple Usage](#simple-usage)
|
54
|
+
|
55
|
+
- [Optional Fields](#optional-fields)
|
56
|
+
|
57
|
+
- [Default Fields](#default-fields)
|
58
|
+
|
59
|
+
- [Coalescing rules](#coalescing-rules)
|
60
|
+
|
61
|
+
- [Naming the keys](#naming-the-keys)
|
62
|
+
|
63
|
+
- [Multiple Types Field](#multiple-types-field)
|
64
|
+
|
65
|
+
- [Open Hash](#open-hash)
|
66
|
+
|
67
|
+
|
68
|
+
- [2. Complex Structures](#2-complex-structures)
|
69
|
+
|
70
|
+
- [Nested Schemas](#nested-schemas)
|
71
|
+
|
72
|
+
- [Array of Schemas](#array-of-schemas)
|
73
|
+
|
74
|
+
- [Array of Any Type](#array-of-any-type)
|
75
|
+
|
76
|
+
- [Dictionary Schema](#dictionary-schema)
|
77
|
+
|
78
|
+
- [Recursive Schema](#recursive-schema)
|
79
|
+
|
80
|
+
- [Selector Based Type Selection](#selector-based-type-selection)
|
81
|
+
|
82
|
+
|
83
|
+
- [Rules and Post Processing](#rules-and-post-processing)
|
84
|
+
|
85
|
+
- [Postprocessing](#postprocessing)
|
86
|
+
|
87
|
+
- [Rules](#rules)
|
88
|
+
|
89
|
+
- [Locals Variables](#locals-variables)
|
90
|
+
|
91
|
+
|
92
|
+
- [Schema Composition](#schema-composition)
|
93
|
+
|
94
|
+
- [Schema Factory Methods](#schema-factory-methods)
|
95
|
+
|
96
|
+
- [Schema Inheritance](#schema-inheritance)
|
97
|
+
|
98
|
+
- [Schema Aggregation](#schema-aggregation)
|
99
|
+
|
100
|
+
- [Field Inheritance](#field-inheritance)
|
101
|
+
|
102
|
+
|
103
|
+
- [Under the hood](#under-the-hood)
|
104
|
+
|
105
|
+
- [Add Custom coalescing rules](#add-custom-coalescing-rules)
|
106
|
+
|
107
|
+
- [Reflecting on the schema](#reflecting-on-the-schema)
|
108
|
+
|
109
|
+
- [Field Extensions](#field-extensions)
|
110
|
+
|
111
|
+
|
112
|
+
- [Data classes](#data-classes)
|
113
|
+
|
114
|
+
- [Using Data Classes](#using-data-classes)
|
115
|
+
|
116
|
+
|
117
|
+
- [Verse::Schema Documentation](#verseschema-documentation)
|
118
|
+
|
119
|
+
- [Complex Example](#complex-example)
|
120
|
+
|
121
|
+
|
122
|
+
|
123
|
+
|
124
|
+
## 1. Basic Usage
|
125
|
+
|
126
|
+
|
127
|
+
### Simple Usage
|
128
|
+
|
129
|
+
|
130
|
+
```ruby
|
131
|
+
it "demonstrates basic schema validation" do
|
132
|
+
# Create a schema with name and age fields
|
133
|
+
schema = Verse::Schema.define do
|
134
|
+
field(:name, String).meta(label: "Name", description: "The name of the person")
|
135
|
+
field(:age, Integer).rule("must be 18 or older"){ |age| age >= 18 }
|
136
|
+
end
|
137
|
+
|
138
|
+
# Validate data
|
139
|
+
result = schema.validate({ name: "John", age: 18 })
|
140
|
+
|
141
|
+
# Check if validation succeeded
|
142
|
+
if result.success?
|
143
|
+
# Access the validated and coerced data
|
144
|
+
result.value # => {name: "John", age: 18}
|
145
|
+
else
|
146
|
+
# Access validation errors
|
147
|
+
result.errors # => {}
|
148
|
+
end
|
149
|
+
|
150
|
+
# If validation fails, you can access the errors
|
151
|
+
invalid_result = schema.validate({ name: "John", age: 17 })
|
152
|
+
invalid_result.success? # => false
|
153
|
+
invalid_result.errors # => {age: ["must be 18 or older"]}
|
154
|
+
|
155
|
+
expect(result.success?).to be true
|
156
|
+
expect(result.value).to eq({ name: "John", age: 18 })
|
157
|
+
expect(invalid_result.success?).to be false
|
158
|
+
expect(invalid_result.errors).to eq({ age: ["must be 18 or older"] })
|
159
|
+
end
|
160
|
+
|
161
|
+
```
|
162
|
+
|
163
|
+
|
164
|
+
### Optional Fields
|
165
|
+
|
166
|
+
|
167
|
+
```ruby
|
168
|
+
it "demonstrates optional field usage" do
|
169
|
+
# Optional field using field?
|
170
|
+
schema = Verse::Schema.define do
|
171
|
+
field(:name, String).meta(label: "Name", description: "The name of the person")
|
172
|
+
field?(:age, Integer).rule("must be 18 or older") { |age| age >= 18 }
|
173
|
+
end
|
174
|
+
|
175
|
+
# Validation succeeds without the optional field
|
176
|
+
schema.validate({ name: "John" }).success? # => true
|
177
|
+
|
178
|
+
# But fails if the optional field is present but invalid
|
179
|
+
schema.validate({ name: "John", age: 17 }).success? # => false
|
180
|
+
|
181
|
+
# Note that if a key is found but set to nil, the schema will be invalid
|
182
|
+
schema.validate({ name: "John", age: nil }).success? # => false
|
183
|
+
|
184
|
+
# To make it valid with nil, define the field as union of Integer and NilClass
|
185
|
+
schema_with_nil = Verse::Schema.define do
|
186
|
+
field(:name, String).meta(label: "Name", description: "The name of the person")
|
187
|
+
field(:age, [Integer, NilClass]).rule("must be 18 or older") { |age|
|
188
|
+
next true if age.nil?
|
189
|
+
|
190
|
+
age >= 18
|
191
|
+
}
|
192
|
+
end
|
193
|
+
|
194
|
+
# Now nil is valid
|
195
|
+
schema_with_nil.validate({ name: "John", age: nil }).success? # => true
|
196
|
+
|
197
|
+
expect(schema.validate({ name: "John" }).success?).to be true
|
198
|
+
expect(schema.validate({ name: "John", age: 17 }).success?).to be false
|
199
|
+
expect(schema.validate({ name: "John", age: 17 }).errors).to eq({ age: ["must be 18 or older"] })
|
200
|
+
expect(schema.validate({ name: "John", age: nil }).success?).to be false
|
201
|
+
# Assuming default type error message for nil when Integer is expected
|
202
|
+
expect(schema.validate({ name: "John", age: nil }).errors).to eq({ age: ["must be an integer"] })
|
203
|
+
expect(schema_with_nil.validate({ name: "John", age: nil }).success?).to be true
|
204
|
+
end
|
205
|
+
|
206
|
+
```
|
207
|
+
|
208
|
+
|
209
|
+
### Default Fields
|
210
|
+
|
211
|
+
|
212
|
+
```ruby
|
213
|
+
it "demonstrates default field values" do
|
214
|
+
# Use a static value
|
215
|
+
schema1 = Verse::Schema.define do
|
216
|
+
field(:type, String).default("reply")
|
217
|
+
end
|
218
|
+
|
219
|
+
# Empty input will use the default value
|
220
|
+
schema1.validate({}).value # => {type: "reply"}
|
221
|
+
|
222
|
+
# Or use a block which will be called
|
223
|
+
schema2 = Verse::Schema.define do
|
224
|
+
field(:type, String).default { "reply" }
|
225
|
+
end
|
226
|
+
|
227
|
+
schema2.validate({}).value # => {type: "reply"}
|
228
|
+
|
229
|
+
# Using required after default disables default
|
230
|
+
schema3 = Verse::Schema.define do
|
231
|
+
field(:type, String).default("ignored").required
|
232
|
+
end
|
233
|
+
|
234
|
+
schema3.validate({}).success? # => false
|
235
|
+
schema3.validate({}).errors # => {type: ["is required"]}
|
236
|
+
|
237
|
+
expect(schema1.validate({}).value).to eq({ type: "reply" })
|
238
|
+
expect(schema2.validate({}).value).to eq({ type: "reply" })
|
239
|
+
expect(schema3.validate({}).success?).to be false
|
240
|
+
expect(schema3.validate({}).errors).to eq({ type: ["is required"] })
|
241
|
+
end
|
242
|
+
|
243
|
+
```
|
244
|
+
|
245
|
+
|
246
|
+
### Coalescing rules
|
247
|
+
|
248
|
+
|
249
|
+
```ruby
|
250
|
+
it "demonstrates coalescer rules" do
|
251
|
+
# Verse::Schema will try to coalesce the data to the type of the field.
|
252
|
+
# This means that if you pass a string in an Integer field,
|
253
|
+
# it will try to convert it to an integer.
|
254
|
+
|
255
|
+
schema = Verse::Schema.define do
|
256
|
+
field(:name, String)
|
257
|
+
field(:age, Integer)
|
258
|
+
end
|
259
|
+
|
260
|
+
# Coalescer will try to coerce the data to the type
|
261
|
+
# of the field. So if you pass a string, it will
|
262
|
+
# try to convert it to an integer.
|
263
|
+
result = schema.validate({ name: 1, age: "18" })
|
264
|
+
|
265
|
+
expect(result.success?).to be true
|
266
|
+
expect(result.value).to eq({ name: "1", age: 18 })
|
267
|
+
end
|
268
|
+
|
269
|
+
```
|
270
|
+
|
271
|
+
```ruby
|
272
|
+
it "quick match if the class of the input is the same as the field" do
|
273
|
+
# If the input is of the same class as the field,
|
274
|
+
# it will be a quick match and no coercion will be
|
275
|
+
# performed.
|
276
|
+
schema = Verse::Schema.define do
|
277
|
+
field(:age, [Integer, Float])
|
278
|
+
end
|
279
|
+
|
280
|
+
result = schema.validate({ age: 18.0 })
|
281
|
+
|
282
|
+
expect(result.success?).to be true
|
283
|
+
expect(result.value[:age]).to be_a(Float)
|
284
|
+
end
|
285
|
+
|
286
|
+
```
|
287
|
+
|
288
|
+
```ruby
|
289
|
+
it "stops when finding a good candidate" do
|
290
|
+
# The coalescer go through all the different types in the definition order
|
291
|
+
# and stop when it finds a good candidate.
|
292
|
+
#
|
293
|
+
# The example schema above would never coalesce to Float
|
294
|
+
# because it would find Float first:
|
295
|
+
schema = Verse::Schema.define do
|
296
|
+
field(:age, [Float, Integer])
|
297
|
+
end
|
298
|
+
|
299
|
+
result = schema.validate({ age: "18" })
|
300
|
+
|
301
|
+
expect(result.success?).to be true
|
302
|
+
|
303
|
+
# It was able to coalesce to Float first
|
304
|
+
# In this case, it would be judicious to define the field
|
305
|
+
# as [Integer, Float], Integer being more constrained, to avoid this behavior
|
306
|
+
expect(result.value[:age]).to be_a(Float)
|
307
|
+
end
|
308
|
+
|
309
|
+
```
|
310
|
+
|
311
|
+
|
312
|
+
### Naming the keys
|
313
|
+
|
314
|
+
|
315
|
+
```ruby
|
316
|
+
it "demonstrates using different keys for fields" do
|
317
|
+
# If the key of the input schema is different from the output schema,
|
318
|
+
# you can use the `key` method to specify the key in the input schema
|
319
|
+
# that should be used for the output schema.
|
320
|
+
|
321
|
+
# Define a schema with different keys for fields
|
322
|
+
schema = Verse::Schema.define do
|
323
|
+
# key can be passed as option
|
324
|
+
field(:name, String, key: :firstName)
|
325
|
+
# or using the chainable syntax
|
326
|
+
field(:email, String).key(:email_address)
|
327
|
+
end
|
328
|
+
|
329
|
+
# Validate data with the original key
|
330
|
+
result1 = schema.validate({
|
331
|
+
firstName: "John",
|
332
|
+
email_address: "john@example.tld"
|
333
|
+
})
|
334
|
+
result1.success? # => true
|
335
|
+
|
336
|
+
expect(result1.value).to eq({
|
337
|
+
name: "John",
|
338
|
+
email: "john@example.tld"
|
339
|
+
})
|
340
|
+
end
|
341
|
+
|
342
|
+
```
|
343
|
+
|
344
|
+
|
345
|
+
### Multiple Types Field
|
346
|
+
|
347
|
+
|
348
|
+
```ruby
|
349
|
+
it "demonstrates fields that accept multiple types" do
|
350
|
+
# Define a schema that accepts a String or a Nested Schema
|
351
|
+
content_hash = Verse::Schema.define do
|
352
|
+
field(:content, String)
|
353
|
+
field(:created_at, Time)
|
354
|
+
end
|
355
|
+
|
356
|
+
schema = Verse::Schema.define do
|
357
|
+
field(:title, String)
|
358
|
+
field(:content, [String, content_hash])
|
359
|
+
end
|
360
|
+
|
361
|
+
# Validate with a String content
|
362
|
+
result1 = schema.validate({
|
363
|
+
title: "My Post",
|
364
|
+
content: "This is a simple string content"
|
365
|
+
})
|
366
|
+
|
367
|
+
# Validate with a Hash content
|
368
|
+
result2 = schema.validate({
|
369
|
+
title: "My Post",
|
370
|
+
content: {
|
371
|
+
content: "This is a structured content",
|
372
|
+
created_at: "2023-01-01T12:00:00Z"
|
373
|
+
}
|
374
|
+
})
|
375
|
+
|
376
|
+
# Both are valid
|
377
|
+
expect(result1.success?).to be true
|
378
|
+
expect(result2.success?).to be true
|
379
|
+
|
380
|
+
# But invalid content will fail
|
381
|
+
invalid_result = schema.validate({
|
382
|
+
title: "My Post",
|
383
|
+
content: { invalid: "structure" } # Doesn't match `content_hash` schema
|
384
|
+
})
|
385
|
+
expect(invalid_result.success?).to be false
|
386
|
+
# Assuming error messages for missing fields in the nested hash schema
|
387
|
+
expect(invalid_result.errors).to eq({ "content.content": ["is required"], "content.created_at": ["is required"] })
|
388
|
+
end
|
389
|
+
|
390
|
+
```
|
391
|
+
|
392
|
+
|
393
|
+
### Open Hash
|
394
|
+
|
395
|
+
|
396
|
+
```ruby
|
397
|
+
it "demonstrates schemas that allow extra fields" do
|
398
|
+
# By default, schemas are closed, which means that
|
399
|
+
# fields not defined in the schema will be ignored.
|
400
|
+
# To allow extra fields, you can use the `extra_fields` method:
|
401
|
+
|
402
|
+
# Define a schema that allows extra fields
|
403
|
+
schema = Verse::Schema.define do
|
404
|
+
field(:name, String)
|
405
|
+
|
406
|
+
# This allows any additional fields to be included
|
407
|
+
extra_fields
|
408
|
+
end
|
409
|
+
|
410
|
+
# Validate with only the defined fields
|
411
|
+
result1 = schema.validate({
|
412
|
+
name: "John"
|
413
|
+
})
|
414
|
+
|
415
|
+
# Validate with extra fields
|
416
|
+
result2 = schema.validate({
|
417
|
+
name: "John",
|
418
|
+
age: 30,
|
419
|
+
email: "john@example.com"
|
420
|
+
})
|
421
|
+
|
422
|
+
# Both are valid
|
423
|
+
expect(result1.success?).to be true
|
424
|
+
expect(result2.success?).to be true
|
425
|
+
|
426
|
+
# Extra fields are preserved in the output
|
427
|
+
expect(result2.value).to eq({
|
428
|
+
name: "John",
|
429
|
+
age: 30,
|
430
|
+
email: "john@example.com"
|
431
|
+
})
|
432
|
+
end
|
433
|
+
|
434
|
+
```
|
435
|
+
|
436
|
+
|
437
|
+
|
438
|
+
## 2. Complex Structures
|
439
|
+
|
440
|
+
|
441
|
+
### Nested Schemas
|
442
|
+
|
443
|
+
|
444
|
+
```ruby
|
445
|
+
it "demonstrates nested schema usage" do
|
446
|
+
# Define a simple schema first
|
447
|
+
simple_schema = Verse::Schema.define do
|
448
|
+
field(:name, String).meta(label: "Name", description: "The name of the person")
|
449
|
+
field(:age, Integer).rule("must be 18 or older") { |age| age >= 18 }
|
450
|
+
end
|
451
|
+
|
452
|
+
# Nested schema using a reference
|
453
|
+
nested_schema1 = Verse::Schema.define do
|
454
|
+
field(:data, simple_schema)
|
455
|
+
end
|
456
|
+
|
457
|
+
# Validate nested data
|
458
|
+
result = nested_schema1.validate({
|
459
|
+
data: {
|
460
|
+
name: "John",
|
461
|
+
age: 30
|
462
|
+
}
|
463
|
+
})
|
464
|
+
|
465
|
+
result.success? # => true
|
466
|
+
result.value # => { data: { name: "John", age: 30 } }
|
467
|
+
|
468
|
+
# Or define using subblock and Hash type
|
469
|
+
nested_schema2 = Verse::Schema.define do
|
470
|
+
field(:data, Hash) do
|
471
|
+
field(:name, String).meta(label: "Name", description: "The name of the person")
|
472
|
+
field(:age, Integer).rule("must be 18 or older") { |age| age >= 18 }
|
473
|
+
end
|
474
|
+
end
|
475
|
+
|
476
|
+
# Both approaches produce equivalent schemas
|
477
|
+
nested_schema2.validate({
|
478
|
+
data: {
|
479
|
+
name: "John",
|
480
|
+
age: 30
|
481
|
+
}
|
482
|
+
}).success? # => true
|
483
|
+
|
484
|
+
expect(result.success?).to be true
|
485
|
+
expect(result.value).to eq({ data: { name: "John", age: 30 } })
|
486
|
+
|
487
|
+
expect(nested_schema2.validate({
|
488
|
+
data: {
|
489
|
+
name: "John",
|
490
|
+
age: 30
|
491
|
+
}
|
492
|
+
}).success?).to be true
|
493
|
+
end
|
494
|
+
|
495
|
+
```
|
496
|
+
|
497
|
+
|
498
|
+
### Array of Schemas
|
499
|
+
|
500
|
+
|
501
|
+
```ruby
|
502
|
+
it "demonstrates array of schemas" do
|
503
|
+
# Define an array of schemas using Array type
|
504
|
+
array_schema = Verse::Schema.define do
|
505
|
+
field(:data, Array) do
|
506
|
+
field(:name, String).meta(label: "Name", description: "The name of the person")
|
507
|
+
field(:age, Integer).rule("must be 18 or older") { |age| age >= 18 }
|
508
|
+
end
|
509
|
+
end
|
510
|
+
|
511
|
+
# Validate an array of items
|
512
|
+
result = array_schema.validate({
|
513
|
+
data: [
|
514
|
+
{ name: "John", age: 30 },
|
515
|
+
{ name: "Jane", age: 25 }
|
516
|
+
]
|
517
|
+
})
|
518
|
+
|
519
|
+
# Check the result
|
520
|
+
result.success? # => true
|
521
|
+
result.value # => { data: [ { name: "John", age: 30 }, { name: "Jane", age: 25 } ] }
|
522
|
+
|
523
|
+
# If any item in the array is invalid, the whole validation fails
|
524
|
+
invalid_result = array_schema.validate({
|
525
|
+
data: [
|
526
|
+
{ name: "John", age: 30 },
|
527
|
+
{ name: "Jane", age: 17 } # Age is invalid
|
528
|
+
]
|
529
|
+
})
|
530
|
+
|
531
|
+
invalid_result.success? # => false
|
532
|
+
invalid_result.errors # => { "data.1.age": ["must be 18 or older"] }
|
533
|
+
|
534
|
+
expect(result.success?).to be true
|
535
|
+
expect(result.value).to eq({
|
536
|
+
data: [
|
537
|
+
{ name: "John", age: 30 },
|
538
|
+
{ name: "Jane", age: 25 }
|
539
|
+
]
|
540
|
+
})
|
541
|
+
expect(invalid_result.success?).to be false
|
542
|
+
expect(invalid_result.errors).to eq({ "data.1.age": ["must be 18 or older"] })
|
543
|
+
end
|
544
|
+
|
545
|
+
```
|
546
|
+
|
547
|
+
|
548
|
+
### Array of Any Type
|
549
|
+
|
550
|
+
|
551
|
+
```ruby
|
552
|
+
it "demonstrates array of any type" do
|
553
|
+
# Array of simple type using 'of' option
|
554
|
+
array_schema1 = Verse::Schema.define do
|
555
|
+
field(:data, Array, of: Integer)
|
556
|
+
end
|
557
|
+
|
558
|
+
# Validate array of integers (with automatic coercion)
|
559
|
+
result = array_schema1.validate({
|
560
|
+
data: [1, "2", "3"] # String values will be coerced to integers
|
561
|
+
})
|
562
|
+
|
563
|
+
result.success? # => true
|
564
|
+
result.value # => { data: [1, 2, 3] }
|
565
|
+
|
566
|
+
# This works with Schema too
|
567
|
+
person_schema = Verse::Schema.define do
|
568
|
+
field(:name, String)
|
569
|
+
field(:age, Integer).rule("must be 18 or older") { |age| age >= 18 }
|
570
|
+
end
|
571
|
+
|
572
|
+
# Create an array of person schemas
|
573
|
+
array_schema2 = Verse::Schema.define do
|
574
|
+
field(:people, Array, of: person_schema)
|
575
|
+
end
|
576
|
+
|
577
|
+
# Validate array of people
|
578
|
+
result2 = array_schema2.validate({
|
579
|
+
people: [
|
580
|
+
{ name: "John", age: 30 },
|
581
|
+
{ name: "Jane", age: 25 }
|
582
|
+
]
|
583
|
+
})
|
584
|
+
|
585
|
+
result2.success? # => true
|
586
|
+
|
587
|
+
expect(result.success?).to be true
|
588
|
+
expect(result.value).to eq({ data: [1, 2, 3] })
|
589
|
+
expect(result2.success?).to be true
|
590
|
+
end
|
591
|
+
|
592
|
+
```
|
593
|
+
|
594
|
+
|
595
|
+
### Dictionary Schema
|
596
|
+
|
597
|
+
|
598
|
+
```ruby
|
599
|
+
it "demonstrates dictionary schemas" do
|
600
|
+
# Define a dictionary schema with Integer values
|
601
|
+
schema = Verse::Schema.define do
|
602
|
+
field(:scores, Hash, of: Integer)
|
603
|
+
end
|
604
|
+
|
605
|
+
# Validate a dictionary
|
606
|
+
result = schema.validate({
|
607
|
+
scores: {
|
608
|
+
math: "95",
|
609
|
+
science: "87",
|
610
|
+
history: 92.0
|
611
|
+
}
|
612
|
+
})
|
613
|
+
|
614
|
+
# The validation succeeds and coerces string values to integers
|
615
|
+
expect(result.success?).to be true
|
616
|
+
expect(result.value).to eq({
|
617
|
+
scores: {
|
618
|
+
math: 95,
|
619
|
+
science: 87,
|
620
|
+
history: 92
|
621
|
+
}
|
622
|
+
})
|
623
|
+
|
624
|
+
# Invalid values will cause validation to fail
|
625
|
+
invalid_result = schema.validate({
|
626
|
+
scores: {
|
627
|
+
math: "95",
|
628
|
+
science: "invalid",
|
629
|
+
history: "92"
|
630
|
+
}
|
631
|
+
})
|
632
|
+
expect(invalid_result.success?).to be false
|
633
|
+
# Assuming error message for type coercion failure in dictionary
|
634
|
+
expect(invalid_result.errors).to eq({ "scores.science": ["must be an integer"] })
|
635
|
+
end
|
636
|
+
|
637
|
+
```
|
638
|
+
|
639
|
+
|
640
|
+
### Recursive Schema
|
641
|
+
|
642
|
+
|
643
|
+
```ruby
|
644
|
+
it "demonstrates recursive schema" do
|
645
|
+
# Define a schema that can contain itself
|
646
|
+
recursive_schema = Verse::Schema.define do
|
647
|
+
field(:name, String).meta(label: "Name", description: "The name of the item")
|
648
|
+
field(:children, Array, of: self).default([])
|
649
|
+
end
|
650
|
+
|
651
|
+
# This allows for tree-like structures
|
652
|
+
tree_data = {
|
653
|
+
name: "Root",
|
654
|
+
children: [
|
655
|
+
{
|
656
|
+
name: "Child 1",
|
657
|
+
children: [
|
658
|
+
{ name: "Grandchild 1" }
|
659
|
+
]
|
660
|
+
},
|
661
|
+
{ name: "Child 2" }
|
662
|
+
]
|
663
|
+
}
|
664
|
+
|
665
|
+
# Validate the recursive structure
|
666
|
+
result = recursive_schema.validate(tree_data)
|
667
|
+
|
668
|
+
result.success? # => true
|
669
|
+
|
670
|
+
# The validated data maintains the same structure
|
671
|
+
# but with any coercions or transformations applied
|
672
|
+
|
673
|
+
expect(result.success?).to be true
|
674
|
+
end
|
675
|
+
|
676
|
+
```
|
677
|
+
|
678
|
+
|
679
|
+
### Selector Based Type Selection
|
680
|
+
|
681
|
+
|
682
|
+
```ruby
|
683
|
+
it "demonstrates using raw selector schema" do
|
684
|
+
selector_schema = Verse::Schema.selector(
|
685
|
+
a: [String, Integer],
|
686
|
+
b: [Hash, Array]
|
687
|
+
)
|
688
|
+
|
689
|
+
result = selector_schema.validate("string", locals: { selector: :a })
|
690
|
+
expect(result.success?).to be true
|
691
|
+
|
692
|
+
result = selector_schema.validate(42, locals: { selector: :a })
|
693
|
+
expect(result.success?).to be true
|
694
|
+
|
695
|
+
result = selector_schema.validate({ key: "value" }, locals: { selector: :b })
|
696
|
+
expect(result.success?).to be true
|
697
|
+
result = selector_schema.validate([1, 2, 3], locals: { selector: :b })
|
698
|
+
expect(result.success?).to be true
|
699
|
+
|
700
|
+
# Invalid case - wrong type for the selector
|
701
|
+
invalid_result = selector_schema.validate("invalid", locals: { selector: :b })
|
702
|
+
expect(invalid_result.success?).to be false
|
703
|
+
# Assuming error message format for type mismatch in selector
|
704
|
+
# Currently, the error message will be related to the last type of the
|
705
|
+
# array
|
706
|
+
expect(invalid_result.errors).to eq({ nil => ["must be an array"] })
|
707
|
+
|
708
|
+
# Invalid case - missing selector
|
709
|
+
missing_selector_result = selector_schema.validate("invalid")
|
710
|
+
expect(missing_selector_result.success?).to be false
|
711
|
+
expect(missing_selector_result.errors).to eq({ nil => ["selector not provided for this schema"] })
|
712
|
+
end
|
713
|
+
|
714
|
+
```
|
715
|
+
|
716
|
+
```ruby
|
717
|
+
it "demonstrates selector based type selection" do
|
718
|
+
facebook_schema = Verse::Schema.define do
|
719
|
+
field(:url, String)
|
720
|
+
field?(:title, String)
|
721
|
+
end
|
722
|
+
|
723
|
+
google_schema = Verse::Schema.define do
|
724
|
+
field(:search, String)
|
725
|
+
field?(:location, String)
|
726
|
+
end
|
727
|
+
|
728
|
+
# Define a schema with a selector field
|
729
|
+
schema = Verse::Schema.define do
|
730
|
+
field(:type, Symbol).in?(%i[facebook google])
|
731
|
+
field(:data, {
|
732
|
+
facebook: facebook_schema,
|
733
|
+
google: google_schema
|
734
|
+
}, over: :type)
|
735
|
+
end
|
736
|
+
|
737
|
+
# Validate data with different types
|
738
|
+
result1 = schema.validate({
|
739
|
+
type: :facebook,
|
740
|
+
data: { url: "https://facebook.com" }
|
741
|
+
})
|
742
|
+
|
743
|
+
result2 = schema.validate({
|
744
|
+
type: :google,
|
745
|
+
data: { search: "conference 2023" }
|
746
|
+
})
|
747
|
+
|
748
|
+
expect(result1.success?).to be true
|
749
|
+
expect(result2.success?).to be true
|
750
|
+
|
751
|
+
# Invalid case - wrong type for the selector
|
752
|
+
invalid_result = schema.validate({
|
753
|
+
type: :facebook,
|
754
|
+
data: { search: "invalid" } # `search` is not in `facebook_schema`
|
755
|
+
})
|
756
|
+
|
757
|
+
expect(invalid_result.success?).to be false
|
758
|
+
# Assuming error message format for missing required field in selected schema
|
759
|
+
expect(invalid_result.errors).to eq({ "data.url": ["is required"] })
|
760
|
+
end
|
761
|
+
|
762
|
+
```
|
763
|
+
|
764
|
+
|
765
|
+
|
766
|
+
## Rules and Post Processing
|
767
|
+
|
768
|
+
|
769
|
+
### Postprocessing
|
770
|
+
|
771
|
+
|
772
|
+
```ruby
|
773
|
+
it "demonstrates postprocessing with transform" do
|
774
|
+
Event = Struct.new(:type, :data, :created_at) unless defined?(Event)
|
775
|
+
|
776
|
+
event_schema = Verse::Schema.define do
|
777
|
+
field(:type, String)
|
778
|
+
field(:data, Hash).transform{ |input| input.transform_keys(&:to_sym) }
|
779
|
+
field(:created_at, Time)
|
780
|
+
|
781
|
+
# Transform the output of this schema definition.
|
782
|
+
transform do |input|
|
783
|
+
Event.new(input[:type], input[:data], input[:created_at])
|
784
|
+
end
|
785
|
+
end
|
786
|
+
|
787
|
+
output = event_schema.validate({
|
788
|
+
type: "user.created",
|
789
|
+
data: { "name" => "John" },
|
790
|
+
created_at: "2020-01-01T00:00:00Z"
|
791
|
+
}).value
|
792
|
+
|
793
|
+
expect(output).to be_a(Event)
|
794
|
+
expect(output.type).to eq("user.created")
|
795
|
+
expect(output.data).to eq({ name: "John" })
|
796
|
+
expect(output.created_at).to be_a(Time)
|
797
|
+
end
|
798
|
+
|
799
|
+
```
|
800
|
+
|
801
|
+
```ruby
|
802
|
+
it "demonstrates chaining multiple post processors" do
|
803
|
+
# Create a schema with multiple rules on a field
|
804
|
+
schema = Verse::Schema.define do
|
805
|
+
field(:age, Integer)
|
806
|
+
.rule("must be at least 18") { |age| age >= 18 }
|
807
|
+
.rule("must be under 100") { |age| age < 100 }
|
808
|
+
end
|
809
|
+
|
810
|
+
# Valid age
|
811
|
+
result1 = schema.validate({ age: 30 })
|
812
|
+
expect(result1.success?).to be true
|
813
|
+
|
814
|
+
# Too young
|
815
|
+
result2 = schema.validate({ age: 16 })
|
816
|
+
expect(result2.success?).to be false
|
817
|
+
expect(result2.errors).to eq({ age: ["must be at least 18"] })
|
818
|
+
|
819
|
+
# Too old
|
820
|
+
result3 = schema.validate({ age: 120 })
|
821
|
+
expect(result3.success?).to be false
|
822
|
+
expect(result3.errors).to eq({ age: ["must be under 100"] })
|
823
|
+
end
|
824
|
+
|
825
|
+
```
|
826
|
+
|
827
|
+
```ruby
|
828
|
+
it "demonstrates rule with error_builder parameter" do
|
829
|
+
# Create a schema with a rule that uses the error_builder
|
830
|
+
schema = Verse::Schema.define do
|
831
|
+
field(:data, Hash).rule("must contain required keys") do |data, error_builder|
|
832
|
+
valid = true
|
833
|
+
|
834
|
+
# Check for required keys
|
835
|
+
%w[name email].each do |key|
|
836
|
+
unless data.key?(key.to_sym) || data.key?(key)
|
837
|
+
error_builder.add(:data, "missing required key: #{key}")
|
838
|
+
valid = false
|
839
|
+
end
|
840
|
+
end
|
841
|
+
|
842
|
+
valid
|
843
|
+
end
|
844
|
+
end
|
845
|
+
|
846
|
+
# Valid data
|
847
|
+
result1 = schema.validate({ data: { name: "John", email: "john@example.com" } })
|
848
|
+
expect(result1.success?).to be true
|
849
|
+
|
850
|
+
# Missing name
|
851
|
+
result2 = schema.validate({ data: { email: "john@example.com" } })
|
852
|
+
expect(result2.success?).to be false
|
853
|
+
expect(result2.errors).to eq({ data: ["missing required key: name", "must contain required keys"] })
|
854
|
+
|
855
|
+
# Missing email
|
856
|
+
result3 = schema.validate({ data: { name: "John" } })
|
857
|
+
expect(result3.success?).to be false
|
858
|
+
expect(result3.errors).to eq({ data: ["missing required key: email", "must contain required keys"] })
|
859
|
+
end
|
860
|
+
|
861
|
+
```
|
862
|
+
|
863
|
+
|
864
|
+
### Rules
|
865
|
+
|
866
|
+
|
867
|
+
```ruby
|
868
|
+
it "demonstrates per schema rules" do
|
869
|
+
# Multiple fields rule
|
870
|
+
multiple_field_rule_schema = Verse::Schema.define do
|
871
|
+
field(:name, String)
|
872
|
+
field(:age, Integer)
|
873
|
+
|
874
|
+
rule(%i[age name], "age must be 18 and name must NOT be John") do |schema|
|
875
|
+
schema[:age] >= 18 && schema[:name] != "John"
|
876
|
+
end
|
877
|
+
end
|
878
|
+
|
879
|
+
# Valid case
|
880
|
+
result1 = multiple_field_rule_schema.validate({
|
881
|
+
name: "Jane",
|
882
|
+
age: 20
|
883
|
+
})
|
884
|
+
expect(result1.success?).to be true
|
885
|
+
|
886
|
+
# Invalid case - rule violation
|
887
|
+
result2 = multiple_field_rule_schema.validate({
|
888
|
+
name: "John",
|
889
|
+
age: 20
|
890
|
+
})
|
891
|
+
expect(result2.success?).to be false
|
892
|
+
expect(result2.errors).to eq({ age: ["age must be 18 and name must NOT be John"], name: ["age must be 18 and name must NOT be John"] })
|
893
|
+
end
|
894
|
+
|
895
|
+
```
|
896
|
+
|
897
|
+
```ruby
|
898
|
+
it "demonstrates reusable rules defined with Verse::Schema.rule" do
|
899
|
+
# Define a reusable rule object
|
900
|
+
is_positive = Verse::Schema.rule("must be positive") { |value| value > 0 }
|
901
|
+
|
902
|
+
# Define another reusable rule
|
903
|
+
is_even = Verse::Schema.rule("must be even") { |value| value.even? }
|
904
|
+
|
905
|
+
# Create a schema that uses the reusable rules
|
906
|
+
schema = Verse::Schema.define do
|
907
|
+
field(:quantity, Integer)
|
908
|
+
.rule(is_positive)
|
909
|
+
.rule(is_even)
|
910
|
+
|
911
|
+
field(:price, Float)
|
912
|
+
.rule(is_positive) # Reuse the same rule
|
913
|
+
end
|
914
|
+
|
915
|
+
# Valid case
|
916
|
+
result1 = schema.validate({ quantity: 10, price: 9.99 })
|
917
|
+
expect(result1.success?).to be true
|
918
|
+
|
919
|
+
# Invalid quantity (negative)
|
920
|
+
result2 = schema.validate({ quantity: -2, price: 9.99 })
|
921
|
+
expect(result2.success?).to be false
|
922
|
+
expect(result2.errors).to eq({ quantity: ["must be positive"] })
|
923
|
+
|
924
|
+
# Invalid quantity (odd)
|
925
|
+
result3 = schema.validate({ quantity: 5, price: 9.99 })
|
926
|
+
expect(result3.success?).to be false
|
927
|
+
expect(result3.errors).to eq({ quantity: ["must be even"] })
|
928
|
+
|
929
|
+
# Invalid price (zero)
|
930
|
+
result4 = schema.validate({ quantity: 10, price: 0.0 })
|
931
|
+
expect(result4.success?).to be false
|
932
|
+
expect(result4.errors).to eq({ price: ["must be positive"] })
|
933
|
+
end
|
934
|
+
|
935
|
+
```
|
936
|
+
|
937
|
+
|
938
|
+
### Locals Variables
|
939
|
+
|
940
|
+
|
941
|
+
```ruby
|
942
|
+
it "demonstrates locals variables" do
|
943
|
+
schema = Verse::Schema.define do
|
944
|
+
field(:age, Integer).rule("must be greater than %<min_age>s") { |age|
|
945
|
+
age > locals[:min_age]
|
946
|
+
}
|
947
|
+
end
|
948
|
+
|
949
|
+
# Valid case
|
950
|
+
result1 = schema.validate({ age: 22 }, locals: { min_age: 21 })
|
951
|
+
expect(result1.success?).to be true
|
952
|
+
|
953
|
+
# Invalid case
|
954
|
+
result2 = schema.validate({ age: 18 }, locals: { min_age: 21 })
|
955
|
+
expect(result2.success?).to be false
|
956
|
+
expect(result2.errors).to eq({ age: ["must be greater than 21"] })
|
957
|
+
end
|
958
|
+
|
959
|
+
```
|
960
|
+
|
961
|
+
|
962
|
+
|
963
|
+
## Schema Composition
|
964
|
+
|
965
|
+
|
966
|
+
### Schema Factory Methods
|
967
|
+
|
968
|
+
|
969
|
+
```ruby
|
970
|
+
it "demonstrates schema factory methods" do
|
971
|
+
# Verse::Schema offer methods to create array, dictionary, and scalar schemas
|
972
|
+
|
973
|
+
# Define a base item schema
|
974
|
+
item_schema = Verse::Schema.define do
|
975
|
+
field(:name, String)
|
976
|
+
end
|
977
|
+
|
978
|
+
# Create an array schema using the factory method
|
979
|
+
array_schema = Verse::Schema.array(item_schema)
|
980
|
+
|
981
|
+
# Create a dictionary schema using the factory method
|
982
|
+
dictionary_schema = Verse::Schema.dictionary(item_schema)
|
983
|
+
|
984
|
+
# Create a scalar schema using the factory method
|
985
|
+
scalar_schema = Verse::Schema.scalar(Integer, String)
|
986
|
+
|
987
|
+
# Validate using the array schema
|
988
|
+
array_result = array_schema.validate([
|
989
|
+
{ name: "Item 1" },
|
990
|
+
{ name: "Item 2" }
|
991
|
+
])
|
992
|
+
expect(array_result.success?).to be true
|
993
|
+
|
994
|
+
# Validate using the dictionary schema
|
995
|
+
dict_result = dictionary_schema.validate({
|
996
|
+
item1: { name: "First Item" },
|
997
|
+
item2: { name: "Second Item" }
|
998
|
+
})
|
999
|
+
expect(dict_result.success?).to be true
|
1000
|
+
|
1001
|
+
# Validate using the scalar schema
|
1002
|
+
scalar_result1 = scalar_schema.validate(42)
|
1003
|
+
scalar_result2 = scalar_schema.validate("Hello")
|
1004
|
+
expect(scalar_result1.success?).to be true
|
1005
|
+
expect(scalar_result2.success?).to be true
|
1006
|
+
end
|
1007
|
+
|
1008
|
+
```
|
1009
|
+
|
1010
|
+
|
1011
|
+
### Schema Inheritance
|
1012
|
+
|
1013
|
+
|
1014
|
+
```ruby
|
1015
|
+
it "demonstrates schema inheritance" do
|
1016
|
+
# Schema can inherit from other schemas.
|
1017
|
+
# Please be aware that this is not a classical inheritance model,
|
1018
|
+
# but rather a structural inheritance model.
|
1019
|
+
# In a way, it is similar to traits concept.
|
1020
|
+
|
1021
|
+
# Define a parent schema
|
1022
|
+
parent = Verse::Schema.define do
|
1023
|
+
field(:type, Symbol)
|
1024
|
+
field(:id, Integer)
|
1025
|
+
|
1026
|
+
rule(:type, "should be filled") { |x| x[:type].to_s != "" }
|
1027
|
+
end
|
1028
|
+
|
1029
|
+
# Define a child schema that inherits from the parent
|
1030
|
+
child_a = Verse::Schema.define(parent) do
|
1031
|
+
rule(:type, "must start with x") { |x| x[:type].to_s =~ /^x/ }
|
1032
|
+
field(:data, Hash) do
|
1033
|
+
field(:x, Float)
|
1034
|
+
field(:y, Float)
|
1035
|
+
end
|
1036
|
+
end
|
1037
|
+
|
1038
|
+
# Another child schema with different rules
|
1039
|
+
child_b = Verse::Schema.define(parent) do
|
1040
|
+
rule(:type, "must start with y") { |x| x[:type].to_s =~ /^y/ }
|
1041
|
+
field(:data, Hash) do
|
1042
|
+
field(:content, String)
|
1043
|
+
end
|
1044
|
+
end
|
1045
|
+
|
1046
|
+
# Validate using child_a schema
|
1047
|
+
result_a = child_a.validate({
|
1048
|
+
type: :xcoord,
|
1049
|
+
id: 1,
|
1050
|
+
data: {
|
1051
|
+
x: 10.5,
|
1052
|
+
y: 20.3
|
1053
|
+
}
|
1054
|
+
})
|
1055
|
+
|
1056
|
+
# Validate using child_b schema
|
1057
|
+
result_b = child_b.validate({
|
1058
|
+
type: :ydata,
|
1059
|
+
id: 2,
|
1060
|
+
data: {
|
1061
|
+
content: "Some content"
|
1062
|
+
}
|
1063
|
+
})
|
1064
|
+
|
1065
|
+
# Both validations succeed
|
1066
|
+
expect(result_a.success?).to be true
|
1067
|
+
expect(result_b.success?).to be true
|
1068
|
+
|
1069
|
+
# Invalid data for child_a
|
1070
|
+
invalid_a = child_a.validate({
|
1071
|
+
type: :ycoord, # Should start with 'x'
|
1072
|
+
id: 1,
|
1073
|
+
data: {
|
1074
|
+
x: 10.5,
|
1075
|
+
y: 20.3
|
1076
|
+
}
|
1077
|
+
})
|
1078
|
+
expect(invalid_a.success?).to be false
|
1079
|
+
expect(invalid_a.errors).to eq({ type: ["must start with x"] })
|
1080
|
+
end
|
1081
|
+
|
1082
|
+
```
|
1083
|
+
|
1084
|
+
```ruby
|
1085
|
+
it "tests inheritance relationships between schemas" do
|
1086
|
+
# Define a parent schema
|
1087
|
+
parent = Verse::Schema.define do
|
1088
|
+
field(:name, String)
|
1089
|
+
field(:age, Integer)
|
1090
|
+
end
|
1091
|
+
|
1092
|
+
# Define a child schema that inherits from the parent
|
1093
|
+
child = Verse::Schema.define(parent) do
|
1094
|
+
field(:email, String)
|
1095
|
+
end
|
1096
|
+
|
1097
|
+
# Define a schema with the same fields but not inherited
|
1098
|
+
similar = Verse::Schema.define do
|
1099
|
+
field(:name, String)
|
1100
|
+
field(:age, Integer)
|
1101
|
+
end
|
1102
|
+
|
1103
|
+
# Define a schema with different fields
|
1104
|
+
different = Verse::Schema.define do
|
1105
|
+
field(:title, String)
|
1106
|
+
field(:count, Integer)
|
1107
|
+
end
|
1108
|
+
|
1109
|
+
# Test inheritance relationships
|
1110
|
+
expect(child.inherit?(parent)).to be true # Child inherits from parent
|
1111
|
+
expect(child < parent).to be true # Using the < operator
|
1112
|
+
expect(child <= parent).to be true # Using the <= operator
|
1113
|
+
|
1114
|
+
expect(parent.inherit?(child)).to be false # Parent doesn't inherit from child
|
1115
|
+
expect(parent < child).to be false # Using the < operator
|
1116
|
+
expect(parent <= child).to be false # Using the <= operator
|
1117
|
+
|
1118
|
+
# Similar schema has the same fields as parent
|
1119
|
+
# In Verse::Schema, inheritance is structural, not nominal
|
1120
|
+
# So a schema with the same fields "inherits" from another schema
|
1121
|
+
expect(similar.inherit?(parent)).to be true # Similar structurally inherits from parent
|
1122
|
+
expect(similar < parent).to be true # Using the < operator
|
1123
|
+
expect(similar <= parent).to be true # Using the <= operator
|
1124
|
+
|
1125
|
+
expect(different.inherit?(parent)).to be false # Different doesn't inherit from parent
|
1126
|
+
expect(different < parent).to be false # Using the < operator
|
1127
|
+
expect(different <= parent).to be false # Using the <= operator
|
1128
|
+
|
1129
|
+
# Test self-comparison
|
1130
|
+
expect(parent <= parent).to be true # A schema is <= to itself
|
1131
|
+
expect(parent < parent).to be false # A schema is not < itself
|
1132
|
+
end
|
1133
|
+
|
1134
|
+
```
|
1135
|
+
|
1136
|
+
|
1137
|
+
### Schema Aggregation
|
1138
|
+
|
1139
|
+
|
1140
|
+
```ruby
|
1141
|
+
it "demonstrates combining schemas" do
|
1142
|
+
# It is sometime useful to combine two schemas into one.
|
1143
|
+
# This is done using the `+` operator.
|
1144
|
+
# The resulting schema will have all the fields of both schemas.
|
1145
|
+
# If the same field is defined in both schemas, the combination will
|
1146
|
+
# be performed at the field level, so the field type will be the union
|
1147
|
+
# of the two fields.
|
1148
|
+
# The rules and post-processing will be combined as well, in such order
|
1149
|
+
# that the first schema transforms will be applied first, and then the second one.
|
1150
|
+
|
1151
|
+
# Define two schemas to combine
|
1152
|
+
schema1 = Verse::Schema.define do
|
1153
|
+
field(:age, Integer).rule("must be major") { |age|
|
1154
|
+
age >= 18
|
1155
|
+
}
|
1156
|
+
end
|
1157
|
+
|
1158
|
+
schema2 = Verse::Schema.define do
|
1159
|
+
field(:content, [String, Hash])
|
1160
|
+
end
|
1161
|
+
|
1162
|
+
# Combine the schemas
|
1163
|
+
combined_schema = schema1 + schema2
|
1164
|
+
|
1165
|
+
# Validate using the combined schema
|
1166
|
+
result = combined_schema.validate({
|
1167
|
+
age: 25,
|
1168
|
+
content: "Some content"
|
1169
|
+
})
|
1170
|
+
|
1171
|
+
# The validation succeeds
|
1172
|
+
expect(result.success?).to be true
|
1173
|
+
|
1174
|
+
# Invalid data will still fail
|
1175
|
+
invalid_result = combined_schema.validate({
|
1176
|
+
age: 16, # Too young
|
1177
|
+
content: "Some content"
|
1178
|
+
})
|
1179
|
+
expect(invalid_result.success?).to be false
|
1180
|
+
expect(invalid_result.errors).to eq({ age: ["must be major"] })
|
1181
|
+
end
|
1182
|
+
|
1183
|
+
```
|
1184
|
+
|
1185
|
+
|
1186
|
+
### Field Inheritance
|
1187
|
+
|
1188
|
+
|
1189
|
+
```ruby
|
1190
|
+
it "tests inheritance relationships between fields" do
|
1191
|
+
# Create fields with different types
|
1192
|
+
string_field = Verse::Schema::Field.new(:name, String, {})
|
1193
|
+
integer_field = Verse::Schema::Field.new(:age, Integer, {})
|
1194
|
+
number_field = Verse::Schema::Field.new(:count, Numeric, {})
|
1195
|
+
|
1196
|
+
# Integer is a subclass of Numeric
|
1197
|
+
expect(integer_field.inherit?(number_field)).to be true
|
1198
|
+
expect(integer_field < number_field).to be true
|
1199
|
+
expect(integer_field <= number_field).to be true
|
1200
|
+
|
1201
|
+
# String is not a subclass of Numeric
|
1202
|
+
expect(string_field.inherit?(number_field)).to be false
|
1203
|
+
expect(string_field < number_field).to be false
|
1204
|
+
expect(string_field <= number_field).to be false
|
1205
|
+
|
1206
|
+
# Test with same type but different names
|
1207
|
+
name_field = Verse::Schema::Field.new(:name, String, {})
|
1208
|
+
title_field = Verse::Schema::Field.new(:title, String, {})
|
1209
|
+
|
1210
|
+
# Same type, different names - should still be comparable
|
1211
|
+
expect(name_field.inherit?(title_field)).to be true
|
1212
|
+
expect(name_field < title_field).to be true
|
1213
|
+
expect(name_field <= title_field).to be true
|
1214
|
+
|
1215
|
+
# Test with Hash fields and nested schemas
|
1216
|
+
person_schema = Verse::Schema.define do
|
1217
|
+
field(:name, String)
|
1218
|
+
field(:age, Integer)
|
1219
|
+
end
|
1220
|
+
|
1221
|
+
employee_schema = Verse::Schema.define do
|
1222
|
+
field(:name, String)
|
1223
|
+
field(:age, Integer)
|
1224
|
+
field(:salary, Float)
|
1225
|
+
end
|
1226
|
+
|
1227
|
+
person_field = Verse::Schema::Field.new(:person, person_schema, {})
|
1228
|
+
employee_field = Verse::Schema::Field.new(:employee, employee_schema, {})
|
1229
|
+
|
1230
|
+
# Test schema field inheritance
|
1231
|
+
# This might fail if the implementation is incorrect
|
1232
|
+
begin
|
1233
|
+
result = employee_field.inherit?(person_field)
|
1234
|
+
expect([true, false]).to include(result)
|
1235
|
+
rescue NotImplementedError => e
|
1236
|
+
# If it raises NotImplementedError, that's also valuable information
|
1237
|
+
puts "NotImplementedError raised: #{e.message}"
|
1238
|
+
end
|
1239
|
+
end
|
1240
|
+
|
1241
|
+
```
|
1242
|
+
|
1243
|
+
|
1244
|
+
|
1245
|
+
## Under the hood
|
1246
|
+
|
1247
|
+
|
1248
|
+
### Add Custom coalescing rules
|
1249
|
+
|
1250
|
+
|
1251
|
+
|
1252
|
+
### Reflecting on the schema
|
1253
|
+
|
1254
|
+
|
1255
|
+
```ruby
|
1256
|
+
it "demonstrates schema reflection" do
|
1257
|
+
# It exists 4 schema class type:
|
1258
|
+
# 1. Verse::Schema::Struct
|
1259
|
+
# the default schema type, with field definition
|
1260
|
+
# 2. Verse::Schema::Array
|
1261
|
+
# a schema that contains an array of items
|
1262
|
+
# `values` attribute being an array of type
|
1263
|
+
# 3. Verse::Schema::Dictionary
|
1264
|
+
# a schema that contains a dictionary of items
|
1265
|
+
# `values` attribute being an array of type
|
1266
|
+
# 4. Verse::Schema::Selector
|
1267
|
+
# a schema that contains a selector of items
|
1268
|
+
# `values` attribute being a selection hash
|
1269
|
+
complex_schema_example = Verse::Schema.define do
|
1270
|
+
field(:name, String).meta(label: "Name", description: "The name of the person")
|
1271
|
+
|
1272
|
+
field(:data) do
|
1273
|
+
field(:content, String).filled
|
1274
|
+
end
|
1275
|
+
|
1276
|
+
field(:dictionary, Verse::Schema.dictionary(String))
|
1277
|
+
field(:array, Array) do
|
1278
|
+
field(:item, [String, Integer])
|
1279
|
+
end
|
1280
|
+
end
|
1281
|
+
|
1282
|
+
# Inspect is a good way to see the schema definition
|
1283
|
+
puts complex_schema_example.inspect
|
1284
|
+
# => #<struct{
|
1285
|
+
# name: String,
|
1286
|
+
# data: #<struct{content: String} 0x1400>,
|
1287
|
+
# dictionary: #<dictionary<String> 0x1414>,
|
1288
|
+
# array: #<collection<#<struct{item: String|Integer} 0x1428>> 0x143c>
|
1289
|
+
# } 0x1450>
|
1290
|
+
|
1291
|
+
# You can reflect on the schema to get information about its fields:
|
1292
|
+
expect(complex_schema_example.extra_fields?).to be false
|
1293
|
+
|
1294
|
+
complex_schema_example.fields.each do |field|
|
1295
|
+
puts "Field name: #{field.name}"
|
1296
|
+
puts "Field type: #{field.type}"
|
1297
|
+
puts "Field metadata: #{field.meta}"
|
1298
|
+
|
1299
|
+
puts "Is required: #{field.required?}"
|
1300
|
+
end
|
1301
|
+
|
1302
|
+
# Of course, you can also traverse the schema tree to get information about nested fields:
|
1303
|
+
arr_value = complex_schema_example.fields.find{ |field| field.name == :array }.type.values
|
1304
|
+
puts arr_value[0].fields.map(&:name) # => [:item]
|
1305
|
+
end
|
1306
|
+
|
1307
|
+
```
|
1308
|
+
|
1309
|
+
|
1310
|
+
### Field Extensions
|
1311
|
+
|
1312
|
+
|
1313
|
+
|
1314
|
+
|
1315
|
+
## Data classes
|
1316
|
+
|
1317
|
+
|
1318
|
+
### Using Data Classes
|
1319
|
+
|
1320
|
+
|
1321
|
+
```ruby
|
1322
|
+
it "demonstrates nested data classes" do
|
1323
|
+
# Data classes allow you to create structured data objects from schemas.
|
1324
|
+
# This can be very useful to avoid hash nested key access
|
1325
|
+
# which tends to make your code less readable.
|
1326
|
+
#
|
1327
|
+
# Under the hood, dataclass will take your schema, duplicate it
|
1328
|
+
# and for each field with nested Verse::Schema::Base, it will
|
1329
|
+
# add a transformer to convert the value to the dataclass of the schema.
|
1330
|
+
|
1331
|
+
# Data class will automatically use dataclass of other nested schemas.
|
1332
|
+
# Define a schema for an address
|
1333
|
+
address_schema = Verse::Schema.define do
|
1334
|
+
field(:street, String)
|
1335
|
+
field(:city, String)
|
1336
|
+
field(:zip, String)
|
1337
|
+
end
|
1338
|
+
|
1339
|
+
# Create a data class for address
|
1340
|
+
Address = address_schema.dataclass
|
1341
|
+
|
1342
|
+
# Define a schema for a person with a nested address
|
1343
|
+
person_schema = Verse::Schema.define do
|
1344
|
+
field(:name, String)
|
1345
|
+
field(:address, address_schema)
|
1346
|
+
end
|
1347
|
+
|
1348
|
+
# Create a data class for person
|
1349
|
+
Person = person_schema.dataclass
|
1350
|
+
|
1351
|
+
# Create a person with a nested address
|
1352
|
+
person = Person.new({
|
1353
|
+
name: "John Doe",
|
1354
|
+
address: {
|
1355
|
+
street: "123 Main St",
|
1356
|
+
city: "Anytown",
|
1357
|
+
zip: "12345"
|
1358
|
+
}
|
1359
|
+
})
|
1360
|
+
|
1361
|
+
# The nested address is also a data class
|
1362
|
+
expect(person.address).to be_a(Address)
|
1363
|
+
expect(person.address.street).to eq("123 Main St")
|
1364
|
+
expect(person.address.city).to eq("Anytown")
|
1365
|
+
expect(person.address.zip).to eq("12345")
|
1366
|
+
|
1367
|
+
# In case you find some weird behavior, you can always check
|
1368
|
+
# the schema of the dataclass.
|
1369
|
+
# The dataclass schema used to generate the dataclass
|
1370
|
+
# can be found in the class itself:
|
1371
|
+
expect(Person.schema).to be_a(Verse::Schema::Struct)
|
1372
|
+
end
|
1373
|
+
|
1374
|
+
```
|
1375
|
+
|
1376
|
+
```ruby
|
1377
|
+
it "demonstrates recursive data classes" do
|
1378
|
+
# Define a schema for a tree node
|
1379
|
+
tree_node_schema = Verse::Schema.define do
|
1380
|
+
field(:value, String)
|
1381
|
+
field(:children, Array, of: self).default([])
|
1382
|
+
end
|
1383
|
+
|
1384
|
+
# Create a data class for the tree node
|
1385
|
+
TreeNode = tree_node_schema.dataclass
|
1386
|
+
|
1387
|
+
# Create a tree structure
|
1388
|
+
root = TreeNode.new({
|
1389
|
+
value: "Root",
|
1390
|
+
children: [
|
1391
|
+
{ value: "Child 1" },
|
1392
|
+
{ value: "Child 2" }
|
1393
|
+
]
|
1394
|
+
})
|
1395
|
+
|
1396
|
+
# Access the tree structure
|
1397
|
+
expect(root.value).to eq("Root")
|
1398
|
+
expect(root.children.map(&:value)).to eq(["Child 1", "Child 2"])
|
1399
|
+
expect(root.children[0].children).to be_empty
|
1400
|
+
end
|
1401
|
+
|
1402
|
+
```
|
1403
|
+
|
1404
|
+
```ruby
|
1405
|
+
it "works with dictionary, array, scalar and selector too" do
|
1406
|
+
schema = Verse::Schema.define do
|
1407
|
+
field(:name, String)
|
1408
|
+
field(:type, Symbol).in?(%i[student teacher])
|
1409
|
+
|
1410
|
+
teacher_data = define do
|
1411
|
+
field(:subject, String)
|
1412
|
+
field(:years_of_experience, Integer)
|
1413
|
+
end
|
1414
|
+
|
1415
|
+
student_data = define do
|
1416
|
+
field(:grade, Integer)
|
1417
|
+
field(:school, String)
|
1418
|
+
end
|
1419
|
+
|
1420
|
+
# Selector
|
1421
|
+
field(:data, { student: student_data, teacher: teacher_data }, over: :type)
|
1422
|
+
|
1423
|
+
# Array of Scalar
|
1424
|
+
comment_schema = define do
|
1425
|
+
field(:text, String)
|
1426
|
+
field(:created_at, Time)
|
1427
|
+
end
|
1428
|
+
|
1429
|
+
# Verbose but to test everything.
|
1430
|
+
field(:comment, Verse::Schema.array(
|
1431
|
+
Verse::Schema.scalar(String, comment_schema)
|
1432
|
+
))
|
1433
|
+
|
1434
|
+
score_schema = define do
|
1435
|
+
field(:a, Integer)
|
1436
|
+
field(:b, Integer)
|
1437
|
+
end
|
1438
|
+
|
1439
|
+
# Dictionary
|
1440
|
+
field(:scores, Hash, of: score_schema)
|
1441
|
+
end
|
1442
|
+
|
1443
|
+
# Get the dataclass:
|
1444
|
+
Person = schema.dataclass
|
1445
|
+
|
1446
|
+
# Create a valid instance
|
1447
|
+
person = Person.new({
|
1448
|
+
name: "John Doe",
|
1449
|
+
type: :student,
|
1450
|
+
data: {
|
1451
|
+
grade: 10,
|
1452
|
+
school: "High School"
|
1453
|
+
},
|
1454
|
+
comment: [
|
1455
|
+
{ text: "Great job!", created_at: "2023-01-01T12:00:00Z" },
|
1456
|
+
"This is a comment"
|
1457
|
+
],
|
1458
|
+
scores: {
|
1459
|
+
math: { a: 90.5, b: 95 },
|
1460
|
+
science: { a: 85, b: 88 }
|
1461
|
+
}
|
1462
|
+
})
|
1463
|
+
|
1464
|
+
expect(person.data.grade).to eq(10)
|
1465
|
+
expect(person.data.school).to eq("High School")
|
1466
|
+
expect(person.comment[0].text).to eq("Great job!")
|
1467
|
+
expect(person.comment[0].created_at).to be_a(Time)
|
1468
|
+
expect(person.comment[1]).to eq("This is a comment")
|
1469
|
+
expect(person.scores[:math].a).to eq(90)
|
1470
|
+
|
1471
|
+
# Invalid schema
|
1472
|
+
|
1473
|
+
expect {
|
1474
|
+
Person.new({
|
1475
|
+
name: "Invalid Person",
|
1476
|
+
type: :student,
|
1477
|
+
data: {
|
1478
|
+
subject: "Math", # Invalid field for student
|
1479
|
+
years_of_experience: 5 # Invalid field for student
|
1480
|
+
},
|
1481
|
+
comment: [
|
1482
|
+
{ text: "Great job!", created_at: "2023-01-01T12:00:00Z" },
|
1483
|
+
"This is a comment"
|
1484
|
+
],
|
1485
|
+
scores: {
|
1486
|
+
math: { a: 90.5, b: 95 },
|
1487
|
+
science: { a: 85, b: 88 }
|
1488
|
+
}
|
1489
|
+
})
|
1490
|
+
}.to raise_error(Verse::Schema::InvalidSchemaError).with_message(
|
1491
|
+
"Invalid schema:\n" \
|
1492
|
+
"data.grade: [\"is required\"]\n" \
|
1493
|
+
"data.school: [\"is required\"]"
|
1494
|
+
)
|
1495
|
+
end
|
1496
|
+
|
1497
|
+
```
|
1498
|
+
|
1499
|
+
|
1500
|
+
|
1501
|
+
## Verse::Schema Documentation
|
1502
|
+
|
1503
|
+
|
1504
|
+
### Complex Example
|
1505
|
+
|
1506
|
+
|
1507
|
+
```ruby
|
1508
|
+
it "demonstrates a comprehensive example" do
|
1509
|
+
# Define a complex schema that combines multiple features
|
1510
|
+
schema = Verse::Schema.define do
|
1511
|
+
# Define nested schemas
|
1512
|
+
facebook_event = define do
|
1513
|
+
field(:url, String)
|
1514
|
+
extra_fields # Allow additional fields
|
1515
|
+
end
|
1516
|
+
|
1517
|
+
google_event = define do
|
1518
|
+
field(:search, String)
|
1519
|
+
extra_fields # Allow additional fields
|
1520
|
+
end
|
1521
|
+
|
1522
|
+
# Define an event schema that uses the nested schemas
|
1523
|
+
event = define do
|
1524
|
+
field(:at, Time)
|
1525
|
+
field(:type, Symbol).in?(%i[created updated])
|
1526
|
+
field(:provider, String).in?(%w[facebook google])
|
1527
|
+
field(:data, [facebook_event, google_event]) # Union type
|
1528
|
+
field(:source, String)
|
1529
|
+
|
1530
|
+
# Conditional validation based on provider
|
1531
|
+
rule(:data, "invalid event data structure") do |hash|
|
1532
|
+
case hash[:provider]
|
1533
|
+
when "facebook"
|
1534
|
+
facebook_event.valid?(hash[:data])
|
1535
|
+
when "google"
|
1536
|
+
google_event.valid?(hash[:data])
|
1537
|
+
else
|
1538
|
+
false
|
1539
|
+
end
|
1540
|
+
end
|
1541
|
+
end
|
1542
|
+
|
1543
|
+
# The main schema field is an array of events
|
1544
|
+
field(:events, Array, of: event)
|
1545
|
+
end
|
1546
|
+
|
1547
|
+
# Create a complex data structure to validate
|
1548
|
+
data = {
|
1549
|
+
events: [
|
1550
|
+
{
|
1551
|
+
at: "2023-01-01T12:00:00Z",
|
1552
|
+
type: :created,
|
1553
|
+
provider: "facebook",
|
1554
|
+
data: {
|
1555
|
+
url: "https://facebook.com/event/123",
|
1556
|
+
title: "Facebook Event" # Extra field
|
1557
|
+
},
|
1558
|
+
source: "api"
|
1559
|
+
},
|
1560
|
+
{
|
1561
|
+
at: "2023-01-02T14:30:00Z",
|
1562
|
+
type: :updated,
|
1563
|
+
provider: "google",
|
1564
|
+
data: {
|
1565
|
+
search: "conference 2023",
|
1566
|
+
location: "New York" # Extra field
|
1567
|
+
},
|
1568
|
+
source: "webhook"
|
1569
|
+
}
|
1570
|
+
]
|
1571
|
+
}
|
1572
|
+
|
1573
|
+
# Validate the complex data
|
1574
|
+
result = schema.validate(data)
|
1575
|
+
|
1576
|
+
# The validation succeeds
|
1577
|
+
expect(result.success?).to be true
|
1578
|
+
|
1579
|
+
# The output maintains the structure with coerced values
|
1580
|
+
expect(result.value[:events][0][:at]).to be_a(Time)
|
1581
|
+
expect(result.value[:events][1][:at]).to be_a(Time)
|
1582
|
+
end
|
1583
|
+
|
1584
|
+
```
|
1585
|
+
|
1586
|
+
|
1587
|
+
|
1588
|
+
|
1589
|
+
## Contributing
|
1590
|
+
|
1591
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/verse-rb/verse-schema.
|