DEV Community

Cover image for Crafting mini RubyOnRails
Vladislav Kopylov
Vladislav Kopylov

Posted on

Crafting mini RubyOnRails

Introduction

Once I watched Maple Ong's talk Building a Ruby web app using the Ruby Standard Library on Euruko 2021. The talk inspired me and I've got an idea to write a little web-server from scratch. Then I started to improve the original script and decided to write an MVC-framework as RubyOnRails on plain Ruby. Such a good challenge.

I called it MiniRails, the source code is available in mono repo on GitHub. In the article I describe the process of creating the library. The goal of the article is to describe which concepts and ideas I used: how MVC layers work, how router matches a client request and how I implemented a test library. Some code examples are short to show the main concept without excess amount of code. If you want to look deeper, there is always links to the course code on Github. I hope my experience will be useful for readers.

Build a rack middleware

Begin with writing a rack-middleware. Rack is a standard library for writing a web server. The main structure is simple. Here is an example:

module MiniActionDispatch
  # Rack-middleware to render hello world page
  class HelloHandler
    # Receive rack-env and build response in rack format
    def call(env)
      [200, { "Content-Type" => 'text/html' }, ["<h1>Hello to Ruby on MiniRails</h1>"]]
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Middleware for the application looks like this. At the first sight it's more difficult example, but the algorithm is clear.

# Rack-middleware to handler rack-request
class MiniActionDispatch::RequestHandler
  def call(env)
    # Wrap data to convenient interface
    req = Rack::Request.new(env)

    # Fetch params from a request
    method_token = req.request_method
    path = req.path || req.path_info

    # Wrap params to data-object to handle request params and http-headers
    action_params = ::MiniActionParams.parse(req)
    params = action_params.params
    headers = action_params.headers

    # DELETE, PUT, PATCH support
    if method_token == 'POST' && ['DELETE', 'PUT', 'PATCH'].include?(params[:_method]&.upcase)
      method_token = params[:_method].upcase
    end

    # Match path with route map and find the controller to handle request
    selected_route = MiniActiveRouter::Base.instance.find(method_token, path)
    controller_name, controler_method_name = selected_route.controller_data
    # Route's placeholder support such as :id
    placeholders = selected_route.parse_placeholders(path)
    params = params.merge(placeholders)

    # Find a controller
    controller_class = Object.const_get "#{controller_name.camelize}Controller"
    controller = controller_class.new(params, headers)

    # Run controller's action
    # Construct the HTTP response and return it
    controller.build_response(controler_method_name)
  end
end
Enter fullscreen mode Exit fullscreen mode

The algorithm has 3 steps:
* Handle user request data
* Match route and find a controller
* Pass data to controller and execute a method

Dive deeper and look how the router works

For routing I've written MiniActiveRouter module. My goal was to implement basic algorithm from Rails router, such as in the example below:

MiniActiveRouter::Base.instance.draw do
  get '/', to: 'home#index'

  # Group scope - JSON
  get '/api/groups', to: 'api/groups#index'
  get '/api/groups/:id', to: 'api/groups#show'
  post '/api/groups', to: 'api/groups#create'
  patch '/api/groups/:id', to: 'api/groups#update'
  delete '/api/groups/:id', to: 'api/groups#destroy'

  not_found to: 'not_found#index'
end
Enter fullscreen mode Exit fullscreen mode

There are get, post, patch, put, delete functions to define routes and not_found method for 404 page handler. Routes also support placeholders such as "id" in path. MiniActiveRouter::Base is a singleton class, current code is here. The singleton stores array of routes in @map variable and holds a route for 404 page in @fallback_route variable.

class MiniActiveRouter::Base
  include ::Singleton

  def initialize
    @map = []
    @fallback_route = nil
  end

  # NOTE: Method for drawing routes map
  # Use it in config/router.rb file
  def draw(&block)
    instance_eval &block
  end

  # @param path [String, Regexp]
  # @param arg [Hash]
  # NOTE: post, delete, put, patch method are similar
  def get(path, arg)
    write_to_map('GET', path, **arg)
  end

  # NOTE: Method for set a route for 404 page
  def not_found(to: )
    @fallback_route = Route.new(nil, nil, to: to)
  end

  private
  def write_to_map(method, path, to:)
    transformed_path = transform_path(path)
    @map << Route.new(method, transformed_path, to: to)
  end
end
Enter fullscreen mode Exit fullscreen mode

Drawing routes algorithm isn't difficult. For draw function it uses instance_eval , it make the code inside the do-end block plain and easy to read. get, post, patch, etc methods are just wrappers for write_to_map function.

Adding placeholder in path feature wasn't a trivial task. I wanted to define a route in the following manner:

patch '/api/groups/:id', to: 'api/groups#update'
patch '/groups/:group_id/items/:id', to: 'items#update'
Enter fullscreen mode Exit fullscreen mode

The easiest way I figured out is using regular expressions. Regexp has groups feature. For route /api/groups/:id we can write a regexp /\/api\/groups\/(?<id>[0-9]*)/ and it will match group "id".

"/api/groups/123" =~ /\/api\/groups\/(?<id>[0-9]*)/
# => 0
match_data = Regexp.last_match
# => #<MatchData "/api/groups/123" id:"123">
Enter fullscreen mode Exit fullscreen mode

For placeholders feature I've written the method transform_path. It match placeholders, if they are here it converts string to a regexp.

class MiniActiveRouter::Base
  # If a string has placeholders (for example "items/:id")
  # It converts string to regexp with groups (for example /items\/(:id[0-9a-zA-Z]*)/)
  def transform_path(path)
    # 1: Find all placeholders
    placeholders = path.scan(/:[0-9a-zA-Z\-_]*/)
    return path if placeholders.size == 0

    # 2: Replace each placeholder to (?<placeholder_name>[0-9a-zA-Z]*)
    placeholders.each do |placeholder|
      path = path.gsub(placeholder, "(?<#{placeholder}>[0-9a-zA-Z\\-_]*)")
    end

    # 3: Return the route as an regexp
    return Regexp.new("^#{path}$")
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

How does the route matching work? There is a line of code in the the previous rack-middleware. Method .find receives a method token, a path and matches data with all available routes.

