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!