lib/ecoportal/api/common/content/double_model.rb in ecoportal-api-v2-1.1.7 vs lib/ecoportal/api/common/content/double_model.rb in ecoportal-api-v2-1.1.8

- old
+ new

@@ -4,25 +4,25 @@ module Content # Basic model class, to **build _get_ / _set_ `methods`** for a given property # which differs of `attr_*` ruby native class methods because `pass*` # completelly **links** the methods **to a subjacent `Hash` model** class DoubleModel < Common::BaseModel - NOT_USED = Common::Content::ClassHelpers::NOT_USED - extend Common::Content::ClassHelpers - include Common::Content::ModelHelpers - class UnlinkedModel < StandardError - def initialize (msg = "Something went wrong when linking the document.", from: nil, key: nil) + def initialize(msg = "Something went wrong when linking the document.", from: nil, key: nil) msg += " From: #{from}." if from msg += " key: #{key}." if key super(msg) end end class NoKeyMethod < StandardError end + NOT_USED = Common::Content::ClassHelpers::NOT_USED + extend Common::Content::ClassHelpers + include Common::Content::ModelHelpers + class << self attr_reader :key def key? !!key @@ -116,20 +116,21 @@ # - This ensures that does that do, will get the correct patch update model # @param method [Symbol] the method that exposes the value # as well as its `key` in the underlying `Hash` model. # @param default [Value] the default value that # this `key` will be written in the model when it doesn't exixt - def passforced(method, default: , read_only: false) + def passforced(method, default:, read_only: false) model_forced_keys[method.to_s.freeze] = default passthrough(method, read_only: read_only) end # Ensures `doc` has the `model_forced_keys`. If it doesn't, it adds those missing # with the defined `default` values def enforce!(doc) - return unless doc && doc.is_a?(Hash) - return if model_forced_keys.empty? + return unless doc.is_a?(Hash) + return if model_forced_keys.empty? + model_forced_keys.each do |key, default| doc[key] = default unless doc.key?(key) end doc end @@ -137,37 +138,33 @@ # Same as `attr_accessor` but links to a subjacent `Hash` model property # @param methods [Array<Symbol>] the method that exposes the value # as well as its `key` in the underlying `Hash` model. # @param read_only [Boolean] should it only define the reader? def passthrough(*methods, read_only: false) - pass_reader *methods - pass_writer *methods unless read_only + pass_reader(*methods) + pass_writer(*methods) unless read_only self end # To link as a `Time` date to a subjacent `Hash` model property # @see Ecoportal::API::Common::Content::DoubleModel#passthrough # @param methods [Array<Symbol>] the method that exposes the value # as well as its `key` in the underlying `Hash` model. # @param read_only [Boolean] should it only define the reader? def passdate(*methods, read_only: false) pass_reader(*methods) {|value| to_time(value)} - unless read_only - pass_writer(*methods) {|value| to_time(value)&.iso8601} - end + pass_writer(*methods) {|value| to_time(value)&.iso8601} unless read_only self end # To link as a `Boolean` to a subjacent `Hash` model property # @param methods [Array<Symbol>] the method that exposes the value # as well as its `key` in the underlying `Hash` model. # @param read_only [Boolean] should it only define the reader? def passboolean(*methods, read_only: false) pass_reader(*methods) {|value| value} - unless read_only - pass_writer(*methods) {|value| !!value} - end + pass_writer(*methods) {|value| !!value} unless read_only self end # To link as plain `Array` to a subjacent `Hash` model property # @param methods [Array<Symbol>] the method that exposes the value @@ -184,22 +181,22 @@ klass.uniq = uniq end define_method method do return instance_variable_get(var) if instance_variable_defined?(var) - new_obj = dim_class.new(parent: self, key: method, read_only: self.read_only?) + new_obj = dim_class.new(parent: self, key: method, read_only: read_only?) variable_set(var, new_obj) end end end # Helper to embed one nested object under one property # @param method [Symbol] the method that exposes the embeded object # @param key [Symbol] the `key` that embeds it to the underlying `Hash` model # @nullable [Boolean] to specify if this object can be `nil` # @param klass [Class, String] the class of the embedded object - def embeds_one(method, key: method, nullable: false, klass:) + def embeds_one(method, klass:, key: method, nullable: false) embed(method, key: key, nullable: nullable, multiple: false, klass: klass) end # @note # - if you have a dedicated `Enumerable` class to manage `many`, you should use `:enum_class` @@ -210,78 +207,87 @@ # @param klass [Class, String] the class of the individual elements it embeds # @param enum_class [Class, String] the class of the collection that will hold the individual elements # @param read_only [Boolean] whether or not should try to **work around** items `klass` missing a `key` # - If set to `true` this is meant only for read purposes (won't be able to successufully insert) def embeds_many(method, key: method, klass: nil, enum_class: nil, - order_matters: false, order_key: nil, read_only: self.read_only?) + order_matters: false, order_key: nil, read_only: read_only?) if enum_class eclass = enum_class elsif klass eclass = new_class("#{method}::#{klass}", inherits: Common::Content::CollectionModel) do |dim_class| - dim_class.klass = klass + # NOTE: new_class may resolve the namespace of the class to an already existing class + dim_class.klass ||= klass dim_class.order_matters = order_matters dim_class.order_key = order_key dim_class.read_only! if read_only end else raise "You should either specify the 'klass' of the elements or the 'enum_class'" end - embed(method, key: key, multiple: true, klass: eclass, read_only: read_only) do |instance_with_called_method| + + embed( + method, key: key, + multiple: true, klass: eclass, + read_only: read_only + ) do |instance_with_called_method| # keep reference to the original class to resolve the `klass` dependency # See stackoverflow: https://stackoverflow.com/a/73709529/4352306 referrer_class = instance_with_called_method.class - eclass.klass = {referrer_class => klass} if klass + eclass.klass = {referrer_class => klass} if klass + # This helps `resolve_class` to correctly resolve a symbol + # by using referrer_class as a base module to resolve it end end private - def embed(method, key: method, nullable: false, multiple: false, klass:, read_only: self.read_only?, &block) + def embed( + method, klass:, key: method, + nullable: false, multiple: false, read_only: read_only?, + &embed_block + ) method = method.to_s.freeze var = instance_variable_name(method).freeze - k = key.to_s.freeze + obj_k = key.to_s.freeze # retrieving method (getter) define_method(method) do - yield(self) if block_given? + # set item klass as referrer to klass (to allow resolve symbol) + embed_block&.call(self) return instance_variable_get(var) if instance_variable_defined?(var) - unless nullable - doc[k] ||= multiple ? [] : {} - end - return variable_set(var, nil) unless doc[k] + doc[obj_k] ||= (multiple ? [] : {}) unless nullable + return variable_set(var, nil) unless doc[obj_k] + embedded_class = self.class.resolve_class(klass) + setup_items_key(embedded_class, doc[obj_k]) if multiple && read_only - if multiple && read_only - if doc[k].is_a?(Array) && embedded_class < Common::Content::CollectionModel - if (item_class = embedded_class.klass) && !item_class.key? - item_class.passkey :id - doc[k].each_with_index do |item_doc, i| - item_doc["id"] = "#{i}" unless item_doc.key?("id") - end - end - end + embedded_class.new( + doc[obj_k], + parent: self, + key: obj_k, + read_only: read_only? || read_only + ).tap do |collection| + variable_set(var, collection) end - - embedded_class.new(doc[k], parent: self, key: k, read_only: self.read_only? || read_only).tap do |obj| - variable_set(var, obj) - end end end # The list of keys that will be forced in the model def model_forced_keys - @forced_model_keys ||= {} + @model_forced_keys ||= {} end end - inheritable_class_vars :forced_model_keys, :key, :read_only + inheritable_class_vars :model_forced_keys, :key, :read_only - # `_key` refers to the parent's property that links to this model + # `_key` refers to the `_parent`'s property that links to this model + # @note while `key` refers to the value of theproperty of this model + # that is key (identifies an item in a set of elements) attr_reader :_parent, :_key, :_read_only - def initialize(doc = {}, parent: self, key: nil, read_only: self.class.read_only?) + def initialize(doc = {}, parent: self, key: nil, read_only: self.class.read_only?) # rubocop:disable Lint/MissingSuper @_dim_vars = [] @_parent = parent || self @_key = key || self @_read_only = read_only @@ -290,14 +296,14 @@ if (_parent == self) || read_only @doc = JSON.parse(doc.to_json) @original_doc = JSON.parse(@doc.to_json) end - if key_method? && doc && doc.is_a?(Hash) - self.key = doc[key_method] - #puts "\n$(#{self.key}<=>#{self.class})" - end + return unless key_method? && doc && doc.is_a?(Hash) + + self.key = doc[key_method] + #puts "\n$(#{self.key}<=>#{self.class})" end # @note `read_only` allows for some optimizations, such as storing values # in instance variables, for optimization purposes def read_only? @@ -309,21 +315,26 @@ _parent.root end # @return [String] the `value` of the `key` method (i.e. `id` value) def key - raise NoKeyMethod.new "No key_method defined for #{self.class}" unless key_method? - self.method(key_method).call + raise NoKeyMethod, "No key_method defined for #{self.class}" unless key_method? + + method(key_method).call end # @param [String] the `value` of the `key` method (i.e. `id` value) def key=(value) - raise NoKeyMethod.new "No key_method defined for #{self.class}" unless key_method? - method = "#{key_method}=" - self.method(method).call(value) + raise NoKeyMethod, "No key_method defined for #{self.class}" unless key_method? + + method("#{key_method}=").call(value) end + def resolved_doc_key + [_doc_key(_key)] + end + # Offers a method for child classes to transform the key, # provided that the child's `doc` can be accessed def _doc_key(value) if value.is_a?(Content::DoubleModel) && !value.is_root? #print "?(#{value.class}<=#{value._parent.class})" @@ -335,29 +346,26 @@ end # @return [nil, Hash] the underlying `Hash` model as is (carrying current changes) def doc return @doc if doc_var? + raise UnlinkedModel.new(from: "#{self.class}#doc", key: _key) unless linked? - if is_root? - @doc - else - # transform parent's `_key` to this object into a - # path key that can rerieve from the parents's doc - _parent.doc.dig(*[_doc_key(_key)].flatten) - end + return @doc if is_root? + + # transform parent's `_key` to this object into a + # path key that can rerieve from the parents's doc + _parent.doc.dig(*resolved_doc_key.flatten) end # The `original_doc` holds the model as is now on server-side. # @return [nil, Hash] the underlying `Hash` model as after last `consolidate!` changes def original_doc raise UnlinkedModel.new(from: "#{self.class}#original_doc", key: _key) unless linked? - if is_root? - @original_doc - else - _parent.original_doc.dig(*[_doc_key(_key)].flatten) - end + return @original_doc if is_root? + + _parent.original_doc.dig(*resolved_doc_key.flatten) end def as_json doc end @@ -374,11 +382,11 @@ end # @return [Boolean] stating if there are changes def dirty? au = as_update - !((au == {}) || (au == nil)) + !((au == {}) || au.nil?) end # It makes `original_doc` to be like `doc` # @note # - after executing it, there will be no pending changes @@ -394,11 +402,11 @@ # @key [Symbol] the specific part of the model you want to `reset` def reset!(key = nil) if key keys = [key].flatten.compact odoc = original_doc.dig(*keys) - odoc = odoc && JSON.parse(odoc.to_json) + odoc &&= JSON.parse(odoc.to_json) dig_set(doc, keys, odoc) else replace_doc(JSON.parse(original_doc.to_json)) end end @@ -408,17 +416,15 @@ self end def replace_doc(new_doc) raise UnlinkedModel.new(from: "#{self.class}#replace_doc", key: _key) unless linked? - if is_root? - @doc = new_doc - else - dig_set(_parent.doc, [_doc_key(_key)].flatten, new_doc) - _parent.variable_remove!(_key) unless new_doc - #variables_remove! - end + return (@doc = new_doc) if is_root? + + dig_set(_parent.doc, resolved_doc_key.flatten, new_doc) + _parent.variable_remove!(_key) unless new_doc + #variables_remove! end protected def doc_var? @@ -436,15 +442,13 @@ is_root? || !!_parent.doc end def replace_original_doc(new_doc) raise UnlinkedModel.new(from: "#{self.class}#replace_original_doc", key: _key) unless linked? - if is_root? - @original_doc = new_doc - else - dig_set(_parent.original_doc, [_doc_key(_key)].flatten, new_doc) - end + return (@original_doc = new_doc) if is_root? + + dig_set(_parent.original_doc, resolved_doc_key.flatten, new_doc) end # Helper to track down persistent variables def variable_set(key, value) var = instance_variable_name(key) @@ -453,20 +457,20 @@ end # Helper to remove tracked down instance variables def variable_remove!(key) var = instance_variable_name(key) - unless !@_dim_vars.include?(var) - @_dim_vars.delete(var) - remove_instance_variable(var) - end + return unless @_dim_vars.include?(var) + + @_dim_vars.delete(var) + remove_instance_variable(var) end # Removes all the persistent variables def variables_remove! #puts "going to remove vars: #{@_dim_vars} on #{self.class} (parent: #{identify_parent(self._parent)})" - @_dim_vars.dup.map {|k| variable_remove!(k)} + @_dim_vars.dup.map {|var| variable_remove!(var)} end private def identify_parent(object) @@ -500,9 +504,23 @@ def key_method self.class.key end + # It allows to work-around missing item_key + def setup_items_key(embedded_class, obj_doc) + # only if is going to be a collection + return unless obj_doc.is_a?(Array) && embedded_class < Common::Content::CollectionModel + return unless (item_class = embedded_class.klass) + # if already has key don't need to work around + return if item_class&.key + + # apply work around + item_class.passkey :id + obj_doc.each_with_index do |item_doc, idx| + item_doc["id"] = idx.to_s unless item_doc.key?("id") + end + end end end end end end