selected_route = MiniActiveRouter::Base.instance.find(method_token, path)
# Example: MiniActiveRouter::Base.instance.find('GET', '/items/1234')
Enter fullscreen mode Exit fullscreen mode

Under the hood, the algorithm is simple: match all routes, if there is not a match return the default route if it exists.

class MiniActiveRouter::Base
  # NOTE: it iterates each route from @map
  # Returns matched router or @fallback_route
  # @param method [String]
  # @param path [String]
  # @return [Route]
  def find(method, path)
    matched_route = @map.find{ |route| route.match?(method, path) }
    if matched_route.present?
      matched_route
    elsif @fallback_route.present?
      @fallback_route
    else
      raise "ERROR: Can't find route for #{method}##{path}"
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Controllers layer

After the matching, we know which controller could handle the client request. Below you can see the code from rack-middleware.

controller_name, controler_method_name = selected_route.controller_data
# Find controller
controller_class = Object.const_get "#{controller_name.camelize}Controller"
controller = controller_class.new(params, headers)

# Run controller's action
# Construct the HTTP response and return it
controller.build_response(controler_method_name)
Enter fullscreen mode Exit fullscreen mode

Inside the application a controller looks familiar as an average controller in Ruby On Rails application.

class ItemsController < ApplicationController
  before_action :find_group

  def index
    @items = @group.items.sort_by(&:active?).map{ |i| ItemDecorator.new(i) }
    render :index
  end

  def create
    @item = Item.new(permited_params)
    if @item.save
      redirect_to "/groups/#{@group.id}/items"
    else
      @alert = @item.errors.full_messages.join(', ')
      render :new, status: '422 Unprocessable Entity'
    end
  end

  private
  def find_group
    @group = Group.find(params[:group_id])
  end
end
Enter fullscreen mode Exit fullscreen mode

Also it supports before_action callback and rescue_from handler.

class ApplicationController < MiniActionController::Base
  rescue_from ::MiniActiveRecord::RecordNotFound, with: :not_found

  private
  def not_found
    render('not_found/index', status: '404 Not Found')
  end
end
Enter fullscreen mode Exit fullscreen mode

Let's look how #build_response method works.

class MiniActionController::Base
  # @param controler_method_name [String, Symbol]
  def build_response(controler_method_name)
    begin
      # 1: Run all callbacks
      run_callbacks_for(controler_method_name.to_sym)
      # 2: Run the controller action
      response = public_send(controler_method_name)
    rescue StandardError => e
      # 3: If there is an exception, try to find :rescue_from handler
      response = try_to_rescue(e)
    end
    build_rack_response(response)
  end
end
Enter fullscreen mode Exit fullscreen mode

There are few steps:
* Run all callbacks if they exist
* Run the controller action
* Build a standard rack response
* If it catches an error, it tries to rescue the exception

Callbacks

How to define and run callbacks? You can see the code in MiniActionController::Callbacks module. There is the method before_action in MiniRails. Using it we can define callbacks with conditions such as:

before_action :method_name, only: [:index, :show], unless: -> { @foo.nil? }
before_action :method_name, except: [:index, :show], if: -> { @foo }
Enter fullscreen mode Exit fullscreen mode

before_action method stores data in callbacks. In order to share data between class and class instance I use the class_attribute feature from ActiveSupport . Here is the link to docs. It's very useful feature, I like it and often use it.

module MiniActionController::Callbacks
  def self.included(base)
    base.class_attribute :callbacks
    base.callbacks = []
  end
end
Enter fullscreen mode Exit fullscreen mode

Return to MiniActionController::Base#build_response method, run_callbacks_for method iterates each before_action defined callback, matches conditions such as only:, except:, if:, unless: and then it executes a method.

Rescuing

How to rescue an exceptions? You can see the code in MiniActionController::Rescuable module.

module MiniActionController::Rescuable
  def self.included(base)
    base.class_attribute :rescue_attempts
    base.rescue_attempts = []
    base.extend ClassMethods
  end

  private
  # NOTE: Find first handle for the exception and run
  # @param exception [StandardError]
  def try_to_rescue(exception)
    rescue_attempt = self.class.rescue_attempts.find do |meta|
      exception.is_a?(meta[:exception])
    end
    raise exception if rescue_attempt.nil?

     send(rescue_attempt[:with])
  end

  module ClassMethods
    # Example of usage:
    # rescue_from User::NotAuthorized, with: :deny_access
    # rescue_from ActiveRecord::RecordInvalid, with: :show_errors
    # @param exception [StandardError]
    # @param with [String, Symbol]
    def rescue_from(exception, with: nil)
      rescue_attempt = { exception: exception, with: with }
      self.rescue_attempts = self.rescue_attempts + [rescue_attempt]
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Algorithm is similar. It uses the class attribute rescue_attempts to store rescue handlers. Function rescue_from adds data to rescue_attempts, method try_to_rescue receives an exception and tries to find a handle.

Views layer

Rendering

We saw how controllers layer works, let's see how it renders views. For views layer I've implemented MiniActionView namespace. For view files I use ERB-files, because it's simple and familiar to use. Example of a simple view:

# Controler method
def new
  @group = Group.new
  render :new, status: "200 OK"
end

# View new.html.erb file
<h2>Create new group</h2>

<%= render_partial 'shared/_new_group_form', locals: { item: @group } %>

<a href="/">Go to back</a>
Enter fullscreen mode Exit fullscreen mode

It supports partial file render, and received instance variables from a controller method.

How are controllers layer and views layer connected and how it passes instance variables from controller method to a view? The easiest way is to include MiniActionView module to MiniActionController, but I don't consider it as a good idea, I prefer a different way.

Let's see how method render works. The code inside MiniActionController::Render module collects all instance variables inside collect_variables method and passes data as a Hash to a MiniActionView::Base instance.

module MiniActionController::Render
  # @param view_name [String, Symbol]
  # @param status [String]
  # @return [MiniActionController::Response]
  def render(view_name, status: MiniActionController::DEFAULT_STATUS)
    # collect and forward instance variables to MiniActionView::Base
    variables_to_pass = collect_variables
    MiniActionView::Base.new(variables_to_pass, entity).render(view_name, status: status)
  end

  private
  def collect_variables
    instance_variables.reduce({}) do |memo, var_symbol|
      memo[var_symbol] = instance_variable_get(var_symbol)
      memo
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

