domainic-attributer 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (40) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +14 -0
  3. data/LICENSE +21 -0
  4. data/README.md +396 -0
  5. data/lib/domainic/attributer/attribute/callback.rb +68 -0
  6. data/lib/domainic/attributer/attribute/coercer.rb +93 -0
  7. data/lib/domainic/attributer/attribute/mixin/belongs_to_attribute.rb +68 -0
  8. data/lib/domainic/attributer/attribute/signature.rb +338 -0
  9. data/lib/domainic/attributer/attribute/validator.rb +128 -0
  10. data/lib/domainic/attributer/attribute.rb +256 -0
  11. data/lib/domainic/attributer/attribute_set.rb +208 -0
  12. data/lib/domainic/attributer/class_methods.rb +247 -0
  13. data/lib/domainic/attributer/dsl/attribute_builder/option_parser.rb +247 -0
  14. data/lib/domainic/attributer/dsl/attribute_builder.rb +233 -0
  15. data/lib/domainic/attributer/dsl/initializer.rb +130 -0
  16. data/lib/domainic/attributer/dsl/method_injector.rb +97 -0
  17. data/lib/domainic/attributer/dsl.rb +5 -0
  18. data/lib/domainic/attributer/instance_methods.rb +65 -0
  19. data/lib/domainic/attributer/undefined.rb +44 -0
  20. data/lib/domainic/attributer.rb +114 -0
  21. data/lib/domainic-attributer.rb +3 -0
  22. data/sig/domainic/attributer/attribute/callback.rbs +48 -0
  23. data/sig/domainic/attributer/attribute/coercer.rbs +59 -0
  24. data/sig/domainic/attributer/attribute/mixin/belongs_to_attribute.rbs +46 -0
  25. data/sig/domainic/attributer/attribute/signature.rbs +223 -0
  26. data/sig/domainic/attributer/attribute/validator.rbs +83 -0
  27. data/sig/domainic/attributer/attribute.rbs +150 -0
  28. data/sig/domainic/attributer/attribute_set.rbs +134 -0
  29. data/sig/domainic/attributer/class_methods.rbs +151 -0
  30. data/sig/domainic/attributer/dsl/attribute_builder/option_parser.rbs +130 -0
  31. data/sig/domainic/attributer/dsl/attribute_builder.rbs +156 -0
  32. data/sig/domainic/attributer/dsl/initializer.rbs +91 -0
  33. data/sig/domainic/attributer/dsl/method_injector.rbs +66 -0
  34. data/sig/domainic/attributer/dsl.rbs +1 -0
  35. data/sig/domainic/attributer/instance_methods.rbs +53 -0
  36. data/sig/domainic/attributer/undefined.rbs +14 -0
  37. data/sig/domainic/attributer.rbs +69 -0
  38. data/sig/domainic-attributer.rbs +1 -0
  39. data/sig/manifest.yaml +2 -0
  40. metadata +89 -0
