Skip to content

Instantly share code, notes, and snippets.

@mnishiguchi
Last active November 23, 2023 20:18
Show Gist options
  • Save mnishiguchi/2122bf334e612cfef1d871aa11a25d7a to your computer and use it in GitHub Desktop.
Save mnishiguchi/2122bf334e612cfef1d871aa11a25d7a to your computer and use it in GitHub Desktop.
ruby, rails - Ruby Poros, tableless models, service objects

Ruby Poros, tableless models, service objects

tableless model

PORO

module Feeds
  class Property
    
    @@attribute_names = [
      :name,
      :latitude,
      :longitude,
      :street,
      :city,
      :state,
      :floorplan,
    ]

    attr_accessor *@@attribute_names

    # https://stackoverflow.com/questions/12763016/how-to-cleanly-initialize-attributes-in-ruby-with-new
    def initialize(params = {})
      @@attribute_names.each do |attr|
        instance_variable_set("@#{attr}", params[attr]) if params[attr]
      end
    end

    # Returns a hash of all the attributes with their names as keys and the values of the attributes as values.
    def attributes
      Hash[@@attribute_names.map { |name| [name, self.send(name)] }]
    end
  end
end
      def attributes
        instance_variables.map{ |ivar| instance_variable_get ivar }
      end

ActiveModel::Model

class ContactForm
  include ActiveModel::Model

  ATTRIBUTE_NAMES = %i[
    name
    phone
    email
    message
    subscribe
  ].freeze

  attr_accessor(*ATTRIBUTE_NAMES)

  validates :name, presence: true, length: { maximum: 50 }
  validates :email, length: { maximum: 255 }, format: { with: /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i }

  # Error messages are available after valid? is called
  def submit
    if valid?
      true
    else
      false
    end
  end

  # Returns a hash of all the attributes with their names as keys and the values of the attributes as values.
  def attributes
    Hash[ATTRIBUTE_NAMES.map { |name| [name, send(name)] }]
  end
end

Form object

Service object

class ApplicationService
  def call
    raise NotImplementedError
  end

  def self.call(*args, &block)
    new(*args, &block).call
  end
end

Virtual attribute with PORO

class GeoLocation
  module AttrAccessors
    module Bbox
      extend ActiveSupport::Concern

      included do
        def bbox
          BoundingBox.new(north: bbox_north, south: bbox_south, east: bbox_east, west: bbox_west)
        end

        def bbox=(bbox_instance)
          %i[west south east north].each do |x|
            raise ArgumentError, "must respond to #{x}" unless bbox_instance.respond_to?(x)
          end
          self.bbox_north = bbox_instance.north
          self.bbox_south = bbox_instance.south
          self.bbox_east = bbox_instance.east
          self.bbox_west = bbox_instance.west
        end
      end
    end
  end
end

ActiveRecord model with PORO sub-model

# migration
class CreateCallEvents < ActiveRecord::Migration[5.2]
  def change
    create_table :call_events do |t|
      t.jsonb :raw_data

      t.string :call_sid, null: false

      t.string :caller_phone, null: false
      t.string :caller_city
      t.string :caller_state
      t.string :caller_zipcode

      t.string :called_phone, null: false
      t.string :called_city
      t.string :called_state
      t.string :called_zipcode

      t.timestamps
    end
  end
end
# model
# A call event from Twilio Webhook
class CallEvent < ApplicationRecord
  validates :raw_data, :call_sid, :caller_phone, :called_phone, presence: true
  validates :call_sid, uniqueness: { case_sensitive: false }

  [:caller, :called].each do |caller_or_called|
    define_method caller_or_called do
      # getter
      CallEvent::Phone.new(
        phone: send("#{caller_or_called}_phone"),
        city: send("#{caller_or_called}_city"),
        state: send("#{caller_or_called}_state"),
        zipcode: send("#{caller_or_called}_zipcode")
      )
    end

    # setter
    define_method "#{caller_or_called}=" do |call_event_phone|
      raise ArgumentError, "must respond to phone" unless call_event_phone.respond_to?(:phone)

      self["#{caller_or_called}_phone"] = call_event_phone.phone
      self["#{caller_or_called}_city"] = call_event_phone.city
      self["#{caller_or_called}_state"] = call_event_phone.state
      self["#{caller_or_called}_zipcode"] = call_event_phone.zipcode
    end
  end

  # sub-model for caller and called
  class Phone
    attr_reader :phone, :city, :state, :zipcode

    def initialize(phone:, city: nil, state: nil, zipcode: nil)
      @phone = phone
      @city = city
      @state = state
      @zipcode = zipcode
    end

    def to_s
      phone
    end
  end