The render method of MiniActionView::Base class executes render_view function in order to render ERB-file and it returns a value-object as a result

module MiniActionView::Base
  # @param view_name [String, Symbol]
  # @param status [String]
  # @param content_type [String] html by default
  # @return [MiniActionController::Response]
  def render(view_name, status: MiniActionController::DEFAULT_STATUS, content_type: 'html')
    response_message = render_view("#{view_name}.html.erb")
    MiniActionController::Response.new(
      status: status, response_message: response_message, content_type: content_type, headers: {},
    )
  end
end
Enter fullscreen mode Exit fullscreen mode

You can see the rendering algorithm in MiniActionView::Render module, here is the main logic in the snippet below:

module MiniActionView::Render
  # NOTE: If view_name has `/` symbol it searches for a file in app/views folder
  # If view_name hasn't `/` symbol it searches for a file in entity folder
  def render_view(view_name, locals: {})
    root_path = MiniRails.root.join('app', 'views')
    root_path = root_path.join(entity) unless view_name.include?('/')
    view_path = root_path.join(view_name).to_s

    # assign data from locals: as local variables
    local_binding = binding
    locals.each do |key, value|
      local_binding.local_variable_set(key, value)
    end
    ERB.new(read_or_open(view_path)).result(local_binding)
  end
end
Enter fullscreen mode Exit fullscreen mode

The locals argument is used to pass value to a view. In Rails you used to write something like render form, locals: {zone: @zone, item: @item}. In order to pass data to ERB-file I create a copy of current binding, assign data and pass the variable local_binding to result method.

In the same module you can see the code how to render partial views inside a current view. render_partial is a function to render a partial view. Under the hood it's just a wrapper for private render_view function.

module MiniActionView::Render
  # @param view_name [String, Symbol]
  # @param collection [Array<Object>] each item will be passed as 'item' variable
  # @param locals [Hash<Symbol,Object>] params to passing data as local_variables
  def render_partial(view_name, collection: [], locals: {})
    if collection.size > 0
      collection.map { |i| render_view(view_name, locals: {item: i}) }.join('')
    else
      render_view(view_name, locals: locals)
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Layout

When view is already rendered it's time to render a layout. You can see the code in MiniActionView::Layout class. The render_response method renders layout with the result of controller's action and builds a rack response.

# Note: Class to render layout for views.
# ERB-file that contains layout-template must be in app/views/layouts/ folder
# For example 'app/views/layouts/application.html.erb'
class MiniActionView::Layout < ::MiniActionView::Base
  # @param layout [String, Symbol]
  # @param response [MiniActionController::Response]
  def render_response(layout, response)
    status_code, _status_text = response.status.split(' ')
    additional_headers = response.headers.map{ |k,v| "#{k}: #{v}" }.join("\n\r")
    headers = {"Content-Type" => "text/html"}.merge(response.headers)
    response_body = render_layout(layout) { response.response_message }
    # Construct the Rack response
    [status_code, headers, [response_body]]
  end

  private
  def render_layout(layout_name)
    view_path = MiniRails.root.join('app', 'views', self.entity, "#{layout_name}.html.erb").to_s
    ERB.new(read_or_open(view_path)).result(binding)
  end
end
Enter fullscreen mode Exit fullscreen mode

JSON response

MiniRails also supports JSON-responses and serialiser-objects for ruby objects. In a controller you able to write the code as:

class Api::GroupsController < ::Api::ApplicationController
  before_action :groups, only: [:index]
  before_action :group, only: [:show, :update, :destroy]

  def index
    render_json(@groups, each_serializer: GroupSerializer)
  end

  def show
    render_json(@group, serializer: GroupSerializer)
  end
end
Enter fullscreen mode Exit fullscreen mode

The source code also locates in MiniActionController::Render module, it's able to receive different data types:

module MiniActionController::Render
  # Examples of usage:
  # String: render_json({a: 123}.to_json)
  # Array: render_json([1,2,3])
  # Array with root: render_json([1,2,3], root: 'data')
  # Object. render_json(Item.all)
  # With serializer: render_json(Item.first, serializer: ItemSerializer)
  # With each_serializer: render_json(Item.all, each_serializer: ItemSerializer)
  #
  # @param object [String, Hash, Object]
  # Object should respond to .as_json and return Hash
  # @param opts [Hash]
  # @option opts [String] :status Http status
  # @option opts [Object] :serializer child of MiniActiveRecord::Serializer
  # @option opts [Object] :each_serializer Param object should be Array
  # @option opts [String] :root
  # @return [MiniActionController::Response]
  def render_json(object, opts = {})
    status = opts[:status] || MiniActionController::DEFAULT_STATUS
    MiniActionView::Json.new(object).render(opts.merge(status: status))
  end
end
Enter fullscreen mode Exit fullscreen mode

The function is just a wrapper for MiniActionView::Json#render method. Layout for JSON response works similar as HTML layout algorithm.

Assets rendering

The library supports JS and CSS assets rendering. There are stylesheet_link_tag and javascript_include_tag methods in a layout template (ERB file) to render HTML-tags.

# todo_list/app/views/layouts/application.html.erb
<!DOCTYPE html>
<html lang="ru">
<head>
  <title>My TODO list</title>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
  <%= stylesheet_link_tag "application" %>
  <%= javascript_include_tag "application" %>
# …
Enter fullscreen mode Exit fullscreen mode

A file with stylesheets can look like:

# todo_list/app/assets/stylesheets/application.css.erb
<%= import 'bootstrap' %>
<%= import 'bootstrap_pricing' %>

/* custom styles */
p.disabled {
  color: #6c757d
}
Enter fullscreen mode Exit fullscreen mode

It renders partial files bootstrap , bootstrap_pricing via import method and has its own css-styles. The algorithm of partial files rendering are written in MiniActionView::Asset class.

