deco_lite 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/deco_lite.gemspec ADDED
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ lib = File.expand_path('lib', __dir__)
4
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
5
+ require 'deco_lite/version'
6
+
7
+ Gem::Specification.new do |spec|
8
+ spec.name = 'deco_lite'
9
+ spec.version = DecoLite::VERSION
10
+ spec.authors = ['Gene M. Angelo, Jr.']
11
+ spec.email = ['public.gma@gmail.com']
12
+
13
+ spec.summary = 'Dynamically creates an active model from a Hash.'
14
+ spec.description = 'Dynamically creates an active model from a Hash.'
15
+ spec.homepage = 'https://github.com/gangelo/deco_lite'
16
+ spec.license = 'MIT'
17
+
18
+ # Prevent pushing this gem to RubyGems.org. To allow pushes either set the 'allowed_push_host'
19
+ # to allow pushing to a single host or delete this section to allow pushing to any host.
20
+ # if spec.respond_to?(:metadata)
21
+ # spec.metadata['allowed_push_host'] = "TODO: Set to 'http://mygemserver.com'"
22
+ # else
23
+ # raise 'RubyGems 2.0 or newer is required to protect against ' \
24
+ # 'public gem pushes.'
25
+ # end
26
+
27
+ spec.files = `git ls-files -z`.split("\x0").reject do |f|
28
+ f.match(%r{^(test|spec|features)/})
29
+ end
30
+ spec.bindir = 'exe'
31
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
32
+ spec.require_paths = ['lib']
33
+
34
+ spec.required_ruby_version = '~> 3.0.1'
35
+
36
+ spec.add_runtime_dependency 'activemodel', '~> 7.0', '>= 7.0.3.1'
37
+ spec.add_runtime_dependency 'activesupport', '~> 7.0', '>= 7.0.3.1'
38
+ spec.add_runtime_dependency 'immutable_struct_ex', '~> 0.2.0'
39
+ # spec.add_development_dependency 'benchmark-ips', '~> 2.3'
40
+ spec.add_development_dependency 'bundler', '~> 2.2', '>= 2.2.17'
41
+ # spec.add_development_dependency 'factory_bot', '~> 6.2'
42
+ spec.add_development_dependency 'pry-byebug', '~> 3.9'
43
+ # #spec.add_development_dependency 'rake', '~> 0'
44
+ # #spec.add_development_dependency 'redcarpet', '~> 3.5', '>= 3.5.1'
45
+ spec.add_development_dependency 'reek', '~> 6.0', '>= 6.0.4'
46
+ spec.add_development_dependency 'rspec', '~> 3.10'
47
+ # This verson of rubocop is returning errors.
48
+ # spec.add_development_dependency 'rubocop', '~> 1.14'
49
+ spec.add_development_dependency 'rubocop', '~> 1.9.1'
50
+ spec.add_development_dependency 'rubocop-performance', '~> 1.11', '>= 1.11.3'
51
+ spec.add_development_dependency 'rubocop-rspec', '~> 2.3'
52
+ spec.add_development_dependency 'simplecov', '~> 0.21.2'
53
+
54
+ # spec.add_development_dependency "bundler", "~> 1.16"
55
+ spec.add_development_dependency 'rake', '~> 10.0'
56
+ # spec.add_development_dependency "rspec", "~> 3.0"
57
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'field_creatable'
4
+ require_relative 'field_retrievable'
5
+
6
+ module DecoLite
7
+ # Defines methods to assign model field values dynamically.
8
+ module FieldAssignable
9
+ include FieldCreatable
10
+ include FieldRetrievable
11
+
12
+ def set_field_values(hash:, field_info:, options:)
13
+ field_info.each do |name, info|
14
+ value = get_field_value(hash: hash, field_info: info)
15
+ set_field_value(field_name: name, value: value, options: options)
16
+ end
17
+ end
18
+
19
+ def set_field_value(field_name:, value:, options:)
20
+ # Create our fields before we send.
21
+ create_field_accessor field_name: field_name, options: options
22
+ send("#{field_name}=", value)
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'fields_optionable'
4
+
5
+ module DecoLite
6
+ # Defines methods to to manage fields that conflict with
7
+ # existing model attributes.
8
+ module FieldConflictable
9
+ include FieldsOptionable
10
+
11
+ def validate_field_conflicts!(field_name:, options:)
12
+ return unless options.strict? && field_conflict?(field_name: field_name)
13
+
14
+ raise "Field '#{field_name}' conflicts with existing attribute; " \
15
+ 'this will raise an error when running in strict mode: ' \
16
+ "options: { #{OPTION_FIELDS}: :#{OPTION_FIELDS_STRICT} }."
17
+ end
18
+
19
+ def field_conflict?(field_name:)
20
+ respond_to? field_name
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'field_conflictable'
4
+
5
+ module DecoLite
6
+ # Takes an array of symbols and creates attr_accessors.
7
+ module FieldCreatable
8
+ include FieldConflictable
9
+
10
+ def create_field_accessors(field_names:, options:)
11
+ return if field_names.blank?
12
+
13
+ field_names.each do |field_name|
14
+ create_field_accessor(field_name: field_name, options: options)
15
+ end
16
+ end
17
+
18
+ def create_field_accessor(field_name:, options:)
19
+ validate_field_conflicts!(field_name: field_name, options: options)
20
+
21
+ self.class.attr_accessor(field_name) if field_name.present?
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DecoLite
4
+ # Creates and returns a hash given the parameters that are used to
5
+ # dynamically create fields and assign values to a model.
6
+ module FieldInformable
7
+ # This method simply navigates the payload hash received and creates qualified
8
+ # hash key names that can be used to verify/map to our field names in this model.
9
+ # This can be used to qualify nested hash fields and saves us some headaches
10
+ # if there are nested field names with the same name:
11
+ #
12
+ # given:
13
+ #
14
+ # hash = {
15
+ # first_name: 'first_name',
16
+ # ...
17
+ # address: {
18
+ # street: '',
19
+ # ...
20
+ # }
21
+ # }
22
+ #
23
+ # get_field_info(hash: hash) #=>
24
+ #
25
+ # {
26
+ # :first_name=>{:field_name=>:first_name, :dig=>[]},
27
+ # ...
28
+ # :address_street=>{:field_name=>:street, :dig=>[:address]},
29
+ # ...
30
+ # }
31
+ #
32
+ # The generated, qualified field names expected to map to our model, because we named
33
+ # them as such.
34
+ #
35
+ # :field_name is the actual, unqualified field name found in the payload hash sent.
36
+ # :dig is the hash key by which :field_name can be found in the payload hash if need be -
37
+ # retained across recursive calls.
38
+ def get_field_info(hash:, namespace: nil, dig: [], field_info: {})
39
+ hash.each do |key, value|
40
+ if value.is_a? Hash
41
+ get_field_info hash: value,
42
+ namespace: namespace,
43
+ dig: dig << key,
44
+ field_info: field_info
45
+ dig.pop
46
+ else
47
+ set_field_info!(field_info: field_info,
48
+ key: key,
49
+ namespace: namespace,
50
+ dig: dig)
51
+ end
52
+ end
53
+
54
+ field_info
55
+ end
56
+
57
+ def set_field_info!(field_info:, key:, namespace:, dig:)
58
+ field_key = [namespace, *dig, key].compact.join('_').to_sym
59
+ field_info[field_key] = {
60
+ field_name: key,
61
+ dig: dig.dup
62
+ }
63
+ end
64
+
65
+ def merge_field_info!(field_info:)
66
+ @field_info.merge!(field_info)
67
+ end
68
+
69
+ def field_names
70
+ field_info&.keys || []
71
+ end
72
+
73
+ attr_reader :field_info
74
+
75
+ private
76
+
77
+ attr_writer :field_info
78
+
79
+ module_function :get_field_info, :set_field_info!, :merge_field_info!
80
+ end
81
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DecoLite
4
+ # Provides methods to manage fields that must be defined from
5
+ # the dynamically loaded data.
6
+ module FieldRequireable
7
+ # Returns field names that will be used to validate the presence of
8
+ # dynamically created fields from loaded objects.
9
+ #
10
+ # You must override this method if you want to return field names that
11
+ # are required to be present. You may simply return a static array, but
12
+ # if you want to dynamically manipulate this field, return a variable.
13
+ def required_fields
14
+ # @required_fields ||= []
15
+ []
16
+ end
17
+
18
+ # Validator for field names. This validator simply checks to make
19
+ # sure that the field was created, which can only occur if:
20
+ # A) The field was defined on the model explicitly (e.g. attr_accessor :field).
21
+ # B) The field was created as a result of loading data dynamically.
22
+ def validate_required_fields
23
+ required_fields.each do |field_name|
24
+ next if required_field_exist? field_name: field_name
25
+
26
+ errors.add(field_name, 'field is missing', type: :missing_required_field)
27
+ end
28
+ end
29
+
30
+ # :reek:ManualDispatch - method added dynamically; this is the best way to check.
31
+ def required_field_exist?(field_name:)
32
+ respond_to?(field_name) && respond_to?("#{field_name}=".to_sym)
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DecoLite
4
+ # Defines methods to retrieve model field values dynamically.
5
+ module FieldRetrievable
6
+ module_function
7
+
8
+ # Returns the value of the field using fully quaified field names.
9
+ def get_field_value(hash:, field_info:)
10
+ hash.dig(*[field_info[:dig], field_info[:field_name]].flatten.compact)
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DecoLite
4
+ # Defines the fields option hash key and acceptable hash key values.
5
+ module FieldsOptionable
6
+ # The option hash key for this option.
7
+ OPTION_FIELDS = :fields
8
+ # The valid option values for this option key.
9
+ OPTION_FIELDS_MERGE = :merge
10
+ OPTION_FIELDS_STRICT = :strict
11
+ # The default value for this option.
12
+ OPTION_FIELDS_DEFAULT = OPTION_FIELDS_MERGE
13
+ # The valid option key values for this option.
14
+ OPTION_FIELDS_VALUES = [OPTION_FIELDS_MERGE, OPTION_FIELDS_STRICT].freeze
15
+ end
16
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'field_assignable'
4
+ require_relative 'field_informable'
5
+
6
+ module DecoLite
7
+ # Provides methods to load and return information about a given hash.
8
+ module HashLoadable
9
+ include FieldAssignable
10
+ include FieldInformable
11
+
12
+ private
13
+
14
+ def load_hash(hash:, options:)
15
+ raise ArgumentError, "Argument hash is not a Hash (#{hash.class})" unless hash.is_a? Hash
16
+
17
+ return if hash.blank?
18
+
19
+ field_info = get_field_info(hash: hash, namespace: options.namespace)
20
+ set_field_values(hash: hash, field_info: field_info, options: options)
21
+ merge_field_info! field_info: field_info
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_model'
4
+ require_relative 'field_requireable'
5
+ require_relative 'hash_loadable'
6
+ require_relative 'model_nameable'
7
+ require_relative 'optionable'
8
+
9
+ module DecoLite
10
+ # This class defines the base class for classes that create
11
+ # dynamic models that can be used as decorators.
12
+ class Model
13
+ include ActiveModel::Model
14
+ include FieldRequireable
15
+ include HashLoadable
16
+ include ModelNameable
17
+ include Optionable
18
+
19
+ validate :validate_required_fields
20
+
21
+ def initialize(options: {})
22
+ @field_info = {}
23
+ # Accept whatever options are sent, but make sure
24
+ # we have defaults set up. #options_with_defaults
25
+ # will merge options into OptionsDefaultable::DEFAULT_OPTIONS
26
+ # so we have defaults for any options not passed in through
27
+ # options.
28
+ self.options = Options.with_defaults options
29
+ end
30
+
31
+ def load(hash:, options: {})
32
+ # Merge options into the default options passed through the
33
+ # constructor; these will override any options passed in when
34
+ # this object was created, allowing us to retain any defaut
35
+ # options while loading, but also provide option customization
36
+ # of options when needed.
37
+ options = Options.with_defaults(options, defaults: self.options)
38
+ load_hash(hash: hash, options: options)
39
+
40
+ self
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DecoLite
4
+ # Provides class methods to return an appropriate model name.
5
+ module ModelNameable
6
+ class << self
7
+ def included(base)
8
+ base.extend(ClassMethods)
9
+ end
10
+ end
11
+
12
+ # Class methods to extend.
13
+ module ClassMethods
14
+ def model_name
15
+ ActiveModel::Name.new(self, nil, to_s.gsub('::', ''))
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DecoLite
4
+ # Defines the namespace option hash key
5
+ module NamespaceOptionable
6
+ # The option hash key for this option.
7
+ OPTION_NAMESPACE = :namespace
8
+ # The default value for this option - no namespace.
9
+ OPTION_NAMESPACE_DEFAULT = nil
10
+ end
11
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'immutable_struct_ex'
4
+ require_relative 'options'
5
+ require_relative 'options_validatable'
6
+
7
+ module DecoLite
8
+ # Defines methods and fields to manage options.
9
+ module Optionable
10
+ include OptionsValidatable
11
+
12
+ def options
13
+ @options || Options.default
14
+ end
15
+
16
+ private
17
+
18
+ def options=(value)
19
+ options_hash = value.to_h
20
+
21
+ validate_options! options: options_hash
22
+
23
+ @options = Options.new(**options_hash)
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'immutable_struct_ex'
4
+ require_relative 'options_defaultable'
5
+ require_relative 'options_validatable'
6
+
7
+ module DecoLite
8
+ # Defines methods to create options.
9
+ module Options
10
+ extend DecoLite::OptionsDefaultable
11
+ extend DecoLite::OptionsValidatable
12
+
13
+ class << self
14
+ def new(**options)
15
+ immutable_struct_ex = ImmutableStructEx.new(**options) do
16
+ def merge?
17
+ fields == OPTION_FIELDS_MERGE
18
+ end
19
+
20
+ def strict?
21
+ fields == OPTION_FIELDS_STRICT
22
+ end
23
+
24
+ def namespace?
25
+ namespace || false
26
+ end
27
+ end
28
+ validate_options! options: immutable_struct_ex.to_h
29
+ immutable_struct_ex
30
+ end
31
+
32
+ def with_defaults(options, defaults: DEFAULT_OPTIONS)
33
+ new(**defaults.to_h.merge(options.to_h))
34
+ end
35
+
36
+ def default
37
+ with_defaults({})
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'fields_optionable'
4
+ require_relative 'namespace_optionable'
5
+
6
+ module DecoLite
7
+ # Defines default options and their optionn values.
8
+ module OptionsDefaultable
9
+ include DecoLite::FieldsOptionable
10
+ include DecoLite::NamespaceOptionable
11
+
12
+ DEFAULT_OPTIONS = {
13
+ OPTION_FIELDS => OPTION_FIELDS_DEFAULT,
14
+ OPTION_NAMESPACE => OPTION_NAMESPACE_DEFAULT
15
+ }.freeze
16
+ end
17
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'fields_optionable'
4
+ require_relative 'namespace_optionable'
5
+
6
+ module DecoLite
7
+ # Methods to validate options.
8
+ module OptionsValidatable
9
+ include DecoLite::FieldsOptionable
10
+ include DecoLite::NamespaceOptionable
11
+
12
+ OPTIONS = [OPTION_FIELDS, OPTION_NAMESPACE].freeze
13
+
14
+ def validate_options!(options:)
15
+ raise ArgumentError, 'options is not a Hash' unless options.is_a? Hash
16
+
17
+ validate_options_present! options: options
18
+
19
+ validate_option_keys! options: options
20
+ validate_option_fields! fields: options[:fields]
21
+ validate_option_namespace! namespace: options[:namespace]
22
+ end
23
+
24
+ def validate_options_present!(options:)
25
+ raise ArgumentError, 'options is blank?' if options.blank?
26
+ end
27
+
28
+ def validate_option_keys!(options:)
29
+ invalid_options = options.except(*OPTIONS)&.keys
30
+ raise ArgumentError, "One or more option keys were unrecognized: #{invalid_options}" unless invalid_options.blank?
31
+ end
32
+
33
+ def validate_option_fields!(fields:)
34
+ return if OPTION_FIELDS_VALUES.include?(fields)
35
+
36
+ raise ArgumentError,
37
+ "option :fields value or type is invalid. #{OPTION_FIELDS_VALUES} (Symbol) " \
38
+ "was expected, but '#{fields}' (#{fields.class}) was received."
39
+ end
40
+
41
+ def validate_option_namespace!(namespace:)
42
+ # :namespace is optional.
43
+ return if namespace.blank? || namespace.is_a?(Symbol)
44
+
45
+ raise ArgumentError, 'option :namespace value or type is invalid. A Symbol was expected, ' \
46
+ "but '#{namespace}' (#{namespace.class}) was received."
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Defines the version of this gem.
4
+ module DecoLite
5
+ VERSION = '0.1.0'
6
+ end
data/lib/deco_lite.rb ADDED
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ Dir[File.join('.', 'lib/deco_lite/**/*.rb')].each do |f|
4
+ require f
5
+ end