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