class MiniActionView::Asset
  # It renders text file and ERB files
  # @param file_path [String]
  # @return [String]
  def render(file_path = nil)
    file_path ||= @original_file_path
    file_context = File.open(file_path).read
    if file_path.to_s =~ /\.erb$/
      ERB.new(file_context).result(binding)
    else
      file_context
    end
  end

  private
  def import(file_name)
    @original_file_path = @current_folder.join("#{file_name}#{@file_extention}")
    # Try to find original file or file with .erb
    if File.exist?(@original_file_path)
      render(@original_file_path)
    elsif File.exist?("#{@original_file_path}.erb")
      render("#{@original_file_path}.erb")
    else
      raise "ERROR: Can not open file '#{@original_file_path}'"
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

In assets file we can use import method which is just a wrapper for render function.

How to handle client request, compile assets and return response? For it there is MiniActionDispatch::AssetHandler rack-middleware.

# Rack-middleware to handler request and render assets
class MiniActionDispatch::AssetHandler
  def initialize(app)
    @app = app
  end

  # NOTE: Attempt to find asset by path.
  # If doesn't find a file, pass the request to another middleware
  def call(env)
    attempt(env) || @app.call(env)
  end

  private
  def attempt(env)
    request = Rack::Request.new(env)
    return nil unless valid_request?(request)

    path_info = request.path_info
    file_path = find_original_file(path_info)
    if file_path.present?
      file_context = ::MiniActionView::Asset.new(file_path).render
      # Build rack answer
      return [200, build_headers(path_info), [file_context]]
    end
    # Return nil in order to pass request to another middleware
    nil
  end
end
Enter fullscreen mode Exit fullscreen mode

JS-asset files are rendered absolutely the same way.

Models layer

In order to implement a models layer I've written the MiniActiveRecord module. A standard model looks like:

class Item < MiniActiveRecord::Base
  attribute :title, type: String
  attribute :group_id, type: String
  attribute :done, type: [TrueClass, FalseClass], default: false

  validates :title, presence: true, length: { max: 100, min: 3 }
  validates :group_id, presence: true

  belongs_to :group

  scope :active, -> { where(done: false) }
  scope :not_active, -> { where(done: true) }
end
Enter fullscreen mode Exit fullscreen mode

There are attributes, validations, relations and scopes. Let's look at the code deeper.

Attributes

Firstly, I had to define attributes. I wanted to have an interface which is similar to mongoid. The code is incapsulated in MiniActiveRecord::Attribute module.

module MiniActiveRecord::Attribute
  def self.included(base)
    base.extend ClassMethods
    # Define storage to collect info about all defined attributes.
    base.class_attribute :fields
    base.fields = []

    # Define base attributes.
    base.attribute :id, type: String
    base.attribute :created_at, type: DateTime
  end

  module ClassMethods
    # @param field_name [String, Symbol]
    # @option options [Class, Array<Class>] :type
    # @option options [Object] :default The field's default
    def attribute(field_name, type: String, default: nil)
      new_field_params = { name: field_name.to_sym, type: type, default: default }
      self.fields = fields | [new_field_params]

      instance_eval do
        # Define a getter
        define_method(field_name) do
          field_params = fields.find{ |i| i[:name] == field_name.to_sym }
          instance_variable_get("@#{field_name}") || field_params[:default]
        end

        # Define a setter
        define_method("#{field_name}=") do |value|
          # CODE
          instance_variable_set("@#{field_name}", value)
        end
      end
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

I use class_attribute feature to collect info about all implemented attributes to fields variable. attribute method saves data to fields variable, defined getter and setter with meta programming.

Relations

Relations feature is one of the easiest and smallers part. You can see the code inside MiniActiveRecord::Association module.

# NOTE: Module with assiciation logic, such as: has_many, belongs_to, etc.
module MiniActiveRecord::Association
  # Example of usage: has_many :items
  # It will create method .items
  # The method returns Array
  # @param assosiation_name [String, Symbol]
  # @param class_name [String] Model name
  def has_many(association_name, class_name:)
    instance_eval do
      define_method(association_name) do
        another_model = Object.const_get(class_name)
        attribute = "#{self.class.name.downcase}_id"
        another_model.where(attribute.to_sym => id)
      end
    end
  end

  # Example of usage: has_many :user
  # It will create method .user
  # The method returns Object
  # @param assosiation_name [String, Symbol]
  def belongs_to(association_name)
    instance_eval do
      define_method(association_name) do
        class_name = association_name.to_s.camelize
        another_model = Object.const_get(class_name)
        refer_id = public_send("#{association_name}_id")
        another_model.find(refer_id)
      end
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

I've written has_many and belongs_to relations. Both methods create other methods with meta programming. The logic is clear and it doesn't work like a magic.

Validations

So, we have attributes and relations. It's time for validations. The code is written inside MiniActiveRecord::Validation module.

module MiniActiveRecord::Validation
  def self.included(base)
    base.class_attribute :validations
    base.validations = []
    base.extend ClassMethods
  end

  module ClassMethods
    # Example of usage:
    #   validates :title, presence: true
    #   validates :title, length: { max: 100, min: 3 }
    # @param field_name [String, Symbol]
    # @param presence [Boolean]
    # @param length [Hash]
    # @option length [Number] :max
    # @option length [Number] :min
    def validates(field_name, presence: nil, length: {})
      if presence.present?
        validates_presence_of(field_name)
      end
      if length.present?
        validates_length_of(field_name, max: length[:max], min: length[:min])
      end
    end

    # @param field_name [String, Symbol]
    def validates_presence_of(field_name)
      new_validation = { field_name: field_name.to_sym, type: :presence_of }
      self.validations = validations | [new_validation]
    end

    def validates_length_of(field_name, max: nil, min: nil)
      new_validation = { field_name: field_name.to_sym, type: :length_of, max: max, min: min }
      self.validations = validations | [new_validation]
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

I again use class_attribute feature in order to store meta data about all validations in the validations variable. Then I defined two methods for validations validates_presence_of , validates_length_of and wrote a wrapper validates to use it easily.

Each validation class is an implementation of visitor OOP-pattern. It makes it easy to read the code and add one more validation.

# validation/presence_of_validation
# NOTE: Validation class that checks existence of a value
class MiniActiveRecord::Validation::PresenceOfValidation < BaseValidation
  def call
    value = object.public_send(field_name)
    return true if value.present?

    object.errors.add(field_name, 'must be present')
  end
end

