module Ecoportal module API module Common 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 < Ecoportal::API::Common::BaseModel class UnlinkedModel < StandardError 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 = ClassHelpers::NOT_USED extend ClassHelpers include ModelHelpers class << self attr_reader :key def key? !!key end # key property (and method) of this model # @note this is auto-set when `passkey` is used def key=(value) @key = value.to_s.freeze end def new_uuid(length: 24) uid(length) end def read_only? @read_only = false if @read_only.nil? @read_only end # Be able to define if a class should be read-only def read_only! @read_only = true end # Same as `attr_reader` but links to a subjacent `Hash` model property # @note it does **not** create an _instance variable_ # @param methods [Array] the method that exposes the value # as well as its `key` in the underlying `Hash` model. def pass_reader(*methods) methods.each do |method| method = method.to_s.freeze define_method method do value = send(:doc)[method] value = yield(value) if block_given? value end end self end # Same as `attr_writer` but links to a subjacent `Hash` model property # @note it does **not** create an _instance variable_ # @param methods [Array] the method that exposes the value # as well as its `key` in the underlying `Hash` model. def pass_writer(*methods) methods.each do |method| method = method.to_s.freeze define_method "#{method}=" do |value| value = yield(value) if block_given? send(:doc)[method] = value end end self end # This method is essential to give stability to the model # @note `Content::CollectionModel` needs to find elements in the doc `Array`. # The only way to do it is via the access key (i.e. `id`). However, there is # no chance you can avoid infinite loop for `get_key` without setting an # instance variable key at the moment of the object creation, when the # `doc` is firstly received # @param method [Symbol] the method that exposes the value # as well as its `key` in the underlying `Hash` model. def passkey(method) method = method.to_s.freeze var = instance_variable_name(method) self.key = method define_method method do return instance_variable_get(var) if instance_variable_defined?(var) value = send(:doc)[method] value = yield(value) if block_given? value end define_method "#{method}=" do |value| variable_set(var, value) value = yield(value) if block_given? send(:doc)[method] = value end self end # These are methods that should always be present in patch update # @note # - `DoubleModel` can be used with objects that do not use `patch_ver` # - 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) 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.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 # Same as `attr_accessor` but links to a subjacent `Hash` model property # @param methods [Array] 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 self end # To link as a `Time` date to a subjacent `Hash` model property # @see Ecoportal::API::Common::Content::DoubleModel#passthrough # @param methods [Array] 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)} 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] 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} pass_writer(*methods) {|value| !!value} unless read_only self end # To link as plain `Array` to a subjacent `Hash` model property # @param methods [Array] the method that exposes the value # as well as its `key` in the underlying `Hash` model. # @param order_matters [Boolean] does the order matter # @param uniq [Boolean] should it contain unique elements def passarray(*methods, order_matters: true, uniq: true) methods.each do |method| method = method.to_s.freeze var = instance_variable_name(method) dim_class = new_class(method, inherits: ArrayModel) do |klass| klass.order_matters = order_matters 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: 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, 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` # - otherwise, just indicate the child class in `:klass` and it will auto generate the class # @param method [Symbol] the method that exposes the embeded object # @param key [Symbol] the `key` that embeds it to the underlying `Hash` model # @param order_matters [Boolean] to state if the order will matter # @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: read_only?) if enum_class eclass = enum_class elsif klass eclass = new_class("#{method}::#{klass}", inherits: CollectionModel) do |dim_class| # 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| # 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 # 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, klass:, key: method, nullable: false, multiple: false, read_only: read_only?, &embed_block ) method = method.to_s.freeze var = instance_variable_name(method).freeze obj_k = key.to_s.freeze # retrieving method (getter) define_method(method) do # 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) 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 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 end end # The list of keys that will be forced in the model def model_forced_keys @model_forced_keys ||= {} end end inheritable_class_vars :model_forced_keys, :key, :read_only # `_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?) # rubocop:disable Lint/MissingSuper @_dim_vars = [] @_parent = parent || self @_key = key || self @_read_only = read_only self.class.enforce!(doc) if (_parent == self) || read_only @doc = JSON.parse(doc.to_json) @original_doc = JSON.parse(@doc.to_json) 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? @_read_only end def root return self if is_root? _parent.root end # @return [String] the `value` of the `key` method (i.e. `id` value) def key 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, "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})" value._parent._doc_key(value) else #print "!(#{value}<=#{self.class})" value end 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? 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? return @original_doc if is_root? _parent.original_doc.dig(*resolved_doc_key.flatten) end def as_json doc end def to_json(*args) doc.to_json(*args) end # @return [nil, Hash] the patch `Hash` model including only the changes between # `original_doc` and `doc` def as_update new_doc = as_json HashDiffPatch.patch_diff(new_doc, original_doc) end # @return [Boolean] stating if there are changes def dirty? au = as_update !((au == {}) || au.nil?) end # It makes `original_doc` to be like `doc` # @note # - after executing it, there will be no pending changes # - you should technically run this command, after a successful update request to the server def consolidate! replace_original_doc(JSON.parse(doc.to_json)) end # It makes `doc` to be like `original` # @note # - after executing it, changes in `doc` will be lost # - you should technically run this command only if you want to remove certain changes # @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 &&= JSON.parse(odoc.to_json) dig_set(doc, keys, odoc) else replace_doc(JSON.parse(original_doc.to_json)) end end def print_pretty puts JSON.pretty_generate(as_json) self end def replace_doc(new_doc) raise UnlinkedModel.new(from: "#{self.class}#replace_doc", key: _key) unless linked? 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? !!defined?(@doc) end # Both requisites # @note that for optimization purposes, `@doc` var may be used when # the object is `read_only?` def is_root? _parent == self && doc_var? end def linked? is_root? || !!_parent.doc end def replace_original_doc(new_doc) raise UnlinkedModel.new(from: "#{self.class}#replace_original_doc", key: _key) unless linked? 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) @_dim_vars.push(var).uniq! instance_variable_set(var, value) end # Helper to remove tracked down instance variables def variable_remove!(key) var = instance_variable_name(key) 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 {|var| variable_remove!(var)} end private def identify_parent(object) case object when Ecoportal::API::V2::Page::Stage "stage #{object.name}" when Ecoportal::API::V2::Page::Section "section #{object.heading}" end end def instance_variable_name(key) self.class.instance_variable_name(key) end def dig_set(obj, keys, value) if keys.length == 1 obj[keys.first] = value else dig_set(obj[keys.first], keys.slice(1..-1), value) end end def used_param?(val) self.class.used_param?(val) end def key_method? self.class.key? end 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 < 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