deco_lite 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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