boltthreads/activerecord-tablefree

View on GitHub
lib/activerecord/tablefree.rb

Summary

Maintainability
A
1 hr
Test Coverage
require 'cgi'
require 'active_record'

require 'activerecord/tablefree/version'
require 'activerecord/tablefree/cast_type'
require 'activerecord/tablefree/schema_cache'
require 'activerecord/tablefree/connection'
require 'activerecord/tablefree/transaction'

module ActiveRecord
  # = ActiveRecord::Tablefree
  #
  # Allow classes to behave like ActiveRecord models, but without an associated
  # database table. A great way to capitalize on validations. Based on the
  # original post at http://www.railsweenie.com/forums/2/topics/724 (which seems
  # to have disappeared from the face of the earth).
  #
  # = Example usage
  #
  #  class ContactMessage < ActiveRecord::Base
  #
  #    has_no_table
  #
  #    column :name,    :string
  #    column :email,   :string
  #    column :message, :string
  #
  #  end
  #
  #  msg = ContactMessage.new( params[:msg] )
  #  if msg.valid?
  #    ContactMessageSender.deliver_message( msg )
  #    redirect_to :action => :sent
  #  end
  #
  module Tablefree
    class NoDatabase < StandardError; end
    class Unsupported < StandardError; end
    class InvalidColumnType < ArgumentError; end

    def self.included(base) #:nodoc:
      base.send :extend, ActsMethods
    end

    module ActsMethods #:nodoc:
      # A model that needs to be tablefree will call this method to indicate
      # it.
      def has_no_table(options = { database: :fail_fast })
        raise ArgumentError, "Invalid database option '#{options[:database]}'" unless %i[fail_fast pretend_success].member? options[:database]
        # keep our options handy
        class_attribute :tablefree_options
        self.tablefree_options = {
          database: options[:database],
          columns_hash: {}
        }

        # extend
        extend  ActiveRecord::Tablefree::SingletonMethods
        extend  ActiveRecord::Tablefree::ClassMethods

        # include
        include ActiveRecord::Tablefree::InstanceMethods

        # setup columns
        include ActiveModel::AttributeAssignment
        include ActiveRecord::ModelSchema
      end

      def tablefree?
        false
      end
    end

    module SingletonMethods
      # Used internally by ActiveRecord 5.  This is the special hook that makes everything else work.
      def load_schema!
        @columns_hash = tablefree_options[:columns_hash].except(*ignored_columns)
        @columns_hash.each do |name, column|
          define_attribute(
            name,
            connection.lookup_cast_type_from_column(column),
            default: column.default,
            user_provided_default: false
          )
        end
      end

      # Register a new column.
      def column(name, sql_type = nil, default = nil, null = true)
        cast_class = "ActiveRecord::Type::#{sql_type.to_s.camelize}".constantize rescue nil
        raise InvalidColumnType, "sql_type is #{sql_type} (#{sql_type.class}), which is not supported" unless cast_class.respond_to?(:new)
        cast_type = cast_class.new
        tablefree_options[:columns_hash][name.to_s] = ActiveRecord::ConnectionAdapters::Column.new(name.to_s, default, cast_type, sql_type.to_s, null)
      end

      # Register a set of columns with the same SQL type
      def add_columns(sql_type, *args)
        args.each do |col|
          column col, sql_type
        end
      end

      def destroy(*_args)
        case tablefree_options[:database]
        when :pretend_success
          new
        when :fail_fast
          raise NoDatabase, "Can't #destroy on Tablefree class"
        end
      end

      def destroy_all(*_args)
        case tablefree_options[:database]
        when :pretend_success
          []
        when :fail_fast
          raise NoDatabase, "Can't #destroy_all on Tablefree class"
        end
      end

      case ActiveRecord::VERSION::MAJOR
      when 5
        def find_by_sql(*_args)
          case tablefree_options[:database]
          when :pretend_success
            []
          when :fail_fast
            raise NoDatabase, "Can't #find_by_sql on Tablefree class"
          end
        end
      else
        raise Unsupported, 'Unsupported ActiveRecord version'
      end

      def transaction
        #        case tablefree_options[:database]
        #        when :pretend_success
        @_current_transaction_records ||= []
        yield
        #        when :fail_fast
        #          raise NoDatabase.new("Can't #transaction on Tablefree class")
        #        end
      end

      def tablefree?
        true
      end

      def table_exists?
        false
      end
    end

    module ClassMethods
      def from_query_string(query_string)
        if query_string.blank?
          new
        else
          params = query_string.split('&').collect do |chunk|
            next if chunk.empty?
            key, value = chunk.split('=', 2)
            next if key.empty?
            value = value.nil? ? nil : CGI.unescape(value)
            [CGI.unescape(key), value]
          end.compact.to_h

          new(params)
        end
      end

      def connection
        @_connection ||= ActiveRecord::Tablefree::Connection.new
      end
    end

    module InstanceMethods
      def to_query_string(prefix = nil)
        attributes.to_a.collect { |(name, value)| escaped_var_name(name, prefix) + '=' + escape_for_url(value) if value }.compact.join('&')
      end

      def quote_value(_value, _column = nil)
        ''
      end

      %w[create create_record _create_record update update_record _update_record].each do |method_name|
        define_method(method_name) do |*_args|
          case self.class.tablefree_options[:database]
          when :pretend_success
            true
          when :fail_fast
            raise NoDatabase, "Can't ##{method_name} a Tablefree object"
          end
        end
      end

      def destroy
        case self.class.tablefree_options[:database]
        when :pretend_success
          @destroyed = true
          freeze
        when :fail_fast
          raise NoDatabase, "Can't #destroy a Tablefree object"
        end
      end

      def reload(*_args)
        case self.class.tablefree_options[:database]
        when :pretend_success
          self
        when :fail_fast
          raise NoDatabase, "Can't #reload a Tablefree object"
        end
      end

      def add_to_transaction; end

      private

      def escaped_var_name(name, prefix = nil)
        prefix ? "#{CGI.escape(prefix)}[#{CGI.escape(name)}]" : CGI.escape(name)
      end

      def escape_for_url(value)
        case value
        when true then '1'
        when false then '0'
        when nil then ''
        else CGI.escape(value.to_s)
        end
      rescue
        ''
      end
    end
  end
end

ActiveRecord::Base.send(:include, ActiveRecord::Tablefree)