@@ -0,0 +1,256 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'domainic/attributer/attribute/callback'
4
+ require 'domainic/attributer/attribute/coercer'
5
+ require 'domainic/attributer/attribute/signature'
6
+ require 'domainic/attributer/attribute/validator'
7
+ require 'domainic/attributer/undefined'
8
+
9
+ module Domainic
10
+ module Attributer
11
+ # A class representing a managed attribute in the Domainic::Attributer system.
12
+ #
13
+ # This class serves as the core component of the attribute management system.
14
+ # It coordinates type information, visibility settings, value coercion,
15
+ # validation, and change notifications for an attribute. Each instance
16
+ # represents a single attribute definition within a class.
17
+ #
18
+ # @author {https://aaronmallen.me Aaron Allen}
19
+ # @since 0.1.0
20
+ class Attribute
21
+ # @rbs!
22
+ # type initialize_options = {
23
+ # ?callbacks: Array[Callback::handler] | Callback::handler,
24
+ # ?coercers: Array[Coercer::handler] | Coercer::handler,
25
+ # ?default: untyped,
26
+ # ?description: String?,
27
+ # name: String | Symbol,
28
+ # ?nilable: bool,
29
+ # ?position: Integer?,
30
+ # ?read: Signature::visibility_symbol,
31
+ # ?required: bool,
32
+ # type: Signature::type_symbol,
33
+ # ?validators: Array[Validator::handler] | Validator::handler,
34
+ # ?write: Signature::visibility_symbol
35
+ # }
36
+
37
+ # @rbs @base: __todo__
38
+ # @rbs @callback: Callback
39
+ # @rbs @coercer: Coercer
40
+ # @rbs @default: untyped
41
+ # @rbs @description: String?
42
+ # @rbs @name: Symbol
43
+ # @rbs @signature: Signature
44
+ # @rbs @validator: Validator
45
+
46
+ # @return [Class, Module] the class or module this attribute belongs to
47
+ attr_reader :base #: __todo__
48
+
49
+ # @return [String, nil] the description of the attribute
50
+ attr_reader :description #: String?
51
+
52
+ # @return [Symbol] the name of the attribute
53
+ attr_reader :name #: Symbol
54
+
55
+ # @return [Signature] the signature configuration for this attribute
56
+ attr_reader :signature #: Signature
57
+
58
+ # Initialize a new Attribute instance.
59
+ #
60
+ # @param base [Class, Module] the class or module this attribute belongs to
61
+ # @param options [Hash] the options to create the attribute with
62
+ # @option options [Array<Proc>, Proc] :callbacks callbacks to trigger on value changes
63
+ # @option options [Array<Proc, Symbol>, Proc, Symbol] :coercers handlers for value coercion
64
+ # @option options [Object] :default the default value or generator
65
+ # @option options [String] :description a description of the attribute
66
+ # @option options [String, Symbol] :name the name of the attribute
67
+ # @option options [Boolean] :nilable (true) whether the attribute can be nil
68
+ # @option options [Integer] :position the position for ordered attributes
69
+ # @option options [Symbol] :read the read visibility
70
+ # @option options [Boolean] :required (false) whether the attribute is required
71
+ # @option options [Symbol] :type the type of attribute
72
+ # @option options [Array<Proc, Object>, Proc, Object] :validators handlers for value validation
73
+ # @option options [Symbol] :write the write visibility
74
+ #
75
+ # @raise [ArgumentError] if the configuration is invalid
76
+ # @return [void]
77
+ #
78
+ # @rbs (
79
+ # __todo__ base,
80
+ # ?callbacks: Array[Callback::handler] | Callback::handler,
81
+ # ?coercers: Array[Coercer::handler] | Coercer::handler,
82
+ # ?default: untyped,
83
+ # ?description: String?,
84
+ # name: String | Symbol,
85
+ # ?nilable: bool,
86
+ # ?position: Integer?,
87
+ # ?read: Signature::visibility_symbol,
88
+ # ?required: bool,
89
+ # type: Signature::type_symbol,
90
+ # ?validators: Array[Validator::handler] | Validator::handler,
91
+ # ?write: Signature::visibility_symbol
92
+ # ) -> void
93
+ def initialize(base, **options)
94
+ options = options.transform_keys(&:to_sym)
95
+ # @type var options: initialize_options
96
+ validate_and_apply_initialize_options!(base, options)
97
+ rescue StandardError => e
98
+ raise ArgumentError, e.message
99
+ end
100
+
101
+ # Apply a value to the attribute on an instance.
102
+ #
103
+ # This method applies all attribute constraints (coercion, validation) to a value
104
+ # and sets it on the given instance. It manages the complete lifecycle of setting
105
+ # an attribute value including:
106
+ # 1. Handling default values
107
+ # 2. Coercing the value
108
+ # 3. Validating the result
109
+ # 4. Setting the value
110
+ # 5. Triggering callbacks
111
+ #
112
+ # @param instance [Object] the instance to set the value on
113
+ # @param value [Object] the value to set
114
+ #
115
+ # @raise [ArgumentError] if the value is invalid
116
+ # @return [void]
117
+ # @rbs (untyped instance, untyped value) -> void
118
+ def apply!(instance, value = Undefined)
119
+ old_value = instance.instance_variable_get(:"@#{name}")
120
+
121
+ coerced_value = value == Undefined ? generate_default(instance) : value
122
+ coerced_value = @coercer.call(instance, coerced_value) unless coerced_value == Undefined
123
+
124
+ @validator.call(instance, coerced_value)
125
+
126
+ instance.instance_variable_set(:"@#{name}", coerced_value == Undefined ? nil : coerced_value)
127
+
128
+ @callback.call(instance, old_value, coerced_value)
129
+ end
130
+
131
+ # Check if this attribute has a default value.
132
+ #
133
+ # @return [Boolean] true if a default value is set
134
+ # @rbs () -> bool
135
+ def default?
136
+ @default != Undefined
137
+ end
138
+
139
+ # Create a duplicate instance for a new base class.
140
+ #
141
+ # @param new_base [Class, Module] the new base class
142
+ #
143
+ # @return [Attribute] the duplicated instance
144
+ # @rbs (__todo__ new_base) -> Attribute
145
+ def dup_with_base(new_base)
146
+ raise ArgumentError, "invalid base: #{new_base}" unless new_base.is_a?(Class) || new_base.is_a?(Module)
147
+
148
+ dup.tap { |duped| duped.instance_variable_set(:@base, new_base) }
149
+ end
150
+
151
+ # Generate the default value for this attribute.
152
+ #
153
+ # @param instance [Object] the instance to generate the default for
154
+ #
155
+ # @return [Object] the generated default value
156
+ # @rbs (untyped instance) -> untyped
157
+ def generate_default(instance)
158
+ @default.is_a?(Proc) ? instance.instance_exec(&@default) : @default
159
+ end
160
+
161
+ # Merge this attribute's configuration with another.
162
+ #
163
+ # @param other [Attribute] the attribute to merge with
164
+ #
165
+ # @raise [ArgumentError] if other is not an Attribute
166
+ # @return [Attribute] a new attribute with merged configuration
167
+ # @rbs (Attribute other) -> Attribute
168
+ def merge(other)
169
+ raise ArgumentError, 'other must be an instance of Attribute' unless other.is_a?(self.class)
170
+
171
+ self.class.new(other.base, **to_options, **other.send(:to_options)) # steep:ignore InsufficientKeywordArguments
172
+ end
173
+
174
+ private
175
+
176
+ # Apply initialization options to create attribute components.
177
+ #
178
+ # @param base [Class, Module] the base class
179
+ # @param options [Hash] the initialization options
180
+ #
181
+ # @return [void]
182
+ # @rbs (__todo__ base, initialize_options options) -> void
183
+ def apply_initialize_options!(base, options)
184
+ @base = base
185
+ @callback = Callback.new(self, options.fetch(:callbacks, []))
186
+ @coercer = Coercer.new(self, options.fetch(:coercers, []))
187
+ @default = options.fetch(:default, Undefined)
188
+ @description = options.fetch(:description, nil)
189
+ @name = options.fetch(:name).to_sym
190
+ @signature = Signature.new(
191
+ self, type: options.fetch(:type), **options.slice(:nilable, :position, :read, :required, :write)
192
+ )
193
+ @validator = Validator.new(self, options.fetch(:validators, []))
194
+ end
195
+
196
+ # Initialize a copy of this attribute.
197
+ #
198
+ # @param source [Attribute] the source attribute
199
+ #
200
+ # @return [Attribute] the initialized copy
201
+ # @rbs override
202
+ def initialize_copy(source)
203
+ @base = source.base
204
+ @callback = source.instance_variable_get(:@callback).dup_with_attribute(self)
205
+ @coercer = source.instance_variable_get(:@coercer).dup_with_attribute(self)
206
+ @default = source.instance_variable_get(:@default)
207
+ @description = source.description
208
+ @name = source.name
209
+ @signature = source.signature.dup_with_attribute(self)
210
+ @validator = source.instance_variable_get(:@validator).dup_with_attribute(self)
211
+ super
212
+ end
213
+
214
+ # Get this attribute's configuration as options.
215
+ #
216
+ # @return [Hash] the configuration options
217
+ # @rbs () -> initialize_options
218
+ def to_options
219
+ {
220
+ callbacks: @callback.instance_variable_get(:@handlers),
221
+ coercers: @coercer.instance_variable_get(:@handlers),
222
+ default: @default,
223
+ description: @description,
224
+ name: @name,
225
+ validators: @validator.instance_variable_get(:@handlers)
226
+ }.merge(signature.send(:to_options)) #: initialize_options
227
+ end
228
+
229
+ # Validate and apply initialization options.
230
+ #
231
+ # @param base [Class, Module] the base class
232
+ # @param options [Hash] the initialization options
233
+ #
234
+ # @return [void]
235
+ # @rbs (__todo__ base, initialize_options options) -> void
236
+ def validate_and_apply_initialize_options!(base, options)
237
+ validate_initialize_options!(base, options)
238
+ apply_initialize_options!(base, options)
239
+ end
240
+
241
+ # Validate initialization options.
242
+ #
243
+ # @param base [Class, Module] the base class
244
+ # @param options [Hash] the initialization options
245
+ #
246
+ # @raise [ArgumentError] if any options are invalid
247
+ # @return [void]
248
+ # @rbs (__todo__ base, initialize_options options) -> void
249
+ def validate_initialize_options!(base, options)
250
+ raise ArgumentError, "invalid base: #{base}" unless base.is_a?(Class) || base.is_a?(Module)
251
+ raise ArgumentError, 'missing keyword :name' unless options.key?(:name)
252
+ raise ArgumentError, 'missing keyword :type' unless options.key?(:type)
253
+ end
254
+ end
255
+ end
256
+ end
@@ -0,0 +1,208 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'domainic/attributer/attribute'
4
+ require 'forwardable'
5
+
6
+ module Domainic
7
+ module Attributer
8
+ # A class representing an ordered collection of attributes.
9
+ #
10
+ # This class manages a set of attributes for a given class or module. It maintains
11
+ # attributes in a specific order determined by their type (argument vs option),
12
+ # default values, and position. The collection supports standard operations like
13
+ # adding, selecting, and merging attributes while maintaining proper ownership
14
+ # relationships with their base class.
15
+ #
16
+ # @author {https://aaronmallen.me Aaron Allen}
17
+ # @since 0.1.0
18
+ class AttributeSet
19
+ extend Forwardable
20
+
21
+ # @rbs @base: __todo__
22
+ # @rbs @lookup: Hash[Symbol, Attribute]
23
+
24
+ # Initialize a new AttributeSet.
25
+ #
26
+ # @param base [Class, Module] the class or module this set belongs to
27
+ # @param attributes [Array<Attribute>] initial attributes to add
28
+ #
29
+ # @return [void]
30
+ # @rbs (__todo__ base, ?Array[Attribute] attributes) -> void
31
+ def initialize(base, attributes = [])
32
+ @base = base
33
+ @lookup = {}
34
+ attributes.each { |attribute| add(attribute) }
35
+ end
36
+
37
+ # Get an attribute by name.
38
+ #
39
+ # @param attribute_name [String, Symbol] the name of the attribute
40
+ #
41
+ # @return [Attribute, nil] the attribute if found
42
+ # @rbs (String | Symbol attribute_name) -> Attribute?
43
+ def [](attribute_name)
44
+ @lookup[attribute_name.to_sym]
45
+ end
46
+
47
+ # Add an attribute to the set.
48
+ #
49
+ # If an attribute with the same name exists, the attributes are merged.
50
+ # If the attribute belongs to a different base class, it is duplicated
51
+ # with the correct base. After adding, attributes are sorted by type
52
+ # and position.
53
+ #
54
+ # @param attribute [Attribute] the attribute to add
55
+ #
56
+ # @raise [ArgumentError] if attribute is invalid
57
+ # @return [void]
58
+ # @rbs (Attribute attribute) -> void
59
+ def add(attribute)
60
+ raise ArgumentError, "Invalid attribute: #{attribute.inspect}" unless attribute.is_a?(Attribute)
61
+
62
+ @lookup[attribute.name] = if @lookup.key?(attribute.name)
63
+ @lookup[attribute.name].merge(attribute).dup_with_base(@base)
64
+ elsif attribute.base != @base
65
+ attribute.dup_with_base(@base)
66
+ else
67
+ attribute
68
+ end
69
+
70
+ sort_lookup
71
+ nil
72
+ end
73
+
74
+ # Check if an attribute exists in the set.
75
+ #
76
+ # @param attribute_name [String, Symbol] the name to check
77
+ #
78
+ # @return [Boolean] true if the attribute exists
79
+ def attribute?(attribute_name)
80
+ @lookup.key?(attribute_name.to_sym)
81
+ end
82
+
83
+ # Get all attribute names.
84
+ #
85
+ # @return [Array<Symbol>] the attribute names
86
+ # @rbs () -> Array[Symbol]
87
+ def attribute_names
88
+ @lookup.keys
89
+ end
90
+
91
+ # Get all attributes.
92
+ #
93
+ # @return [Array<Attribute>] the attributes
94
+ # @rbs () -> Array[Attribute]
95
+ def attributes
96
+ @lookup.values
97
+ end
98
+
99
+ # @rbs! def count: () ?{ (Symbol, Attribute) -> boolish } -> Integer
100
+ def_delegators :@lookup, :count
101
+
102
+ # Create a duplicate set for a new base class.
103
+ #
104
+ # @param new_base [Class, Module] the new base class
105
+ #
106
+ # @return [AttributeSet] the duplicated set
107
+ # @rbs (__todo__ base) -> AttributeSet
108
+ def dup_with_base(new_base)
109
+ dup.tap do |duped|
110
+ duped.instance_variable_set(:@base, new_base)
111
+ duped.instance_variable_set(
112
+ :@lookup,
113
+ @lookup.transform_values { |attribute| attribute.dup_with_base(new_base) }
114
+ )
115
+ end
116
+ end
117
+
118
+ # Iterate over attribute name/value pairs.
119
+ #
120
+ # @yield [name, attribute] each name/attribute pair
121
+ # @yieldparam name [Symbol] the attribute name
122
+ # @yieldparam attribute [Attribute] the attribute
123
+ #
124
+ # @return [self]
125
+ # @rbs () { ([Symbol, Attribute]) -> untyped } -> self
126
+ def each(...)
127
+ @lookup.each(...)
128
+ self
129
+ end
130
+ alias each_pair each
131
+
132
+ # @rbs! def empty?: () -> bool
133
+ def_delegators :@lookup, :empty?
134
+
135
+ # Create a new set excluding specified attributes.
136
+ #
137
+ # @param attribute_names [Array<String, Symbol>] names to exclude
138
+ #
139
+ # @return [AttributeSet] new set without specified attributes
140
+ # @rbs (*String | Symbol attribute_names) -> AttributeSet
141
+ def except(*attribute_names)
142
+ self.class.new(@base, @lookup.except(*attribute_names.map(&:to_sym)).values)
143
+ end
144
+
145
+ # @rbs! def length: () -> Integer
146
+ def_delegators :@lookup, :length
147
+
148
+ # Merge another set into this one.
149
+ #
150
+ # @param other [AttributeSet] the set to merge
151
+ #
152
+ # @return [AttributeSet] new set with merged attributes
153
+ # @rbs (AttributeSet other) -> AttributeSet
154
+ def merge(other)
155
+ self.class.new(other.instance_variable_get(:@base), attributes + other.attributes)
156
+ end
157
+
158
+ # Create a new set with rejected attributes.
159
+ #
160
+ # @yield [name, attribute] each name/attribute pair
161
+ # @yieldparam name [Symbol] the attribute name
162
+ # @yieldparam attribute [Attribute] the attribute
163
+ #
164
+ # @return [AttributeSet] new set without rejected attributes
165
+ # @rbs () { (Symbol, Attribute) -> boolish } -> AttributeSet
166
+ def reject(...)
167
+ self.class.new(@base, @lookup.reject(...).values)
168
+ end
169
+
170
+ # Create a new set with selected attributes.
171
+ #
172
+ # @yield [name, attribute] each name/attribute pair
173
+ # @yieldparam name [Symbol] the attribute name
174
+ # @yieldparam attribute [Attribute] the attribute
175
+ #
176
+ # @return [AttributeSet] new set with selected attributes
177
+ # @rbs () { (Symbol, Attribute) -> boolish } -> AttributeSet
178
+ def select(...)
179
+ self.class.new(@base, @lookup.select(...).values)
180
+ end
181
+
182
+ # @rbs! def size: () -> Integer
183
+ def_delegators :@lookup, :size
184
+
185
+ private
186
+
187
+ # Sort attributes by type and position.
188
+ #
189
+ # Attributes are sorted first by type (required arguments, defaulted arguments,
190
+ # then options), and then by their position within those groups.
191
+ #
192
+ # @return [void]
193
+ # @rbs () -> void
194
+ def sort_lookup
195
+ @lookup = @lookup.sort_by do |_, attribute|
196
+ [
197
+ if attribute.signature.option?
198
+ 2
199
+ else
200
+ (attribute.default? ? 1 : 0)
201
+ end,
202
+ attribute.signature.position
203
+ ]
204
+ end.to_h
205
+ end
206
+ end
207
+ end
208
+ end