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

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