# validation/length_of_validation
# NOTE: Validation class that checks string length
class MiniActiveRecord::Validation::LengthOfValidation < BaseValidation
  def call
    value = object.public_send(field_name)
    return if value.nil?

    max = meta_data[:max]
    min = meta_data[:min]
    if max.present? && value.size > max
      object.errors.add(field_name, "must be less then #{max}")
    end
    if min.present? && value.size < min
      object.errors.add(field_name, "must be greater then #{min}")
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

How to run all validations? The algorithm is the same as in RubyOnRails, method valid? runs all validations.

module MiniActiveRecord::Validation
  def valid?
    validate!
    errors.size == 0
  end

  # Runs all validations
  def validate!
    # 1: Init new error-object
    @errors_object = ::MiniActiveRecord::Validation::Errors.new
    validation_namespace = ::MiniActiveRecord::Validation
    # 2: Run each validation
    self.validations.each do |validation|
      class_object = "#{validation[:type].to_s.camelize}Validation"

      if validation_namespace.const_defined?(class_object)
        klass = validation_namespace.const_get(class_object)
        klass.new(validation, self).call
      else
        raise "ERROR: Can not find class #{validation_namespace}::class_object"
      end
    end
    self
  end

  # @return [MiniActiveRecord::Validation::Errors]
  def errors
    @errors_object
  end
end
Enter fullscreen mode Exit fullscreen mode

User can explicitly run valid? method, or the library can do it implicitly using save, or save! methods. Example:

# Code in controller
def create
  @item = Item.new(permited_params)
  if @item.save
    redirect_to "/groups/#{@group.id}/items"
  else
    render :new, status: '422 Unprocessable Entity'
  end
end

module MiniActiveRecord::Operate
  # @return [Boolean]
  def save
    return false unless valid?
    # CODE is here
    true
  end

  # @return [Boolean]
  # @raise MiniActiveRecord::RecordInvalid
  def save!
    if save
      true
    else
      raise MiniActiveRecord::RecordInvalid
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Scopes and querying

Querying is a very important part of the models layer. We are used to write code like this:

