require 'montage_rails/log_subscriber'
require 'montage_rails/relation'
require 'montage_rails/base/column'
require 'active_model'
require 'virtus'
module MontageRails
class Base
extend ActiveModel::Callbacks
extend ActiveModel::Naming
include ActiveModel::Model
include Virtus.model
define_model_callbacks :save, :create, :initialize
class << self
# Delegates all of the relation methods to the class level object, so they can be called on the base class
#
delegate :limit, :offset, :order, :where, :first, :select, :pluck, to: :relation
# Delegate the connection to the base module for ease of reference
#
delegate :connection, :notify, to: MontageRails
cattr_accessor :table_name
# Define a new instance of the query cache
#
def cache
@cache ||= QueryCache.new(MontageRails.no_caching)
end
# Hook into the Rails logger
#
def logger
@logger ||= Rails.logger
end
# Setup a class level instance of the MontageRails::Relation object
#
def relation
@relation = Relation.new(self)
end
# Define a has_many relationship
#
def has_many(table, options = {})
class_eval do
if options[:as]
define_method(table.to_s.tableize.to_sym) do
table.to_s.classify.constantize.where(
"#{options[:as]}_id".to_sym => id,
"#{options[:as]}_type".to_sym => self.class.name.demodulize
)
end
else
define_method(table.to_s.tableize.to_sym) do
table.to_s.classify.constantize.where("#{self.class.table_name.demodulize.underscore.singularize.foreign_key} = #{id}")
end
end
end
end
# Define a belongs_to relationship
#
def belongs_to(table)
class_eval do
define_method(table.to_s.tableize.singularize.to_sym) do
table.to_s.classify.constantize.find_by_id(__send__(table.to_s.foreign_key))
end
define_method("#{table.to_s.tableize.singularize}=") do |record|
self.__send__("#{table.to_s.foreign_key}=", record.id)
self
end
end
end
# The pluralized table name used in API requests
#
def table_name
self.name.demodulize.underscore.pluralize
end
# Redefine the table name
#
def set_table_name(value)
instance_eval do
define_singleton_method(:table_name) do
value
end
end
end
alias_method :table_name=, :set_table_name
# Returns an array of MontageRails::Base::Column's for the schema
#
def columns
@columns ||= [].tap do |ary|
response = connection.schema(table_name)
return [] unless response.schema.respond_to?(:fields)
ary << Column.new("id", "text", false)
ary << Column.new("created_at", "datetime", false)
ary << Column.new("updated_at", "datetime", false)
response.schema.fields.each do |field|
ary << Column.new(field["name"], field["datatype"], field["required"])
instance_eval do
define_singleton_method("find_by_#{field["name"]}") do |value|
where("#{field["name"]} = '#{value}'").first
end
end
end
end
end
# Fetch all the documents
#
def all
relation.to_a
end
# Find a record by the id
#
def find_by_id(value)
response = cache.get_or_set_query(self, value) { connection.document(table_name, value) }
if response.success?
new(response.document.items.merge(persisted: true))
else
nil
end
end
alias_method :find, :find_by_id
# Find the record using the given params, or initialize a new one with those params
#
def find_or_initialize_by(params = {})
return nil if params.empty?
query = relation.where(params)
response = cache.get_or_set_query(self, query) { connection.documents(table_name, query) }
if response.success? && response.documents.any?
new(attributes_from_response(response).merge(persisted: true))
else
new(params)
end
end
# Returns an array of the column names for the table
#
def column_names
columns.map { |c| c.name }
end
# Initialize and save a new instance of the object
#
def create(params = {})
new(params).save
end
# Returns a string like 'Post id:integer, title:string, body:text'
#
def inspect
if self == Base
super
else
attr_list = columns.map { |c| "#{c.name}: #{c.type}" } * ', '
"#{super}(#{attr_list})"
end
end
def method_missing(method_name, *args, &block)
__send__(:columns)
if respond_to?(method_name.to_sym)
__send__(method_name.to_sym, *args)
else
super(method_name, *args, &block)
end
end
def respond_to_missing?(method_name, include_private = false)
__send__(:column_names).include?(method_name.to_s.split("_").first) || super(method_name, include_private)
end
def attributes_from_response(response)
case response.members
when Montage::Documents then response.documents.first.attributes.merge(persisted: true)
when Montage::Document then response.document.attributes.merge(persisted: true)
when Montage::Errors then raise MontageAPIError, "There was an error with the Montage API: #{response.errors.attributes}"
when Montage::Error then raise MontageAPIError, "There was an error with the Montage API: #{response.error.attributes}"
else raise MontageAPIError, "There was an error with the Montage API, please try again."
end
end
end
attr_reader :persisted
alias_method :persisted?, :persisted
delegate :connection, :notify, to: MontageRails
delegate :attributes_from_response, to: "self.class"
def initialize(params = {})
run_callbacks :initialize do
initialize_columns
@persisted = params[:persisted] ? params[:persisted] : false
@current_method = "Load"
@errors = ActiveModel::Errors.new(self)
super(params)
@old_attributes = attributes.clone
end
end
def ==(other)
attributes == other.attributes
end
# Save the record to the database
#
# Will return false if the attributes are not valid
#
# Upon successful creation or update, will return true, otherwise returns false
#
def save
run_callbacks :save do
return false unless valid? && attributes_valid?
if persisted?
@current_method = "Update"
if dirty?
@response = notify(self) do
connection.create_or_update_documents(self.class.table_name, [updateable_attributes(true)])
end
initialize(attributes_from_response(@response))
else
return initialize(@old_attributes)
end
else
run_callbacks :create do
@current_method = "Create"
@response = notify(self) do
connection.create_or_update_documents(self.class.table_name, [updateable_attributes(false)])
end
if @response.success?
@persisted = true
initialize(attributes_from_response(@response))
else
break
end
end
end
end
@response.success? ? self : false
end
# The bang method for save, which will raise an exception if saving is not successful
#
def save!
response = save
unless response
raise MontageAPIError, "There was an error saving your data"
end
response
end
# Update the given attributes for the document
#
# Returns false if the given attributes aren't valid
#
# Returns a copy of self if updating is successful
#
def update_attributes(params)
@old_attributes = attributes.clone
params.each do |key, value|
if respond_to?(key.to_sym)
coerced_value = column_for(key.to_s).coerce(value)
send("#{key}=", coerced_value)
end
end
return self unless dirty?
if valid? && attributes_valid?
@current_method = id.nil? ? "Create" : "Update"
response = notify(self) do
connection.create_or_update_documents(self.class.table_name, [updateable_attributes(!id.nil?)])
end
initialize(attributes_from_response(response))
@persisted = true
self
else
initialize(@old_attributes)
false
end
end
# Checks if the attributes have changed, and returns true if they are "dirty"
#
def dirty?
@old_attributes != attributes
end
# Destroy the copy of this record from the database
#
def destroy
@current_method = "Delete"
notify(self) { connection.delete_document(self.class.table_name, id) }
@persisted = false
self
end
# Reload the current document
#
def reload
@current_method = "Load"
response = notify(self) do
connection.document(self.class.table_name, id)
end
initialize(attributes_from_response(response))
@persisted = true
self
end
def new_record?
!persisted?
end
# Returns the Column class instance for the attribute passed in
#
def column_for(name)
self.class.columns.select { |column| column.name == name }.first
end
# Performs a check to ensure that required columns have a value
#
def attributes_valid?
attributes.each do |key, value|
next unless column_class = column_for(key.to_s)
return false unless column_class.value_valid?(value)
end
end
# The attributes used to update the document
#
def updateable_attributes(include_id = false)
include_id ? attributes.except(:created_at, :updated_at) : attributes.except(:created_at, :updated_at, :id)
end
# Required for notifications to work, returns a payload suitable
# for the log subscriber
#
def payload
{
reql: reql_payload[@current_method],
name: "#{self.class.name} #{@current_method}"
}
end
# Returns an #inspect-like string for the value of the
# attribute +attr_name+. String attributes are elided after 50
# characters, and Date and Time attributes are returned in the
# :db format. Other attributes return the value of
# #inspect without modification.
#
# person = Person.create!(:name => "David Heinemeier Hansson " * 3)
#
# person.attribute_for_inspect(:name)
# # => '"David Heinemeier Hansson David Heinemeier Hansson D..."'
#
# person.attribute_for_inspect(:created_at)
# # => '"2009-01-12 04:48:57"'
#
def attribute_for_inspect(attr_name)
value = attributes[attr_name]
if value.is_a?(String) && value.length > 50
"#{value[0..50]}...".inspect
elsif value.is_a?(Date) || value.is_a?(Time)
%("#{value.to_s(:db)}")
else
value.inspect
end
end
# Returns the contents of the record as a nicely formatted string.
#
def inspect
attributes_as_nice_string = self.class.column_names.collect { |name|
if attributes[name.to_sym] || new_record?
"#{name}: #{attribute_for_inspect(name.to_sym)}"
end
}.compact.join(", ")
"#<#{self.class} #{attributes_as_nice_string}>"
end
private
def initialize_columns
self.class.columns.each do |column|
self.class.__send__(:attribute, column.name.to_sym, Column::TYPE_MAP[column.type])
end
end
def reql_payload
{
"Load" => id,
"Update" => "#{id}: #{updateable_attributes(true)}",
"Create" => updateable_attributes,
"Delete" => id,
"Save" => updateable_attributes
}
end
end
end