bronze 0.0.1.alpha → 0.1.0

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