Rule AST

The DSL in dry-schema is used to create rule objects that are provided by dry-logic. These rules are built using an AST, which uses simple data structures to represent predicates and how they are composed into complex rules and operations.

The AST can be used to convert it into another representation - for example meta-data that can be used to produce documentation.

Accessing the AST

To access schema’s rule AST use Schema#to_ast method:

schema = Dry::Schema.Params do
  required(:email).filled(:string)
  optional(:age).filled(:integer, gt?: 18)
end

schema.to_ast
# => [:set,
#  [[:and,
#    [[:predicate, [:key?, [[:name, :email], [:input, Undefined]]]],
#     [:key, [:email, [:and, [[:predicate, [:str?, [[:input, Undefined]]]], [:predicate, [:filled?, [[:input, Undefined]]]]]]]]]],
#   [:implication,
#    [[:predicate, [:key?, [[:name, :age], [:input, Undefined]]]],
#     [:key,
#      [:age,
#       [:and,
#        [[:and, [[:predicate, [:int?, [[:input, Undefined]]]], [:predicate, [:filled?, [[:input, Undefined]]]]]],
#         [:predicate, [:gt?, [[:num, 18], [:input, Undefined]]]]]]]]]]]]

Writing an AST compiler

Even though such a data structure may look scary, it’s actually very easy to write a compiler that will turn it into something useful. Let’s say you want to generate meta-data about the schema and use it for documentation purposes. To do this, you can write an AST compiler.

Here’s a simple example to give you the idea:

require 'dry/schema'

class DocCompiler
  def visit(node)
    meth, rest = node
    public_send(:"visit_#{meth}", rest)
  end

  def visit_set(nodes)
    nodes.map { |node| visit(node) }.flatten(1)
  end

  def visit_and(node)
    left, right = node
    [visit(left), visit(right)].compact
  end

  def visit_key(node)
    name, rest = node

    predicates = visit(rest).flatten(1).reduce(:merge)
    validations = predicates.map { |name, args| predicate_description(name, args) }.compact

    { key: name, validations: validations }
  end

  def visit_implication(node)
    _, right = node.map(&method(:visit))
    right.merge(optional: true)
  end

  def visit_predicate(node)
    name, args = node

    return if name.equal?(:key?)

    { name => args.map(&:last).reject { |v| v.equal?(Dry::Schema::Undefined) } }
  end

  def predicate_description(name, args)
    case name
    when :str? then "must be a string"
    when :gt? then "must be greater than #{args[0]}"
    else
      raise NotImplementedError, "#{name} not supported yet"
    end
  end
end

With such a compiler we can now turn schema’s rule AST into a list of hashes that describe keys and their validations:

compiler = DocCompiler.new

compiler.visit(schema.to_ast)
# [
#   {:key=>:email, :validations=>["must be a string"]},
#   {:key=>:age, :validations=>["must be greater than 18"], :optional=>true}
# ]