diff --git a/lib/dry/schema/ast_error_compiler.rb b/lib/dry/schema/ast_error_compiler.rb new file mode 100644 index 00000000..15f67284 --- /dev/null +++ b/lib/dry/schema/ast_error_compiler.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +require 'dry/schema/error_compiler' +require 'dry/schema/ast_error_set' + +module Dry + module Schema + # Compiles rule results AST into machine-readable format + # + # @api private + class AstErrorCompiler < ErrorCompiler + # @api private + def call(ast) + AstErrorSet[ast] + end + + def with(*args) + self + end + end + end +end diff --git a/lib/dry/schema/ast_error_set.rb b/lib/dry/schema/ast_error_set.rb new file mode 100644 index 00000000..c728429f --- /dev/null +++ b/lib/dry/schema/ast_error_set.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require 'dry/schema/error_set' + +module Dry + module Schema + # A set of AST errors used to generate machine-readable errors + # + # @see Result#message_set + # + # @api public + class AstErrorSet < ErrorSet + include Enumerable + + private + + # @api private + def errors_map(errors = self.errors) + errors + end + end + end +end diff --git a/lib/dry/schema/config.rb b/lib/dry/schema/config.rb index 63f0271e..6213bbaa 100644 --- a/lib/dry/schema/config.rb +++ b/lib/dry/schema/config.rb @@ -36,6 +36,16 @@ class Config # @api public setting(:types, Dry::Types) + # @!method error_compiler + # + # Return configured error_compiler + # + # @return [Dry::Schema::ErrorCompiler] + # + # @api public + setting(:error_compiler, :message) + + # @!method messages # # Return configuration for message backend diff --git a/lib/dry/schema/error_compiler.rb b/lib/dry/schema/error_compiler.rb new file mode 100644 index 00000000..6ca6be15 --- /dev/null +++ b/lib/dry/schema/error_compiler.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Dry + module Schema + # Compiles rule results AST into some type of errors + # + # @api private + class ErrorCompiler + # @api private + def call(_ast) + raise NotImplementedError + end + + def with(_args) + raise NotImplementedError + end + end + end +end diff --git a/lib/dry/schema/error_set.rb b/lib/dry/schema/error_set.rb new file mode 100644 index 00000000..ad7625ee --- /dev/null +++ b/lib/dry/schema/error_set.rb @@ -0,0 +1,110 @@ +# frozen_string_literal: true + +require 'dry/equalizer' + +module Dry + module Schema + # A set of generic errors + # + # @see Result#message_set + # + # @api public + class ErrorSet + include Enumerable + include Dry::Equalizer(:errors, :options) + + # A list of compiled errors + # + # @return [Array] + attr_reader :errors + + # Options hash + # + # @return [Hash] + attr_reader :options + + # @api private + def self.[](errors, options = EMPTY_HASH) + new(errors, options) + end + + # @api private + def initialize(errors, options = EMPTY_HASH) + @errors = errors + @options = options + end + + # Iterate over errors + # + # @example + # result.errors.each do |message| + # puts message.text + # end + # + # @return [Array] + # + # @api public + def each(&block) + return self if empty? + return to_enum unless block + + errors.each(&block) + end + + # Dump message set to a hash + # + # @return [HashArray>] + # + # @api public + def to_h + @to_h ||= errors_map + end + alias_method :to_hash, :to_h + + # Get a list of errors for the given key + # + # @param [Symbol] key + # + # @return [Array] + # + # @api public + def [](key) + to_h[key] + end + + # Get a list of errors for the given key + # + # @param [Symbol] key + # + # @return [Array] + # + # @raise KeyError + # + # @api public + def fetch(key) + self[key] || raise(KeyError, "+#{key}+ error was not found") + end + + # Check if an error set is empty + # + # @return [Boolean] + # + # @api public + def empty? + @empty ||= errors.empty? + end + + # @api private + def freeze + to_h + empty? + super + end + + # @api private + def errors_map(_errors) + raise NotImplementedError + end + end + end +end diff --git a/lib/dry/schema/extensions/hints/message_set_methods.rb b/lib/dry/schema/extensions/hints/message_set_methods.rb index d07e5dd5..9d229093 100644 --- a/lib/dry/schema/extensions/hints/message_set_methods.rb +++ b/lib/dry/schema/extensions/hints/message_set_methods.rb @@ -19,9 +19,9 @@ module MessageSetMethods attr_reader :failures # @api private - def initialize(messages, options = EMPTY_HASH) + def initialize(errors, options = EMPTY_HASH) super - @hints = messages.select(&:hint?) + @hints = errors.select(&:hint?) @failures = options.fetch(:failures, true) end @@ -34,7 +34,7 @@ def initialize(messages, options = EMPTY_HASH) # # @api public def to_h - @to_h ||= failures ? messages_map : messages_map(hints) + @to_h ||= failures ? errors_map : errors_map(hints) end alias_method :to_hash, :to_h @@ -42,14 +42,15 @@ def to_h # @api private def unique_paths - messages.uniq(&:path).map(&:path) + errors.uniq(&:path).map(&:path) end # @api private - def messages_map(messages = self.messages) + def errors_map(errors = self.errors) return EMPTY_HASH if empty? - messages.reduce(placeholders) { |hash, msg| + initialize_placeholders! + errors.reduce(placeholders) { |hash, msg| node = msg.path.reduce(hash) { |a, e| a.is_a?(Hash) ? a[e] : a.last[e] } (node[0].is_a?(::Array) ? node[0] : node) << msg.dump hash @@ -61,7 +62,7 @@ def messages_map(messages = self.messages) # rubocop:disable Metrics/AbcSize # rubocop:disable Metrics/PerceivedComplexity def initialize_placeholders! - @placeholders = unique_paths.each_with_object(EMPTY_HASH.dup) { |path, hash| + @placeholders ||= unique_paths.each_with_object(EMPTY_HASH.dup) { |path, hash| curr_idx = 0 last_idx = path.size - 1 node = hash diff --git a/lib/dry/schema/message_compiler.rb b/lib/dry/schema/message_compiler.rb index 3e9f26ae..9a8fea7f 100644 --- a/lib/dry/schema/message_compiler.rb +++ b/lib/dry/schema/message_compiler.rb @@ -2,6 +2,7 @@ require 'dry/initializer' +require 'dry/schema/error_compiler' require 'dry/schema/constants' require 'dry/schema/message' require 'dry/schema/message_set' @@ -12,7 +13,7 @@ module Schema # Compiles rule results AST into human-readable format # # @api private - class MessageCompiler + class MessageCompiler < ErrorCompiler extend Dry::Initializer resolve_key_predicate = proc { |node, opts| @@ -65,7 +66,7 @@ def call(ast) current_messages = EMPTY_ARRAY.dup compiled_messages = ast.map { |node| visit(node, EMPTY_OPTS.dup(current_messages)) } - MessageSet[compiled_messages, failures: options.fetch(:failures, true)] + MessageSet[compiled_messages.flatten, failures: options.fetch(:failures, true)] end # @api private diff --git a/lib/dry/schema/message_compiler/visitor_opts.rb b/lib/dry/schema/message_compiler/visitor_opts.rb index a926fbe3..89eeb995 100644 --- a/lib/dry/schema/message_compiler/visitor_opts.rb +++ b/lib/dry/schema/message_compiler/visitor_opts.rb @@ -1,12 +1,13 @@ # frozen_string_literal: true +require 'dry/schema/error_compiler' require 'dry/schema/constants' require 'dry/schema/message' module Dry module Schema # @api private - class MessageCompiler + class MessageCompiler < ErrorCompiler # Optimized option hash used by visitor methods in message compiler # # @api private diff --git a/lib/dry/schema/message_set.rb b/lib/dry/schema/message_set.rb index 91963751..9c23f0fd 100644 --- a/lib/dry/schema/message_set.rb +++ b/lib/dry/schema/message_set.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -require 'dry/equalizer' +require 'dry/schema/error_set' module Dry module Schema @@ -9,14 +9,8 @@ module Schema # @see Result#message_set # # @api public - class MessageSet + class MessageSet < ErrorSet include Enumerable - include Dry::Equalizer(:messages, :options) - - # A list of compiled message objects - # - # @return [Array] - attr_reader :messages # An internal hash that is filled in with dumped messages # when a message set is coerced to a hash @@ -24,97 +18,14 @@ class MessageSet # @return [Hash[Array,Hash]>] attr_reader :placeholders - # Options hash - # - # @return [Hash] - attr_reader :options - - # @api private - def self.[](messages, options = EMPTY_HASH) - new(messages.flatten, options) - end - - # @api private - def initialize(messages, options = EMPTY_HASH) - @messages = messages - @options = options - initialize_placeholders! - end - - # Iterate over messages - # - # @example - # result.errors.each do |message| - # puts message.text - # end - # - # @return [Array] - # - # @api public - def each(&block) - return self if empty? - return to_enum unless block - - messages.each(&block) - end - - # Dump message set to a hash - # - # @return [HashArray>] - # - # @api public - def to_h - @to_h ||= messages_map - end - alias_method :to_hash, :to_h - - # Get a list of message texts for the given key - # - # @param [Symbol] key - # - # @return [Array] - # - # @api public - def [](key) - to_h[key] - end - - # Get a list of message texts for the given key - # - # @param [Symbol] key - # - # @return [Array] - # - # @raise KeyError - # - # @api public - def fetch(key) - self[key] || raise(KeyError, "+#{key}+ message was not found") - end - - # Check if a message set is empty - # - # @return [Boolean] - # - # @api public - def empty? - @empty ||= messages.empty? - end - - # @api private - def freeze - to_h - empty? - super - end - private # @api private - def messages_map(messages = self.messages) + def errors_map(errors = self.errors) return EMPTY_HASH if empty? - messages.group_by(&:path).reduce(placeholders) do |hash, (path, msgs)| + initialize_placeholders! + errors.group_by(&:path).reduce(placeholders) do |hash, (path, msgs)| node = path.reduce(hash) { |a, e| a[e] } msgs.each do |msg| @@ -129,14 +40,14 @@ def messages_map(messages = self.messages) # @api private def paths - @paths ||= messages.map(&:path).uniq + @paths ||= errors.map(&:path).uniq end # @api private def initialize_placeholders! return @placeholders = EMPTY_HASH if empty? - @placeholders = paths.reduce(EMPTY_HASH.dup) do |hash, path| + @placeholders ||= paths.reduce(EMPTY_HASH.dup) do |hash, path| curr_idx = 0 last_idx = path.size - 1 node = hash diff --git a/lib/dry/schema/rule_applier.rb b/lib/dry/schema/rule_applier.rb index cc068ac8..aa6cbf4f 100644 --- a/lib/dry/schema/rule_applier.rb +++ b/lib/dry/schema/rule_applier.rb @@ -7,6 +7,7 @@ require 'dry/schema/result' require 'dry/schema/messages' require 'dry/schema/message_compiler' +require 'dry/schema/ast_error_compiler' module Dry module Schema @@ -23,7 +24,13 @@ class RuleApplier option :config, default: -> { Config.new } # @api private - option :message_compiler, default: -> { MessageCompiler.new(Messages.setup(config.messages)) } + option :message_compiler, default: (proc do + if config.error_compiler == :ast + AstErrorCompiler.new + else + MessageCompiler.new(Messages.setup(config.messages)) + end + end) # @api private def call(input) diff --git a/spec/integration/schema_spec.rb b/spec/integration/schema_spec.rb index 77c1364e..20c9a66c 100644 --- a/spec/integration/schema_spec.rb +++ b/spec/integration/schema_spec.rb @@ -247,4 +247,40 @@ end end end + + context 'with ast errors' do + subject(:schema) do + Dry::Schema.define do + config.error_compiler = :ast + + required(:email).value(:str?) + optional(:age).value(:int?, gt?: 18) + end + end + + it 'passes when input is valid' do + expect(schema.(email: 'jane@doe')).to be_success + end + + it 'fails when input is not valid' do + expect(schema.(age: 12)).to be_failure + end + + it 'produces ast errors' do + result = schema.(email: 1, age: 12) + + expect(result.errors.to_h).to eq( + [ + [ + :key, + [:email, [:predicate, [:str?, [[:input, 1]]]]] + ], + [ + :failure, + [:age, [:key, [:age, [:predicate, [:gt?, [[:num, 18], [:input, 12]]]]]]] + ] + ] + ) + end + end end diff --git a/spec/unit/dry/schema/ast_error_compiler_spec.rb b/spec/unit/dry/schema/ast_error_compiler_spec.rb new file mode 100644 index 00000000..1e100eb8 --- /dev/null +++ b/spec/unit/dry/schema/ast_error_compiler_spec.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +RSpec.describe Dry::Schema::AstErrorCompiler, '#call' do + subject(:ast_error_compiler) do + Dry::Schema::AstErrorCompiler.new + end + + it 'returns an empty hash when there are no errors' do + expect(ast_error_compiler.([])).to be_empty + end +end diff --git a/spec/unit/dry/schema/error_compiler_spec.rb b/spec/unit/dry/schema/error_compiler_spec.rb new file mode 100644 index 00000000..fa523a2d --- /dev/null +++ b/spec/unit/dry/schema/error_compiler_spec.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +RSpec.describe Dry::Schema::ErrorCompiler, '#call' do + subject(:message_compiler) do + Dry::Schema::MessageCompiler.new(Dry::Schema::Messages::YAML.build) + end + + it 'returns an empty hash when there are no errors' do + expect(message_compiler.([])).to be_empty + end +end