dry-schema icon indicating copy to clipboard operation
dry-schema copied to clipboard

Extension info, not work with macros `maybe`

Open ermolaev opened this issue 4 years ago • 3 comments

Describe the bug

UserSchema.info raised an error if schema has .maybe

To Reproduce

UserSchema = Dry::Schema.JSON do
  optional(:age).maybe(:integer)
end
UserSchema.info
undefined method `visit_not' for #<Dry::Schema::Info::SchemaCompiler:0x00005625dd027ef8>
Did you mean?  visit_and
               visit_set
               visit_key
lib/dry/schema/extensions/info/schema_compiler.rb:46:in `public_send'
lib/dry/schema/extensions/info/schema_compiler.rb:46:in `visit'
lib/dry/schema/extensions/info/schema_compiler.rb:74:in `block in visit_implication'

Expected behavior

A clear and concise description of what you expected to happen.

My environment

  • Affects my production application: YES
  • Ruby version: 2.7
  • OS: linux

ermolaev avatar Jun 30 '21 11:06 ermolaev

@ermolaev As a quick fix, I think you can copy the following class: https://github.com/dry-rb/dry-schema/blob/d0f601c370f6e399e83067cbf321947904d735fd/lib/dry/schema/extensions/info/schema_compiler.rb

and add the following method:

def visit_not(_node, opts = {})
      key = opts[:key]
      keys[key][:nullable] = true
end

And instead of calling .info, use

> UserSchema = Dry::Schema.JSON do
>     optional(:age).maybe(:integer)
> end
> compiler = Dry::SchemaCompiler.new
> compiler.call(UserSchema.to_ast)
> compiler.keys
 => {:age=>{:required=>false, :nullable=>true, :type=>"integer"}} 

I've also noticed an issue with the following definition:

required(:field).array(:int?)

This returns the field with type integer, and there is no indication that the definition is an array.

You could also do a workaround by overriding the visit_predicate method and adding an array flag.

def visit_predicate(node, opts = {})
      name, rest = node

      key = opts[:key]

      if name.equal?(:key?)
        keys[rest[0][1]] = { required: opts.fetch(:required, true) }
      elsif name.equal?(:array?)
        keys[key][:array] = true
      else
        type = PREDICATE_TO_TYPE[name]
        keys[key][:type] = type if type
      end
    end

Also, keep in mind that EMPTY_HASH will not be available in your custom class namespace, you can replace it with {}.

The full file should look something like this:

module Dry
  class SchemaCompiler
    PREDICATE_TO_TYPE = {
        array?: 'array',
        bool?: 'boolean',
        date?: 'date',
        date_time?: 'datetime',
        decimal?: 'float',
        float?: 'float',
        hash?: 'hash',
        int?: 'integer',
        nil?: 'nil',
        str?: 'string',
        time?: 'time'
    }.freeze

    # @api private
    attr_reader :keys

    # @api private
    def initialize
      @keys = {}
    end

    # @api private
    def to_h
      { keys: keys }
    end

    # @api private
    def call(ast)
      visit(ast)
    end

    # @api private
    def visit(node, opts = {})
      meth, rest = node
      public_send(:"visit_#{meth}", rest, opts)
    end

    # @api private
    def visit_set(node, opts = {})
      target = (key = opts[:key]) ? self.class.new : self

      node.map { |child| target.visit(child, opts) }

      return unless key

      target_info = opts[:member] ? { member: target.to_h } : target.to_h
      type = opts[:member] ? 'array' : 'hash'

      keys.update(key => { **keys[key], type: type, **target_info })
    end

    # @api private
    def visit_and(node, opts = {})
      left, right = node

      visit(left, opts)
      visit(right, opts)
    end

    def visit_not(_node, opts = {})
      key = opts[:key]
      keys[key][:nullable] = true
    end

    # @api private
    def visit_implication(node, opts = {})
      node.each do |el|
        visit(el, opts.merge(required: false))
      end
    end

    # @api private
    def visit_each(node, opts = {})
      visit(node, opts.merge(member: true))
    end

    # @api private
    def visit_key(node, opts = {})
      name, rest = node
      visit(rest, opts.merge(key: name, required: true))
    end

    # @api private
    def visit_predicate(node, opts = {})
      name, rest = node

      key = opts[:key]

      if name.equal?(:key?)
        keys[rest[0][1]] = { required: opts.fetch(:required, true) }
      elsif name.equal?(:array?)
        keys[key][:array] = true
      else
        type = PREDICATE_TO_TYPE[name]
        keys[key][:type] = type if type
      end
    end
  end
end

Jane-Terziev avatar Jul 22 '21 09:07 Jane-Terziev

@Jane-Terziev looks good, and it works, you can create PR? I think @solnic will give more detailed feedback, on PR review

ermolaev avatar Jul 22 '21 11:07 ermolaev

I opened #471, looking for feedback

santiagodoldan avatar Nov 07 '23 20:11 santiagodoldan