dynamicschema 2.0.0 → 2.2.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 CHANGED
@@ -1,26 +1,21 @@
1
1
  # DynamicSchema
2
2
 
3
- The **DynamicSchema** gem provides an elegant and expressive way to define domain-specific
4
- language (DSL) schemas, making it effortless to build and validate complex Ruby `Hash`
5
- constructs.
3
+ The **DynamicSchema** gem provides an elegant and expressive way to define domain-specific language (DSL) schemas, making it effortless to build and validate complex Ruby `Hash` constructs.
6
4
 
7
- This is particularly useful when dealing with intricate configuration or interfacing with
8
- external APIs, where data structures need to adhere to specific formats and validations.
9
- By allowing default values, type constraints, nested schemas, and transformations,
10
- DynamicSchema ensures that your data structures are both robust and flexible.
5
+ This is particularly useful when dealing with intricate configuration or interfacing with external APIs, where data structures need to adhere to specific formats and validations. By allowing default values, type constraints, nested schemas, and transformations, DynamicSchema ensures that your data structures are both robust and flexible.
11
6
 
12
7
  You can trivially define a custom schema:
13
8
 
14
9
  ```ruby
15
- openai_request_schema = DynamicSchema.define do
10
+ openai_request_schema = DynamicSchema.define do
16
11
  model String, default: 'gpt-4o'
17
12
  max_tokens Integer, default: 1024
18
13
  temperature Float, in: 0..1
19
14
 
20
- message arguments: [ :role ], as: :messages, array: true do
15
+ message arguments: [ :role ], as: :messages, array: true do
21
16
  role Symbol, in: [ :system, :user, :assistant ]
22
- content array: true do
23
- type Symbol, default: :text
17
+ content array: true do
18
+ type Symbol, default: :text
24
19
  text String
25
20
  end
26
21
  end
@@ -30,10 +25,10 @@ end
30
25
  And then repeatedly use that schema to elegantly build a schema-conformant `Hash`:
31
26
  ```ruby
32
27
  request = openai_request_schema.build {
33
- message :system do
28
+ message :system do
34
29
  content text: "You are a helpful assistant that talks like a pirate."
35
30
  end
36
- message :user do
31
+ message :user do
37
32
  content text: ARGV[0] || "say hello!"
38
33
  end
39
34
  }