end
    private def create_call_event
      CallEvent.create!(
        raw_data: raw_data,
        call_sid: raw_data["CallSid"],
        caller: caller_info,
        called: called_info
      )
    end

    private def caller_info
      CallEvent::Phone.new(
        phone: raw_data["From"] || raw_data["Called"],
        city: raw_data["FromCity"] || raw_data["CalledCity"],
        state: raw_data["FromState"] || raw_data["CalledState"],
        zipcode: raw_data["FromZip"] || raw_data["CalledZip"]
      )
    end

    private def called_info
      CallEvent::Phone.new(
        phone: raw_data["To"] || raw_data["Called"],
        city: raw_data["ToCity"] || raw_data["CalledCity"],
        state: raw_data["ToState"] || raw_data["CalledState"],
        zipcode: raw_data["ToZip"] || raw_data["CalledZip"]
      )
    end

    private def raw_data
      @raw_data ||= params.permit(*PERMITTED_PARAM_KEYS).to_h.with_indifferent_access
    end
@mnishiguchi
Copy link
Author

Initialize with multible attrs

    class Base
      attr_accessor :address
      attr_accessor :name
      attr_accessor :first_name
      attr_accessor :last_name

      def initialize(args)
        args.each do |key, value|
          self.send("#{key}=", value) if respond_to?("#{key}=")
        end

@mnishiguchi
Copy link
Author

ActiveModel::Attributes and ActiveModel::Type

This is awesome!

class Definition
  # https://github.com/rails/rails/blob/master/activemodel/lib/active_model/model.rb
  include ActiveModel
  include ActiveModel::Attributes
  include ActiveModel::AttributeAssignment
  include ActiveModel::Validations
  include ActiveModel::Validations::Callbacks
  include ApplicationRecord::Base

  # Model Attributes
  # https://github.com/rails/rails/blob/master/activemodel/lib/active_model/attributes.rb
  # https://github.com/rails/rails/blob/master/activemodel/lib/active_model/type.rb
  attribute :model_id, :string
  attribute :word_sort_key, :string
  attribute :word, :string
  attribute :definition, :string
  attribute :created_at, :time
  attribute :updated_at, :time
  attribute :definition_id, :string
  attribute :user_id, :string
  attribute :tags, :string
  attribute :like_count, :integer

@mnishiguchi
Copy link
Author

Create an object with a factory method

class Grade
  class << self
    def for(value)
      return value if value.is_a?(Grade)

      new(value)
    end

    protected :new

    # ...
  end

@mnishiguchi
Copy link
Author

ActiveRecord serialize

TL;DR

  • Make a PORO class that defines .load and .dump
  • In an ActiveRecord class, call serialize to tell rails to convert an attribute value to an object vice versa.

@mnishiguchi
Copy link
Author

Simple Active Model

# An abstract class that implement common logic for generating data for summary charts and table.
class PropertyEvent
  class Summary
    module Models
      class Base
        include ActiveModel::Model
        include ActiveModel::AttributeAssignment

        def [](key)
          send(key)
        end
      end

      class ChartSlice < Base
        attr_accessor(
          :color,
          :label,
          :value
        )
      end

      class TableRow < Base
        attr_accessor(
          :name,
          :event_count,
          :event_percentage,
          :lead_count,
          :lead_percentage,
          :lead_contribution_count,
          :lead_contribution_details
        )

        def initialize(*)
          super
          assign_default_values
        end

        private

        def assign_default_values
          self.name ||= ""
          self.event_count ||= 0
          self.event_percentage ||= 0
          self.lead_count ||= 0
          self.lead_percentage ||= 0
          self.lead_contribution_count ||= 0
          self.lead_contribution_details ||= 0
        end
      end
    end
  end
end

@mnishiguchi
Copy link
Author

mnishiguchi commented Dec 31, 2019

Value object

tomdalling/value_semantics gem

@mnishiguchi
Copy link
Author

mnishiguchi commented Mar 12, 2020

