bronze 0.0.1.alpha → 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.
@@ -1,14 +1,5 @@
1
- # lib/bronze.rb
2
-
3
- require 'sleeping_king_studios/tools/all'
1
+ # frozen_string_literal: true
4
2
 
5
3
  # A component-based application toolkit designed around dependency injection,
6
4
  # composable objects, and modern design principles.
7
- module Bronze
8
- # The file path to the root of the Bronze directory.
9
- def self.gem_path
10
- @gem_path ||= __dir__.sub %r{/lib\z}, ''
11
- end # method
12
- end # module
13
-
14
- require 'bronze/version'
5
+ module Bronze; end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bronze'
4
+
5
+ module Bronze
6
+ # Namespace for library classes and modules that implement or enhance data
7
+ # entities, which store information about business objects in a datastore-
8
+ # independent implementation.
9
+ module Entities; end
10
+ end
@@ -0,0 +1,241 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'sleeping_king_studios/tools/toolbox/mixin'
4
+
5
+ require 'bronze/entities'
6
+ require 'bronze/entities/attributes/builder'
7
+
8
+ module Bronze::Entities
9
+ # Module for defining attributes on an entity class.
10
+ module Attributes
11
+ extend SleepingKingStudios::Tools::Toolbox::Mixin
12
+
13
+ # Class methods to define when including Attributes in a class.
14
+ module ClassMethods
15
+ # Defines an attribute with the specified name and type.
16
+ #
17
+ # @example Defining an Attribute
18
+ # class Book
19
+ # include Bronze::Entities::Attributes
20
+ #
21
+ # attribute :title, String
22
+ # end # class
23
+ #
24
+ # book.title
25
+ # #=> nil
26
+ #
27
+ # book.title = 'Romance of the Three Kingdoms'
28
+ # book.title
29
+ # #=> 'Romance of the Three Kingdoms'
30
+ #
31
+ # @param (see Attributes::Builder#build)
32
+ #
33
+ # @option (see Attributes::Builder#build)
34
+ #
35
+ # @return (see Attributes::Builder#build)
36
+ #
37
+ # @raise (see Attributes::Builder#build)
38
+ def attribute(attribute_name, attribute_type, attribute_options = {})
39
+ metadata =
40
+ build_attribute(attribute_name, attribute_type, attribute_options)
41
+
42
+ (@attributes ||= {})[metadata.name] = metadata
43
+ end
44
+
45
+ # Returns the metadata for the attributes defined for the current class.
46
+ #
47
+ # @return [Hash{Symbol => Attributes::Metadata}] the metadata for each
48
+ # attribute.
49
+ #
50
+ # @note This method allocates a new hash each time it is called and is not
51
+ # cached. To loop through the attributes, use the ::each_attribute
52
+ # method instead.
53
+ def attributes
54
+ each_attribute
55
+ .with_object({}) do |(name, metadata), hsh|
56
+ hsh[name] = metadata
57
+ end
58
+ .freeze
59
+ end
60
+
61
+ # @overload each_attribute
62
+ # Returns an enumerator that iterates through the attributes defined on
63
+ # the entity class and any parent classes.
64
+ #
65
+ # @return [Enumerator] the enumerator.
66
+ #
67
+ # @overload each_attribute
68
+ # Iterates through the attributes defined on the entity class and any
69
+ # parent classes, and yields the name and metadata of each attribute to
70
+ # the block.
71
+ #
72
+ # @yieldparam name [Symbol] The name of the attribute.
73
+ # @yieldparam name [Bronze::Entities::Attributes::Metadata] The metadata
74
+ # for the attribute.
75
+ def each_attribute
76
+ return enum_for(:each_attribute) unless block_given?
77
+
78
+ if superclass.respond_to?(:each_attribute)
79
+ superclass.each_attribute { |name, metadata| yield(name, metadata) }
80
+ end
81
+
82
+ (@attributes ||= {}).each { |name, metadata| yield(name, metadata) }
83
+ end
84
+
85
+ private
86
+
87
+ def attribute_builder
88
+ Bronze::Entities::Attributes::Builder.new(self)
89
+ end
90
+
91
+ def build_attribute(attribute_name, attribute_type, attribute_options)
92
+ attribute_builder
93
+ .build(attribute_name, attribute_type, attribute_options)
94
+ end
95
+ end
96
+
97
+ # @param attributes [Hash] The default attributes with which to initialize
98
+ # the entity. Defaults to an empty hash.
99
+ def initialize(attributes = {})
100
+ initialize_attributes(attributes)
101
+ end
102
+
103
+ # Compares with the other object.
104
+ #
105
+ # If the other object is a Hash, returns true if the entity attributes hash
106
+ # is equal to the given hash. Otherwise, returns true if the other object
107
+ # has the same class and attributes as the entity.
108
+ #
109
+ # @param other [Bronze::Entities::Attributes, Hash] The object to compare.
110
+ #
111
+ # @return [Boolean] true if the other object matches the entity, otherwise
112
+ # false.
113
+ def ==(other)
114
+ return attributes == other if other.is_a?(Hash)
115
+
116
+ other.class == self.class && other.attributes == attributes
117
+ end
118
+
119
+ # Updates the attributes with the given hash. If an attribute is not in the
120
+ # hash, it is unchanged.
121
+ #
122
+ # @raise ArgumentError if one of the keys is not a valid attribute
123
+ def assign_attributes(hash)
124
+ validate_attributes(hash)
125
+
126
+ self.class.each_attribute do |name, metadata|
127
+ next if metadata.read_only?
128
+ next unless hash.key?(name) || hash.key?(name.to_s)
129
+
130
+ set_attribute(name, hash[name] || hash[name.to_s])
131
+ end
132
+ end
133
+ alias_method :assign, :assign_attributes
134
+
135
+ # @return true if the entity has an attribute with the given name, otherwise
136
+ # false.
137
+ def attribute?(name)
138
+ attribute_name = name.intern
139
+
140
+ self.class.each_attribute.any? { |key, _metadata| key == attribute_name }
141
+ rescue NoMethodError
142
+ raise ArgumentError, "invalid attribute #{name.inspect}"
143
+ end
144
+
145
+ # Returns the current value of each attribute.
146
+ #
147
+ # @return [Hash{Symbol => Object}] the attribute values.
148
+ def attributes
149
+ each_attribute.with_object({}) do |attr_name, hsh|
150
+ hsh[attr_name] = get_attribute(attr_name)
151
+ end
152
+ end
153
+
154
+ # Replaces the attributes with the given hash. If a non-primary key
155
+ # attribute is not in the hash, it is set to nil.
156
+ #
157
+ # @raise ArgumentError if one of the keys is not a valid attribute
158
+ def attributes=(hash)
159
+ validate_attributes(hash)
160
+
161
+ self.class.each_attribute do |name, metadata|
162
+ next if metadata.primary_key?
163
+
164
+ @attributes[name] = hash[name] || hash[name.to_s]
165
+ end
166
+ end
167
+
168
+ # @param name [String] The name of the attribute.
169
+ #
170
+ # @return [Object] the value of the given attribute.
171
+ #
172
+ # @raise ArgumentError when the attribute name is not a valid attribute
173
+ def get_attribute(name)
174
+ unless attribute?(name)
175
+ raise ArgumentError, "invalid attribute #{name.inspect}"
176
+ end
177
+
178
+ @attributes[name.intern]
179
+ end
180
+
181
+ # @return [String] a human-readable representation of the entity, composed
182
+ # of the class name and the attribute keys and values.
183
+ def inspect # rubocop:disable Metrics/AbcSize
184
+ buffer = +'#<'
185
+ buffer << self.class.name
186
+ each_attribute.with_index do |(name, _metadata), index|
187
+ buffer << ',' unless index.zero?
188
+ buffer << ' ' << name.to_s << ': ' << get_attribute(name).inspect
189
+ end
190
+ buffer << '>'
191
+ end
192
+
193
+ # @param name [String] The name of the attribute.
194
+ # @param value [Object] The new value of the attribute.
195
+ #
196
+ # @return [Object] the new value of the given attribute.
197
+ #
198
+ # @raise ArgumentError when the attribute name is not a valid attribute
199
+ def set_attribute(name, value)
200
+ unless attribute?(name)
201
+ raise ArgumentError, "invalid attribute #{name.inspect}"
202
+ end
203
+
204
+ @attributes[name.intern] = value
205
+ end
206
+
207
+ private
208
+
209
+ def each_attribute
210
+ return enum_for(:each_attribute) unless block_given?
211
+
212
+ self.class.each_attribute { |name, _metadata| yield(name) }
213
+ end
214
+
215
+ def initialize_attributes(data)
216
+ @attributes = {}
217
+
218
+ validate_attributes(data)
219
+
220
+ self.class.each_attribute do |name, metadata|
221
+ name = name.intern if name.is_a?(String)
222
+ value = data[name] || data[name.to_s] || metadata.default
223
+
224
+ @attributes[name] = value
225
+ end
226
+ end
227
+
228
+ def validate_attributes(obj)
229
+ unless obj.is_a?(Hash)
230
+ raise ArgumentError,
231
+ "expected attributes to be a Hash, but was #{obj.inspect}"
232
+ end
233
+
234
+ obj.each_key do |name|
235
+ unless attribute?(name)
236
+ raise ArgumentError, "invalid attribute #{name.inspect}"
237
+ end
238
+ end
239
+ end
240
+ end
241
+ end
@@ -0,0 +1,249 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bronze/entities'
4
+ require 'bronze/entities/attributes/metadata'
5
+ require 'bronze/transforms/attributes/big_decimal_transform'
6
+ require 'bronze/transforms/attributes/date_time_transform'
7
+ require 'bronze/transforms/attributes/date_transform'
8
+ require 'bronze/transforms/attributes/symbol_transform'
9
+ require 'bronze/transforms/attributes/time_transform'
10
+
11
+ module Bronze::Entities::Attributes
12
+ # Service class to define attributes on an entity.
13
+ class Builder # rubocop:disable Metrics/ClassLength
14
+ # Provides a list of the valid options for the attribute_options parameter
15
+ # for Builder#build.
16
+ VALID_OPTIONS = %w[
17
+ allow_nil
18
+ default
19
+ default_transform
20
+ foreign_key
21
+ primary_key
22
+ read_only
23
+ transform
24
+ ].map(&:freeze).freeze
25
+
26
+ class << self
27
+ # Registers a transform as the default transform for attributes with the
28
+ # specified type or a subtype of the specified type.
29
+ #
30
+ # This default is not retroactive - any attributes already defined will
31
+ # use their existing default transform, if any. If more than one
32
+ # registered transform has a matching type, the most recently defined
33
+ # transform will be used.
34
+ #
35
+ # @param type [Class] The attribute type. When defining an attribute, if
36
+ # the type of the attribute is this class or a subclass of this class
37
+ # and no :transform option is given, the transform for the attribute
38
+ # will be the transform passed to ::attribute_transform.
39
+ # @param transform [Class, Bronze::Transforms::Transform] The transform to
40
+ # use as the default. If this value is a transform instance, the default
41
+ # transform for matching attributes will be the given transform.
42
+ # Otherwise, will set the default transform to the result of ::instance
43
+ # (if defined) or ::new.
44
+ def attribute_transform(type, transform)
45
+ (@attribute_transforms ||= {})[type] = transform
46
+ end
47
+
48
+ private
49
+
50
+ def transform_for_attribute(attribute_type)
51
+ (@attribute_transforms ||= {}).reverse_each do |type, transform|
52
+ return transform if attribute_type <= type
53
+ end
54
+
55
+ return super if superclass.respond_to?(:transform_for_attribute)
56
+ end
57
+ end
58
+
59
+ attribute_transform BigDecimal,
60
+ Bronze::Transforms::Attributes::BigDecimalTransform
61
+
62
+ attribute_transform Date,
63
+ Bronze::Transforms::Attributes::DateTransform
64
+
65
+ attribute_transform DateTime,
66
+ Bronze::Transforms::Attributes::DateTimeTransform
67
+
68
+ attribute_transform Symbol,
69
+ Bronze::Transforms::Attributes::SymbolTransform
70
+
71
+ attribute_transform Time,
72
+ Bronze::Transforms::Attributes::TimeTransform
73
+
74
+ # @param entity_class [Class] The entity class on which attributes will be
75
+ # defined.
76
+ def initialize(entity_class)
77
+ @entity_class = entity_class
78
+ end
79
+
80
+ # @return [Class] the entity class on which attributes will be defined.
81
+ attr_reader :entity_class
82
+
83
+ # Defines an attribute on the entity class.
84
+ #
85
+ # @example Defining an Attribute
86
+ # class Book < Bronze::Entities::Entity; end
87
+ #
88
+ # book = Book.new
89
+ # book.title
90
+ # #=> NoMethodError: undefined method `title'
91
+ #
92
+ # builder = Bronze::Entities::Attributes::Builder.new(Book)
93
+ # builder.define_attribute :title, String
94
+ #
95
+ # book.title
96
+ # #=> nil
97
+ #
98
+ # book.title = 'Romance of the Three Kingdoms'
99
+ # book.title
100
+ # #=> 'Romance of the Three Kingdoms'
101
+ #
102
+ # @param attribute_name [Symbol, String] The name of the attribute to
103
+ # define.
104
+ # @param attribute_type [Class] The type of the attribute to define.
105
+ # @param attribute_options [Hash] Additional options for building the
106
+ # attribute.
107
+ #
108
+ # @option attribute_options [Object, Proc] :default The default value for
109
+ # the attribute. If the attribute value is nil or has not been set, the
110
+ # attribute will be set to the default. If the default is a Proc, the
111
+ # Proc will be called each time and the attribute set to the return value.
112
+ # Otherwise, the attribute will be set to the default value.
113
+ # @option attribute_options [Boolean] :default_transform If a transform is
114
+ # set, marks the transform as a default transform, which can be overriden
115
+ # when normalizing the attribute.
116
+ # @option attribute_options [Boolean] :foreign_key Marks the attribute as a
117
+ # foreign key. Will be set to true by association builders, and generally
118
+ # should not be set manually. Defaults to false.
119
+ # @option attribute_options [Boolean] :read_only If true, the writer method
120
+ # for the attribute will be set as private. Defaults to false.
121
+ # @option attribute_options [Class, Bronze::Transform] :transform If set,
122
+ # the attribute will be normalized using this transform. By default,
123
+ # certain attribute types will be transformed - BigDecimal, Date,
124
+ # DateTime, Symbol, and Time.
125
+ #
126
+ # @return [Attributes::Metadata] The generated metadata for the
127
+ # attribute.
128
+ #
129
+ # @raise Builder::Error if the attribute name or attribute type is missing
130
+ # or invalid.
131
+ def build(attribute_name, attribute_type, attribute_options = {})
132
+ validate_attribute_name(attribute_name)
133
+ validate_attribute_opts(attribute_options)
134
+
135
+ characterize(
136
+ attribute_name,
137
+ attribute_type,
138
+ attribute_options
139
+ )
140
+ .tap { |metadata| define_property_methods(metadata) }
141
+ end
142
+
143
+ private
144
+
145
+ def attributes_module
146
+ @attributes_module ||= define_attributes_module
147
+ end
148
+
149
+ def characterize(attribute_name, attribute_type, attribute_options)
150
+ Bronze::Entities::Attributes::Metadata.new(
151
+ attribute_name,
152
+ attribute_type,
153
+ normalize_options(attribute_options, type: attribute_type)
154
+ )
155
+ end
156
+
157
+ def define_attributes_module
158
+ mod =
159
+ if entity_class.const_defined?(:Attributes, false)
160
+ entity_class::Attributes
161
+ else
162
+ entity_class.const_set(:Attributes, Module.new)
163
+ end
164
+
165
+ entity_class.send(:include, mod) unless entity_class < mod
166
+
167
+ mod
168
+ end
169
+
170
+ def define_property_methods(metadata)
171
+ define_reader(metadata)
172
+ define_writer(metadata)
173
+ end
174
+
175
+ def define_reader(metadata)
176
+ attr_name = metadata.name
177
+
178
+ attributes_module.send :define_method,
179
+ metadata.reader_name,
180
+ -> { get_attribute(attr_name) }
181
+ end
182
+
183
+ def define_writer(metadata)
184
+ attr_name = metadata.name
185
+
186
+ attributes_module.send :define_method,
187
+ metadata.writer_name,
188
+ ->(value) { set_attribute(attr_name, value) }
189
+
190
+ return unless metadata.read_only?
191
+
192
+ attributes_module.send(:private, metadata.writer_name)
193
+ end
194
+
195
+ def normalize_options(options, type:)
196
+ options = options.each.with_object({}) do |(key, value), hsh|
197
+ hsh[key.intern] = value
198
+ end
199
+
200
+ unless options.key?(:default_transform)
201
+ options[:default_transform] = !options[:transform]
202
+ end
203
+
204
+ options[:transform] =
205
+ normalize_transform(options[:transform], type: type)
206
+
207
+ options
208
+ end
209
+
210
+ def normalize_transform(transform, type:)
211
+ transform ||= self.class.send(:transform_for_attribute, type)
212
+
213
+ return nil if transform.nil?
214
+
215
+ transform_instance(transform)
216
+ end
217
+
218
+ def transform_instance(transform)
219
+ return transform unless transform.is_a?(Class)
220
+
221
+ return transform.instance if transform.respond_to?(:instance)
222
+
223
+ transform.new
224
+ end
225
+
226
+ def validate_attribute_name(attribute_name)
227
+ unless attribute_name.is_a?(String) || attribute_name.is_a?(Symbol)
228
+ message = 'expected attribute name to be a String or Symbol, but was ' \
229
+ "#{attribute_name.inspect}"
230
+
231
+ raise ArgumentError, message, caller[1..-1]
232
+ end
233
+
234
+ return unless attribute_name.to_s.empty?
235
+
236
+ raise ArgumentError, "attribute name can't be blank", caller[1..-1]
237
+ end
238
+
239
+ def validate_attribute_opts(attribute_options)
240
+ attribute_options.each do |key, _value|
241
+ next if VALID_OPTIONS.include?(key.to_s)
242
+
243
+ raise ArgumentError,
244
+ "invalid attribute option #{key.inspect}",
245
+ caller[1..-1]
246
+ end
247
+ end
248
+ end
249
+ end