validrb 0.5.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,654 @@
1
+ # Validrb
2
+
3
+ A powerful Ruby schema validation library with type coercion, inspired by Pydantic and Zod. Define schemas once, validate data with automatic type coercion, generate JSON Schema, and serialize results.
4
+
5
+ [![Ruby](https://img.shields.io/badge/ruby-%3E%3D%203.0-ruby.svg)](https://www.ruby-lang.org/)
6
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
7
+
8
+ ## Features
9
+
10
+ - **Type Coercion** - Automatic conversion of strings to integers, booleans, dates, etc.
11
+ - **Rich Constraints** - min/max, length, format, enum, and custom validations
12
+ - **Schema Composition** - Extend, merge, pick, omit, and partial schemas
13
+ - **Nested Validation** - Deep validation of objects and arrays
14
+ - **Union Types** - Accept multiple types for a single field
15
+ - **Discriminated Unions** - Polymorphic data with type discriminators
16
+ - **Conditional Validation** - Validate fields based on other field values
17
+ - **Custom Types** - Define your own types with custom coercion
18
+ - **I18n Support** - Internationalized error messages
19
+ - **JSON Schema Generation** - Export schemas to JSON Schema format
20
+ - **Serialization** - Convert validated data to JSON-ready primitives
21
+ - **Zero Dependencies** - Pure Ruby, no external runtime dependencies
22
+
23
+ ## Installation
24
+
25
+ Add to your Gemfile:
26
+
27
+ ```ruby
28
+ gem 'validrb'
29
+ ```
30
+
31
+ Or install directly:
32
+
33
+ ```bash
34
+ gem install validrb
35
+ ```
36
+
37
+ ## Quick Start
38
+
39
+ ```ruby
40
+ require 'validrb'
41
+
42
+ # Define a schema
43
+ UserSchema = Validrb.schema do
44
+ field :name, :string, min: 1, max: 100
45
+ field :email, :string, format: :email
46
+ field :age, :integer, min: 0, optional: true
47
+ field :role, :string, enum: %w[admin user guest], default: "user"
48
+ end
49
+
50
+ # Parse with automatic coercion
51
+ result = UserSchema.safe_parse({
52
+ name: "John Doe",
53
+ email: "john@example.com",
54
+ age: "25" # String automatically coerced to integer
55
+ })
56
+
57
+ if result.success?
58
+ puts result.data # => { name: "John Doe", email: "john@example.com", age: 25, role: "user" }
59
+ else
60
+ puts result.errors.full_messages
61
+ end
62
+
63
+ # Or raise on failure
64
+ user = UserSchema.parse(params) # Raises Validrb::ValidationError on failure
65
+ ```
66
+
67
+ ## Table of Contents
68
+
69
+ - [Types](#types)
70
+ - [Constraints](#constraints)
71
+ - [Field Options](#field-options)
72
+ - [Schema Options](#schema-options)
73
+ - [Schema Composition](#schema-composition)
74
+ - [Custom Validators](#custom-validators)
75
+ - [Conditional Validation](#conditional-validation)
76
+ - [Union Types](#union-types)
77
+ - [Discriminated Unions](#discriminated-unions)
78
+ - [Refinements](#refinements)
79
+ - [Validation Context](#validation-context)
80
+ - [Custom Types](#custom-types)
81
+ - [Serialization](#serialization)
82
+ - [JSON Schema Generation](#json-schema-generation)
83
+ - [Schema Introspection](#schema-introspection)
84
+ - [I18n Support](#i18n-support)
85
+ - [Error Handling](#error-handling)
86
+
87
+ ## Types
88
+
89
+ ### Built-in Types
90
+
91
+ | Type | Ruby Class | Coerces From |
92
+ |------|------------|--------------|
93
+ | `:string` | String | Symbol, Numeric |
94
+ | `:integer` | Integer | String, Float (whole numbers) |
95
+ | `:float` | Float | String, Integer |
96
+ | `:boolean` | TrueClass/FalseClass | "true"/"false", "yes"/"no", "1"/"0", 1/0 |
97
+ | `:decimal` | BigDecimal | String, Integer, Float |
98
+ | `:date` | Date | ISO8601 String, DateTime, Time, Unix timestamp |
99
+ | `:datetime` | DateTime | ISO8601 String, Date, Time, Unix timestamp |
100
+ | `:time` | Time | ISO8601 String, DateTime, Date, Unix timestamp |
101
+ | `:array` | Array | (validates items with `of:` option) |
102
+ | `:object` | Hash | (validates with nested `schema:`) |
103
+
104
+ ### Type Examples
105
+
106
+ ```ruby
107
+ schema = Validrb.schema do
108
+ # Basic types
109
+ field :name, :string
110
+ field :count, :integer
111
+ field :price, :float
112
+ field :active, :boolean
113
+
114
+ # Precise decimals
115
+ field :amount, :decimal
116
+
117
+ # Date/time types
118
+ field :birth_date, :date
119
+ field :created_at, :datetime
120
+ field :timestamp, :time
121
+
122
+ # Arrays with typed items
123
+ field :tags, :array, of: :string
124
+ field :scores, :array, of: :integer
125
+
126
+ # Nested objects
127
+ field :address, :object, schema: AddressSchema
128
+ end
129
+ ```
130
+
131
+ ## Constraints
132
+
133
+ ```ruby
134
+ schema = Validrb.schema do
135
+ # Numeric min/max
136
+ field :age, :integer, min: 0, max: 150
137
+ field :price, :float, min: 0.01
138
+
139
+ # String length (min/max applied to length)
140
+ field :username, :string, min: 3, max: 20
141
+
142
+ # Exact length
143
+ field :pin, :string, length: 4
144
+
145
+ # Length range
146
+ field :password, :string, length: 8..128
147
+
148
+ # Length with options
149
+ field :bio, :string, length: { min: 10, max: 500 }
150
+
151
+ # Named formats
152
+ field :email, :string, format: :email
153
+ field :website, :string, format: :url
154
+ field :id, :string, format: :uuid
155
+
156
+ # Custom regex
157
+ field :code, :string, format: /\A[A-Z]{2}-\d{4}\z/
158
+
159
+ # Enum (allowed values)
160
+ field :status, :string, enum: %w[pending active completed]
161
+ field :priority, :integer, enum: [1, 2, 3]
162
+ end
163
+ ```
164
+
165
+ ### Available Formats
166
+
167
+ `:email`, `:url`, `:uuid`, `:phone`, `:alphanumeric`, `:alpha`, `:numeric`, `:hex`, `:slug`
168
+
169
+ ## Field Options
170
+
171
+ | Option | Type | Description |
172
+ |--------|------|-------------|
173
+ | `optional` | Boolean | Field can be missing (default: false) |
174
+ | `nullable` | Boolean | Field accepts nil value (default: false) |
175
+ | `default` | Any/Proc | Default value when missing |
176
+ | `message` | String | Custom error message |
177
+ | `preprocess` | Proc | Transform input BEFORE validation |
178
+ | `transform` | Proc | Transform value AFTER validation |
179
+ | `coerce` | Boolean | Enable type coercion (default: true) |
180
+ | `when` | Proc/Symbol | Only validate if condition is true |
181
+ | `unless` | Proc/Symbol | Only validate if condition is false |
182
+ | `union` | Array | Accept any of these types |
183
+ | `literal` | Array | Accept only exact values |
184
+ | `refine` | Proc/Array | Custom validation predicates |
185
+
186
+ ### Examples
187
+
188
+ ```ruby
189
+ schema = Validrb.schema do
190
+ # Optional field
191
+ field :nickname, :string, optional: true
192
+
193
+ # Nullable field (accepts nil)
194
+ field :deleted_at, :datetime, nullable: true
195
+
196
+ # Default values
197
+ field :role, :string, default: "user"
198
+ field :created_at, :datetime, default: -> { DateTime.now }
199
+
200
+ # Preprocessing (runs BEFORE validation)
201
+ field :email, :string, format: :email,
202
+ preprocess: ->(v) { v.to_s.strip.downcase }
203
+
204
+ # Transform (runs AFTER validation)
205
+ field :tags, :string, transform: ->(v) { v.split(",").map(&:strip) }
206
+
207
+ # Disable coercion (strict type checking)
208
+ field :count, :integer, coerce: false
209
+
210
+ # Custom error message
211
+ field :age, :integer, min: 18, message: "Must be 18 or older"
212
+ end
213
+ ```
214
+
215
+ ## Schema Options
216
+
217
+ ```ruby
218
+ # Strict mode - reject unknown keys
219
+ schema = Validrb.schema(strict: true) do
220
+ field :name, :string
221
+ end
222
+
223
+ schema.safe_parse({ name: "John", extra: "rejected" })
224
+ # => Failure with error on :extra
225
+
226
+ # Passthrough mode - keep unknown keys
227
+ schema = Validrb.schema(passthrough: true) do
228
+ field :name, :string
229
+ end
230
+
231
+ schema.parse({ name: "John", extra: "kept" })
232
+ # => { name: "John", extra: "kept" }
233
+ ```
234
+
235
+ ## Schema Composition
236
+
237
+ ```ruby
238
+ BaseSchema = Validrb.schema do
239
+ field :id, :integer
240
+ field :created_at, :datetime, default: -> { DateTime.now }
241
+ end
242
+
243
+ # Extend with additional fields
244
+ UserSchema = BaseSchema.extend do
245
+ field :name, :string
246
+ field :email, :string, format: :email
247
+ end
248
+
249
+ # Pick specific fields
250
+ PublicUserSchema = UserSchema.pick(:id, :name)
251
+
252
+ # Omit specific fields
253
+ SafeUserSchema = UserSchema.omit(:password)
254
+
255
+ # Merge two schemas (second takes precedence)
256
+ MergedSchema = Schema1.merge(Schema2)
257
+
258
+ # Make all fields optional (useful for PATCH updates)
259
+ UpdateSchema = UserSchema.partial
260
+ ```
261
+
262
+ ## Custom Validators
263
+
264
+ ```ruby
265
+ schema = Validrb.schema do
266
+ field :password, :string, min: 8
267
+ field :password_confirmation, :string
268
+
269
+ # Cross-field validation
270
+ validate do |data|
271
+ if data[:password] != data[:password_confirmation]
272
+ error(:password_confirmation, "doesn't match password")
273
+ end
274
+ end
275
+
276
+ # Base-level errors (not tied to a field)
277
+ validate do |data|
278
+ if data[:items]&.empty?
279
+ base_error("At least one item is required")
280
+ end
281
+ end
282
+ end
283
+ ```
284
+
285
+ ## Conditional Validation
286
+
287
+ ```ruby
288
+ schema = Validrb.schema do
289
+ field :account_type, :string, enum: %w[personal business]
290
+
291
+ # Validate only when condition is true
292
+ field :company_name, :string,
293
+ when: ->(data) { data[:account_type] == "business" }
294
+
295
+ # Validate unless condition is true
296
+ field :personal_id, :string,
297
+ unless: ->(data) { data[:account_type] == "business" }
298
+
299
+ # Symbol shorthand (checks if field is truthy)
300
+ field :subscribe, :boolean, default: false
301
+ field :email, :string, format: :email, when: :subscribe
302
+ end
303
+ ```
304
+
305
+ ## Union Types
306
+
307
+ ```ruby
308
+ schema = Validrb.schema do
309
+ # Accept multiple types (tries in order, put specific types first)
310
+ field :id, :string, union: [:integer, :string]
311
+ end
312
+
313
+ schema.parse({ id: 123 }) # => { id: 123 }
314
+ schema.parse({ id: "abc-123" }) # => { id: "abc-123" }
315
+ schema.parse({ id: "456" }) # => { id: 456 } (coerced to integer)
316
+ ```
317
+
318
+ ## Discriminated Unions
319
+
320
+ For polymorphic data, use discriminated unions to select the right schema based on a discriminator field:
321
+
322
+ ```ruby
323
+ CreditCardSchema = Validrb.schema do
324
+ field :type, :string
325
+ field :card_number, :string
326
+ field :expiry, :string
327
+ end
328
+
329
+ PayPalSchema = Validrb.schema do
330
+ field :type, :string
331
+ field :email, :string, format: :email
332
+ end
333
+
334
+ PaymentSchema = Validrb.schema do
335
+ field :payment, :discriminated_union,
336
+ discriminator: :type,
337
+ mapping: {
338
+ "credit_card" => CreditCardSchema,
339
+ "paypal" => PayPalSchema
340
+ }
341
+ end
342
+
343
+ PaymentSchema.parse({
344
+ payment: { type: "credit_card", card_number: "4111...", expiry: "12/25" }
345
+ })
346
+
347
+ PaymentSchema.parse({
348
+ payment: { type: "paypal", email: "user@example.com" }
349
+ })
350
+ ```
351
+
352
+ ## Refinements
353
+
354
+ Add custom validation predicates beyond built-in constraints:
355
+
356
+ ```ruby
357
+ schema = Validrb.schema do
358
+ # Simple refinement
359
+ field :age, :integer, refine: ->(v) { v >= 18 }
360
+
361
+ # With custom message
362
+ field :password, :string,
363
+ refine: {
364
+ check: ->(v) { v.match?(/[A-Z]/) },
365
+ message: "must contain an uppercase letter"
366
+ }
367
+
368
+ # Multiple refinements
369
+ field :code, :string,
370
+ refine: [
371
+ { check: ->(v) { v.length >= 8 }, message: "too short" },
372
+ { check: ->(v) { v.match?(/\d/) }, message: "needs a digit" },
373
+ { check: ->(v) { v.match?(/[A-Z]/) }, message: "needs uppercase" }
374
+ ]
375
+ end
376
+ ```
377
+
378
+ ## Validation Context
379
+
380
+ Pass request-level data through the validation pipeline:
381
+
382
+ ```ruby
383
+ schema = Validrb.schema do
384
+ field :amount, :decimal,
385
+ refine: ->(value, ctx) {
386
+ ctx.nil? || value <= ctx[:max_amount]
387
+ }
388
+
389
+ field :admin_only, :string,
390
+ when: ->(data, ctx) { ctx && ctx[:is_admin] }
391
+
392
+ validate do |data, ctx|
393
+ if ctx && ctx[:restricted] && data[:amount] > 100
394
+ error(:amount, "exceeds limit in restricted mode")
395
+ end
396
+ end
397
+ end
398
+
399
+ # Create and pass context
400
+ ctx = Validrb.context(max_amount: 1000, is_admin: true)
401
+ result = schema.safe_parse(data, context: ctx)
402
+ ```
403
+
404
+ ## Custom Types
405
+
406
+ Define your own types with custom coercion and validation:
407
+
408
+ ```ruby
409
+ Validrb.define_type(:money) do
410
+ coerce { |v| BigDecimal(v.to_s.gsub(/[$,]/, "")) }
411
+ validate { |v| v >= 0 }
412
+ error_message { "must be a valid money amount" }
413
+ end
414
+
415
+ Validrb.define_type(:slug) do
416
+ coerce { |v| v.to_s.downcase.gsub(/[^a-z0-9]+/, "-").gsub(/^-|-$/, "") }
417
+ validate { |v| v.match?(/\A[a-z0-9]+(?:-[a-z0-9]+)*\z/) }
418
+ end
419
+
420
+ schema = Validrb.schema do
421
+ field :price, :money
422
+ field :url_slug, :slug
423
+ end
424
+
425
+ schema.parse({ price: "$1,234.56", url_slug: "Hello World!" })
426
+ # => { price: #<BigDecimal:1234.56>, url_slug: "hello-world" }
427
+ ```
428
+
429
+ ## Serialization
430
+
431
+ Convert validated data to JSON-ready primitives:
432
+
433
+ ```ruby
434
+ schema = Validrb.schema do
435
+ field :name, :string
436
+ field :created_at, :date
437
+ field :amount, :decimal
438
+ end
439
+
440
+ result = schema.safe_parse({
441
+ name: "Test",
442
+ created_at: "2024-01-15",
443
+ amount: "99.99"
444
+ })
445
+
446
+ # Serialize to hash with primitives
447
+ result.dump
448
+ # => { "name" => "Test", "created_at" => "2024-01-15", "amount" => "99.99" }
449
+
450
+ # Serialize to JSON
451
+ result.to_json
452
+ # => '{"name":"Test","created_at":"2024-01-15","amount":"99.99"}'
453
+
454
+ # Schema-level dump (parse + serialize)
455
+ schema.dump(data) # Raises on validation error
456
+ schema.safe_dump(data) # Returns Result
457
+ ```
458
+
459
+ ## JSON Schema Generation
460
+
461
+ Generate JSON Schema from your Validrb schemas:
462
+
463
+ ```ruby
464
+ schema = Validrb.schema do
465
+ field :id, :integer
466
+ field :name, :string, min: 1, max: 100
467
+ field :email, :string, format: :email
468
+ field :age, :integer, optional: true, min: 0
469
+ field :role, :string, enum: %w[admin user], default: "user"
470
+ end
471
+
472
+ json_schema = schema.to_json_schema
473
+ # => {
474
+ # "$schema" => "https://json-schema.org/draft-07/schema#",
475
+ # "type" => "object",
476
+ # "required" => ["id", "name", "email"],
477
+ # "properties" => {
478
+ # "id" => { "type" => "integer" },
479
+ # "name" => { "type" => "string", "minLength" => 1, "maxLength" => 100 },
480
+ # "email" => { "type" => "string" },
481
+ # "age" => { "type" => "integer", "minimum" => 0 },
482
+ # "role" => { "type" => "string", "enum" => ["admin", "user"], "default" => "user" }
483
+ # }
484
+ # }
485
+ ```
486
+
487
+ ## OpenAPI 3.0 Generation
488
+
489
+ Generate complete OpenAPI 3.0 specifications from your schemas:
490
+
491
+ ```ruby
492
+ # Create an OpenAPI generator
493
+ generator = Validrb::OpenAPI.generator
494
+
495
+ # Register schemas
496
+ generator.register("User", UserSchema)
497
+ generator.register("CreateUser", CreateUserSchema)
498
+
499
+ # Build paths
500
+ paths = Validrb::OpenAPI::PathBuilder.new(generator)
501
+ .get("/users", summary: "List users")
502
+ .post("/users", schema: CreateUserSchema, summary: "Create user")
503
+ .get("/users/{id}", summary: "Get user")
504
+ .put("/users/{id}", schema: UpdateUserSchema, summary: "Update user")
505
+ .to_h
506
+
507
+ # Generate the OpenAPI document
508
+ doc = generator.generate(
509
+ info: {
510
+ title: "My API",
511
+ version: "1.0.0",
512
+ description: "API documentation"
513
+ },
514
+ servers: ["https://api.example.com"],
515
+ paths: paths
516
+ )
517
+
518
+ # Export as JSON or YAML
519
+ puts generator.to_json(info: { title: "My API", version: "1.0.0" })
520
+ puts generator.to_yaml(info: { title: "My API", version: "1.0.0" })
521
+ ```
522
+
523
+ ### Import from OpenAPI/JSON Schema
524
+
525
+ Create Validrb schemas from existing OpenAPI or JSON Schema definitions:
526
+
527
+ ```ruby
528
+ # Import from OpenAPI document
529
+ openapi_doc = JSON.parse(File.read("openapi.json"))
530
+ importer = Validrb::OpenAPI.import(openapi_doc)
531
+
532
+ # Access imported schemas
533
+ user_schema = importer["User"]
534
+ post_schema = importer["Post"]
535
+
536
+ # Use for validation
537
+ result = user_schema.safe_parse(params)
538
+
539
+ # Import a single JSON Schema
540
+ json_schema = {
541
+ "type" => "object",
542
+ "properties" => {
543
+ "name" => { "type" => "string", "minLength" => 1 },
544
+ "age" => { "type" => "integer", "minimum" => 0 }
545
+ },
546
+ "required" => ["name"]
547
+ }
548
+
549
+ schema = Validrb::OpenAPI.import_schema(json_schema)
550
+ schema.parse({ name: "John", age: 25 })
551
+ ```
552
+
553
+ ## Schema Introspection
554
+
555
+ Inspect schema structure programmatically:
556
+
557
+ ```ruby
558
+ schema.field_names # => [:id, :name, :email, :age, :role]
559
+ schema.required_fields # => [:id, :name, :email]
560
+ schema.optional_fields # => [:age]
561
+ schema.fields_with_defaults # => [:role]
562
+ schema.conditional_fields # => []
563
+
564
+ # Get field details
565
+ field = schema.field(:name)
566
+ field.type.type_name # => "string"
567
+ field.constraint_values # => { min: 1, max: 100 }
568
+ field.optional? # => false
569
+ ```
570
+
571
+ ## I18n Support
572
+
573
+ Customize error messages with internationalization:
574
+
575
+ ```ruby
576
+ # Add custom translations
577
+ Validrb::I18n.add_translations(:en,
578
+ required: "cannot be blank",
579
+ min: "must be at least %{value}"
580
+ )
581
+
582
+ # Switch locale
583
+ Validrb::I18n.add_translations(:es,
584
+ required: "es requerido",
585
+ min: "debe ser al menos %{value}"
586
+ )
587
+ Validrb::I18n.locale = :es
588
+
589
+ # Reset to defaults
590
+ Validrb::I18n.reset!
591
+ ```
592
+
593
+ ## Error Handling
594
+
595
+ ```ruby
596
+ # safe_parse returns a Result object
597
+ result = schema.safe_parse(data)
598
+
599
+ result.success? # => true/false
600
+ result.failure? # => true/false
601
+ result.data # => validated data (if success)
602
+ result.errors # => ErrorCollection (if failure)
603
+
604
+ # Error details
605
+ result.errors.each do |error|
606
+ error.path # => [:user, :email]
607
+ error.message # => "must be a valid email"
608
+ error.code # => :format
609
+ error.to_s # => "user.email must be a valid email"
610
+ end
611
+
612
+ # Error collection methods
613
+ result.errors.messages # => ["must be a valid email", ...]
614
+ result.errors.full_messages # => ["user.email must be a valid email", ...]
615
+ result.errors.to_h # => { [:user, :email] => ["must be a valid email"] }
616
+
617
+ # parse raises on failure
618
+ begin
619
+ schema.parse(invalid_data)
620
+ rescue Validrb::ValidationError => e
621
+ e.errors # => ErrorCollection
622
+ e.message # => Summary of errors
623
+ end
624
+ ```
625
+
626
+ ## Requirements
627
+
628
+ - Ruby >= 3.0
629
+ - No runtime dependencies
630
+
631
+ ## Development
632
+
633
+ ```bash
634
+ # Install dependencies
635
+ bundle install
636
+
637
+ # Run tests
638
+ bundle exec rspec
639
+
640
+ # Run demo
641
+ bundle exec ruby demo.rb
642
+ ```
643
+
644
+ ## Contributing
645
+
646
+ Bug reports and pull requests are welcome on GitHub.
647
+
648
+ ## License
649
+
650
+ MIT License. See [LICENSE](LICENSE) for details.
651
+
652
+ ## Credits
653
+
654
+ Inspired by [Pydantic](https://pydantic.dev/) (Python) and [Zod](https://zod.dev/) (TypeScript).
data/Rakefile ADDED
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+ require "rubocop/rake_task"
6
+
7
+ RSpec::Core::RakeTask.new(:spec)
8
+ RuboCop::RakeTask.new
9
+
10
+ task default: %i[spec]
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Validrb
4
+ module Constraints
5
+ # Registry for constraint types
6
+ @registry = {}
7
+
8
+ class << self
9
+ attr_reader :registry
10
+
11
+ def register(name, klass)
12
+ @registry[name.to_sym] = klass
13
+ end
14
+
15
+ def lookup(name)
16
+ @registry[name.to_sym]
17
+ end
18
+
19
+ def build(name, *args, **kwargs)
20
+ klass = lookup(name)
21
+ raise ArgumentError, "Unknown constraint: #{name}" unless klass
22
+
23
+ klass.new(*args, **kwargs)
24
+ end
25
+ end
26
+
27
+ # Base class for all constraints
28
+ class Base
29
+ attr_reader :options
30
+
31
+ def initialize(**options)
32
+ @options = options.freeze
33
+ freeze
34
+ end
35
+
36
+ # Validate a value and return an array of Error objects (empty if valid)
37
+ def call(value, path: [])
38
+ return [] if valid?(value)
39
+
40
+ [Error.new(path: path, message: error_message(value), code: error_code)]
41
+ end
42
+
43
+ # Override in subclasses to implement validation logic
44
+ def valid?(_value)
45
+ raise NotImplementedError, "#{self.class}#valid? must be implemented"
46
+ end
47
+
48
+ # Override in subclasses to provide error message
49
+ def error_message(_value)
50
+ raise NotImplementedError, "#{self.class}#error_message must be implemented"
51
+ end
52
+
53
+ # Override in subclasses to provide error code
54
+ def error_code
55
+ :constraint_error
56
+ end
57
+ end
58
+ end
59
+ end