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/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"