How to create ActiveRecord tableless Model in Rails 5?

I found an article that describes how to do this.

I think the important part is to just

extend ActiveModel::Naming

Instead of using

< ActiveRecord::Base

Hope this helps :)


You can use

class Person
  include ActiveModel::Model
  attr_accessor :name, :email
  ...
end

and then you get a lot of the functionality of an activerecord model, like validations.


Finally I have decided to left that code and move on. But with time I think it should be rewritten to relational solution or use JSON field.

Rails 5

class TableLess
  include ActiveModel::Validations
  include ActiveModel::Conversion
  include ActiveModel::Serialization
  extend ActiveModel::Naming

  class Error < StandardError;
  end

  module Type
    class JSON < ActiveModel::Type::Value
      def type
        :json
      end

      private
      def cast_value(value)
        (value.class == String) ? ::JSON.parse(value) : value
      end
    end

    class Symbol < ActiveModel::Type::Value
      def type
        :symbol
      end

      private
      def cast_value(value)
        (value.class == String || value.class == Symbol) ? value.to_s : nil
      end
    end
  end

  def initialize(attributes = {})
    attributes = self.class.columns.map { |c| [c, nil] }.to_h.merge(attributes)
    attributes.symbolize_keys.each do |name, value|
      send("#{name}=", value)
    end
  end

  def self.column(name, sql_type = :string, default = nil, null = true)
    @@columns ||= {}
    @@columns[self.name] ||= []
    @@columns[self.name]<< name.to_sym
    attr_reader name
    caster = case sql_type
               when :integer
                 ActiveModel::Type::Integer
               when :string
                 ActiveModel::Type::String
               when :float
                 ActiveModel::Type::Float
               when :datetime
                 ActiveModel::Type::DateTime
               when :boolean
                 ActiveModel::Type::Boolean
               when :json
                 TableLess::Type::JSON
               when :symbol
                 TableLess::Type::Symbol
               when :none
                 ActiveModel::Type::Value
               else
                 raise TableLess::Error.new('Type unknown')
             end
    define_column(name, caster, default, null)
  end

  def self.define_column(name, caster, default = nil, null = true)
    define_method "#{name}=" do |value|
      casted_value = caster.new.cast(value || default)
      set_attribute_after_cast(name, casted_value)
    end
  end

  def self.columns
    @@columns[self.name]
  end

  def set_attribute_after_cast(name, casted_value)
    instance_variable_set("@#{name}", casted_value)
  end

  def attributes
    kv = self.class.columns.map {|key| [key, send(key)]}
    kv.to_h
  end

  def persisted?
    false
  end

end

and example

class Machine < TableLess
  column :foo, :integer
  column :bar, :float
  column :winamp, :boolean
end

I was able to implement this with a small patch in Rails 4 and a bigger patch in Rails 5. In Rails 5 column information retrieved right from the database with no chance for us to interrupt this process, other than overriding the load_schema! method. At least I didn't find a way yet.

I personally would like to see a better out of the box solution because I find it useful in some cases when we don't need to store the data. Perhaps a better way would be to implement an adapter for NullDatabase, but our use case is pretty simple and this solution worked well for us.

Please note I didn't test Rails 5 solution much, I am upgrading an app from 4 to 5 now and just rewritten this to work with Rails 5.

Rails 5

class AbstractModel < ApplicationRecord
  self.abstract_class = true

  def self.attribute_names
    @attribute_names ||= attribute_types.keys
  end

  def self.load_schema!
    @columns_hash ||= Hash.new

    # From active_record/attributes.rb
    attributes_to_define_after_schema_loads.each do |name, (type, options)|
      if type.is_a?(Symbol)
        type = ActiveRecord::Type.lookup(type, **options.except(:default))
      end

      define_attribute(name, type, **options.slice(:default))

      # Improve Model#inspect output
      @columns_hash[name.to_s] = ActiveRecord::ConnectionAdapters::Column.new(name.to_s, options[:default])
    end

    # Apply serialize decorators
    attribute_types.each do |name, type|
      decorated_type = attribute_type_decorations.apply(name, type)
      define_attribute(name, decorated_type)
    end
  end

  def persisted?
    false
  end
end

class Market::ContractorSearch < AbstractModel
  attribute :keywords,           :text,    :default => nil
  attribute :rating,             :text,    :default => []
  attribute :city,               :string,  :default => nil
  attribute :state_province_id,  :integer, :default => nil
  attribute :contracted,         :boolean, :default => false

  serialize :rating

  belongs_to :state_province

  has_many :categories, :class_name => 'Market::Category'
  has_many :expertises, :class_name => 'Market::Expertise'
end

Rails 4

class AbstractModel < ActiveRecord::Base
  def self.columns
    @columns ||= add_user_provided_columns([])
  end

  def self.table_exists?
    false
  end

  def persisted?
    false
  end
end

class Market::ContractorSearch < AbstractModel
  attribute :keywords,           Type::Text.new,    :default => nil
  attribute :rating,             Type::Text.new,    :default => [].to_yaml
  attribute :city,               Type::String.new,  :default => nil
  attribute :state_province_id,  Type::Integer.new, :default => nil
  attribute :contracted,         Type::Boolean.new, :default => false

  serialize :rating

  belongs_to :state_province

  has_many :categories, :class_name => 'Market::Category'
  has_many :expertises, :class_name => 'Market::Expertise'
end

Have fun!