# frozen_string_literal: true
require "set"
require "securerandom"
require_relative "dsl/let"
require_relative "dsl/on"
module Factrey
# {Blueprint} DSL implementation.
class DSL
# Methods reserved for DSL.
RESERVED_METHODS = %i[
ref ext let let_default_name node on type args
__send__ __id__ nil? object_id class instance_exec initialize block_given? raise
].to_set.freeze
(instance_methods + private_instance_methods).each do |method|
undef_method(method) unless RESERVED_METHODS.include?(method)
end
# @param blueprint [Blueprint]
# @param ext [Object]
def initialize(blueprint:, ext:)
@blueprint = blueprint
@ext = ext
@ancestors = []
end
include Ref::ShorthandMethods
# @return [Object] the external object passed to {Factrey.blueprint}
attr_reader :ext
# By preceding let(name).
to the declaration, give a name to the node.
# @param name [Symbol, nil] defaults to {Blueprint::Type#name} if omitted
# @return [Let]
# @example
# bp =
# Factrey.blueprint do
# article # no meaningful name is given (See Blueprint::Node#anonymous?)
# let.article # named as article
# let(:article2).article # named as article2
# end
# bp.instantiate #=> { article: ..., article2: ..., ... }
def let(name = nil, &)
raise TypeError, "name must be a Symbol" if name && !name.is_a?(Symbol)
raise ArgumentError, "nested let" if @let_scope
let = Let.new(self, name)
return let unless block_given?
@let_scope = let
ret = yield
@let_scope = nil
ret
end
# Overrides the default name given by {#let}.
#
# This method does nothing if it is not preceded by {#let}.
# @param name [Symbol]
# @return [Let, Blueprint]
# @example
# class Factrey::DSL do
# # Define a shortcut method for user(:admin)
# def admin_user(...) = let_default_name(:admin_user).user(:admin, ...)
# end
# Factrey.blueprint do
# admin_user # no meaningful name is given (See Blueprint::Node#anonymous?)
# let.admin_user # named as admin_user
# let(:user2).admin_user # named as user2
# end
def let_default_name(name, &)
raise TypeError, "name must be a Symbol" unless name.is_a?(Symbol)
if @let_scope && @let_scope.name.nil?
@let_scope = nil # consumed
let(name, &)
else
return self unless block_given?
yield
end
end
# Add a node to the blueprint.
#
# This method is usually not called directly. Use the shorthand method defined by {.add_type} instead.
# @param type [Blueprint::Type]
def node(type, ...)
name = @let_scope ? (@let_scope.name || type.name) : nil
@let_scope = nil # consumed
node = @blueprint.add_node(name, type, ancestors: @ancestors)
on(node.name, ...)
end
# Enter the node to configure arguments and child nodes.
# @example
# Factrey.blueprint do
# let.blog do
# let(:article1).article
# let(:article2).article
# end
#
# # Add article to `blog`
# on.blog { let(:article3).article }
# # Add title to `article2`
# on.article2(title: "This is an article 2")
# end
def on(name = nil, ...)
return On.new(self) if name.nil? && !block_given?
node = @blueprint.nodes[name]
raise ArgumentError, "unknown node: #{name}" unless node
stashed_ancestors = @ancestors
@ancestors = node.ancestors + [node]
args(...)
@ancestors = stashed_ancestors
node
end
# Add arguments to the current node.
# @example
# Factrey.blueprint do
# let.blog
#
# # The following two lines are equivalent:
# on.blog { args :premium, title: "Who-ha" }
# on.blog(:premium, title: "Who-ha")
# end
def args(*args, **kwargs)
raise NameError, "Cannot use args at toplevel" if @ancestors.empty?
@ancestors.last.args.concat(args)
@ancestors.last.kwargs.update(kwargs)
yield if block_given?
end
class << self
# @return [Hash{Symbol => Type}] the types defined in this DSL
def types
@types ||= {}
end
# Add a new type that will be available in this DSL.
# A helper method with the same name as the type name is also defined in the DSL. For example,
# if you have added the foo
type, you can declare node with #foo
.
#
# {.add_type} is called automatically when you use factory_bot-blueprint
gem.
# @param type [Blueprint::Type] blueprint type
# @example
# factory = ->(type, _ctx, *args, **kwargs) { FactoryBot.create(type.name, *args, **kwargs) }
# Factrey::DSL.add_type(Factrey::Blueprint::Type.new(:blog, &factory))
# Factrey::DSL.add_type(Factrey::Blueprint::Type.new(:article, auto_references: :blog, &factory))
#
# Factrey.blueprint do
# blog do
# article(title: "Article 1")
# article(title: "Article 2")
# end
# end
def add_type(type)
if RESERVED_METHODS.member? type.name
raise ArgumentError, "Cannot use reserved method name '#{type.name}' for type name"
end
if types.member? type.name
raise ArgumentError, "duplicate type definition: #{type.name}" if types[type.name] != type
return
end
types[type.name] = type
define_method(type.name) { |*args, **kwargs, &block| node(type, *args, **kwargs, &block) }
end
end
end
end