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
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
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
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
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'
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">
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
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')
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
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)
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
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
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
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 }
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
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
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>
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
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
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
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
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
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
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
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" %> # …
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 }
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
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
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
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
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
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
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
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
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
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)
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
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
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')
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
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
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 }
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
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
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
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
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
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
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
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
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
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
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
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
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
anddescribe
- 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
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
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
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
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
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
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)
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])
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
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
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
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
Top comments (0)