verse-schema 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.rspec +3 -0
- data/.rubocop-https---relaxed-ruby-style-rubocop-yml +153 -0
- data/.rubocop.yml +32 -0
- data/Gemfile +23 -0
- data/Gemfile.lock +87 -0
- data/README.md +1591 -0
- data/Rakefile +15 -0
- data/benchmarks/troubleshoot.rb +73 -0
- data/lib/tasks/readme.rake +94 -0
- data/lib/tasks/readme_doc_extractor.rb +158 -0
- data/lib/verse/schema/base.rb +80 -0
- data/lib/verse/schema/coalescer/register.rb +132 -0
- data/lib/verse/schema/coalescer.rb +81 -0
- data/lib/verse/schema/collection.rb +175 -0
- data/lib/verse/schema/dictionary.rb +160 -0
- data/lib/verse/schema/error_builder.rb +43 -0
- data/lib/verse/schema/field/ext.rb +24 -0
- data/lib/verse/schema/field.rb +394 -0
- data/lib/verse/schema/invalid_schema_error.rb +18 -0
- data/lib/verse/schema/optionable.rb +47 -0
- data/lib/verse/schema/post_processor.rb +56 -0
- data/lib/verse/schema/result.rb +30 -0
- data/lib/verse/schema/scalar.rb +154 -0
- data/lib/verse/schema/selector.rb +172 -0
- data/lib/verse/schema/struct.rb +315 -0
- data/lib/verse/schema/version.rb +7 -0
- data/lib/verse/schema.rb +75 -0
- data/sig/verse/schema.rbs +6 -0
- data/templates/README.md.erb +73 -0
- metadata +83 -0
data/Rakefile
ADDED
@@ -0,0 +1,15 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "bundler/gem_tasks"
|
4
|
+
require "rspec/core/rake_task"
|
5
|
+
|
6
|
+
RSpec::Core::RakeTask.new(:spec)
|
7
|
+
|
8
|
+
require "rubocop/rake_task"
|
9
|
+
|
10
|
+
RuboCop::RakeTask.new
|
11
|
+
|
12
|
+
# Load custom tasks
|
13
|
+
Dir.glob("#{File.dirname(__FILE__)}/lib/tasks/**/*.rake").each { |r| load r }
|
14
|
+
|
15
|
+
task default: %i[spec rubocop]
|
@@ -0,0 +1,73 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# Troubleshoot performances
|
4
|
+
require "bundler"
|
5
|
+
|
6
|
+
Bundler.require
|
7
|
+
|
8
|
+
require "verse/schema"
|
9
|
+
|
10
|
+
# Good real life example struggling in term of perfs
|
11
|
+
ShiftEntrySchema = Verse::Schema.define do
|
12
|
+
field :from, Time
|
13
|
+
field :to, Time
|
14
|
+
|
15
|
+
field? :project_id, [Integer, NilClass]
|
16
|
+
|
17
|
+
field :productive, TrueClass
|
18
|
+
field :billable, TrueClass
|
19
|
+
|
20
|
+
field(:details, String).default("")
|
21
|
+
|
22
|
+
rule :from, "from should be less than to" do |object|
|
23
|
+
next object[:from] <= object[:to]
|
24
|
+
end
|
25
|
+
|
26
|
+
rule :project_id, "Project id must be set if productive set to true" do |object|
|
27
|
+
if object[:productive] && object[:project_id].nil?
|
28
|
+
next false
|
29
|
+
end
|
30
|
+
|
31
|
+
true
|
32
|
+
end
|
33
|
+
|
34
|
+
rule :project_id, "Billable must have a project_id" do |object|
|
35
|
+
if object[:billable] && object[:project_id].nil?
|
36
|
+
next false
|
37
|
+
end
|
38
|
+
|
39
|
+
true
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
ShiftEntry = ShiftEntrySchema.dataclass do
|
44
|
+
def duration
|
45
|
+
to - from
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
require "ruby-prof"
|
50
|
+
|
51
|
+
GC.compact
|
52
|
+
|
53
|
+
def run_profiler
|
54
|
+
RubyProf.start
|
55
|
+
|
56
|
+
100_000.times do
|
57
|
+
ShiftEntry.new({ "to" => "2024-10-16 12:00:00",
|
58
|
+
"from" => "2024-10-16 04:00:00",
|
59
|
+
"details" => "Worked on the project",
|
60
|
+
"billable" => true,
|
61
|
+
"productive" => true,
|
62
|
+
"project_id" => 1 })
|
63
|
+
end
|
64
|
+
|
65
|
+
# Stop profiling
|
66
|
+
result = RubyProf.stop
|
67
|
+
|
68
|
+
# Print a flat report to the console (or choose other report formats)
|
69
|
+
printer = RubyProf::GraphPrinter.new(result)
|
70
|
+
printer.print($stdout, min_percent: 3) # Adjust min_percent to filter results
|
71
|
+
end
|
72
|
+
|
73
|
+
run_profiler
|
@@ -0,0 +1,94 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "readme_doc_extractor"
|
4
|
+
|
5
|
+
namespace :readme do
|
6
|
+
desc "Generate README.md from specs"
|
7
|
+
task :generate do
|
8
|
+
# Ensure RSpec is loaded
|
9
|
+
require "rspec"
|
10
|
+
|
11
|
+
# Load the application
|
12
|
+
require_relative "../../lib/verse/schema"
|
13
|
+
|
14
|
+
# Load the spec environment
|
15
|
+
require_relative "../../spec/spec_helper"
|
16
|
+
|
17
|
+
# Load all the spec files
|
18
|
+
Dir["#{File.dirname(__FILE__)}/../../spec/**/*_spec.rb"].each { |f| require f }
|
19
|
+
|
20
|
+
# Extract documentation from specs
|
21
|
+
doc_extractor = ReadmeDocExtractor.new
|
22
|
+
examples = doc_extractor.extract_from_specs
|
23
|
+
|
24
|
+
# Debug output
|
25
|
+
puts "Found #{examples.keys.size} sections:"
|
26
|
+
examples.each do |section, section_examples|
|
27
|
+
puts " - #{section}: #{section_examples.size} examples"
|
28
|
+
end
|
29
|
+
|
30
|
+
# Generate README from template
|
31
|
+
template_path = File.join(File.dirname(__FILE__), "../../templates/README.md.erb")
|
32
|
+
|
33
|
+
# Generate the README
|
34
|
+
readme_content = doc_extractor.generate_readme(template_path)
|
35
|
+
|
36
|
+
# Write to README.md
|
37
|
+
File.write(File.join(File.dirname(__FILE__), "../../README.md"), readme_content)
|
38
|
+
|
39
|
+
puts "README.md generated successfully"
|
40
|
+
end
|
41
|
+
|
42
|
+
desc "Generate a chapter of the README from specs"
|
43
|
+
task :generate_chapter, [:chapter_name] do |_t, args|
|
44
|
+
chapter_name = args[:chapter_name]
|
45
|
+
|
46
|
+
if chapter_name.nil? || chapter_name.empty?
|
47
|
+
puts "Please provide a chapter name"
|
48
|
+
puts "Usage: rake readme:generate_chapter[chapter_name]"
|
49
|
+
exit 1
|
50
|
+
end
|
51
|
+
|
52
|
+
# Ensure RSpec is loaded
|
53
|
+
require "rspec"
|
54
|
+
|
55
|
+
# Load the application
|
56
|
+
require_relative "../../lib/verse/schema"
|
57
|
+
|
58
|
+
# Load the spec environment
|
59
|
+
require_relative "../../spec/spec_helper"
|
60
|
+
|
61
|
+
# Load all the spec files
|
62
|
+
Dir["#{File.dirname(__FILE__)}/../../spec/**/*_spec.rb"].each { |f| require f }
|
63
|
+
|
64
|
+
# Extract documentation from specs
|
65
|
+
doc_extractor = ReadmeDocExtractor.new
|
66
|
+
examples = doc_extractor.extract_from_specs
|
67
|
+
|
68
|
+
# Check if the chapter exists
|
69
|
+
unless examples.key?(chapter_name)
|
70
|
+
puts "Chapter '#{chapter_name}' not found"
|
71
|
+
puts "Available chapters: #{examples.keys.join(", ")}"
|
72
|
+
exit 1
|
73
|
+
end
|
74
|
+
|
75
|
+
# Generate chapter content
|
76
|
+
chapter_content = <<~MARKDOWN
|
77
|
+
### #{chapter_name}
|
78
|
+
|
79
|
+
#{examples[chapter_name].map { |example| "```ruby\n#{example}\n```" }.join("\n\n")}
|
80
|
+
MARKDOWN
|
81
|
+
|
82
|
+
# Write to a temporary file
|
83
|
+
temp_file = File.join(File.dirname(__FILE__), "../../tmp/#{chapter_name.downcase.gsub(/\s+/, "_")}.md")
|
84
|
+
|
85
|
+
# Create the tmp directory if it doesn't exist
|
86
|
+
tmp_dir = File.dirname(temp_file)
|
87
|
+
FileUtils.mkdir_p(tmp_dir) unless File.directory?(tmp_dir)
|
88
|
+
|
89
|
+
File.write(temp_file, chapter_content)
|
90
|
+
|
91
|
+
puts "Chapter '#{chapter_name}' generated successfully at #{temp_file}"
|
92
|
+
puts "You can now copy this content to your README.md file"
|
93
|
+
end
|
94
|
+
end
|
@@ -0,0 +1,158 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "rspec"
|
4
|
+
require "erb"
|
5
|
+
|
6
|
+
class ReadmeDocExtractor
|
7
|
+
attr_reader :chapters
|
8
|
+
|
9
|
+
def initialize
|
10
|
+
@chapters = {}
|
11
|
+
end
|
12
|
+
|
13
|
+
# Extract documentation from specs tagged with :readme
|
14
|
+
def extract_from_specs
|
15
|
+
# Load RSpec configuration
|
16
|
+
RSpec.configure do |config|
|
17
|
+
config.formatter = "progress"
|
18
|
+
config.color = false
|
19
|
+
end
|
20
|
+
|
21
|
+
# Find all specs tagged with :readme
|
22
|
+
readme_specs = find_readme_specs
|
23
|
+
|
24
|
+
# Run the specs and collect examples
|
25
|
+
run_specs(readme_specs)
|
26
|
+
|
27
|
+
# Return the chapters organized hierarchically
|
28
|
+
@chapters
|
29
|
+
end
|
30
|
+
|
31
|
+
# Generate README content from the extracted chapters and a template
|
32
|
+
def generate_readme(template_path)
|
33
|
+
# Load the template
|
34
|
+
template = File.read(template_path)
|
35
|
+
|
36
|
+
# Create a binding with the chapters
|
37
|
+
chapters_binding = binding
|
38
|
+
|
39
|
+
# Render the template with ERB
|
40
|
+
ERB.new(template, trim_mode: "-").result(chapters_binding)
|
41
|
+
end
|
42
|
+
|
43
|
+
private
|
44
|
+
|
45
|
+
# Find all specs tagged with :readme
|
46
|
+
def find_readme_specs
|
47
|
+
puts "Looking for specs tagged with :readme"
|
48
|
+
|
49
|
+
# Load all spec files
|
50
|
+
puts "Loading spec files..."
|
51
|
+
Dir["#{File.dirname(__FILE__)}/../../spec/**/*_spec.rb"].each do |f|
|
52
|
+
puts " - Loading #{f}"
|
53
|
+
require f
|
54
|
+
end
|
55
|
+
|
56
|
+
# Find specs tagged with :readme
|
57
|
+
readme_specs = RSpec.world.example_groups.select do |group|
|
58
|
+
group.metadata[:readme]
|
59
|
+
end
|
60
|
+
|
61
|
+
puts "Found #{readme_specs.size} readme specs"
|
62
|
+
readme_specs.each do |group|
|
63
|
+
puts " - #{group.description}"
|
64
|
+
end
|
65
|
+
|
66
|
+
# Return the specs
|
67
|
+
readme_specs.map { |group| [group, group.examples] }.to_h
|
68
|
+
end
|
69
|
+
|
70
|
+
# Run the specs and collect examples into chapters and sections
|
71
|
+
def run_specs(readme_specs)
|
72
|
+
readme_specs.each_key do |group|
|
73
|
+
collect_chapter_from_group(group)
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
# Collect chapter and its sections from a group
|
78
|
+
def collect_chapter_from_group(group)
|
79
|
+
chapter_name = group.description.to_s
|
80
|
+
puts "Collecting chapter: #{chapter_name}"
|
81
|
+
return unless group.metadata[:readme]
|
82
|
+
|
83
|
+
@chapters[chapter_name] = {}
|
84
|
+
|
85
|
+
# Process child groups (sections)
|
86
|
+
puts " Chapter has #{group.children.size} sections (children)"
|
87
|
+
group.children.each do |child|
|
88
|
+
section_name = child.description.to_s
|
89
|
+
puts " - Processing section: #{section_name}, readme_section: #{child.metadata[:readme_section]}"
|
90
|
+
next unless child.metadata[:readme_section]
|
91
|
+
|
92
|
+
@chapters[chapter_name][section_name] ||= []
|
93
|
+
|
94
|
+
# Process examples in this section
|
95
|
+
puts " Section has #{child.examples.size} examples"
|
96
|
+
child.examples.each do |example|
|
97
|
+
puts " - Example: #{example.description}"
|
98
|
+
next if example.metadata[:skip]
|
99
|
+
|
100
|
+
# Extract code from the example
|
101
|
+
code = extract_code_from_example(example)
|
102
|
+
if code
|
103
|
+
puts " Extracted code (#{code.lines.count} lines)"
|
104
|
+
@chapters[chapter_name][section_name] << code
|
105
|
+
else
|
106
|
+
puts " No code extracted"
|
107
|
+
end
|
108
|
+
end
|
109
|
+
end
|
110
|
+
end
|
111
|
+
|
112
|
+
# Extract code from an example
|
113
|
+
def extract_code_from_example(example)
|
114
|
+
# Get the example block directly using instance_variable_get
|
115
|
+
example_block = example.instance_variable_get(:@example_block)
|
116
|
+
return nil unless example_block
|
117
|
+
|
118
|
+
# Get the source code of the block
|
119
|
+
block_source = example_block.source
|
120
|
+
return nil unless block_source
|
121
|
+
|
122
|
+
# Clean up the code (only handle indentation)
|
123
|
+
clean_code(block_source)
|
124
|
+
end
|
125
|
+
|
126
|
+
# Clean up the code - only handle indentation
|
127
|
+
def clean_code(code)
|
128
|
+
# Remove trailing whitespace from each line
|
129
|
+
code = code.gsub(/[ \t]+$/, "")
|
130
|
+
|
131
|
+
# Fix indentation by processing each line individually
|
132
|
+
lines = code.lines
|
133
|
+
non_empty_lines = lines.reject { |line| line.strip.empty? }
|
134
|
+
min_indent = non_empty_lines.map { |line| line[/^ */].size }.min || 0
|
135
|
+
|
136
|
+
# Remove rubocop comments
|
137
|
+
lines = lines.reject { |line| line =~ /# *rubocop:.*/ }
|
138
|
+
# Remove lines with :nodoc:
|
139
|
+
lines = lines.reject { |line| line =~ /# *:nodoc:/ }
|
140
|
+
|
141
|
+
# Remove the minimum indentation from each line
|
142
|
+
if min_indent > 0
|
143
|
+
lines = lines.map do |line|
|
144
|
+
if line.strip.empty?
|
145
|
+
line
|
146
|
+
else
|
147
|
+
line.sub(/^ {#{min_indent}}/, "")
|
148
|
+
end
|
149
|
+
end
|
150
|
+
code = lines.join
|
151
|
+
end
|
152
|
+
|
153
|
+
# Ensure the code ends with a newline
|
154
|
+
code += "\n" unless code.end_with?("\n")
|
155
|
+
|
156
|
+
code
|
157
|
+
end
|
158
|
+
end
|
@@ -0,0 +1,80 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "./field"
|
4
|
+
require_relative "./result"
|
5
|
+
require_relative "./error_builder"
|
6
|
+
require_relative "./post_processor"
|
7
|
+
require_relative "./invalid_schema_error"
|
8
|
+
|
9
|
+
module Verse
|
10
|
+
module Schema
|
11
|
+
# Abstract base class for all schemas types.
|
12
|
+
class Base
|
13
|
+
attr_reader :post_processors
|
14
|
+
|
15
|
+
# Initialize a new schema.
|
16
|
+
def initialize(post_processors: nil)
|
17
|
+
@post_processors = post_processors
|
18
|
+
end
|
19
|
+
|
20
|
+
def rule(fields = nil, message = "rule failed", &block)
|
21
|
+
pp = PostProcessor.new do |value, error|
|
22
|
+
case block.arity
|
23
|
+
when 1, -1, -2 # -1/-2 are for dealing with &:method block.
|
24
|
+
error.add(fields, message) unless instance_exec(value, &block)
|
25
|
+
when 2
|
26
|
+
error.add(fields, message) unless instance_exec(value, error, &block)
|
27
|
+
else
|
28
|
+
# :nocov:
|
29
|
+
raise ArgumentError, "invalid block arity"
|
30
|
+
# :nocov:
|
31
|
+
end
|
32
|
+
|
33
|
+
value
|
34
|
+
end
|
35
|
+
|
36
|
+
if @post_processors
|
37
|
+
@post_processors.attach(pp)
|
38
|
+
else
|
39
|
+
@post_processors = pp
|
40
|
+
end
|
41
|
+
|
42
|
+
self
|
43
|
+
end
|
44
|
+
|
45
|
+
def transform(&block)
|
46
|
+
callback = proc do |value, error_builder|
|
47
|
+
stop if error_builder.errors.any?
|
48
|
+
instance_exec(value, error_builder, &block)
|
49
|
+
end
|
50
|
+
|
51
|
+
if @post_processors
|
52
|
+
@post_processors.attach(
|
53
|
+
PostProcessor.new(&callback)
|
54
|
+
)
|
55
|
+
else
|
56
|
+
@post_processors = PostProcessor.new(&callback)
|
57
|
+
end
|
58
|
+
|
59
|
+
self
|
60
|
+
end
|
61
|
+
|
62
|
+
def valid?(input) = validate(input).success?
|
63
|
+
|
64
|
+
def validate(input, error_builder: nil, locals: {}) = raise NotImplementedError
|
65
|
+
|
66
|
+
def new(arg)
|
67
|
+
result = validate(arg)
|
68
|
+
return result.value if result.success?
|
69
|
+
|
70
|
+
raise InvalidSchemaError, result.errors
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
require_relative "./collection"
|
77
|
+
require_relative "./dictionary"
|
78
|
+
require_relative "./scalar"
|
79
|
+
require_relative "./selector"
|
80
|
+
require_relative "./struct"
|
@@ -0,0 +1,132 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "date"
|
4
|
+
require "time"
|
5
|
+
|
6
|
+
module Verse
|
7
|
+
module Schema
|
8
|
+
module Coalescer
|
9
|
+
# open hash, deep symbolize keys
|
10
|
+
def self.deep_symbolize_keys(value)
|
11
|
+
case value
|
12
|
+
when Array
|
13
|
+
value.map{ |x| deep_symbolize_keys(x) }
|
14
|
+
when Hash
|
15
|
+
value.map do |k, v|
|
16
|
+
[k.to_sym, deep_symbolize_keys(v)]
|
17
|
+
end.to_h
|
18
|
+
else
|
19
|
+
value
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
register(String) do |value|
|
24
|
+
case value
|
25
|
+
when String
|
26
|
+
value
|
27
|
+
when Numeric
|
28
|
+
value.to_s
|
29
|
+
else
|
30
|
+
raise Coalescer::Error, "must be a string"
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
register(Integer) do |value|
|
35
|
+
Integer(value)
|
36
|
+
rescue TypeError, ArgumentError
|
37
|
+
raise Coalescer::Error, "must be an integer"
|
38
|
+
end
|
39
|
+
|
40
|
+
register(Float) do |value|
|
41
|
+
Float(value)
|
42
|
+
rescue TypeError, ArgumentError
|
43
|
+
raise Coalescer::Error, "must be a float"
|
44
|
+
end
|
45
|
+
|
46
|
+
register(Symbol) do |value|
|
47
|
+
case value
|
48
|
+
when Symbol
|
49
|
+
next value
|
50
|
+
when Numeric
|
51
|
+
value.to_s.to_sym
|
52
|
+
when String
|
53
|
+
raise Coalescer::Error, "must be a symbol" if value.empty?
|
54
|
+
|
55
|
+
value.to_sym
|
56
|
+
else
|
57
|
+
raise Coalescer::Error, "must be a symbol"
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
register(Time) do |value|
|
62
|
+
case value
|
63
|
+
when Time
|
64
|
+
value
|
65
|
+
when String
|
66
|
+
# Optimization: Try specific format first, fallback to general parse
|
67
|
+
begin
|
68
|
+
# Attempt fast parsing with the common format used in JSON
|
69
|
+
format = "%Y-%m-%d %H:%M:%S"
|
70
|
+
Time.strptime(value, format)
|
71
|
+
rescue ArgumentError # Raised by strptime on format mismatch
|
72
|
+
# Fallback to slower, more general parsing if strptime failed
|
73
|
+
Time.parse(value)
|
74
|
+
end
|
75
|
+
else
|
76
|
+
raise Coalescer::Error, "must be a datetime"
|
77
|
+
end
|
78
|
+
rescue ArgumentError
|
79
|
+
raise Coalescer::Error, "must be a datetime"
|
80
|
+
end
|
81
|
+
|
82
|
+
register(Date) do |value|
|
83
|
+
case value
|
84
|
+
when Date
|
85
|
+
value
|
86
|
+
when String
|
87
|
+
Date.parse(value)
|
88
|
+
else
|
89
|
+
raise Coalescer::Error, "must be a date"
|
90
|
+
end
|
91
|
+
rescue Date::Error
|
92
|
+
raise Coalescer::Error, "must be a date"
|
93
|
+
end
|
94
|
+
|
95
|
+
register(Hash) do |value|
|
96
|
+
# Open hash without contract.
|
97
|
+
|
98
|
+
raise Coalescer::Error, "must be a hash" unless value.is_a?(Hash)
|
99
|
+
|
100
|
+
Coalescer.deep_symbolize_keys(value)
|
101
|
+
end
|
102
|
+
|
103
|
+
register(Array) do |value|
|
104
|
+
raise Coalescer::Error, "must be an array" unless value.is_a?(Array)
|
105
|
+
|
106
|
+
value
|
107
|
+
end
|
108
|
+
|
109
|
+
register(nil, NilClass) do |value|
|
110
|
+
next nil if value.nil? || value == ""
|
111
|
+
|
112
|
+
raise Coalescer::Error, "must be nil"
|
113
|
+
end
|
114
|
+
|
115
|
+
register(TrueClass, FalseClass, true, false) do |value|
|
116
|
+
case value
|
117
|
+
when TrueClass, FalseClass
|
118
|
+
value
|
119
|
+
when String
|
120
|
+
next true if %w[t y true yes].include?(value)
|
121
|
+
next false if %[f n false no].include?(value)
|
122
|
+
|
123
|
+
raise Coalescer::Error, "must be a boolean"
|
124
|
+
when Numeric
|
125
|
+
value != 0
|
126
|
+
else
|
127
|
+
raise Coalescer::Error, "must be a boolean"
|
128
|
+
end
|
129
|
+
end
|
130
|
+
end
|
131
|
+
end
|
132
|
+
end
|
@@ -0,0 +1,81 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Verse
|
4
|
+
module Schema
|
5
|
+
module Coalescer
|
6
|
+
Error = Class.new(StandardError)
|
7
|
+
|
8
|
+
@mapping = {}
|
9
|
+
|
10
|
+
DEFAULT_MAPPER = lambda do |type|
|
11
|
+
if type == Base
|
12
|
+
proc do |value, opts, locals:|
|
13
|
+
opts[:schema].validate(value, locals:)
|
14
|
+
end
|
15
|
+
elsif type.is_a?(Base)
|
16
|
+
proc do |value, _opts, locals:|
|
17
|
+
type.validate(value, locals:)
|
18
|
+
end
|
19
|
+
elsif type.is_a?(Class)
|
20
|
+
proc do |value|
|
21
|
+
next value if value.is_a?(type)
|
22
|
+
|
23
|
+
raise Error, "invalid cast to `#{type}` for `#{value}`"
|
24
|
+
end
|
25
|
+
else
|
26
|
+
proc do |value|
|
27
|
+
raise Error, "invalid cast to `#{type}` for `#{value}`"
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
class << self
|
33
|
+
def register(*mapping, &block)
|
34
|
+
mapping.each do |key|
|
35
|
+
@mapping[key] = block
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
def transform(value, type, opts = {}, locals: {})
|
40
|
+
if type.is_a?(Array)
|
41
|
+
# fast-path for when the type match already
|
42
|
+
type.each do |t|
|
43
|
+
return value if t.is_a?(Class) && value.is_a?(t)
|
44
|
+
end
|
45
|
+
|
46
|
+
converted = nil
|
47
|
+
|
48
|
+
last_error_message = nil
|
49
|
+
|
50
|
+
found = false
|
51
|
+
|
52
|
+
type.each do |t|
|
53
|
+
converted = @mapping.fetch(t) do
|
54
|
+
DEFAULT_MAPPER.call(t)
|
55
|
+
end.call(value, opts, locals:)
|
56
|
+
|
57
|
+
if !converted.is_a?(Result) ||
|
58
|
+
(converted.is_a?(Result) && converted.success?)
|
59
|
+
found = true
|
60
|
+
break
|
61
|
+
end
|
62
|
+
rescue StandardError => e
|
63
|
+
last_error_message = e.message
|
64
|
+
# next
|
65
|
+
end
|
66
|
+
|
67
|
+
return converted if found || converted.is_a?(Result)
|
68
|
+
|
69
|
+
raise Error, (last_error_message || "invalid cast")
|
70
|
+
else
|
71
|
+
@mapping.fetch(type) do
|
72
|
+
DEFAULT_MAPPER.call(type)
|
73
|
+
end.call(value, opts, locals:)
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
require_relative "./coalescer/register"
|