How to create ActiveRecord tableless Model in Rails 5?
Asked Answered
W

4

9

I tried create new model which has auto type casting without table in database. I've tried to inherit from ActiveRecord::Base it's thrown exception ActiveRecord::StatementInvalid: PG::UndefinedTable: ERROR: relation "people" does not exist

Class implementation:

class Person < ActiveRecord::Base

  def self.columns
    @columns ||= [];
  end

  def self.column(name, sql_type = nil, default = nil, null = true)
    @columns << ActiveRecord::ConnectionAdapters::Column.new(name.to_s, default, sql_type.to_s, null)
  end

  columns

  column :from_email, :string
  column :to_email, :string
  column :article_id, :integer
  column :message, :text

  def initialize
  end

end

stack trace:

ActiveRecord::StatementInvalid: PG::UndefinedTable: ERROR:  relation "people" does not exist
LINE 8:                WHERE a.attrelid = '"people"'::regclass
^
  :               SELECT a.attname, format_type(a.atttypid, a.atttypmod),
                         pg_get_expr(d.adbin, d.adrelid), a.attnotnull, a.atttypid, a.atttypmod,
                         (SELECT c.collname FROM pg_collation c, pg_type t
                         WHERE c.oid = a.attcollation AND t.oid = a.atttypid AND a.attcollation <> t.typcollation),
                         col_description(a.attrelid, a.attnum) AS comment
FROM pg_attribute a LEFT JOIN pg_attrdef d
ON a.attrelid = d.adrelid AND a.attnum = d.adnum
WHERE a.attrelid = '"people"'::regclass
AND a.attnum > 0 AND NOT a.attisdropped
ORDER BY a.attnum

from /activerecord-5.0.1/lib/active_record/connection_adapters/postgresql/database_statements.rb:88:in `async_exec'
from /activerecord-5.0.1/lib/active_record/connection_adapters/postgresql/database_statements.rb:88:in `block in query'
from /activerecord-5.0.1/lib/active_record/connection_adapters/abstract_adapter.rb:589:in `block in log'
from /activesupport-5.0.1/lib/active_support/notifications/instrumenter.rb:21:in `instrument'
from /activerecord-5.0.1/lib/active_record/connection_adapters/abstract_adapter.rb:583:in `log'
from /activerecord-5.0.1/lib/active_record/connection_adapters/postgresql/database_statements.rb:87:in `query'
from /activerecord-5.0.1/lib/active_record/connection_adapters/postgresql_adapter.rb:739:in `column_definitions'
from /activerecord-5.0.1/lib/active_record/connection_adapters/postgresql/schema_statements.rb:227:in `columns'
from /activerecord-5.0.1/lib/active_record/connection_adapters/schema_cache.rb:56:in `columns'
from /activerecord-5.0.1/lib/active_record/connection_adapters/schema_cache.rb:62:in `columns_hash'
from /activerecord-5.0.1/lib/active_record/model_schema.rb:441:in `load_schema!'
from /activerecord-5.0.1/lib/active_record/attributes.rb:233:in `load_schema!'
from /activerecord-5.0.1/lib/active_record/attribute_decorators.rb:28:in `load_schema!'
from /activerecord-5.0.1/lib/active_record/model_schema.rb:436:in `load_schema'
from /activerecord-5.0.1/lib/active_record/model_schema.rb:349:in `attribute_types'
from /activerecord-5.0.1/lib/active_record/attribute_methods.rb:179:in `has_attribute?'
... 3 levels...
from /railties-5.0.1/lib/rails/commands/console_helper.rb:9:in `start'
from /railties-5.0.1/lib/rails/commands/commands_tasks.rb:78:in `console'
from /railties-5.0.1/lib/rails/commands/commands_tasks.rb:49:in `run_command!'
from /railties-5.0.1/lib/rails/commands.rb:18:in `<top (required)>'
from /activesupport-5.0.1/lib/active_support/dependencies.rb:293:in `require'
from /activesupport-5.0.1/lib/active_support/dependencies.rb:293:in `block in require'
from /activesupport-5.0.1/lib/active_support/dependencies.rb:259:in `load_dependency'
from /activesupport-5.0.1/lib/active_support/dependencies.rb:293:in `require'
from /project/rails/bin/rails:9:in `<top (required)>'
from /activesupport-5.0.1/lib/active_support/dependencies.rb:287:in `load'
from /activesupport-5.0.1/lib/active_support/dependencies.rb:287:in `block in load'
from /activesupport-5.0.1/lib/active_support/dependencies.rb:259:in `load_dependency'
from /activesupport-5.0.1/lib/active_support/dependencies.rb:287:in `load'

EDIT:

Neither

extend ActiveModel::Naming

nor

include ActiveModel::Model

cannot implement the implicit type conversion.

Westmorland answered 5/1, 2017 at 21:25 Comment(4)
Please explain your reasoning for this. ActiveRecord is intended specifically for database tables. Active Record connects classes to relational database tables to establish an almost zero-configuration persistence layer for applications. Since you have no table and no intention of persistence this is clearly not the right fit so explain your scenario rather than your error.Cetacean
Scenario is that I would like to have settings for hole system in one place and want to make changes for them easy way in admin panel. I am using PostgreSQL and gem "rails-settings-cached". Gem is ok, but when I fetch data from view to controller, params are only strings. I would like to has type conversion, so I think ActiveModel would be the most dry way. How do you think?Westmorland
Why not actually use a persisted model then? Otherwise everything your app reboots em a deployment all your settings will be gone. Either than our use a pre-configured singleton class for settingsCetacean
Check this tableless article. It seems you are missing to_model and persisted? methods to work with extend ActiveModel::NamingSquama
W
2

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
Westmorland answered 22/8, 2017 at 7:7 Comment(0)
U
18

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.

Upholster answered 5/1, 2017 at 21:35 Comment(0)
K
11

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!

Kenna answered 17/8, 2017 at 19:41 Comment(1)
Thanks @alexander-s for your answer. I have put my code in case it might be useful for youWestmorland
S
2

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 :)

Sourpuss answered 5/1, 2017 at 21:43 Comment(0)
W
2

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
Westmorland answered 22/8, 2017 at 7:7 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.