Simple PORO validation

  def initialize(order)
    @order = order
    @errors = []
  end

  def valid?
    !invalid?
  end

  def invalid?
    validate.positive?
  end

  # Minimal validation for dependencies.
  def validate
    @validate ||= begin
      %w[
        adjustment
        contact_detail
        payment
        shipping_detail
      ].each do |method_name|
        @errors << "#{method_name.humanize} is required" unless send(method_name)
      end
      @errors << 'Shipping detail address is required' unless shipping_detail&.address
      @errors.size
    end
  end

@mnishiguchi
Copy link
Author

mnishiguchi commented Mar 26, 2021

simple form object with a controller

module SomeContext
  class RefundsController < ::ApplicationController
    before_action :login_required
    before_action :find_order
    before_action :find_problem

    def new
      @refund = Refund.new
    end

    def create
      @refund = Refund.new(refund_params)
      if @refund.save
        flash[:success] = "Refund created successfully."
        redirect_to order_problem_url(@order, @problem)
      else
        render :new
      end
    end

    private

    def find_order
      @order = Order.find(params.fetch(:order_id))
    end

    def find_problem
      @problem = @order.problem.find(params.fetch(:problem_id))
    end

    def refund_params
      params.require(:refund).permit(:amount)
    end
  end

  class Refund
    include ActiveModel::Model

    attr_accessor :amount

    validates :amount, numericality: { greater_than: 0 }

    def save
      if valid?
        # TODO: process refund
      else
        false
      end
    end
  end
end
<h1 class="h2">New Voucher</h1>

<div class="row my-3">
  <div class="col-md-6 col-md-offset-3">
    <%= form_for(@voucher, url: order_problem_voucher_path(@order, @problem)) do |f| %>

      <%= render 'order_problems/vouchers/fields', f: f %>

      <div class="btn-group my-2">
        <%= link_to "Cancel", order_problem_path(@order, @problem), class: "btn btn-light" %>
        <%= f.button "Create voucher", class: "btn btn-primary", data: { disable_with: "Processing..." } %>
      </div>
    <% end %>
  </div>
</div>

@mnishiguchi
Copy link
Author

mnishiguchi commented Jul 22, 2021

Dave Copeland

here's an example from our app that gets the data from a yaml file - Dave Copeland

class OperatingLocation
  include ActiveModel::Model
  attr_accessor :us_state_code, :intakeq_location_id, :time_zone_name
  def self.all
    @all ||= begin
               locations = YAML.load(File.read(Rails.root / "config" / "operating_locations.yml"))
               locations.map { |us_state_code, data|
                 OperatingLocation.new(
                   us_state_code: us_state_code,
                   intakeq_location_id: data.fetch("intakeq_location_id").to_s,
                   time_zone_name: data.fetch("time_zone")
                 )
               }
             end
  end
end
  • basically ActiveModel::Model + attr_accessor gives you a lot of AR-style stuff
  • to make it work with view helpers and the like you need to add a few more methods: to_param and persisted?

@mnishiguchi
Copy link
Author

mnishiguchi commented Aug 19, 2021

Use Active Model in form helper

  • model_name determines the param key
    class FormStep1
      include ActiveModel::Model

      attr_accessor :dt_reported, :reported_by, :error_reporter_id, :responsibility

      delegate :model_name, :to_model, to: :@model

      def initialize(attrs = {})
        super
        @model = ::OrderError.new(attrs)
      end

      validates :dt_reported, presence: true
      validates :reported_by, presence: true
      validates :error_reporter_id, presence: true
      validates :responsibility, presence: true
    end
    class Voucher
      include ActiveModel::Model

      attr_accessor :amount

      validates :amount, numericality: { greater_than: 0 }

      def model_name
        ActiveModel::Name.new(self, nil, "voucher")
      end

      def save
        if valid?
          # TODO: process voucher
          raise NotImplementedError
        else
          false
        end
      end
    end

@mnishiguchi
Copy link
Author

Dry::Struct

https://github.com/customink/rails_backend/blob/master/app/presenters/art_presenter.rb#L6

# This class will contain all required logic to properly "present" the data needed for Rockwell (BladeRunner) to
# perform the required work. This means loading information such as the correct design blueprint,
# order information (strategy - digital flag) and other endpoint data into a single structured response.
class ArtPresenter < ::Dry::Struct
  attribute :order_details_id, ::Types::Coercible::Integer.optional
  attribute :design_id, ::Types::Coercible::Integer.optional
  attribute :palette, ::Types::Strict::String.optional

@mnishiguchi
Copy link
Author

