motion-prime/models/_sync_mixin.rb
module MotionPrime
module ModelSyncMixin
extend ::MotionSupport::Concern
def self.included(base)
base.class_attribute :_sync_url
base.class_attribute :_updatable_attributes
base.class_attribute :_associations
end
# Get normalized sync url of this Prime::Model
#
# @param method [Symbol] http method
# @return url [String] url to use in model sync
def sync_url(method = :get, options = {})
url = self.class.sync_url
url = url.call(method, self, options) if url.is_a?(Proc)
normalize_sync_url(url)
end
# Get normalized sync url of associated Prime::Model
#
# @param key [Symbol] association name
# @return url [String] url to use in model association sync
def association_sync_url(key, options, sync_options = {})
url = options[:sync_url]
url = url.call(self, sync_options) if url.is_a?(Proc)
normalize_sync_url(url)
end
# Destroy model on server and delete on local
#
# @param block [Proc] block to be executed after destroy
# @return self[Prime::Model] deleted model.
def destroy(&block)
use_callback = block_given?
api_client.delete(sync_url(:delete)) do
block.call() if use_callback
end
delete
end
# Fetch model from server and save on local
#
def fetch!(options = {}, &block)
fetch(options.merge(save: true), &block)
end
# Fetch model from server
#
# @param options [Hash] fetch options
# @option options [Symbol] :method Http method to calculate url, `:get` by default
# @option options [Boolean or Array] :associations Also fetch associations
# @option options [Boolean] :save Save model after fetch
# @param block [Proc] block to be executed after fetch
def fetch(options = {}, &block)
use_callback = block_given?
method = options[:method] || :get
url = sync_url(method, options)
will_fetch_model = !url.blank?
will_fetch_associations = options.fetch(:associations, true)
will_fetch_associations = false unless has_associations_to_fetch?(options)
fetch_with_url url, options do |data, status_code|
save if options[:save]
block.call(data, status_code, data) if use_callback && !will_fetch_associations
end if will_fetch_model
fetch_associations(options) do |data, status_code|
# run callback only if it wasn't run on fetch
block.call(data, status_code, data) if use_callback
end if will_fetch_associations
end
# Update on server and save response on local
#
def update!(options = {}, &block)
update(options.merge(save_response: true), &block)
end
# Update on server
# @param options [Hash] update options
# @option options [Symbol] :method Http method to calculate url, by default `:post` for new record and `:put` for existing
# @param block [Proc] block to be executed after update
def update(options = {}, &block)
use_callback = block_given?
method = options[:method] || (persisted? ? :put : :post)
url = sync_url(method, options)
will_update_model = !url.blank?
update_with_url url, options do |data, status_code|
block.call(data, status_code, data) if use_callback
end if will_update_model
end
# Fetch model from server using url
#
# @param url [String] url to fetch
# @param block [Proc] block to be executed after fetch
def fetch_with_url(url, options = {}, &block)
use_callback = block_given?
api_client.get(url) do |data, status_code|
if data.present?
fetch_with_attributes(data, save_associations: options[:save], &block)
end
block.call(data, status_code, data) if use_callback
end
end
# Update on server using url
#
# @param url [String] url to update
# @param block [Proc] block to be executed after update
def update_with_url(url, options = {}, &block)
use_callback = block_given?
filtered_attributes = filtered_updatable_attributes(options)
attributes = attributes_to_post_data(model_name, filtered_attributes)
post_data = options[:params_root] || {}
post_data.merge!(attributes)
method = options[:method] || (persisted? ? :put : :post)
api_client.send(method, url, post_data, options) do |data, status_code|
assign_response_data = options.fetch(:save_response, true)
if assign_response_data && status_code.to_s =~ /20\d/ && data.is_a?(Hash)
set_attributes_from_response(data)
save if options[:save_response]
end
block.call(data, status_code, data) if use_callback
end
end
def set_attributes_from_response(data)
self.id ||= data.delete('id')
fetch_with_attributes(data)
end
# Assign model attributes, using fetch. Differenct between assign_attributes and fetch_with_attributes is
# that you can create method named fetch_:attribute and it will be used to assign attribute only on fetch.
#
# @example
# class User < Prime::Model
# attribute :created_at
# def fetch_created_at(value)
# self.created_at = Date.parse(value)
# end
# end
# user = User.new
# user.fetch_with_attributes(created_at: '2007-03-01T13:00:00Z')
# user.created_at # => 2007-03-01 13:00:00 UTC
#
# @params attributes [Hash] attributes to be assigned
# @params options [Hash] options
# @option options [Boolean] :save_associations Save included to hash associations
# @return model [Prime::Model] the model
def fetch_with_attributes(attrs, options = {})
track_changed_attributes do
attrs.each do |key, value|
if respond_to?(:"fetch_#{key}")
self.send(:"fetch_#{key}", value)
elsif has_association?(key) && (value.is_a?(Hash) || value.is_a?(Array))
fetch_association_with_attributes(key.to_sym, value, save: options[:save_associations])
elsif respond_to?(:"#{key}=")
self.send(:"#{key}=", value)
# TODO: self.info[:"#{key}"] = value is much faster, maybe we could use it
end
end
end
self
end
def associations
@associations ||= (self.class._associations || {}).clone
end
def associations_to_fetch(options = {})
associations.select { |key, v| fetch_association?(key, options) }
end
def fetch_associations(sync_options = {}, &block)
use_callback = block_given?
associations_to_fetch(sync_options).keys.each_with_index do |key, index|
if use_callback && associations.count - 1 == index
fetch_association(key, sync_options, &block)
else
fetch_association(key, sync_options)
end
end
end
def has_associations_to_fetch?(options = {})
associations_to_fetch(options).present?
end
def has_association?(key)
!associations[key.to_sym].nil?
end
def fetch_association?(key, options = {})
allowed_associations = options[:associations].map(&:to_sym) if options[:associations].is_a?(Array)
return false if allowed_associations.try(:exclude?, key.to_sym)
options = associations[key.to_sym]
return false if options[:if] && !options[:if].to_proc.call(self)
association_sync_url(key, options).present?
end
def fetch_association(key, sync_options = {}, &block)
return unless fetch_association?(key, sync_options)
options = associations[key.to_sym]
if options[:type] == :many
fetch_has_many(key, options, sync_options, &block)
else
fetch_has_one(key, options, sync_options, &block)
end
end
def fetch_association_with_attributes(key, data, sync_options = {})
options = associations[key.to_sym]
return unless options
if options[:type] == :many
fetch_has_many_with_attributes(key, data || [], sync_options)
else
fetch_has_one_with_attributes(key, data || {}, sync_options)
end
end
def fetch_has_many(key, options = {}, sync_options = {}, &block)
use_callback = block_given?
NSLog("SYNC: started sync for #{key} in #{self.class_name_without_kvo}")
params = (options[:params] || {}).deep_merge(sync_options[:params] || {})
api_client.get association_sync_url(key, options, sync_options), params do |response, status_code|
data = options[:sync_key] && response ? response[options[:sync_key]] : response
if data
unless data.is_a?(Array)
raise MotionPrime::SyncError, "Expected Array for sync '#{key}', but received object"
end
NSLog("SYNC: finished sync for #{key} in #{self.class_name_without_kvo}")
fetch_has_many_with_attributes(key, data, sync_options)
block.call(data, status_code, response) if use_callback
else
NSLog("SYNC ERROR: failed sync for #{key} in #{self.class_name_without_kvo}")
block.call(data, status_code, response) if use_callback
end
end
end
def update_storage(bags_options, sync_options = {})
should_save = sync_options[:save]
if should_save
models_to_save = bags_options.inject([]) { |result, (key, bag_options)| result + bag_options[:save] }
models_to_delete = bags_options.inject([]) { |result, (key, bag_options)| result + bag_options[:delete] }
models_to_save.each(&:save)
models_to_delete.each(&:delete)
end
bags_changed = false
bags_options.each do |bag_key, bag_options|
next if bag_options[:add].empty? && bag_options[:delete].empty?
bags_changed = true
bag = self.send(:"#{bag_key}_bag")
bag.add(bag_options[:add], silent_validation: true)
bag.save if should_save
end
save if should_save && (bags_changed || has_changed?)
end
def fetch_has_many_with_attributes(key, data, sync_options = {})
# TODO: should we skip add/delete/save unless should_save?
should_save = sync_options[:save]
models_to_add = []
models_to_save = []
models_to_delete = []
track_changed_attributes do
old_collection = self.send(key)
association_options = associations[key]
model_class = association_options.fetch(:class_name, key.classify).constantize
data.each do |attributes|
model = old_collection.detect{ |model| model.id == attributes[:id]}
unless model
model = model_class.new
models_to_add << model
end
model.fetch_with_attributes(attributes, save_associations: should_save)
if should_save && model.has_changed?
models_to_save << model
end
end
old_collection.each do |old_model|
model = data.detect{ |model| model[:id] == old_model.id}
unless model
models_to_delete << old_model
end
end unless sync_options[:append]
end
update_storage({key => {
save: models_to_save,
delete: models_to_delete,
add: models_to_add
}}, sync_options)
end
def fetch_has_one(key, options = {}, sync_options = {}, &block)
use_callback = block_given?
NSLog("SYNC: started sync for #{key} in #{self.class_name_without_kvo}")
params = (options[:params] || {}).deep_merge(sync_options[:params] || {})
api_client.get association_sync_url(key, options, sync_options), params do |response, status_code|
data = options.has_key?(:sync_key) ? response[options[:sync_key]] : response
if data.present?
fetch_has_one_with_attributes(key, data, save_associations: sync_options[:save])
block.call(data, status_code, response) if use_callback
else
NSLog("SYNC ERROR: failed sync for #{key} in #{self.class_name_without_kvo}")
block.call(data, status_code, response) if use_callback
end
end
end
def fetch_has_one_with_attributes(key, data, sync_options = {})
track_changed_attributes do
model = self.send(key)
unless model
model = key.classify.constantize.new
self.send(:"#{key}_bag") << model
end
model.fetch_with_attributes(data)
model.save if sync_options[:save]
end
save if sync_options[:save] && has_changed?
end
def filtered_updatable_attributes(options = {})
slice_attributes = options[:updatable_attributes].map(&:to_sym) if options.has_key?(:updatable_attributes)
updatable_attributes = self.class.updatable_attributes
if updatable_attributes.blank?
return slice_attributes ? attributes_hash.slice(*slice_attributes) : attributes_hash
end
updatable_attributes = updatable_attributes.slice(*slice_attributes) if slice_attributes
updatable_attributes.inject({}) do |hash, (key, options)|
next hash if options[:if] && !send(options[:if])
value = if block = options[:block]
block.call(self, hash)
else
info[key]
end
hash[key] = value
hash
end
end
def normalize_sync_url(url)
normalize_object(url).to_s.gsub(':id', id.to_s)
end
def attributes_to_post_data(root_name, attributes)
result = {:_files => [], root_name => attributes}
result[root_name].each do |name, field_attrs|
next unless field_attrs.is_a?(Hash)
files = Array.wrap(field_attrs.delete(:_files)).map do |file|
file[:name].insert(0, "#{root_name}[#{name}]")
file
end
result[:_files] += files
end
result
end
module ClassMethods
# Fetch model from server
#
# @param id [Integer] model id
# @param options [Hash] fetch options
# @option options [Symbol] :method Http method to calculate url, `:get` by default
# @option options [Boolean or Array] :associations Also fetch associations
# @option options [Boolean] :save Save model after fetch
# @param block [Proc] block to be executed after fetch
def fetch(id, options = {}, &block)
model = self.new(id: id)
model.fetch(options, &block)
end
# Fetch model from server and save on local
def fetch!(id, options = {}, &block)
fetch(id, options.merge(save: true), &block)
end
# Fetch collection from server
#
# @param options [Hash] fetch options
# @option options [Symbol] :method Http method to calculate url, `:get` by default
# @option options [Boolean] :save Save model after fetch
# @param block [Proc] block to be executed after fetch
def fetch_all(options = {}, &block)
use_callback = block_given?
url = self.new.sync_url(options[:method] || :get, options)
fetch_all_with_url url, options do |records, status_code, response|
records.each(&:save) if options[:save]
block.call(records, status_code, response) if use_callback
end if !url.blank?
end
# Fetch collection from server using url
#
# @param url [String] url to fetch
# @param block [Proc] block to be executed after fetch
def fetch_all_with_url(url, options = {}, &block)
use_callback = block_given?
App.delegate.api_client.get(url) do |response, status_code|
if response.present?
records = fetch_all_with_attributes(response, save_associations: options[:save], &block)
else
records = []
end
block.call(records, status_code, response) if use_callback
end
end
# Assign collection attributes, using fetch.
#
# @params attributes [Array<Hash>] attributes to be assigned
# @params options [Hash] options
# @option options [Boolean] :save_associations Save included to hash associations
# @return model [Prime::Model] the model
def fetch_all_with_attributes(data, options ={}, &block)
data.map do |attrs|
item = self.new
item.fetch_with_attributes(attrs)
item
end
end
def new(data = {}, options = {})
model = super
if fetch_attributes = options[:fetch_attributes]
model.fetch_with_attributes(fetch_attributes)
end
model
end
def sync_url(url = nil, &block)
if url || block_given?
self._sync_url = url || block
else
self._sync_url
end
end
def updatable_attributes(*attrs)
return self._updatable_attributes if attrs.blank?
attrs.each do |attribute|
updatable_attribute(attribute)
end
end
def updatable_attribute(attribute, options = {}, &block)
options[:block] = block if block_given?
self._updatable_attributes ||= {}
self._updatable_attributes[attribute] = options
end
end
end
end