@@ -50,6 +45,9 @@ You can find a full OpenAI request example in the `/examples` folder of this rep
50
45
  - [Values](#values)
51
46
  - [Objects](#objects)
52
47
  - [Types](#types)
48
+ - [Custom Types](#custom-types)
49
+ - [Multiple Types](#multiple-types)
50
+ - [Multiple Types with Nested Schema](#multiple-types-with-nested-schema)
53
51
  - [Options](#options)
54
52
  - [default Option](#default-option)
55
53
  - [required Option](#required-option)
@@ -57,12 +55,13 @@ You can find a full OpenAI request example in the `/examples` folder of this rep
57
55
  - [as Option](#as-option)
58
56
  - [in Option (Values Only)](#in-option)
59
57
  - [arguments Option](#arguments-option)
58
+ - [normalize Option](#normalize-option)
60
59
  - [Class Schema](#class-schemas)
61
- - [Definable](#definable)
62
- - [Buildable](#buildable)
60
+ - [Definable](#definable)
61
+ - [Buildable](#buildable)
63
62
  - [Struct](#struct)
64
63
  - [Validation Methods](#validation-methods)
65
- - [Validation Rules](#validation-rules)
64
+ - [Validation Rules](#validation-rules)
66
65
  - [validate!](#validate)
67
66
  - [validate](#validate-1)
68
67
  - [valid?](#valid)
@@ -104,12 +103,11 @@ require 'dynamic_schema'
104
103
 
105
104
  ### Defining Schemas with **DynamicSchema**
106
105
 
107
- DynamicSchema lets you define a DSL made of values, objects, and options, then reuse that DSL to
108
- build and validate Ruby Hashes.
106
+ DynamicSchema lets you define a DSL made of values, objects, and options, then reuse that DSL to build and validate Ruby Hashes.
109
107
 
110
- You start by constructing a `DynamicSchema::Builder`. You can do this by calling:
111
- - `DynamicSchema.define { … }`
112
- - `DynamicSchema::Builder.new.define { … }`
108
+ You start by constructing a `DynamicSchema::Builder`. You can do this by calling:
109
+ - `DynamicSchema.define { … }`
110
+ - `DynamicSchema::Builder.new.define { … }`
113
111
 
114
112
  In both cases, you pass a block that declares the schema values and objects with their options.
115
113
 
@@ -153,9 +151,7 @@ valid = schema.valid?( { api_key: 'secret', chat_options: { temperature: 0.7 }
153
151
 
154
152
  #### Inheritance
155
153
 
156
- You can extend an existing schema using the `inherit:` option. Pass a Proc that describes
157
- the parent schema—typically from a class that includes `DynamicSchema::Definable` via
158
- its `schema` method.
154
+ You can extend an existing schema using the `inherit:` option. Pass a Proc that describes the parent schema—typically from a class that includes `DynamicSchema::Definable` via its `schema` method.
159
155
 
160
156
  ```ruby
161
157
  class BaseSettings
@@ -182,13 +178,9 @@ You can call `build`, `build!`, `validate`, `validate!`, and `valid?` on the bui
182
178
 
183
179
  ## Struct
184
180
 
185
- In addition to building plain Ruby `Hash` values, DynamicSchema can generate lightweight
186
- Ruby classes from a schema. A `DynamicSchema::Struct` exposes readers and writers for the
187
- fields you define, and transparently wraps nested objects so that you can access them with
188
- dot-style accessors rather than deep hash indexing.
181
+ In addition to building plain Ruby `Hash` values, DynamicSchema can generate lightweight Ruby classes from a schema. A `DynamicSchema::Struct` exposes readers and writers for the fields you define, and transparently wraps nested objects so that you can access them with dot-style accessors rather than deep hash indexing.
189
182
 
190
- You create a struct class by passing the same schema shape you would give to a Builder. The
191
- schema can be provided as:
183
+ You create a struct class by passing the same schema shape you would give to a Builder. The schema can be provided as:
192
184
 
193
185
  - a `Proc` that defines the schema
194
186
  - a `DynamicSchema::Builder`
@@ -253,18 +245,15 @@ collection.line_items[ 1 ].quantity # => 2
253
245
  - defining
254
246
  - `DynamicSchema::Struct.define` takes a block that looks exactly like a Builder schema.
255
247
  - Use `array: true` to expose arrays of nested structs.
256
- - You may reference another struct class as a value type; arrays of that type expose
257
- nested accessors for each element.
248
+ - You may reference another struct class as a value type; arrays of that type expose nested accessors for each element.
258
249
  - building
259
- - `StructClass.build( attributes )` constructs an instance and (optionally) coerces typed
260
- scalar fields using the same converters as the Builder.
250
+ - `StructClass.build( attributes )` constructs an instance and (optionally) coerces typed scalar fields using the same converters as the Builder.
261
251
  - `StructClass.build!` additionally validates the instance just like `builder.build!`.
262
252
  - accessing
263
253
  - Use standard Ruby readers/writers: `instance.attribute`, `instance.attribute = value`.
264
254
  - `#to_h` returns a deep Hash of the current values (nested structs become hashes).
265
255
 
266
- You can also create a struct class from a builder or a compiled hash if you already have a
267
- schema elsewhere:
256
+ You can also create a struct class from a builder or a compiled hash if you already have a schema elsewhere:
268
257
 
269
258
  ```ruby
270
259
  builder = DynamicSchema.define do
@@ -282,16 +271,13 @@ NameStruct.build( name: 'Taylor' ).name # => 'Taylor'
282
271
 
283
272
  ---
284
273
 
285
- ## Values
274
+ ## Values
286
275
 
287
- A *value* is a basic building block of your schema. Values represent individual settings,
288
- options or API parameters that you can define with specific types, defaults, and other options.
276
+ A *value* is a basic building block of your schema. Values represent individual settings, options or API parameters that you can define with specific types, defaults, and other options.
289
277
 
290
- When defining a value, you provide the name as though you were calling a Ruby method, with
291
- arguments that include an optional type (which can be a `Class`, `Module` or an `Array` of these)
292
- as well as a `Hash` of options, all of which are optional:
278
+ When defining a value, you provide the name as though you were calling a Ruby method, with arguments that include an optional type (which can be a `Class`, `Module` or an `Array` of these) as well as a `Hash` of options, all of which are optional:
293
279
 
294
- `name {type} default: {true|false}, required: {true|false}, array: {true|false}, as: {name}, in: {Array|Range}`
280
+ `name {type}, default: {value}, required: {true|false}, array: {true|false}, as: {name}, in: {Array|Range}`
295
281
 
296
282
  #### example:
297
283
 
@@ -301,7 +287,7 @@ require 'dynamic_schema'
301
287
  # define a schema structure with values
302
288
  schema = DynamicSchema.define do
303
289
  api_key
304
- version, String, default: '1.0'
290
+ version String, default: '1.0'
305
291
  end
306
292
 
307
293
  # build the schema and set values
@@ -316,30 +302,26 @@ puts result[:version] # => "1.0"
316
302
 
317
303
  - defining
318
304
  - `api_key` defines a value named `api_key`. Any type can be used to assign the value.
319
- - `version, String, default: '1.0'` defines a value with a default.
320
- - building
305
+ - `version String, default: '1.0'` defines a value with a default.
306
+ - building
321
307
  - `schema.build!` accepts both a Hash and a block where you can set the values.
322
308
  - Inside the block, `api_key 'your-api-key'` sets the value of `api_key`.
323
- - accessing
309
+ - accessing
324
310
  - `result[:api_key]` retrieves the value of `api_key`.
325
- - If a value has a default and you don't set it, the default value will be included in
326
- resulting hash.
311
+ - If a value has a default and you don't set it, the default value will be included in resulting hash.
327
312
 
328
313
  ---
329
314
 
330
315
  ## Objects
331
316
 
332
- A schema may be organized hierarchically, by creating collections of related values and
333
- even other collections. These collections are called objects.
317
+ A schema may be organized hierarchically, by creating collections of related values and even other collections. These collections are called objects.
334
318
 
335
- An *object* is defined in a similar manner to a value. Simply provide the name as though
336
- calling a Ruby method, with a Hash of options and a block which encloses the child values
337
- and objects:
319
+ An *object* is defined in a similar manner to a value. Simply provide the name as though calling a Ruby method, with a Hash of options and a block which encloses the child values and objects:
338
320
 
339
321
  ```
340
- name arguments: [ {argument} ], default: {true|false}, required: {true|false}, array: {true|false}, as: {name} do
322
+ name arguments: [ {argument} ], default: {value}, required: {true|false}, array: {true|false}, as: {name} do
341
323
  # child values and objects can be defined here
342
- end
324
+ end
343
325
  ```
344
326
 
345
327
  Notice an *object* does not accept a type as it is always of type `Object`.
@@ -350,7 +332,7 @@ Notice an *object* does not accept a type as it is always of type `Object`.
350
332
  require 'dynamic_schema'
351
333
 
352
334
  schema = DynamicSchema.define do
353
- api_key, String
335
+ api_key String
354
336
  chat_options do
355
337
  model String, default: 'claude-3'
356
338
  max_tokens Integer, default: 1024
@@ -377,7 +359,7 @@ puts result[:chat_options][:stream] # => true
377
359
  - defining
378
360
  - `chat_options do ... end` defines an object named `chat_options`.
379
361
  - Inside the object you can define values that belong to that object.
380
- - building
362
+ - building
381
363
  - In the build block, you can set values for values within objects by nesting blocks.
382
364
  - `chat_options do ... end` allows you to set values inside the `chat_options` object.
383
365
  - accessing
@@ -385,11 +367,9 @@ puts result[:chat_options][:stream] # => true
385
367
 
386
368
  ---
387
369
 
388
- ## Types
370
+ ## Types
389
371
 
390
- An *object* is always of type `Object`. A *value* can have no type or it can be of one or
391
- more types. You specify the value type by providing an instance of a `Class` when defining
392
- the value. If you want to specify multiple types simply provide an array of types.
372
+ An *object* is always of type `Object`. A *value* can have no type or it can be of one or more types. You specify the value type by providing an instance of a `Class` when defining the value. If you want to specify multiple types simply provide an array of types.
393
373
 
394
374
  #### example:
395
375
 
@@ -398,37 +378,209 @@ require 'dynamic_schema'
398
378
 
399
379
  schema = DynamicSchema.define do
400
380
  typeless_value
401
- symbol_value Symbol
381
+ symbol_value Symbol
402
382
  boolean_value [ TrueClass, FalseClass ]
403
383
  end
404
384
 
405
385
  result = schema.build! do
406
386
  typeless_value Struct.new(:name).new(name: 'Kristoph')
407
387
  symbol_value "something"
408
- boolean_value true
409
- end
388
+ boolean_value true
389
+ end
410
390
 
411
391
  puts result[:typeless_value].name # => "Kristoph"
412
392
  puts result[:symbol_value] # => :something
413
- puts result[:boolean_value] # => true
393
+ puts result[:boolean_value] # => true
414
394
  ```
415
395
 
416
396
  - defining
417
- - `typeless_value` defines a value that has no type and will accept an assignment of any type
418
- - `symbol_value` defines a value that accepts symbols or types that can be coerced into
419
- symbols, such as strings (see **Type Coercion**)
397
+ - `typeless_value` defines a value that has no type and will accept an assignment of any type
398
+ - `symbol_value` defines a value that accepts symbols or types that can be coerced into symbols, such as strings (see **Type Coercion**)
420
399
  - `boolean_value` defines a value that can be either `true` or `false`
421
400
 
422
- ## Options
401
+ ### Custom Types
402
+
403
+ You can use any Ruby class as a value type, not just the built-in types. When a custom class is specified as the type, DynamicSchema will validate that values are instances of that class. You can also configure custom class instances using blocks:
404
+
405
+ ```ruby
406
+ require 'dynamic_schema'
407
+
408
+ class Customer
409
+ attr_accessor :name, :email
410
+ end
411
+
412
+ schema = DynamicSchema.define do
413
+ customer Customer
414
+ end
415
+
416
+ # auto-instantiate and configure with a block
417
+ result = schema.build! do
418
+ customer do
419
+ name 'Alice'
420
+ email 'alice@example.com'
421
+ end
422
+ end
423
+
424
+ result[:customer].name # => 'Alice'
425
+ result[:customer].email # => 'alice@example.com'
426
+
427
+ # or provide an existing instance
428
+ existing = Customer.new
429
+ existing.name = 'Bob'
430
+
431
+ result = schema.build! do
432
+ customer existing do
433
+ email 'bob@example.com'
434
+ end
435
+ end
436
+
437
+ result[:customer].name # => 'Bob'
438
+ result[:customer].email # => 'bob@example.com'
439
+ ```
440
+
441
+ When using a block with a custom type:
442
+ - If no instance is provided, DynamicSchema will call `YourClass.new` to create one
443
+ - Inside the block, method calls are translated to setter calls on the instance
444
+ - You can provide an existing instance and still use a block to configure it further
445
+
446
+ This is particularly useful when integrating with `DynamicSchema::Struct` or other custom classes that need to be configured within a schema.
447
+
448
+ ### Multiple Types
449
+
450
+ You can specify multiple types for a value by providing an array of types. The value must match one of the listed types.
451
+
452
+ ```ruby
453
+ schema = DynamicSchema.define do
454
+ enabled [ TrueClass, FalseClass ]
455
+ identifier [ String, Integer ]
456
+ end
457
+
458
+ result = schema.build! do
459
+ enabled true
460
+ identifier 12345
461
+ end
462
+
463
+ result[ :enabled ] # => true
464
+ result[ :identifier ] # => 12345
465
+ ```
466
+
467
+ Validation ensures the value matches one of the specified types:
423
468
 
424
- Both *values* and *objects* can be customized through *options*. The options for both values and
425
- objects include `default`, `required`, `as` and `array`. In addition values support the `in`
426
- criteria option while objects support the `arguments` option.
469
+ ```ruby
470
+ schema.valid?( { enabled: true } ) # => true
471
+ schema.valid?( { enabled: 'yes' } ) # => false (string not in types)
472
+ schema.valid?( { identifier: 'abc' } ) # => true
473
+ schema.valid?( { identifier: 123 } ) # => true
474
+ ```
475
+
476
+ ### Multiple Types with Nested Schema
477
+
478
+ When you combine multiple types with a block, you create a field that can be either a nested object (defined by the block) or one of the scalar types. The decision is made at runtime:
479
+
480
+ - **Hash values** (or blocks in Builder) are processed using the nested schema
481
+ - **Non-hash values** are validated against the scalar types in the array
482
+
483
+ This is useful for APIs where a field might be either a structured object or a simple value like a boolean.
484
+
485
+ #### Builder Example
486
+
487
+ ```ruby
488
+ schema = DynamicSchema.define do
489
+ # 'data' can be either a nested object OR true/false
490
+ data [ Object, TrueClass, FalseClass ] do
491
+ name String
492
+ value Integer
493
+ end
494
+ end
495
+
496
+ # Using as a nested object (with block)
497
+ result = schema.build! do
498
+ data do
499
+ name 'example'
500
+ value 42
501
+ end
502
+ end
503
+ result[ :data ][ :name ] # => 'example'
504
+
505
+ # Using as a nested object (with hash)
506
+ result = schema.build! do
507
+ data( { name: 'from hash', value: 100 } )
508
+ end
509
+ result[ :data ][ :name ] # => 'from hash'
510
+
511
+ # Using as a boolean
512
+ result = schema.build! do
513
+ data true
514
+ end
515
+ result[ :data ] # => true
516
+ ```
517
+
518
+ #### Struct Example
519
+
520
+ ```ruby
521
+ Settings = DynamicSchema::Struct.define do
522
+ config [ Object, TrueClass, FalseClass ] do
523
+ host String
524
+ port Integer
525
+ end
526
+ end
527
+
528
+ # With nested object - accessed via dot notation
529
+ settings = Settings.build( config: { host: 'localhost', port: 8080 } )
530
+ settings.config.host # => 'localhost'
531
+ settings.config.port # => 8080
532
+
533
+ # With boolean - returned as-is
534
+ settings = Settings.build( config: false )
535
+ settings.config # => false
536
+ ```
537
+
538
+ #### Arrays with Multiple Types
427
539
 
428
- ### :default Option
540
+ Combine with `array: true` to create arrays where each element can be either a nested object or a scalar:
541
+
542
+ ```ruby
543
+ schema = DynamicSchema.define do
544
+ items [ Object, TrueClass, FalseClass ], array: true do
545
+ name String
546
+ end
547
+ end
548
+
549
+ result = schema.build! do
550
+ items( { name: 'first' } )
551
+ items true
552
+ items( { name: 'second' } )
553
+ items false
554
+ end
555
+
556
+ result[ :items ] # => [ { name: 'first' }, true, { name: 'second' }, false ]
557
+ ```
429
558
 
430
- The `:default` option allows you to specify a default value that will be used if no value is
431
- provided during build.
559
+ #### Validation
560
+
561
+ Validation checks that hash values conform to the nested schema and non-hash values match one of the scalar types:
562
+
563
+ ```ruby
564
+ schema = DynamicSchema.define do
565
+ data [ Object, TrueClass, FalseClass ] do
566
+ value Integer
567
+ end
568
+ end
569
+
570
+ schema.valid?( { data: { value: 42 } } ) # => true (valid nested object)
571
+ schema.valid?( { data: true } ) # => true (valid boolean)
572
+ schema.valid?( { data: 'invalid' } ) # => false (string not in types)
573
+ ```
574
+
575
+ ---
576
+
577
+ ## Options
578
+
579
+ Both *values* and *objects* can be customized through *options*. The options for both values and objects include `default`, `required`, `as` and `array`. In addition values support the `in` criteria option while objects support the `arguments` option.
580
+
581
+ ### :default Option
582
+
583
+ The `:default` option allows you to specify a default value that will be used if no value is provided during build.
432
584
 
433
585
  #### example:
434
586
 
@@ -445,9 +597,7 @@ puts result[:timeout] # => 30
445
597
 
446
598
  ### :required Option
447
599
 
448
- The `:required` option ensures that a value must be provided when building the schema. If a
449
- required value is missing when using `build!`, `validate`, or `validate!`,
450
- a `DynamicSchema::RequiredOptionError` will be raised.
600
+ The `:required` option ensures that a value must be provided when building the schema. If a required value is missing when using `build!`, `validate`, or `validate!`, a `DynamicSchema::RequiredOptionError` will be raised.
451
601
 
452
602
  #### example:
453
603
 
@@ -468,9 +618,7 @@ end
468
618
 
469
619
  ### :array Option
470
620
 
471
- The `:array` option wraps the value or object in an array in the resulting Hash, even if only
472
- one value is provided. This is particularly useful when dealing with APIs that expect array
473
- inputs.
621
+ The `:array` option wraps the value or object in an array in the resulting Hash, even if only one value is provided. This is particularly useful when dealing with APIs that expect array inputs.
474
622
 
475
623
  #### example:
476
624
 
@@ -496,9 +644,7 @@ puts result[:message] # => [{ text: "Hello world", type: "plain" }]
496
644
 
497
645
  ### :as Option
498
646
 
499
- The `:as` option allows you to use a different name in the DSL than what appears in the final
500
- Hash. This is particularly useful when interfacing with APIs that have specific key
501
- requirements.
647
+ The `:as` option allows you to use a different name in the DSL than what appears in the final Hash. This is particularly useful when interfacing with APIs that have specific key requirements.
502
648
 
503
649
  #### example:
504
650
 
@@ -518,8 +664,7 @@ puts result["Authorization"] # => "Bearer abc123"
518
664
 
519
665
  ### :in Option
520
666
 
521
- The `:in` option provides validation for values, ensuring they fall within a specified Range or
522
- are included in an Array of allowed values. This option is only available for values.
667
+ The `:in` option provides validation for values, ensuring they fall within a specified Range or are included in an Array of allowed values. This option is only available for values.
523
668
 
524
669
  #### example:
525
670
 
@@ -550,12 +695,9 @@ end
550
695
 
551
696
  ### :arguments Option
552
697
 
553
- The `:arguments` option allows objects to accept arguments when building. Any arguments provided
554
- must appear when the object is built ( and so are implicitly 'required' ).
698
+ The `:arguments` option allows objects to accept arguments when building. Any arguments provided must appear when the object is built (and so are implicitly 'required').
555
699
 
556
- If the an argument is provided, the same argument appears in the attributes hash, or in the object
557
- block, the assignemnt in the block will take priority, followed by the attributes assigned and
558
- finally the argument.
700
+ If an argument is provided, the same argument appears in the attributes hash, or in the object block, the assignment in the block will take priority, followed by the attributes assigned and finally the argument.
559
701
 
560
702
  #### example:
561
703
 
@@ -577,131 +719,200 @@ result = schema.build! do
577
719
  end
578
720
  ```
579
721
 
722
+ ### :normalize Option
723
+
724
+ The `:normalize` option allows you to transform values immediately upon assignment. It accepts a lambda or Proc that receives the value and returns the transformed result. When used with arrays, the normalizer is called for each individual item, not the array itself.
725
+
726
+ #### example:
727
+
728
+ ```ruby
729
+ schema = DynamicSchema.define do
730
+ name String, normalize: ->( v ) { v.strip.downcase }
731
+ tags String, array: true, normalize: ->( v ) { v.upcase }
732
+ score Integer, normalize: ->( v ) { v.clamp( 0, 100 ) }
733
+ end
734
+
735
+ result = schema.build! do
736
+ name ' Alice '
737
+ tags [ 'important', 'urgent' ]
738
+ score 150
739
+ end
740
+
741
+ puts result[:name] # => "alice"
742
+ puts result[:tags] # => ["IMPORTANT", "URGENT"]
743
+ puts result[:score] # => 100
744
+ ```
745
+
746
+ The normalizer is applied after type coercion, so you receive the properly typed value:
747
+
748
+ ```ruby
749
+ schema = DynamicSchema.define do
750
+ count Integer, normalize: ->( v ) { v * 2 }
751
+ end
752
+
753
+ result = schema.build! do
754
+ count '5' # coerced to Integer first, then normalized
755
+ end
756
+
757
+ puts result[:count] # => 10
758
+ ```
759
+
760
+ You can also use normalize with objects to transform the entire nested structure:
761
+
762
+ ```ruby
763
+ CustomUser = Struct.new( :name, :email )
764
+
765
+ schema = DynamicSchema.define do
766
+ user normalize: ->( v ) { CustomUser.new( v.to_h[:name], v.to_h[:email] ) } do
767
+ name String
768
+ email String
769
+ end
770
+ end
771
+
772
+ result = schema.build! do
773
+ user do
774
+ name 'Alice'
775
+ email 'alice@example.com'
776
+ end
777
+ end
778
+
779
+ result[:user].class # => CustomUser
780
+ result[:user].name # => "Alice"
781
+ ```
782
+
783
+ The normalize option works with Struct as well:
784
+
785
+ ```ruby
786
+ Person = DynamicSchema::Struct.define do
787
+ name String, normalize: ->( v ) { v.strip.capitalize }
788
+ tags String, array: true, normalize: ->( v ) { v.downcase }
789
+ end
790
+
791
+ person = Person.build( name: ' alice ', tags: [ 'ADMIN', 'USER' ] )
792
+ person.name # => "Alice"
793
+ person.tags # => ["admin", "user"]
794
+ ```
795
+
796
+ Note that any exceptions raised within a normalize lambda are not captured and will propagate to the caller:
797
+
798
+ ```ruby
799
+ schema = DynamicSchema.define do
800
+ value Integer, normalize: ->( v ) { raise "invalid value" if v < 0; v }
801
+ end
802
+
803
+ # This will raise RuntimeError: "invalid value"
804
+ schema.build! { value -1 }
805
+ ```
806
+
580
807
  ## Class Schemas
581
808
 
582
- DynamicSchema provides a number of modules you can include into your own classes to simplify
583
- their definition and construction.
809
+ DynamicSchema provides a number of modules you can include into your own classes to simplify their definition and construction.
584
810
 
585
- ### Definable
811
+ ### Definable
586
812
 
587
- The `Definable` module, when included in a class, will add the `schema` and the `builder` class
588
- methods.
813
+ The `Definable` module, when included in a class, will add the `schema` and the `builder` class methods.
589
814
 
590
- By calling `schema` with a block you can define a schema for that specific class. You may also
591
- retrieve the defined schema by calling 'schema' ( with or without a block ). The 'schema' method
592
- may be called repeatedly to build up a schema with each call adding to the existing schema
593
- ( replacing values and objects of the same name if they appear in subsequent calls ).
815
+ By calling `schema` with a block you can define a schema for that specific class. You may also retrieve the defined schema by calling 'schema' (with or without a block). The 'schema' method may be called repeatedly to build up a schema with each call adding to the existing schema (replacing values and objects of the same name if they appear in subsequent calls).
594
816
 
595
- The `schema` method will integrate with a class hierarchy. By including Definable in a base class
596
- you can call `schema` to define a schema for that base class and then in subsequent derived classes
597
- to augment it for those classes.
817
+ The `schema` method will integrate with a class hierarchy. By including Definable in a base class you can call `schema` to define a schema for that base class and then in subsequent derived classes to augment it for those classes.
598
818
 
599
- The `builder` method will return a memoized builder of the schema defined by calls to the `schema`
600
- method which can be used to build and validate schema conformant hashes.
819
+ The `builder` method will return a memoized builder of the schema defined by calls to the `schema` method which can be used to build and validate schema conformant hashes.
601
820
 
602
- ```ruby
603
- class Setting
604
- include DynamicSchema::Definable
605
- schema do
606
- name String
607
- end
608
- end
821
+ ```ruby
822
+ class Setting
823
+ include DynamicSchema::Definable
824
+ schema do
825
+ name String
826
+ end
827
+ end
609
828
 
610
- class DatabaSetting < Setting
611
- schema do
612
- database do
829
+ class DatabaseSetting < Setting
830
+ schema do
831
+ database do
613
832
  host String
614
- port String
615
- name String
616
- end
617
- end
833
+ port String
834
+ name String
835
+ end
836
+ end
618
837
 
619
838
  def initialize( attributes = {} )
620
- # validate the attributes
839
+ # validate the attributes
621
840
  self.class.builder.validate!( attributes )
622
- # retain them for future access
623
- @attributes = attributes&.dup
841
+ # retain them for future access
842
+ @attributes = attributes&.dup
624
843
  end
625
844
 
626
- end
845
+ end
627
846
  ```
628
847
 
629
- ### Buildable
848
+ ### Buildable
630
849
 
631
- The `Buildable` module can be included in a class, in addition to `Definable`, to facilitate
632
- building that class using a schema assisted builder pattern. The `Buildable` module adds
633
- `build!` and `build` methods to the class which can be used to build that class, with and
634
- without validation respectively.
850
+ The `Buildable` module can be included in a class, in addition to `Definable`, to facilitate building that class using a schema assisted builder pattern. The `Buildable` module adds `build!` and `build` methods to the class which can be used to build that class, with and without validation respectively.
635
851
 
636
- These methods accept both a Hash with attributes that follow the schema, as well as a block
637
- that can be used to build the class instance. The attributes and block can be used simultaneously.
852
+ These methods accept both a Hash with attributes that follow the schema, as well as a block that can be used to build the class instance. The attributes and block can be used simultaneously.
638
853
 
639
- **Important** Note that `Buildable` requires a class method `builder` ( which `Definable`
640
- provides ) and an initializer that accepts a `Hash` of attributes.
854
+ **Important** Note that `Buildable` requires a class method `builder` (which `Definable` provides) and an initializer that accepts a `Hash` of attributes.
641
855
 
642
856
  ```ruby
643
- class Setting
644
- include DynamicSchema::Definable
857
+ class Setting
858
+ include DynamicSchema::Definable
645
859
  include DynamicSchema::Buildable
646
- schema do
647
- name String
648
- end
649
- end
650
-
651
- class DatabaseSetting < Setting
652
- schema do
653
- database do
860
+ schema do
861
+ name String
862
+ end
863
+ end
864
+
865
+ class DatabaseSetting < Setting
866
+ schema do
867
+ database do
654
868
  adapter Symbol
655
869
  host String
656
- port String
657
- name String
658
- end
659
- end
870
+ port String
871
+ name String
872
+ end
873
+ end
660
874
 
661
875
  def initialize( attributes = {} )
662
- # validate the attributes
876
+ # validate the attributes
663
877
  self.class.builder.validate!( attributes )
664
- # retain them for the future
665
- @attributes = attributes&.dup
878
+ # retain them for the future
879
+ @attributes = attributes&.dup
666
880
  end
667
- end
881
+ end
668
882
 
669
- database_settings = DatabaseSetting.build! name: 'settings.database' do
670
- database adapter: :pg do
883
+ database_settings = DatabaseSetting.build! name: 'settings.database' do
884
+ database adapter: :pg do
671
885
  host "localhost"
672
886
  port "127.0.0.1"
673
887
  name "mydb"
674
- end
888
+ end
675
889
  end
676
890
  ```
677
891
 
678
892
  ## Validation
679
893
 
680
- DynamicSchema provides three different methods for validating Hash structures against your
681
- defined schema: `validate!`, `validate`, and `valid?`.
894
+ DynamicSchema provides three different methods for validating Hash structures against your defined schema: `validate!`, `validate`, and `valid?`.
682
895
 
683
- These methods allow you to verify that your data conforms to your schema requirements,
684
- including type constraints, required fields, and value ranges.
896
+ These methods allow you to verify that your data conforms to your schema requirements, including type constraints, required fields, and value ranges.
685
897
 
686
898
  ### Validation Rules
687
899
 
688
900
  When validating, DynamicSchema checks:
689
901
 
690
- 1. **Required Fields**:
902
+ 1. **Required Fields**:
691
903
  Any value or object marked as `required: true` is present.
692
- 2. **Type Constraints**:
904
+ 2. **Type Constraints**:
693
905
  Any values match their specified types or can be coerced to the specified type.
694
906
  3. **Value Ranges**:
695
907
  Any values fall within their specified `:in` constraints.
696
- 4. **Objects**:
908
+ 4. **Objects**:
697
909
  Any objects are recursively validated.
698
- 5. **Arrays**:
910
+ 5. **Arrays**:
699
911
  Any validation rules are applied to each element when `array: true`
700
912
 
701
913
  ### validate!
702
914
 
703
- The `validate!` method performs strict validation and raises an exception when it encounters
704
- the first validation error.
915
+ The `validate!` method performs strict validation and raises an exception when it encounters the first validation error.
705
916
 
706
917
  #### example:
707
918
 
@@ -716,27 +927,26 @@ schema.validate!( { temperature: 0.5 } )
716
927
 
717
928
  # this will raise DynamicSchema::IncompatibleTypeError
718
929
  schema.validate!( {
719
- api_key: ["not-a-string"],
930
+ api_key: ["not-a-string"],
720
931
  temperature: 0.5
721
932
  } )
722
933
 
723
934
  # this will raise DynamicSchema::InOptionError
724
935
  schema.validate!( {
725
936
  api_key: "abc123",
726
- temperature: 1.5
937
+ temperature: 1.5
727
938
  } )
728
939
 
729
940
  # this is valid and will not raise any errors
730
941
  schema.validate!( {
731
- api_key: 123,
942
+ api_key: 123,
732
943
  temperature: 0.5
733
944
  } )
734
945
  ```
735
946
 
736
947
  ### validate
737
948
 
738
- The `validate` method performs validation but instead of raising exceptions, it collects and
739
- returns an array of all validation errors encountered.
949
+ The `validate` method performs validation but instead of raising exceptions, it collects and returns an array of all validation errors encountered.
740
950
 
741
951
  #### example:
742
952
 
@@ -791,8 +1001,7 @@ schema.valid?({
791
1001
  DynamicSchema provides specific error types for different validation failures:
792
1002
 
793
1003
  - `DynamicSchema::RequiredOptionError`: Raised when a required field is missing
794
- - `DynamicSchema::IncompatibleTypeError`: Raised when a value's type doesn't match the schema
795
- and cannot be coerced
1004
+ - `DynamicSchema::IncompatibleTypeError`: Raised when a value's type doesn't match the schema and cannot be coerced
796
1005
  - `DynamicSchema::InOptionError`: Raised when a value falls outside its specified range/set
797
1006
  - `ArgumentError`: Raised when the provided values structure isn't a Hash
798
1007