Group.where(id: '123')
Item.where(group_id: '123')
Item.find('123)
Enter fullscreen mode Exit fullscreen mode

Scoping is also very important. We are used to write code in a model like that, instead of defining new method.

class Item < MiniActiveRecord::Base
  attribute :done, type: [TrueClass, FalseClass], default: false

  scope :active, -> { where(done: false) }
  scope :not_active, -> { where(done: true) }
end
Enter fullscreen mode Exit fullscreen mode

Under the hood, the scope-feature just a stroring meta data about each ruby Proc in a global variable for future use and define new singleton method in the model class. The source code is incapsulated in MiniActiveRecord::Scope module.

module MiniActiveRecord::Scope
  def self.included(base)
    base.extend ClassMethods
    base.class_attribute :scopes
    base.scopes = []
  end

  module ClassMethods
    # @param name [String, Symbol]
    # @param procc [Proc]
    def scope(name, procc)
      new_scope_params = { name: name.to_sym, proc: procc }
      self.scopes = scopes | [new_scope_params]
      self.class_eval do
        define_singleton_method(name, &procc)
      end
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

I again use class_attribute feature and store data in scopes variable for future use. There is no magic. The magic you will see below, querying should support chain methods like:

Group.first.items.active.count
Group.first.items.where(done: false).count
Record.where(a: 'foo').where(b: 'bar')
Enter fullscreen mode Exit fullscreen mode

In order to do it I wrote MiniActiveRecord::Relation module and defined methods as all, where, find_by etc.

module MiniActiveRecord::Relation
  # @param conditions [Hash<Symbol, Object>] Object could be String, Integer, Array
  # @return [MiniActiveRecord::Proxy]
  def where(conditions = {})
    init_proxy.where(conditions)
  end

  # @param conditions [Hash<Symbol, Object>] Object could be String, Integer, Array
  # @return [MiniActiveRecord::Base]
  def find_by(conditions)
    where(conditions).first
  end

  # @param conditions [Hash<Symbol, Object>] Object could be String, Integer, Array
  # @return [Object]
  # @raise [MiniActiveRecord::RecordNotFound]
  # @return [MiniActiveRecord::Base]
  def find_by!(conditions)
    item = where(conditions).first
    raise ::MiniActiveRecord::RecordNotFound if item.nil?
    item
  end

  # @return [MiniActiveRecord::Proxy]
  def all
    where({})
  end

  # @return [MiniActiveRecord::Base]
  # @raise [MiniActiveRecord::RecordNotFound]
  def find(selected_id)
    find_by!({id: selected_id})
  end

  private
  def init_proxy
    # proxy_class is a child of MiniActiveRecord::Proxy
    self.proxy_class.new({})
  end
end
Enter fullscreen mode Exit fullscreen mode

The main method is where, other functions are just wrappers for the function. The where method passes all arguments to MiniActiveRecord::Proxy class. The proxy class is similar to ActiveRecord::Associations::CollectionProxy. It's an implementation of the proxy patter and it's useful for chain methods.

class MiniActiveRecord::Proxy
  # @param where_condition [Hash]
  def initialize(where_condition = {})
    @where_condition = where_condition.transform_keys(&:to_sym)
    @limit = nil
  end

  # @return [MiniActiveRecord::Proxy]
  def all
    where({})
    self
  end

  # @return [MiniActiveRecord::Proxy]
  def where(conditions)
    @where_condition.merge!(conditions.transform_keys(&:to_sym))
    self
  end

  private
  def method_missing(message, *args, &block)
    # The magic is here
  end
end
Enter fullscreen mode Exit fullscreen mode

Each public method returns self, it supports the chain methods. The main magic locates inside method_missing. Firstly, the methods chain should support scopes I stored in scopes variable. Secondly, a proxy should extract data from a database and return an array of MiniActiveRecord::Base objects for us. It's urgent for code like that:

class Item < MiniActiveRecord::Base
  scope :active, -> { where(done: false) }
  scope :not_active, -> { where(done: true) }
end

# .each and .map are methods of Array, not MiniActiveRecord::Proxy
Items.all.active.each { |i| puts i.id }
Items.where(a: 'foo').map { |i| i.do_something }
Enter fullscreen mode Exit fullscreen mode

I wrote the method_missing method which tries to find a scope (actual for active, not_active scopes). If there is no scope, it passes the data to DB driver and wrap raw data into MiniActiveRecord::Base (it's our models Item, Group, etc).

class MiniActiveRecord::Proxy
  # NOTE: Use it in order to exec driver even before data manitulation
  # It pass methods to Array<ActiveRecord::Base>
  # For example:
  # .where().each {}
  # .where().where().map {}
  def method_missing(message, *args, &block)
    # 1: Try to find scope in the model class
    scope_meta = model_class.scopes.find{ |i| i[:name] == message }
    if !scope_meta.nil?
      instance_exec(&scope_meta[:proc])
    else
      # 2: Execute and find method
      execute.public_send(message, *args, &block)
    end
  end

  # Run driver and wrap raw data to a model-class
  # @return [Array<ActiveRecord::Base>]
  def execute
    raw_data = driver.where(@where_condition, table_name, @limit)
    raw_data.map { |data| model_class.new(data) }
  end
end
Enter fullscreen mode Exit fullscreen mode

Data storages

Inspired by Maple Ong's talk, I keep using a yaml-file storage. With the template method OOP-pattern, it's possible to write a driver for different databases in future such as: MongoDB, SQL or Redis. But now I'm good with just a yaml file.

Firstly I implemented an abstract class MiniActiveRecord::Driver with interface.

# NOTE: Abstract class.
# Inherite from the class in order to implement a driver for different DBs.
class MiniActiveRecord::Driver
  class << self
    # @param table_name [String]
    def all(table_name)
      _all(table_name)
    end

    # @param conditions [Hash<Symbol, Object>]
    # @param table_name [String]
    # @param limit [Integer]
    def where(conditions, table_name, limit)
      _where(conditions, table_name, limit)
    end

    # @param selected_id [String, Integer]
    # @param table_name [String]
    def find(selected_id, table_name)
      _find(selected_id, table_name)
    end
    # CODE
  end
end
Enter fullscreen mode Exit fullscreen mode

Then, I inherited MiniActiveRecord::YamlDriver class with low-level code described how to store and manipulate the data from a yaml file.

# NOTE: Driver for YAML-file local data storage
class MiniActiveRecord::YamlDriver < MiniActiveRecord::Driver
  class << self
    private
    def _all(table_name)
      _where({}, nil, table_name)
    end

    def _find(selected_id, table_name)
      _where({id: selected_id}, 1, table_name)
    end

    def _where(conditions, table_name, limit = nil)
      store = init_store(table_name)
      store.transaction do
        memo = store[table_name.to_sym]
        memo = conditions.reduce(memo) do |memo, (cond_key, cond_value)|
          if cond_value.is_a?(Array)
            memo.select{ |i| cond_value.include?(i[cond_key])  }
          else
            memo.select{ |i| i[cond_key] == cond_value }
          end
        end
      memo
    end

    def init_store(table_name)
      full_file_path = MiniRails.root.join("db/db_#{table_name}.yml")
      YAML::Store.new(full_file_path)
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Testing

These three MVC layers and a rack-middleware are enough to write an application. In order to prove that it works, I had to write a library for testing. I had an idea to write something like RSpec from scratch.

Fabrics

Firstly I had to implement a library for fabrics such as FactoryBot. My goal was to implement a factory definition with sequence and trait. I called it MiniFactory and I wanted be able to write the code as:

# todo_list/spec/factories/item_factory.rb
MiniFactory.define do
  factory :item, class: 'Item' do
    sequence(:title) { |i| "title_#{i}" }
    done { false }

    trait :done do
      done { true }
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Start with the factory definition. MiniFactory::define is just a syntax sugar for MiniFactory::Base .

module MiniFactory
  class << self
    def define(&block)
      Base.instance.instance_exec(&block)
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

MiniFactory::Base class is a singleton object which stores the information about defined factories in the @factories variable.

class MiniFactory::Base
  include Singleton
  def initialize
    @factories = {} # Hash to store data about all factories
  end

  # @param factory_name [String, Symbol]
  # @param opts [Hash<Symbol, Object>]
  # @option opts [Object, String] :class
  def factory(factory_name, opts, &block)
    buidler = Builder.new(opts[:class])
    buidler.instance_exec(&block)
    @factories[factory_name.to_sym] = { count: 0, buidler: buidler }
  end
end
Enter fullscreen mode Exit fullscreen mode

In order to define various model attributes I wrote MiniFactory::Builder class which is similar to the factory OOP-pattern. Look at example below, each model has different attributes such as title and done for item factory. How to support it?

MiniFactory.define do
  factory :item, class: 'Item' do
    title { 'Hello' }
    done { false }
  end
end
Enter fullscreen mode Exit fullscreen mode

There is the method_missing method to cope with it. It stores a model attribute name in the @attributes variable and also stores the Proc.

class MiniFactory::Builder
  def initialize(klass)
    @klass = klass
    @attributes = {}
  end

  def method_missing(message, *args, &block)
    @attributes[message] = { type: :attribute, block: block }
  end
end
Enter fullscreen mode Exit fullscreen mode

So, I have all information to create a factory, let's do it. In order to build a factory, I've written the code:

MiniFactory.build(:item)

module MiniFactory
  # @param factory_name [Symbol, String]
  # @param traits_and_opts [Array]
  def self.build(factory_name, *traits_and_opts)
    opts = traits_and_opts.extract_options!
    traits = traits_and_opts.map(&:to_sym)
    Base.instance.build_factory(factory_name, traits, opts)
  end
end

class MiniFactory::Base
  # @param factory_name [String, Symbol]
  # @param traits [Array<Symbol>]
  # @param opts [Hash<Symbol, Object>]
  def build_factory(factory_name, traits, opts)
    # Find MiniFactory::Builder instance
    buidler = @factories[factory_name.to_sym][:buidler]
    # Build new object
    buidler.build_object(number, traits, opts)
  end
end

class MiniFactory::Builder
  # @param number [Integer] Params for sequences
  # @param selected_traits [Array<Symbol>]
  # @param opts [Hash<Symbol, Object>]
  def build_object(number = 1, selected_traits = [], opts = {})
    @klass = Object.const_get(@klass)
    # 1: Collect all attributes
    all_attributes = (opts.keys + @attributes.keys).uniq

    # 3: Assign data from opts params or fetch it from stored proc
    attrs = all_attributes.reduce({}) do |memo, key|
      memo[key] = (opts.key?(key) ? opts[key] : @attributes[key][:block].call)
      memo
    end
    # 4: Assign data to a model instance
    @klass.new(**attrs)
  end
end
Enter fullscreen mode Exit fullscreen mode

Let's add sequence feature. The Sequence is a method for a factory class. It stores data as I wrote in method_missing but with different type. And let's improve fetch_params method to pass a number to sequence's Proc.

class MiniFactory::Builder
  # Example of usage:
  # senquence(:title) { |i| "My title ##{i}" }
  # @param attr_name [String, Symbol]
  def sequence(attr_name, &block)
    @attributes[attr_name.to_sym] = { type: :sequence, block: block }
  end

  def fetch_params(params, number)
    if params[:type] == :attribute
      params[:block].call
    else
      # If the attribute is sequence, pass a number
      params[:block].call(number)
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

What about trait feature? Trait is a factory inside a factory :-)

class MiniFactory::Builder
  def initialize(klass)
    @traits = {}
  end

  # @param trait_name [String, Symbol]
  def trait(trait_name, &block)
    trait_builder = self.class.new(@klass)
    trait_builder.instance_exec(&block)
    @traits[trait_name.to_sym] = trait_builder.attributes
  end
end
Enter fullscreen mode Exit fullscreen mode

Testing and specs

I decided to implement the core functional of RSpec and called it MiniRSpec. Firstly, I had to implement a test tree feature. It's a bunch of methods like context, describe, and it. With these methods I'm able to write a test tree.

MiniRSpec.describe 'Item' do
  describe 'context 1' do
    it 'works' {}
    describe 'context 1.1' do
      it 'works' {}
    end
  end
  describe 'context 2' do
    it 'works' {}
  end
end
Enter fullscreen mode Exit fullscreen mode

It was a real challenge. The structure of the code is an AST (abstract syntax tree), therefore there are 3 types of AST leaves:

  • Main leaf MiniRSpec.describe
  • Context leaf context and describe
  • Test leaf it

Let's begin with a data storage and the main leaf.

# mini_rails/mini_r_spec/base.rb
# Singleton class to store test data as AST in @ast variable
class MiniRSpec::Base
  attr_accessor :ast
  include ::Singleton

  def initialize
    @ast = []
  end
end

# mini_rails/mini_r_spec.rb
# The main leaf
module MiniRSpec
  # Initialize describe leaf and store data in the base object
  def self.describe(title, &block)
    unit = Base.instance
    leaf = DescribeLeaf.new(title)
    leaf.instance_exec(&block)
    unit.ast << leaf
    unit
  end
end
Enter fullscreen mode Exit fullscreen mode

The algorithms of context and it leaves are similar, therefore I implement MiniRSpec::Context module to include it.

# mini_rails/mini_r_spec/context.rb
# NOTE: There is the main logic for describe and it leaves
module MiniRSpec::Context
  # @param described_object [String, Object] Object must respond to method .to_s
  # @return [DescribeLeaf]
  def describe(described_object, &block)
    leaf = DescribeLeaf.new(described_object)
    leaf.instance_exec(&block) if block_given?
    leaf
  end
  alias_method :context, :describe

  # @param described_object [String, Object] Object must respond to method .to_s
  # @return [ItLeaf::Base]
  def it(described_object, &block)
    leaf = ItLeaf::Base.new(described_object)
    leaf.proc = block
    leaf
  end
end

# mini_rails/mini_r_spec/describe_leaf.rb
# NOTE: `describe` leaf
class MiniRSpec::DescribeLeaf
  include Context
  def initialize(title)
    @title = title
    @children = []
  end

  # @described_object [String, Object] Object must respond to method .to_s
  # @return [DescribeLeaf]
  def describe(described_object, &block)
    leaf = super
    @children << leaf
    nil
  end
  # NOTE: Rewrite aliase
  alias_method :context, :describe

  # @described_object [String, Object] Object must respond to method .to_s
  # @return [ItLeaf::Base]
  def it(described_object = '', &block)
    leaf = super
    @children << leaf
    nil
  end
end

# NOTE: `it` leaf
# mini_rails/mini_r_spec/it_leaf/base.rb
class MiniRSpec::ItLeaf::Base
  include Context
  def initialize(title)
    @title = title
    @proc = nil
  end

  def describe(described_object)
   raise 'ERROR: Can not use describe inside "it" block'
  end

  # NOTE: Rewrite aliase
  alias_method :context, :describe
end
Enter fullscreen mode Exit fullscreen mode

The AST is completed. How to run test cases? I had to traverse AST and run each ruby Proc in it leaf.

# mini_rails/mini_r_spec/base.rb
# Singleton class to store test data as AST in @ast variable
class MiniRSpec::Base
  def run_tests
    @ast.each do |node|
    if node.is_a?(ItLeaf::Base)
     node.run_tests
    elsif node.is_a?(DescribeLeaf)
      node.run_tests
    end
  end
end

# mini_rails/mini_r_spec/describe_leaf.rb
# NOTE: `describe` leaf
class MiniRSpec::DescribeLeaf
  # @param context [String]
  def run_tests(context = nil)
    context = [context, title].compact.join(' ')
    children.each do |node|
      if node.is_a?(ItLeaf::Base)
        node.run_tests(context)
      elsif node.is_a?(DescribeLeaf)
        node.run_tests(context)
      end
    end
  end
end

# mini_rails/mini_r_spec/it_leaf/base.rb
# NOTE: `it` leaf
class MiniRSpec::ItLeaf::Base
  # @param context [String]
  def run_tests(context = nil)
    context = [context, title].compact.join(' ')
    return nil if @proc.nil?

    # Clear DB before running the test case
    ::MiniActiveRecord::Base.driver.destroy_database!

    # Run the test case
    instance_exec(&@proc)
    TestManager.instance.add_success(context)
  rescue ::StandardError => e
    TestManager.instance.add_failure(context, e)
  end
end
Enter fullscreen mode Exit fullscreen mode

It works, but there is only core features.

Variables and callbacks

Now I can write and run tests cases but there will be a lot amount of the same code in the test cases. All tests should be DRY, helpers as let, let! and callbacks as before_each help with it. I decided to implement let! and before_each features for the describe leaf to save data in instance variables.

# mini_rails/mini_r_spec/describe_leaf.rb
# NOTE: `describe` leaf
class MiniRSpec::DescribeLeaf   
  def initialize(title)
    # CODE
    @callbacks = [] # Array for before_each callbacks
    @variables = {} # Hash for let! data
  end

  # @param variable_name [String, Symbol]
  def let!(variable_name, &block)
    @variables[variable_name.to_sym] = block
  end

  def before_each(&block)
    @callbacks.push(block)
  end
end
Enter fullscreen mode Exit fullscreen mode

Then I extended run_tests method to collect and pass variables and callbacks to the it block.

# mini_rails/mini_r_spec/describe_leaf.rb
# NOTE: `describe` leaf
class MiniRSpec::DescribeLeaf  
  # @param context [String]
  # @param before_callbacks [Array<Proc>]
  # @param variables [Hash<Symbol, Proc>]
  def run_tests(context = nil, before_callbacks = [], variables = {})
    # CODE
    merged_callbacks = before_callbacks + @callbacks
    merged_variables = variables.merge(@variables)

    children.each do |node|
      if node.is_a?(ItLeaf::Base)
        node.run_tests(context, merged_callbacks, merged_variables)
      elsif node.is_a?(DescribeLeaf)
        node.run_tests(context, merged_callbacks, merged_variables)
      end
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Further I extended the it leaf to run callbacks and receive data from let! handlers.

# mini_rails/mini_r_spec/it_leaf/base.rb
# NOTE: `it` leaf
class MiniRSpec::ItLeaf::Base
  def initialize(title)
    # CODE
    @variables = {} # Hash for let! data
  end

  # @param context [String]
  # @param before_callbacks [Array<Proc>]
  # @param variables [Hash<Symbol, Proc>]
  def run_tests(context = nil, before_callbacks = [], variables = {})
    # CODE
    # Run all let! blocks
    variables.each do |var_name, proc|
      @variables[var_name] = instance_exec(&proc)
    end

    # Run all before-callbacks
    before_callbacks.each do |callback|
      instance_exec(&callback)
    end

    # Run the test case
    instance_exec(&@proc)
    # CODE
  rescue ::StandardError => e
    # CODE
  end

  def method_missing(message, *args, &block)
    if @variables.key?(message) 
      # let! variables support
      @variables[message]
    else
      super
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Test matching

Final point is to implement a test matching such as:

expect(1).to eq(1)
expect(1).not_to eq(2)
expect(1).to be_present
expect(true).to be_truthy
expect([1,2,3]).to include(2)
Enter fullscreen mode Exit fullscreen mode

In order to implement it I wrote the MiniRSpec::Matcher as the template method OOP-pattern. Under the hood the code above is just syntax sugar for the code below:

EqMatcher.new(1) == Matcher.new(1)
EqMatcher.new(2) != Matcher.new(1)
BePresentMatcher.new == Matcher.new(1)
EqMatcher.new(true) == Matcher.new(true)
IncludeMatcher.new(2) == Matcher.new([1,2,3])
Enter fullscreen mode Exit fullscreen mode

Source code looks like this:

# mini_rails/mini_r_spec/matchers.rb
class MiniRSpec::Matcher
  def initialize(value = nil)
    @value = value
  end

  def to(matcher)
    matcher == @value
  end

  def not_to(matcher)
    matcher != @value
  end
end

class MiniRSpec::EqMatcher < Matcher
  def ==(new_value)
    a = @value == new_value
    raise MatchError, "'#{new_value}' does not equal '#{@value}'" if a == false
    a
  end

  def !=(new_value)
    a = @value != new_value
    raise MatchError, "'#{new_value}' equals '#{@value}'" if a == false
    a
  end
end
Enter fullscreen mode Exit fullscreen mode

Conclusions

Write something from scratch is fun. The challenge to write a framework was absolutely new experience to me. After working on it I noticed some useful things and whould like to share my thoughts with you.

class_attribute

It's very cool feature to share data between class and class instance. In the article and in the source code you see that I often use it to implement some features.

A function wrapper

A usable interface is very important when you write a library. A function wrapper can reduce amount of code and make it easy to write and read. Example for validation module:

module MiniActiveRecord::Validation::ClassMethods
  # A function wrapper
  def validates(field_name, presence: nil, length: {})
    if presence.present?
      validates_presence_of(field_name)
    end
    if length.present?
      validates_length_of(field_name, max: length[:max], min: length[:min])
    end
  end

  def validates_presence_of(field_name)
    # Core function
  end

  def validates_length_of(field_name, max: nil, min: nil)
    # Core function
  end
end
Enter fullscreen mode Exit fullscreen mode

Also ActiveSupport's extract_options! method is also useful to make the interface flexible. You can see below an example for factories. build functions receive factory name, traits and options.

module MiniFactory
  class << self
      # Example of usage: build(:factory_name, :trait1, attr1: ''1)
    #   build(: factory_name, :trait1, :trait2, attr1: '1')
    #   build(: factory_name, attr1: '1') 
    def build(factory_name, *traits_and_opts)
      opts = traits_and_opts.extract_options!
      traits = traits_and_opts.map(&:to_sym)
      Base.instance.build_factory(factory_name, traits, opts)
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Use recursion to write a flexible inferface

Data in function's argument can be flexible. There is an example for render module below. view_name params can be a string, a symbol and can be without _. In order to implement it easily I used recursion.

module MiniActionView::Render
  # @param view_name [String, Symbol]
  #   Can be: '_header', :header, 'shared/header'
  #   For symbol it adds _ in the beggining of name.
  # @param collection [Array<Object>] each item will be passed as 'item' variable
  # @param locals [Hash<Symbol,Object>] params to passing data as local_variables
  def render_partial(view_name, collection: [], locals: {})
    if view_name.is_a?(Symbol)
      render_partial("_#{view_name}", collection: collection, locals: locals)
    elsif view_name.exclude?('.html.erb')
      render_partial("#{view_name}.html.erb", collection: collection, locals: locals)
    elsif collection.size > 0
      collection.map { |i| render_view(view_name, locals: {item: i}) }.join('')
    else
      render_view(view_name, locals: locals)
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Top comments (0)