Rails 5.0

      class Item
        include ActiveModel::Model

        ATTRIBUTE_NAMES = %i[itemId productId]
        attr_accessor(*ATTRIBUTE_NAMES)

        validates :itemId, presence: true
        validates :productId, presence: true
      end

Item.new(itemId: 1, productId: 2).as_json
=> {"itemId"=>1, "productId"=>2}

@mnishiguchi
Copy link
Author

mnishiguchi commented Nov 10, 2023

module Inventory
  module SomeApi
    class ShippingInfoSerializer
      attr_reader :customer, :design

      def initialize(customer_or_id)
        @customer = self.class.find_customer(customer_or_id)
        @design = self.class.find_customer_design(@customer)

        raise(ArgumentError, "shipment data is blank") if @customer.shipment&.data.blank?
      end

      def as_json(*)
        shipping_info = ShippingInfo.new(productSourcingOrderId: product_sourcing_order_id, items: items)
        shipping_info.validate!
        shipping_info.attributes
      end

      def product_sourcing_order_id
        customer.warehouse_customer_detail.warehouse_order_id
      end

      def items
        data = customer.shipment.data
        deliver_by = customer.shipment.deliver_by

        normalized_package_items.map do |package_item|
          # sometimes only SKU is provided
          product_id, sku = package_item.values_at("product_id", "sku")
          product_id ||= Inventory::ItemDesign.product_id_from_sku(sku)

          shipment_details = ShippingInfoItemShipmentDetails.new
          shipment_details.trackingNumber = package_item["tracking_number"] || data["tracking_number"]
          shipment_details.shippingServiceProvider = data["carrier"]
          shipment_details.shippingDate = package_item["shipped_at"] || data["shipped_at"] || customer.shipment.shipped_at
          shipment_details.estimatedDeliveryDate = deliver_by
          shipment_details.validate!

          item = ShippingInfoItem.new
          item.productId = Inventory::ItemDesign.composed_product_id(design, product_id: product_id)
          item.shipmentDetails = shipment_details.attributes
          item.validate!
          item
        end
      end

      def normalized_package_items
        packages = customer.shipment.data["packages"].presence

        if packages&.dig(0, "package_items").blank?
          # make pseudo-package-items based on pieces
          customer.pieces.map do |piece|
            { "sku" => piece.product_sku }
          end
        else
          # flatten the nested structure
          packages.flat_map do |package|
            base_item = package.slice("tracking_number", "shipped_at")
            package.fetch("package_items").map { |package_item| base_item.merge(package_item) }
          end
        end
      end

      class << self
        def find_customer(customer)
          Customer.joins(:shipment, :warehouse_customer_detail).includes(fulfillment: { orders: :products }).find(customer.to_param)
        rescue ActiveRecord::RecordNotFound
          raise ArgumentError, "customer does not have warehouse customer detail or shipment association"
        end

        def find_customer_design(customer)
          customer&.fulfillment&.design || raise(ArgumentError, "customer does not have design")
        end
      end

      class Model
        include ActiveModel::Model
        include ActiveModel::Validations::Callbacks

        def attributes
          as_json(except: ["validation_context", "errors"])
        end
      end

      class ShippingInfo < Model
        attr_accessor :productSourcingOrderId, :items

        before_validation -> { self.productSourcingOrderId = productSourcingOrderId.to_s }

        validates :productSourcingOrderId, presence: true
        validates :items, presence: true
      end

      class ShippingInfoItem < Model
        attr_accessor :productId, :shipmentDetails

        before_validation -> { self.productId = productId.to_s }

        validates :productId, presence: true
        validates :shipmentDetails, presence: true
      end

      class ShippingInfoItemShipmentDetails < Model
        attr_accessor :trackingNumber, :shippingServiceProvider, :shippingDate, :estimatedDeliveryDate

        before_validation :format_timestamps

        validates :trackingNumber, presence: true
        validates :shippingServiceProvider, presence: true, inclusion: { in: %w[UPS FedEx USPS DHL] }
        validates :shippingDate, presence: true
        validates :estimatedDeliveryDate, presence: true

        private

        def format_timestamps
          self.shippingDate = shippingDate.to_time.iso8601 if shippingDate.present?
          self.estimatedDeliveryDate = estimatedDeliveryDate.to_time.iso8601 if estimatedDeliveryDate.present?
        end
      end
    end
  end
end

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment