module Ecoportal module API module Common module Content # CollectionModel aims to deal with Arrays of actual objects. # @note to be able to refer to the correct element of the Collection, # it is required that those elements have a unique `key` that allows to identify them class CollectionModel < Content::DoubleModel class << self attr_writer :klass attr_accessor :order_matters, :order_key # The attr that has been defined as `passkey` # in the item class def items_key @items_key ||= "id" end def items_key=(value) @items_key = value && value.to_s.freeze end # Resolves to the nuclear `Class` of the elements # @note # - use block to define `klass` callback # @note When `klass` is resolved, if the items are of type # `DoubleModel`, it sets on the collection class the `items_key` # @note when `klass` is directly resolved (not via doc) only once # it will set @klass as resolved and will use this class from now on. # This is an optimization to cut class lookups # @param value [Hash] base `doc` (raw object) to create the object with # @yield [doc] identifies the target `class` of the raw object # @yieldparam doc [Hash] # @yieldreturn [Klass] the target `class` # @return [Class, Proc, Hash] the target `class` # - `Hash` tracks a symbol pending to be resovle from its referrer # - `Class` an already resolve class # - `Proc` a forker that pivots between multiple classes def klass(value = NOT_USED, &block) @klass = block if block_given? if @klass.is_a?(Proc) && used_param?(value) @klass.call(value) elsif @klass && !@klass.is_a?(Proc) && !@klass.is_a?(Class) @klass = resolve_class(@klass, exception: false) @klass else @klass end.tap do |result| next unless result.is_a?(Class) next unless result < Ecoportal::API::Common::Content::DoubleModel self.items_key = result.key end end # @return [Boolean] are there the factory logics to build item objects defined? def klass? @klass || @new_item end # Optimization def new_item_class_based? return false if @new_item.is_a?(Proc) return false if klass.is_a?(Proc) return true if klass.is_a?(Class) false end # Generates a new object of the target class # @note # - use block to define `new_item` callback, which will prevail over `klass` # - if `new_item` callback was **not** defined, it is required to defnie `klass` # @param doc [Hash] doc to parse # @note if block is given, it ignores `doc` # @yield [doc, parent, key] creates an object instance of the target `klass` # @yieldparam doc [Hash] # @yieldreturn [Klass] instance object of the target `klass` # @parent [CollectionModel] the parent of the new item # @key [Symbol, String] the key value to access the item within collection # Please observe that items in a CollectionModel are identified via their key attr. # Meaning that there is actually no need to define this argument. # @return [Klass] instance object of the target `klass` def new_item(doc = NOT_USED, parent: nil, key: nil, read_only: false, &block) if block_given? @new_item = block return end msg = "To define the 'new_item' callback (factory), you need to use a block" raise msg unless used_param?(doc) msg = "You should define either a 'klass' or a 'new_item' callback first" raise msg unless klass? return @new_item.call(doc, parent, key) if @new_item.is_a?(Proc) raise "Could not find a class for: #{doc}" unless (target_class = klass(doc)) return doc if doc.is_a?(target_class) target_class.new(doc, parent: parent, key: key, read_only: read_only) end end include Enumerable inheritable_class_vars :klass, :order_matters, :order_key, :items_key, :new_item def initialize(ini_doc = [], parent: self, key: nil, read_only: false) msg = "Undefined base 'klass' or 'new_item' callback for #{self.class}" raise msg unless self.class.klass? ini_doc = case ini_doc when Array ini_doc when Enumerable ini_doc.to_a else [] end super(ini_doc, parent: parent, key: key, read_only: read_only) end # @return [Class] the class of the elements of the Collection def items_class self.class.klass end def _doc_pos(value) _doc_key(value) end # Transforms `value` into the actual `key` to access the object in the doc `Array` # @note # - The name of the method is after the paren't class method # - This method would have been better called `_doc_pos` :) def _doc_key(value) #print "*(#{value.class})" return super(value) unless value.is_a?(Hash) || value.is_a?(Content::DoubleModel) if (id = get_key(value)) #print "^" _doc_items.index {|item| get_key(item) == id}.tap do |p| #print "{{#{p}}}" end else show_str = case value when Hash value.pretty_inspect when Content::DoubleModel "#{value} with key: #{value.class.key} (items_key: #{self.class.items_key})" else value end raise UnlinkedModel, "Can't find child: #{show_str}" end end def length count end def empty? count&.zero? end def present? count&.positive? end def each(&block) return to_enum(:each) unless block _items.each(&block) end def _items return @_items if @_items [].tap do |elements| variable_set(:@_items, elements) _doc_items.each do |item_doc| elements << new_item(item_doc) end @_items = elements if read_only? end end # Get an element usign the `key`. # @param value [String, Hash, Ecoportal::API::Common::Content::DoubleModel] # @return [Object] the `items_class` element object def [](value) items_by_key[get_key(value)] end # Checks if an element exists in the collection # @param value [String, Hash, Ecoportal::API::Common::Content::DoubleModel] # @return [Boolean] whether or not it is included def include?(value) items_by_key.key?(get_key(value)) end # @return [Array] the `items_class` element object def values_at(*keys) keys.map {|key| self[key]} end # Tries to find the element `value`, if it exists, it updates it # Otherwise it pushes it to the end # @value [Hash, Ecoportal::API::Common::Content::DoubleModel] the eleement to be added # @return [Object] the `items_class` element object def upsert!(value, pos: NOT_USED, before: NOT_USED, after: NOT_USED) unless value.is_a?(Hash) || value.is_a?(Content::DoubleModel) raise "'Content::DoubleModel' or 'Hash' doc required. Given #{value.class}" end item_doc = value.is_a?(Content::DoubleModel)? value.doc : value item_doc = JSON.parse(item_doc.to_json) if (item = self[value]) item.replace_doc(item_doc) else _doc_upsert(item_doc, pos: pos, before: before, after: after).tap do |pos_idx| _items.insert(pos_idx, new_item(item_doc)) @indexed = false end end (item || self[item_doc]).tap do |itm| yield(itm) if block_given? end end # Deletes all the elements of this `CollectionModel` instance def clear to_a.each {|item| delete!(item)} end # Deletes `value` from this `CollectionModel` instance # @param value [String, Hash, Ecoportal::API::Common::Content::DoubleModel] # - When used as `String`, the `key` value (i.e. `id` value) is expected # - When used as `Hash`, it should be the `doc` of the target element # - When used as `DoubleModel`, it should be the specific object to be deleted def delete!(value) unless value.is_a?(Hash) || value.is_a?(Content::DoubleModel) || value.is_a?(String) raise "'Content::DoubleModel' or 'Hash' doc required" end return unless (item = self[value]) _doc_delete(item.doc) @indexed = false _items.delete(item) end protected def order_matters? self.class.order_matters end def uniq? self.class.uniq end def items_key self.class.items_key end def on_change @indexed = false #variables_remove! end # Gets the `key` of the object `value` def get_key(value) case value when Content::DoubleModel value.key when Hash value[items_key] when String value when Numeric get_key(to_a[value]) end end def _doc_items replace_doc([]) unless doc.is_a?(Array) doc end # @note it does not support a change of `id` on an existing item def items_by_key return @items_by_key if @indexed {}.tap do |hash| variable_set(:@items_by_key, hash) _items.each {|item| hash[item.key] = item} @indexed = true end end private def new_item(value) if self.class.new_item_class_based? self.class.klass.new(value, parent: self, read_only: _read_only) else self.class.new_item(value, parent: self, read_only: _read_only) end end # Helper to remove tracked down instance variables def variable_remove!(key) if @items_by_key && (k = get_key(key)) && (item = @items_by_key[k]) _items.delete(item) if _items.include?(item) @items_by_key.delete(k) else super(key) end end # Removes all the persistent variables def variables_remove! @indexed = false super end # Deletes `value` from `doc` (here referred as `_doc_items`) # @return [Object] the element deleted from `doc` def _doc_delete(value) return unless (current_pos = _doc_key(value)) _doc_items.delete_at(current_pos) end def _doc_upsert(value, pos: NOT_USED, before: NOT_USED, after: NOT_USED) elem = self[value] current_pos = nil current_pos = _doc_key(elem) if elem pos = scope_position(pos: pos, before: before, after: after) pos ||= current_pos if current_pos && pos _doc_items.delete_at(current_pos) pos -= 1 unless pos <= current_pos end pos = _doc_items.length unless pos && pos < _doc_items.length pos.tap do |_i| _doc_items.insert(pos, value) end end def scope_position(pos: NOT_USED, before: NOT_USED, after: NOT_USED) if used_param?(pos) if (elem = self[pos]) _doc_key(elem) - 1 end elsif used_param?(before) if (elem = self[before]) _doc_key(elem) - 1 end elsif used_param?(after) if (elem = self[after]) _doc_key(elem) end end end end end end end end