lib/parse_resource/base.rb in parse_resource-1.7.3 vs lib/parse_resource/base.rb in parse_resource-1.8.0

- old
+ new

@@ -4,12 +4,14 @@ require "erb" require "rest-client" require "json" require "active_support/hash_with_indifferent_access" require "parse_resource/query" +require "parse_resource/query_methods" require "parse_resource/parse_error" require "parse_resource/parse_exceptions" +require "parse_resource/types/parse_geopoint" module ParseResource class Base @@ -24,10 +26,12 @@ include ActiveModel::AttributeMethods extend ActiveModel::Naming extend ActiveModel::Callbacks HashWithIndifferentAccess = ActiveSupport::HashWithIndifferentAccess + attr_accessor :error_instances + define_model_callbacks :save, :create, :update, :destroy # Instantiates a ParseResource::Base object # # @params [Hash], [Boolean] a `Hash` of attributes and a `Boolean` that should be false only if the object already exists @@ -35,36 +39,40 @@ def initialize(attributes = {}, new=true) #attributes = HashWithIndifferentAccess.new(attributes) if new @unsaved_attributes = attributes + @unsaved_attributes.stringify_keys! else @unsaved_attributes = {} end self.attributes = {} + self.error_instances = [] self.attributes.merge!(attributes) self.attributes unless self.attributes.empty? create_setters_and_getters! end # Explicitly adds a field to the model. # # @param [Symbol] name the name of the field, eg `:author`. # @param [Boolean] val the return value of the field. Only use this within the class. - def self.field(name, val=nil) + def self.field(fname, val=nil) + fname = fname.to_sym class_eval do - define_method(name) do - @attributes[name] ? @attributes[name] : @unsaved_attributes[name] + define_method(fname) do + get_attribute("#{fname}") end - define_method("#{name}=") do |val| - val = val.to_pointer if val.respond_to?(:to_pointer) - - @attributes[name] = val - @unsaved_attributes[name] = val - - val + end + unless self.respond_to? "#{fname}=" + class_eval do + define_method("#{fname}=") do |val| + set_attribute("#{fname}", val) + + val + end end end end # Add multiple fields in one line. Same as `#field`, but accepts multiple args. @@ -82,76 +90,63 @@ end def to_pointer klass_name = self.class.model_name klass_name = "_User" if klass_name == "User" - {"__type" => "Pointer", "className" => klass_name, "objectId" => self.id} + klass_name = "_Installation" if klass_name == "Installation" + {"__type" => "Pointer", "className" => klass_name.to_s, "objectId" => self.id} end + def self.to_date_object(date) + date = date.to_time if date.respond_to?(:to_time) + {"__type" => "Date", "iso" => date.iso8601} if date && (date.is_a?(Date) || date.is_a?(DateTime) || date.is_a?(Time)) + end + # Creates setter methods for model fields def create_setters!(k,v) - self.class.send(:define_method, "#{k}=") do |val| - val = val.to_pointer if val.respond_to?(:to_pointer) - - @attributes[k.to_s] = val - @unsaved_attributes[k.to_s] = val - - val + unless self.respond_to? "#{k}=" + self.class.send(:define_method, "#{k}=") do |val| + set_attribute("#{k}", val) + + val + end end end - def self.method_missing(name, *args) - name = name.to_s - if name.start_with?("find_by_") - attribute = name.gsub(/^find_by_/,"") - finder_name = "find_all_by_#{attribute}" + def self.method_missing(method_name, *args) + method_name = method_name.to_s + if method_name.start_with?("find_by_") + attrib = method_name.gsub(/^find_by_/,"") + finder_name = "find_all_by_#{attrib}" define_singleton_method(finder_name) do |target_value| - where({attribute.to_sym => target_value}).first + where({attrib.to_sym => target_value}).first end send(finder_name, args[0]) - elsif name.start_with?("find_all_by_") - attribute = name.gsub(/^find_all_by_/,"") - finder_name = "find_all_by_#{attribute}" + elsif method_name.start_with?("find_all_by_") + attrib = method_name.gsub(/^find_all_by_/,"") + finder_name = "find_all_by_#{attrib}" define_singleton_method(finder_name) do |target_value| - where({attribute.to_sym => target_value}).all + where({attrib.to_sym => target_value}).all end send(finder_name, args[0]) else - super(name.to_sym, *args) + super(method_name.to_sym, *args) end end # Creates getter methods for model fields def create_getters!(k,v) - self.class.send(:define_method, "#{k}") do - - case @attributes[k] - when Hash - - klass_name = @attributes[k]["className"] - klass_name = "User" if klass_name == "_User" - - case @attributes[k]["__type"] - when "Pointer" - result = klass_name.constantize.find(@attributes[k]["objectId"]) - when "Object" - result = klass_name.constantize.new(@attributes[k], false) - when "File" - result = @attributes[k]["url"] - end #todo: support Dates and other types https://www.parse.com/docs/rest#objects-types - - else - result = @attributes[k] + unless self.respond_to? "#{k}" + self.class.send(:define_method, "#{k}") do + get_attribute("#{k}") end - - result - end + end end def create_setters_and_getters! @attributes.each_pair do |k,v| create_setters!(k,v) @@ -168,64 +163,145 @@ def self.load!(app_id, master_key) @@settings = {"app_id" => app_id, "master_key" => master_key} end def self.settings - if @@settings.nil? - path = "config/parse_resource.yml" - #environment = defined?(Rails) && Rails.respond_to?(:env) ? Rails.env : ENV["RACK_ENV"] - environment = ENV["RACK_ENV"] - @@settings = YAML.load(ERB.new(File.new(path).read).result)[environment] + load_settings + end + + # Gets the current class's model name for the URI + def self.model_name_uri + if self.model_name == "User" + "users" + elsif self.model_name == "Installation" + "installations" + else + "classes/#{self.model_name}" end - @@settings end + + # Gets the current class's Parse.com base_uri + def self.model_base_uri + "https://api.parse.com/1/#{model_name_uri}" + end + + # Gets the current instance's parent class's Parse.com base_uri + def model_base_uri + self.class.send(:model_base_uri) + end + # Creates a RESTful resource # sends requests to [base_uri]/[classname] # def self.resource - if @@settings.nil? - path = "config/parse_resource.yml" - environment = defined?(Rails) && Rails.respond_to?(:env) ? Rails.env : ENV["RACK_ENV"] - @@settings = YAML.load(ERB.new(File.new(path).read).result)[environment] - end + load_settings - if model_name == "User" #https://parse.com/docs/rest#users-signup - base_uri = "https://api.parse.com/1/users" - else - base_uri = "https://api.parse.com/1/classes/#{model_name}" - end - #refactor to settings['app_id'] etc app_id = @@settings['app_id'] master_key = @@settings['master_key'] - RestClient::Resource.new(base_uri, app_id, master_key) + RestClient::Resource.new(self.model_base_uri, app_id, master_key) end - - # Creates a RESTful resource for file uploads - # sends requests to [base_uri]/files + + # Batch requests + # Sends multiple requests to /batch + # Set slice_size to send larger batches. Defaults to 20 to prevent timeouts. + # Parse doesn't support batches of over 20. # - def self.upload(file_instance, filename, options={}) - if @@settings.nil? + def self.batch_save(save_objects, slice_size = 20, method = nil) + return true if save_objects.blank? + load_settings + + base_uri = "https://api.parse.com/1/batch" + app_id = @@settings['app_id'] + master_key = @@settings['master_key'] + + res = RestClient::Resource.new(base_uri, app_id, master_key) + + # Batch saves seem to fail if they're too big. We'll slice it up into multiple posts if they are. + save_objects.each_slice(slice_size) do |objects| + # attributes_for_saving + batch_json = { "requests" => [] } + + objects.each do |item| + method ||= (item.new?) ? "POST" : "PUT" + object_path = "/1/#{item.class.model_name_uri}" + object_path = "#{object_path}/#{item.id}" if item.id + json = { + "method" => method, + "path" => object_path + } + json["body"] = item.attributes_for_saving unless method == "DELETE" + batch_json["requests"] << json + end + res.post(batch_json.to_json, :content_type => "application/json") do |resp, req, res, &block| + response = JSON.parse(resp) rescue nil + if resp.code == 400 + puts resp + return false + end + if response && response.is_a?(Array) && response.length == objects.length + merge_all_attributes(objects, response) unless method == "DELETE" + end + end + end + true + end + + def self.merge_all_attributes(objects, response) + i = 0 + objects.each do |item| + item.merge_attributes(response[i]["success"]) if response[i] && response[i]["success"] + i += 1 + end + nil + end + + def self.save_all(objects) + batch_save(objects) + end + + def self.destroy_all(objects=nil) + objects ||= self.all + batch_save(objects, 20, "DELETE") + end + + def self.delete_all(o) + raise StandardError.new("Parse Resource: delete_all doesn't exist. Did you mean destroy_all?") + end + + def self.load_settings + @@settings ||= begin path = "config/parse_resource.yml" environment = defined?(Rails) && Rails.respond_to?(:env) ? Rails.env : ENV["RACK_ENV"] - @@settings = YAML.load(ERB.new(File.new(path).read).result)[environment] + YAML.load(ERB.new(File.new(path).read).result)[environment] end + @@settings + end + + # Creates a RESTful resource for file uploads + # sends requests to [base_uri]/files + # + def self.upload(file_instance, filename, options={}) + load_settings + base_uri = "https://api.parse.com/1/files" #refactor to settings['app_id'] etc app_id = @@settings['app_id'] master_key = @@settings['master_key'] options[:content_type] ||= 'image/jpg' # TODO: Guess mime type here. file_instance = File.new(file_instance, 'rb') if file_instance.is_a? String + filename = filename.parameterize + private_resource = RestClient::Resource.new "#{base_uri}/#{filename}", app_id, master_key private_resource.post(file_instance, options) do |resp, req, res, &block| return false if resp.code == 400 - return JSON.parse(resp) + return JSON.parse(resp) rescue {"code" => 0, "error" => "unknown error"} end false end # Find a ParseResource::Base object by ID @@ -241,52 +317,18 @@ # def self.where(*args) Query.new(self).where(*args) end - # Include the attributes of a parent ojbect in the results - # Similar to ActiveRecord eager loading - # - def self.include_object(parent) - Query.new(self).include_object(parent) - end - # Add this at the end of a method chain to get the count of objects, instead of an Array of objects - def self.count - #https://www.parse.com/docs/rest#queries-counting - Query.new(self).count(1) - end + include ParseResource::QueryMethods - # Find all ParseResource::Base objects for that model. - # - # @return [Array] an `Array` of objects that subclass `ParseResource`. - def self.all - Query.new(self).all - end - # Find the first object. Fairly random, not based on any specific condition. - # - def self.first - Query.new(self).limit(1).first + def self.chunk(attribute) + Query.new(self).chunk(attribute) end - # Limits the number of objects returned - # - def self.limit(n) - Query.new(self).limit(n) - end - - # Skip the number of objects - # - def self.skip(n) - Query.new(self).skip(n) - end - - #def self.order(attribute) - # Query.new(self).order(attribute) - #end - # Create a ParseResource::Base object. # # @param [Hash] attributes a `Hash` of attributes # @return [ParseResource] an object that subclasses `ParseResource`. Or returns `false` if object fails to save. def self.create(attributes = {}) @@ -294,15 +336,16 @@ obj = new(attributes) obj.save obj end - def self.destroy_all - all.each do |object| - object.destroy - end - end + # Replaced with a batch destroy_all method. + # def self.destroy_all(all) + # all.each do |object| + # object.destroy + # end + # end def self.class_attributes @class_attributes ||= {} end @@ -327,32 +370,22 @@ # sends requests to [base_uri]/[classname]/[objectId] def instance_resource self.class.resource["#{self.id}"] end - def create - opts = {:content_type => "application/json"} - attrs = @unsaved_attributes.to_json - result = self.resource.post(attrs, opts) do |resp, req, res, &block| - - case resp.code - when 400 - - # https://www.parse.com/docs/ios/api/Classes/PFConstants.html - error_response = JSON.parse(resp) - pe = ParseError.new(error_response["code"]).to_array - self.errors.add(pe[0], pe[1]) - return false + def pointerize(hash) + new_hash = {} + hash.each do |k, v| + if v.respond_to?(:to_pointer) + new_hash[k] = v.to_pointer + elsif v.is_a?(Date) || v.is_a?(Time) || v.is_a?(DateTime) + new_hash[k] = self.class.to_date_object(v) else - @attributes.merge!(JSON.parse(resp)) - @attributes.merge!(@unsaved_attributes) - attributes = HashWithIndifferentAccess.new(attributes) - @unsaved_attributes = {} - create_setters_and_getters! - return true + new_hash[k] = v end end + new_hash end def save if valid? run_callbacks :save do @@ -366,44 +399,65 @@ false end rescue false end + def create + attrs = attributes_for_saving.to_json + opts = {:content_type => "application/json"} + result = self.resource.post(attrs, opts) do |resp, req, res, &block| + return post_result(resp, req, res, &block) + end + end + def update(attributes = {}) attributes = HashWithIndifferentAccess.new(attributes) @unsaved_attributes.merge!(attributes) - - put_attrs = @unsaved_attributes - put_attrs.delete('objectId') - put_attrs.delete('createdAt') - put_attrs.delete('updatedAt') - put_attrs = put_attrs.to_json + put_attrs = attributes_for_saving.to_json opts = {:content_type => "application/json"} result = self.instance_resource.put(put_attrs, opts) do |resp, req, res, &block| - case resp.code - when 400 - - # https://www.parse.com/docs/ios/api/Classes/PFConstants.html - error_response = JSON.parse(resp) - pe = ParseError.new(error_response["code"], error_response["error"]).to_array - self.errors.add(pe[0], pe[1]) - - return false + return post_result(resp, req, res, &block) + end + end + + # Merges in the return value of a save and resets the unsaved_attributes + def merge_attributes(results) + @attributes.merge!(results) + @attributes.merge!(@unsaved_attributes) + @unsaved_attributes = {} + create_setters_and_getters! + @attributes + end + + def post_result(resp, req, res, &block) + if resp.code.to_s == "200" || resp.code.to_s == "201" + merge_attributes(JSON.parse(resp)) + return true + else + error_response = JSON.parse(resp) + if error_response["error"] + pe = ParseError.new(error_response["code"], error_response["error"]) else - - @attributes.merge!(JSON.parse(resp)) - @attributes.merge!(@unsaved_attributes) - @unsaved_attributes = {} - create_setters_and_getters! - - return true + pe = ParseError.new(resp.code.to_s) end - end + self.errors.add(pe.code.to_s.to_sym, pe.msg) + self.error_instances << pe + return false + end end + + def attributes_for_saving + @unsaved_attributes = pointerize(@unsaved_attributes) + put_attrs = @unsaved_attributes + put_attrs.delete('objectId') + put_attrs.delete('createdAt') + put_attrs.delete('updatedAt') + put_attrs + end def update_attributes(attributes = {}) self.update(attributes) end @@ -423,10 +477,18 @@ @attributes.update(fresh_object.instance_variable_get('@attributes')) @unsaved_attributes = {} self end + + def dirty? + @unsaved_attributes.length > 0 + end + + def clean? + !dirty? + end # provides access to @attributes for getting and setting def attributes @attributes ||= self.class.class_attributes @attributes @@ -436,15 +498,52 @@ def attributes=(n) @attributes = n @attributes end + def get_attribute(k) + attrs = @unsaved_attributes[k.to_s] ? @unsaved_attributes : @attributes + case attrs[k] + when Hash + klass_name = attrs[k]["className"] + klass_name = "User" if klass_name == "_User" + case attrs[k]["__type"] + when "Pointer" + result = klass_name.constantize.find(attrs[k]["objectId"]) + when "Object" + result = klass_name.constantize.new(attrs[k], false) + when "Date" + result = DateTime.parse(attrs[k]["iso"]).to_time_in_current_zone + when "File" + result = attrs[k]["url"] + when "GeoPoint" + result = ParseGeoPoint.new(attrs[k]) + end #todo: support other types https://www.parse.com/docs/rest#objects-types + else + result = attrs["#{k}"] + end + result + end + + def set_attribute(k, v) + if v.is_a?(Date) || v.is_a?(Time) || v.is_a?(DateTime) + v = self.class.to_date_object(v) + elsif v.respond_to?(:to_pointer) + v = v.to_pointer + end + @unsaved_attributes[k.to_s] = v unless v == @attributes[k.to_s] # || @unsaved_attributes[k.to_s] + @attributes[k.to_s] = v + v + end + + # aliasing for idiomatic Ruby - def id; self.objectId rescue nil; end + def id; get_attribute("objectId") rescue nil; end + def objectId; get_attribute("objectId") rescue nil; end - def created_at; self.createdAt; end + def created_at; get_attribute("createdAt"); end - def updated_at; self.updatedAt rescue nil; end + def updated_at; get_attribute("updatedAt"); rescue nil; end def self.included(base) base.extend(ClassMethods) end