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.
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.