base_resource.rb 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256
  1. module Import
  2. class BaseResource
  3. include Import::Helper
  4. attr_reader :resource, :remote_id, :errors
  5. def initialize(resource, *args)
  6. @action = :unknown
  7. handle_args(resource, *args)
  8. initialize_associations_states
  9. import(resource, *args)
  10. end
  11. def import_class
  12. raise NoMethodError, "#{self.class.name} has no implementation of the needed 'import_class' method"
  13. end
  14. def source
  15. self.class.source
  16. end
  17. def remote_id(resource, *_args)
  18. @remote_id ||= resource.delete(:id)
  19. end
  20. def action
  21. return :failed if errors.present?
  22. return :skipped if @resource.blank?
  23. return :unchanged if !attributes_changed?
  24. @action
  25. end
  26. def attributes_changed?
  27. changed_attributes.present? || changed_associations.present?
  28. end
  29. def changed_attributes
  30. return if @resource.blank?
  31. # dry run
  32. return @resource.changes_to_save if @resource.has_changes_to_save?
  33. # live run
  34. @resource.previous_changes
  35. end
  36. def changed_associations
  37. changes = {}
  38. tracked_associations.each do |association|
  39. # skip if no new value will get assigned (no change is performed)
  40. next if !@associations[:after].key?(association)
  41. # skip if both values are equal
  42. next if @associations[:before][association] == @associations[:after][association]
  43. # skip if both values are blank
  44. next if @associations[:before][association].blank? && @associations[:after][association].blank?
  45. # store changes
  46. changes[association] = [@associations[:before][association], @associations[:after][association]]
  47. end
  48. changes
  49. end
  50. def self.source
  51. import_class_namespace
  52. end
  53. def self.import_class_namespace
  54. @import_class_namespace ||= name.to_s.sub('Import::', '')
  55. end
  56. private
  57. def initialize_associations_states
  58. @associations = {}
  59. %i(before after).each do |state|
  60. @associations[state] ||= {}
  61. end
  62. end
  63. def import(resource, *args)
  64. create_or_update(map(resource, *args), *args)
  65. rescue => e
  66. # Don't catch own thrown exceptions from above
  67. raise if e.is_a?(NoMethodError)
  68. handle_error(e)
  69. end
  70. def create_or_update(resource, *args)
  71. return if updated?(resource, *args)
  72. create(resource, *args)
  73. end
  74. def updated?(resource, *args)
  75. @resource = lookup_existing(resource, *args)
  76. return false if !@resource
  77. # lock the current resource for write access
  78. @resource.with_lock do
  79. # delete since we have an update and
  80. # the record is already created
  81. resource.delete(:created_by_id)
  82. # store the current state of the associations
  83. # from the resource hash because if we assign
  84. # them to the instance some (e.g. has_many)
  85. # will get stored even in the dry run :/
  86. store_associations(:after, resource)
  87. associations = tracked_associations
  88. @resource.assign_attributes(resource.except(*associations))
  89. # the return value here is kind of misleading
  90. # and should not be trusted to indicate if a
  91. # resource was actually updated.
  92. # Use .action instead
  93. return true if !attributes_changed?
  94. @action = :updated
  95. return true if @dry_run
  96. @resource.assign_attributes(resource.slice(*associations))
  97. @resource.save!
  98. true
  99. end
  100. end
  101. def lookup_existing(resource, *_args)
  102. synced_instance = ExternalSync.find_by(
  103. source: source,
  104. source_id: remote_id(resource),
  105. object: import_class.name,
  106. )
  107. return if !synced_instance
  108. instance = import_class.find_by(id: synced_instance.o_id)
  109. store_associations(:before, instance)
  110. instance
  111. end
  112. def store_associations(state, source)
  113. @associations[state] = associations_state(source)
  114. end
  115. def associations_state(source)
  116. state = {}
  117. tracked_associations.each do |association|
  118. # we have to support instances and (resource) hashes
  119. # here since in case of an update we only have the
  120. # hash as a source but on create we have an instance
  121. if source.is_a?(Hash)
  122. # ignore if there is no key for the association
  123. # of the Hash (update)
  124. # otherwise wrong changes may get detected
  125. next if !source.key?(association)
  126. state[association] = source[association]
  127. else
  128. state[association] = source.send(association)
  129. end
  130. # sort arrays to avoid wrong change detection
  131. next if !state[association].respond_to?(:sort!)
  132. state[association].sort!
  133. end
  134. state
  135. end
  136. def tracked_associations
  137. # loop over all reflections
  138. import_class.reflect_on_all_associations.collect do |reflection|
  139. # refection name is something like groups or organization (singular/plural)
  140. reflection_name = reflection.name.to_s
  141. # key is something like group_id or organization_id (singular)
  142. key = reflection.klass.name.foreign_key
  143. # add trailing 's' to get pluralized key
  144. if reflection_name.singularize != reflection_name
  145. key = "#{key}s"
  146. end
  147. key.to_sym
  148. end
  149. end
  150. def create(resource, *_args)
  151. @resource = import_class.new(resource)
  152. store_associations(:after, @resource)
  153. @action = :created
  154. return if @dry_run
  155. @resource.save!
  156. external_sync_create(
  157. local: @resource,
  158. remote: resource,
  159. )
  160. end
  161. def external_sync_create(local:, remote:)
  162. ExternalSync.create(
  163. source: source,
  164. source_id: remote_id(remote),
  165. object: import_class.name,
  166. o_id: local.id
  167. )
  168. end
  169. def defaults(_resource, *_args)
  170. {
  171. created_by_id: 1,
  172. updated_by_id: 1,
  173. }
  174. end
  175. def map(resource, *args)
  176. mapped = from_mapping(resource, *args)
  177. attributes = defaults(resource, *args).merge(mapped)
  178. attributes.symbolize_keys
  179. end
  180. def from_mapping(resource, *args)
  181. mapping = mapping(*args)
  182. return resource if !mapping
  183. ExternalSync.map(
  184. mapping: mapping,
  185. source: resource
  186. )
  187. end
  188. def mapping(*args)
  189. Setting.get(mapping_config(*args))
  190. end
  191. def mapping_config(*_args)
  192. self.class.import_class_namespace.gsub('::', '_').underscore + '_mapping'
  193. end
  194. def handle_args(_resource, *args)
  195. return if !args
  196. return if !args.is_a?(Array)
  197. return if args.empty?
  198. last_arg = args.last
  199. return if !last_arg.is_a?(Hash)
  200. handle_modifiers(last_arg)
  201. end
  202. def handle_modifiers(modifiers)
  203. @dry_run = modifiers.fetch(:dry_run, false)
  204. end
  205. def handle_error(e)
  206. @errors ||= []
  207. @errors.push(e)
  208. Rails.logger.error e
  209. end
  210. end
  211. end