smart_properties 1.9.0 → 1.10.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: 9129f5a59082dea813606c2c71007a999765dec0
4
- data.tar.gz: 580c42e4b0d7cc3c728a44374b2bbdbbd683b4cf
3
+ metadata.gz: c5343469849e0419f91004d5beab3e1516f1d609
4
+ data.tar.gz: 90469264e820a223d9d77a896f5899b10c67e3ba
5
5
  SHA512:
6
- metadata.gz: 22e0491c55f9f6aa08621bc2501f66e093aa005412ae647768907d4dcae2b9c730fcf542a3ef5ac8ff30bdb8ef83f7ad62e406d7b4eb72cee449ee65ada9c3ad
7
- data.tar.gz: 0706e812564f577b0da0dd2fd1fcea21aaea5e19321832be0151fb9b21c1bf9ea9c14774b549e1a59ce9e7d3de4defe46e3ffa58835201a694624a0fc6c44c65
6
+ metadata.gz: dacd46d89ef227413d25bb3770dcd3bfad5b340f212a46cbd385fd9063d66fa7979fbb84a030600810acc95b1cefdd4a2c562bf7c2cbab47c6afb90fc5dabc77
7
+ data.tar.gz: 73e2cac3ff5075e1cb1bc61d28c85e8a54d6e14174ad0dfcaf55cd3c1b419d916b3dad78c1a0e481a1aa57582e59ca3fb6e68030e72206820beff2bc681b38a3
data/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # Smartproperties
1
+ # SmartProperties
2
2
 
3
3
  Ruby accessors on steroids.
4
4
 
@@ -61,34 +61,43 @@ require 'smart_properties'
61
61
  class Message
62
62
  include SmartProperties
63
63
 
64
- property :subject, :converts => :to_s,
65
- :required => true
64
+ property :subject, converts: :to_s,
65
+ required: true
66
66
 
67
- property :body, :converts => :to_s
67
+ property :body, converts: :to_s
68
68
 
69
- property :priority, :converts => :to_sym,
70
- :accepts => [:low, :normal, :high],
71
- :default => :normal,
72
- :required => true
69
+ property :priority, converts: :to_sym,
70
+ accepts: [:low, :normal, :high],
71
+ default: :normal,
72
+ required: true
73
73
  end
74
74
  ```
75
75
 
76
76
  Creating an instance of this class without specifying any attributes will
77
- result in an `ArgumentError` telling you to specify the required property
78
- `subject`.
77
+ result in an `SmartProperties::InitializationError` telling you to specify the
78
+ required property `subject`.
79
79
 
80
80
  ```ruby
81
- Message.new # => raises ArgumentError, "Message requires the property subject to be set"
81
+ Message.new # => raises SmartProperties::InitializationError, "Message requires the following properties to be set: subject"
82
82
  ```
83
83
 
84
- Providing the constructor with a title but with an invalid value for the
85
- property `priority` will also result in an `ArgumentError` telling you to
86
- provide a proper value for the property `priority`.
84
+ Creating an instance of this class with all required properties but then
85
+ setting the property `priority` to an invalid value will also result in an
86
+ `SmartProperties::InvalidValueError`. Since the property is required, assigning
87
+ `nil` is also prohibited and will result in a
88
+ `SmartProperties::MissingValueError`. All errors `SmartProperties` raises are
89
+ subclasses of `ArgumentError`.
87
90
 
88
91
  ```ruby
89
- m = Message.new :subject => 'Lorem ipsum'
92
+ m = Message.new subject: 'Lorem ipsum'
90
93
  m.priority # => :normal
91
- m.priority = :urgent # => raises ArgumentError, Message does not accept :urgent as value for the property priority
94
+
95
+ begin
96
+ m.priority = :urgent
97
+ rescue ArgumentError => error
98
+ error.class # => raises SmartProperties::InvalidValueError
99
+ error.message # => "Message does not accept :urgent as value for the property priority"
100
+ end
92
101
  ```
93
102
 
94
103
  Next, we discuss the various configuration options `SmartProperties` provide.
@@ -110,7 +119,7 @@ to implement a property that automatically converts all given input to a
110
119
 
111
120
  ```ruby
112
121
  class Article
113
- property :title, :converts => :to_s
122
+ property :title, converts: :to_s
114
123
  end
115
124
  ```
116
125
 
@@ -122,7 +131,7 @@ converts all given input to a slug representation.
122
131
 
123
132
  ```ruby
124
133
  class Article
125
- property :slug, :converts => lambda { |slug| slug.downcase.gsub(/\s+/, '-').gsub(/\W/, '') }
134
+ property :slug, converts: lambda { |slug| slug.downcase.gsub(/\s+/, '-').gsub(/\W/, '') }
126
135
  end
127
136
  ```
128
137
 
@@ -136,7 +145,7 @@ of type `String` as input.
136
145
 
137
146
  ```ruby
138
147
  class Article
139
- property :title, :accepts => String
148
+ property :title, accepts: String
140
149
  end
141
150
  ```
142
151
 
@@ -146,7 +155,7 @@ example below shows how to implement a property that only accepts `true` or
146
155
 
147
156
  ```ruby
148
157
  class Article
149
- property :published, :accepts => [true, false]
158
+ property :published, accepts: [true, false]
150
159
  end
151
160
  ```
152
161
 
@@ -158,7 +167,7 @@ only accepts values which match the given regular expression.
158
167
 
159
168
  ```ruby
160
169
  class Article
161
- property :title, :accepts => lambda { |title| /^Lorem \w+$/ =~ title }
170
+ property :title, accepts: lambda { |title| /^Lorem \w+$/ =~ title }
162
171
  end
163
172
  ```
164
173
 
@@ -171,12 +180,12 @@ default value.
171
180
 
172
181
  ```ruby
173
182
  class Article
174
- property :id, :default => 42
183
+ property :id, default: 42
175
184
  end
176
185
  ```
177
186
 
178
187
  Default values can also be specified using blocks which are evaluated at
179
- runtime.
188
+ runtime and only if no value was supplied.
180
189
 
181
190
  #### Presence checking
182
191
 
@@ -187,7 +196,7 @@ how to implement a property that may not be `nil`.
187
196
 
188
197
  ```ruby
189
198
  class Article
190
- property :title, :required => true
199
+ property :title, required: true
191
200
  end
192
201
  ```
193
202
 
@@ -203,6 +212,35 @@ class Person
203
212
  end
204
213
  ```
205
214
 
215
+ ### Constructor argument forwarding
216
+
217
+ The `SmartProperties` initializer forwards anything to the super constructor
218
+ it does not process itself. This is true for all positional arguments
219
+ and those keyword arguments that do not correspond to a property. The example
220
+ below demonstrates how Ruby's `SimpleDelegator` in conjunction with
221
+ `SmartProperties` can be used to quickly construct a very flexible presenter.
222
+
223
+ ```ruby
224
+ class PersonPresenter < SimpleDelegator
225
+ include SmartProperties
226
+ property :name_formatter, accepts: Proc,
227
+ required: true,
228
+ default: lambda { |p| "#{p.firstname} #{p.lastname}" }
229
+
230
+ def full_name
231
+ name_formatter.call(self)
232
+ end
233
+ end
234
+
235
+ person = OpenStruct.new(firstname: "John", lastname: "Doe")
236
+ presenter = PersonPresenter.new(person)
237
+ presenter.full_name # => "John Doe"
238
+
239
+ # Changing the format is easy
240
+ presenter.name_formatter = lambda { |p| "#{p.lastename}, #{p.firstname}" }
241
+ presenter.full_name # => "Doe, John"
242
+ ```
243
+
206
244
  ## Contributing
207
245
 
208
246
  1. Fork it
@@ -0,0 +1,73 @@
1
+ module SmartProperties
2
+ class Error < ::ArgumentError; end
3
+ class ConfigurationError < Error; end
4
+
5
+ class AssignmentError < Error
6
+ attr_accessor :sender
7
+ attr_accessor :property
8
+
9
+ def initialize(sender, property, message)
10
+ @sender = sender
11
+ @property = property
12
+ super(message)
13
+ end
14
+ end
15
+
16
+ class MissingValueError < AssignmentError
17
+ def initialize(sender, property)
18
+ super(
19
+ sender,
20
+ property,
21
+ "%s requires the property %s to be set" % [
22
+ sender.class.name,
23
+ property.name
24
+ ]
25
+ )
26
+ end
27
+
28
+ def to_hash
29
+ Hash[property.name, "must be set"]
30
+ end
31
+ end
32
+
33
+ class InvalidValueError < AssignmentError
34
+ attr_accessor :value
35
+
36
+ def initialize(sender, property, value)
37
+ @value = value
38
+ super(
39
+ sender,
40
+ property,
41
+ "%s does not accept %s as value for the property %s" % [
42
+ sender.class.name,
43
+ value.inspect,
44
+ property.name
45
+ ]
46
+ )
47
+ end
48
+
49
+ def to_hash
50
+ Hash[property.name, "does not accept %s as value" % value.inspect]
51
+ end
52
+ end
53
+
54
+ class InitializationError < Error
55
+ attr_accessor :sender
56
+ attr_accessor :properties
57
+
58
+ def initialize(sender, properties)
59
+ @sender = sender
60
+ @properties = properties
61
+ super(
62
+ "%s requires the following properties to be set: %s" % [
63
+ sender.class.name,
64
+ properties.map(&:name).sort.join(', ')
65
+ ]
66
+ )
67
+ end
68
+
69
+ def to_hash
70
+ properties.each_with_object({}) { |property, errors| errors[property.name] = "must be set" }
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,146 @@
1
+ module SmartProperties
2
+ class Property
3
+ MODULE_REFERENCE = :"@_smart_properties_method_scope"
4
+
5
+ # Defines the two index methods #[] and #[]=. This module will be included
6
+ # in the SmartProperties method scope.
7
+ module IndexMethods
8
+ def [](name)
9
+ return if name.nil?
10
+ name &&= name.to_sym
11
+ public_send(name) if self.class.properties.key?(name)
12
+ end
13
+
14
+ def []=(name, value)
15
+ return if name.nil?
16
+ public_send(:"#{name.to_sym}=", value) if self.class.properties.key?(name)
17
+ end
18
+ end
19
+
20
+ attr_reader :name
21
+ attr_reader :converter
22
+ attr_reader :accepter
23
+ attr_reader :instance_variable_name
24
+
25
+ def self.define(scope, name, options = {})
26
+ new(name, options).tap { |p| p.define(scope) }
27
+ end
28
+
29
+ def initialize(name, attrs = {})
30
+ attrs = attrs.dup
31
+
32
+ @name = name.to_sym
33
+ @default = attrs.delete(:default)
34
+ @converter = attrs.delete(:converts)
35
+ @accepter = attrs.delete(:accepts)
36
+ @required = attrs.delete(:required)
37
+
38
+ @instance_variable_name = :"@#{name}"
39
+
40
+ unless attrs.empty?
41
+ raise ConfigurationError, "SmartProperties do not support the following configuration options: #{attrs.keys.map { |m| m.to_s }.sort.join(', ')}."
42
+ end
43
+ end
44
+
45
+ def required?(scope)
46
+ @required.kind_of?(Proc) ? scope.instance_exec(&@required) : !!@required
47
+ end
48
+
49
+ def optional?(scope)
50
+ !required?(scope)
51
+ end
52
+
53
+ def missing?(scope)
54
+ required?(scope) && !present?(scope)
55
+ end
56
+
57
+ def present?(scope)
58
+ !null_object?(get(scope))
59
+ end
60
+
61
+ def convert(scope, value)
62
+ return value unless converter
63
+ return value if null_object?(value)
64
+ scope.instance_exec(value, &converter)
65
+ end
66
+
67
+ def default(scope)
68
+ @default.kind_of?(Proc) ? scope.instance_exec(&@default) : @default
69
+ end
70
+
71
+ def accepts?(value, scope)
72
+ return true unless accepter
73
+ return true if null_object?(value)
74
+
75
+ if accepter.respond_to?(:to_proc)
76
+ !!scope.instance_exec(value, &accepter)
77
+ else
78
+ Array(accepter).any? { |accepter| accepter === value }
79
+ end
80
+ end
81
+
82
+ def prepare(scope, value)
83
+ required = required?(scope)
84
+ raise MissingValueError.new(scope, self) if required && null_object?(value)
85
+ value = convert(scope, value)
86
+ raise MissingValueError.new(scope, self) if required && null_object?(value)
87
+ raise InvalidValueError.new(scope, self, value) unless accepts?(value, scope)
88
+ value
89
+ end
90
+
91
+ def define(klass)
92
+ property = self
93
+
94
+ scope =
95
+ if klass.instance_variable_defined?(MODULE_REFERENCE)
96
+ klass.instance_variable_get(MODULE_REFERENCE)
97
+ else
98
+ m = Module.new { include IndexMethods }
99
+ klass.send(:include, m)
100
+ klass.instance_variable_set(MODULE_REFERENCE, m)
101
+ m
102
+ end
103
+
104
+ scope.send(:attr_reader, name)
105
+ scope.send(:define_method, :"#{name}=") do |value|
106
+ property.set(self, value)
107
+ end
108
+ end
109
+
110
+ def set(scope, value)
111
+ scope.instance_variable_set(instance_variable_name, prepare(scope, value))
112
+ end
113
+
114
+ def set_default(scope)
115
+ return false if present?(scope)
116
+
117
+ default_value = default(scope)
118
+ return false if null_object?(default_value)
119
+
120
+ set(scope, default_value)
121
+ true
122
+ end
123
+
124
+ def get(scope)
125
+ return nil unless scope.instance_variable_defined?(instance_variable_name)
126
+ scope.instance_variable_get(instance_variable_name)
127
+ end
128
+
129
+ private
130
+
131
+ def null_object?(object)
132
+ return true if object == nil
133
+ return true if object.nil?
134
+ false
135
+ rescue NoMethodError => error
136
+ # BasicObject does not respond to #nil? by default, so we need to double
137
+ # check if somebody implemented it and it fails internally or if the
138
+ # error occured because the method is actually not present. In the former
139
+ # case, we want to raise the exception because there is something wrong
140
+ # with the implementation of object#nil?. In the latter case we treat the
141
+ # object as truthy because we don't know better.
142
+ raise error if (class << object; self; end).public_instance_methods.include?(:nil?)
143
+ false
144
+ end
145
+ end
146
+ end
@@ -0,0 +1,81 @@
1
+ module SmartProperties
2
+ class PropertyCollection
3
+ include Enumerable
4
+
5
+ attr_reader :parent
6
+
7
+ def self.for(scope)
8
+ parent = scope.ancestors[1..-1].find do |ancestor|
9
+ ancestor.ancestors.include?(SmartProperties) && ancestor != SmartProperties
10
+ end
11
+
12
+ if parent.nil?
13
+ new
14
+ else
15
+ parent.properties.register(collection = new)
16
+ collection
17
+ end
18
+ end
19
+
20
+ def initialize
21
+ @collection = {}
22
+ @collection_with_parent_collection = {}
23
+ @children = []
24
+ end
25
+
26
+ def []=(name, value)
27
+ name = name.to_s
28
+ collection[name] = value
29
+ collection_with_parent_collection[name] = value
30
+ notify_children
31
+ value
32
+ end
33
+
34
+ def [](name)
35
+ collection_with_parent_collection[name.to_s]
36
+ end
37
+
38
+ def key?(name)
39
+ collection_with_parent_collection.key?(name.to_s)
40
+ end
41
+
42
+ def keys
43
+ collection_with_parent_collection.keys.map(&:to_sym)
44
+ end
45
+
46
+ def values
47
+ collection_with_parent_collection.values
48
+ end
49
+
50
+ def each(&block)
51
+ return to_enum(:each) if block.nil?
52
+ collection_with_parent_collection.each { |name, value| block.call([name.to_sym, value]) }
53
+ end
54
+
55
+ def to_hash
56
+ Hash[each.to_a]
57
+ end
58
+
59
+ def register(child)
60
+ children.push(child)
61
+ child.refresh(collection_with_parent_collection)
62
+ nil
63
+ end
64
+
65
+ protected
66
+
67
+ attr_accessor :children
68
+ attr_accessor :collection
69
+ attr_accessor :collection_with_parent_collection
70
+
71
+ def notify_children
72
+ @children.each { |child| child.refresh(collection_with_parent_collection) }
73
+ end
74
+
75
+ def refresh(parent_collection)
76
+ @collection_with_parent_collection = parent_collection.merge(collection)
77
+ notify_children
78
+ nil
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,3 @@
1
+ module SmartProperties
2
+ VERSION = "1.10.0"
3
+ end
@@ -21,211 +21,7 @@
21
21
  # :required => true
22
22
  #
23
23
  module SmartProperties
24
- VERSION = "1.9.0"
25
-
26
- class Error < ::ArgumentError; end
27
- class ConfigurationError < Error; end
28
-
29
- class AssignmentError < Error
30
- attr_accessor :sender
31
- attr_accessor :property
32
-
33
- def initialize(sender, property, message)
34
- @sender = sender
35
- @property = property
36
- super(message)
37
- end
38
- end
39
-
40
- class MissingValueError < AssignmentError
41
- def initialize(sender, property)
42
- super(
43
- sender,
44
- property,
45
- "%s requires the property %s to be set" % [
46
- sender.class.name,
47
- property.name
48
- ]
49
- )
50
- end
51
-
52
- def to_hash
53
- Hash[property.name, "must be set"]
54
- end
55
- end
56
-
57
- class InvalidValueError < AssignmentError
58
- attr_accessor :value
59
-
60
- def initialize(sender, property, value)
61
- @value = value
62
- super(
63
- sender,
64
- property,
65
- "%s does not accept %s as value for the property %s" % [
66
- sender.class.name,
67
- value.inspect,
68
- property.name
69
- ]
70
- )
71
- end
72
-
73
- def to_hash
74
- Hash[property.name, "does not accept %s as value" % value.inspect]
75
- end
76
- end
77
-
78
- class InitializationError < Error
79
- attr_accessor :sender
80
- attr_accessor :properties
81
-
82
- def initialize(sender, properties)
83
- @sender = sender
84
- @properties = properties
85
- super(
86
- "%s requires the following properties to be set: %s" % [
87
- sender.class.name,
88
- properties.map(&:name).sort.join(', ')
89
- ]
90
- )
91
- end
92
-
93
- def to_hash
94
- properties.each_with_object({}) { |property, errors| errors[property.name] = "must be set" }
95
- end
96
- end
97
-
98
- class Property
99
- # Defines the two index methods #[] and #[]=. This module will be included
100
- # in the SmartProperties method scope.
101
- module IndexMethods
102
- def [](name)
103
- return if name.nil?
104
- name &&= name.to_sym
105
- public_send(name) if self.class.properties.key?(name)
106
- end
107
-
108
- def []=(name, value)
109
- return if name.nil?
110
- public_send(:"#{name.to_sym}=", value) if self.class.properties.key?(name)
111
- end
112
- end
113
-
114
- attr_reader :name
115
- attr_reader :converter
116
- attr_reader :accepter
117
-
118
- def initialize(name, attrs = {})
119
- attrs = attrs.dup
120
-
121
- @name = name.to_sym
122
- @default = attrs.delete(:default)
123
- @converter = attrs.delete(:converts)
124
- @accepter = attrs.delete(:accepts)
125
- @required = attrs.delete(:required)
126
-
127
- unless attrs.empty?
128
- raise ConfigurationError, "SmartProperties do not support the following configuration options: #{attrs.keys.map { |m| m.to_s }.sort.join(', ')}."
129
- end
130
- end
131
-
132
- def required?(scope)
133
- @required.kind_of?(Proc) ? scope.instance_exec(&@required) : !!@required
134
- end
135
-
136
- def convert(value, scope)
137
- return value unless converter
138
- scope.instance_exec(value, &converter)
139
- end
140
-
141
- def default(scope)
142
- @default.kind_of?(Proc) ? scope.instance_exec(&@default) : @default
143
- end
144
-
145
- def accepts?(value, scope)
146
- return true unless value
147
- return true unless accepter
148
-
149
- if accepter.respond_to?(:to_proc)
150
- !!scope.instance_exec(value, &accepter)
151
- else
152
- Array(accepter).any? { |accepter| accepter === value }
153
- end
154
- end
155
-
156
- def prepare(value, scope)
157
- raise MissingValueError.new(scope, self) if required?(scope) && value.nil?
158
- value = convert(value, scope) unless value.nil?
159
- raise MissingValueError.new(scope, self) if required?(scope) && value.nil?
160
- raise InvalidValueError.new(scope, self, value) unless accepts?(value, scope)
161
- value
162
- end
163
-
164
- def define(klass)
165
- property = self
166
-
167
- scope = klass.instance_variable_get(:"@_smart_properties_method_scope") || begin
168
- m = Module.new { include IndexMethods }
169
- klass.send(:include, m)
170
- klass.instance_variable_set(:"@_smart_properties_method_scope", m)
171
- m
172
- end
173
-
174
- scope.send(:attr_reader, name)
175
- scope.send(:define_method, :"#{name}=") do |value|
176
- instance_variable_set("@#{property.name}", property.prepare(value, self))
177
- end
178
- end
179
-
180
- end
181
-
182
- class PropertyCollection
183
-
184
- include Enumerable
185
-
186
- attr_reader :parent
187
-
188
- def initialize(parent = nil)
189
- @parent = parent
190
- @collection = {}
191
- end
192
-
193
- def []=(name, value)
194
- collection[name] = value
195
- end
196
-
197
- def [](name)
198
- collection_with_parent_collection[name]
199
- end
200
-
201
- def key?(name)
202
- collection_with_parent_collection.key?(name)
203
- end
204
-
205
- def keys
206
- collection_with_parent_collection.keys
207
- end
208
-
209
- def values
210
- collection_with_parent_collection.values
211
- end
212
-
213
- def each(&block)
214
- collection_with_parent_collection.each(&block)
215
- end
216
-
217
- protected
218
-
219
- attr_accessor :collection
220
-
221
- def collection_with_parent_collection
222
- parent.nil? ? collection : parent.collection.merge(collection)
223
- end
224
-
225
- end
226
-
227
24
  module ClassMethods
228
-
229
25
  ##
230
26
  # Returns a class's smart properties. This includes the properties that
231
27
  # have been defined in the parent classes.
@@ -233,13 +29,7 @@ module SmartProperties
233
29
  # @return [Hash<String, Property>] A map of property names to property instances.
234
30
  #
235
31
  def properties
236
- @_smart_properties ||= begin
237
- parent = if self != SmartProperties
238
- (ancestors[1..-1].find { |klass| klass.ancestors.include?(SmartProperties) && klass != SmartProperties })
239
- end
240
-
241
- parent.nil? ? PropertyCollection.new : PropertyCollection.new(parent.properties)
242
- end
32
+ @_smart_properties ||= PropertyCollection.for(self)
243
33
  end
244
34
 
245
35
  ##
@@ -290,29 +80,23 @@ module SmartProperties
290
80
  # :required => true
291
81
  #
292
82
  def property(name, options = {})
293
- p = Property.new(name, options)
294
- p.define(self)
295
-
296
- properties[name] = p
83
+ properties[name] = Property.define(self, name, options)
297
84
  end
298
85
  protected :property
299
-
300
86
  end
301
87
 
302
88
  class << self
303
-
304
89
  private
305
90
 
306
- ##
307
- # Extends the class, which this module is included in, with a property
308
- # method to define properties.
309
- #
310
- # @param [Class] base the class this module is included in
311
- #
312
- def included(base)
313
- base.extend(ClassMethods)
314
- end
315
-
91
+ ##
92
+ # Extends the class, which this module is included in, with a property
93
+ # method to define properties.
94
+ #
95
+ # @param [Class] base the class this module is included in
96
+ #
97
+ def included(base)
98
+ base.extend(ClassMethods)
99
+ end
316
100
  end
317
101
 
318
102
  ##
@@ -322,38 +106,41 @@ module SmartProperties
322
106
  # @param [Hash] attrs the set of attributes that is used for initialization
323
107
  #
324
108
  def initialize(*args, &block)
325
- attrs = args.last.is_a?(Hash) ? args.pop : {}
326
- super(*args)
109
+ attrs = args.last.is_a?(Hash) ? args.pop.dup : {}
110
+ properties = self.class.properties
327
111
 
328
- properties = self.class.properties.each.to_a
112
+ # Track missing properties
329
113
  missing_properties = []
330
114
 
331
- # Assign attributes or default values
332
- properties.each do |_, property|
333
- if attrs.key?(property.name)
334
- instance_variable_set("@#{property.name}", property.prepare(attrs[property.name], self))
115
+ # Set values
116
+ properties.each do |name, property|
117
+ if attrs.key?(name)
118
+ property.set(self, attrs.delete(name))
119
+ elsif attrs.key?(name.to_s)
120
+ property.set(self, attrs.delete(name.to_s))
335
121
  else
336
122
  missing_properties.push(property)
337
123
  end
338
124
  end
339
125
 
340
- # Exectue configuration block
126
+ # Call the super constructor and forward unprocessed arguments
127
+ attrs.empty? ? super(*args) : super(*args.push(attrs))
128
+
129
+ # Execute configuration block
341
130
  block.call(self) if block
342
131
 
343
- # Set defaults
344
- missing_properties.each do |property|
345
- variable = "@#{property.name}"
346
- if instance_variable_get(variable).nil? && !(default_value = property.default(self)).nil?
347
- instance_variable_set(variable, property.prepare(default_value, self))
348
- end
349
- end
132
+ # Set default values for missing properties
133
+ missing_properties.delete_if { |property| property.set_default(self) }
350
134
 
351
- # Check presence of all required properties
352
- faulty_properties =
353
- properties.select { |_, property| property.required?(self) && instance_variable_get("@#{property.name}").nil? }.map(&:last)
354
- unless faulty_properties.empty?
355
- error = SmartProperties::InitializationError.new(self, faulty_properties)
356
- raise error
357
- end
135
+ # Recheck - cannot be done while assigning default values because
136
+ # one property might depend on the default value of another property
137
+ missing_properties.delete_if { |property| property.present?(self) || property.optional?(self) }
138
+
139
+ raise SmartProperties::InitializationError.new(self, missing_properties) unless missing_properties.empty?
358
140
  end
359
141
  end
142
+
143
+ require_relative 'smart_properties/property_collection'
144
+ require_relative 'smart_properties/property'
145
+ require_relative 'smart_properties/errors'
146
+ require_relative 'smart_properties/version'
@@ -1,5 +1,5 @@
1
1
  # -*- encoding: utf-8 -*-
2
- require File.expand_path('../lib/smart_properties', __FILE__)
2
+ require_relative 'lib/smart_properties/version'
3
3
 
4
4
  Gem::Specification.new do |gem|
5
5
  gem.authors = ["Konstantin Tennhard"]
@@ -21,4 +21,5 @@ Gem::Specification.new do |gem|
21
21
 
22
22
  gem.add_development_dependency "rspec", "~> 3.0"
23
23
  gem.add_development_dependency "rake", "~> 10.0"
24
+ gem.add_development_dependency "pry"
24
25
  end
data/spec/base_spec.rb CHANGED
@@ -7,6 +7,20 @@ RSpec.describe SmartProperties do
7
7
  expect { klass.property }.to raise_error(NoMethodError)
8
8
  end
9
9
 
10
+ context "when used to build a class with a property that uses none of the features provided by SmartProperties" do
11
+ subject(:klass) { DummyClass.new { property :title } }
12
+
13
+ context "an instance of this class" do
14
+ it 'should accept an instance of BasicObject, which does not respond to nil?, as value for title' do
15
+ instance = klass.new
16
+ expect { instance.title = BasicObject.new }.to_not raise_error
17
+ expect { instance[:title] = BasicObject.new }.to_not raise_error
18
+
19
+ expect { instance = klass.new(title: BasicObject) }.to_not raise_error
20
+ end
21
+ end
22
+ end
23
+
10
24
  context "when used to build a class that has a property called title that utilizes the full feature set of SmartProperties" do
11
25
  subject(:klass) do
12
26
  default_title = double(to_title: 'chunky')
@@ -64,12 +78,58 @@ RSpec.describe SmartProperties do
64
78
  end
65
79
  end
66
80
 
81
+ context 'an instance of this class when initialized with a null object' do
82
+ let(:null_object) do
83
+ Class.new(BasicObject) do
84
+ def nil?
85
+ true
86
+ end
87
+ end
88
+ end
89
+
90
+ it 'should raise an error during initialization' do
91
+ exception = SmartProperties::MissingValueError
92
+ message = "Dummy requires the property title to be set"
93
+ further_expectations = lambda do |error|
94
+ expect(error.to_hash[:title]).to eq('must be set')
95
+ end
96
+
97
+ instance = klass.new
98
+ expect { instance.title = null_object.new }.to raise_error(exception, message, &further_expectations)
99
+ expect { instance[:title] = null_object.new }.to raise_error(exception, message, &further_expectations)
100
+
101
+ expect { klass.new(title: null_object.new) }.to raise_error(exception, message, &further_expectations)
102
+ end
103
+ end
104
+
105
+ context 'an instance of this class when initialized with a subclass of BasicObject that responds to #to_title but by design not to #nil?' do
106
+ let(:title) do
107
+ Class.new(BasicObject) do
108
+ def to_title
109
+ "Chunky bacon"
110
+ end
111
+ end
112
+ end
113
+
114
+ it 'should have the correct title' do
115
+ instance = klass.new(title: title.new)
116
+ expect(instance.title).to eq("Chunky bacon")
117
+ end
118
+ end
119
+
67
120
  context 'an instance of this class when initialized with a title argument' do
68
121
  it "should have the title specified by the corresponding keyword argument" do
69
122
  instance = klass.new(title: double(to_title: 'bacon'))
70
123
  expect(instance.title).to eq('bacon')
71
124
  expect(instance[:title]).to eq('bacon')
72
125
  end
126
+
127
+ it "should have the title specified in the corresponding attributes hash that uses strings as keys" do
128
+ attributes = {"title" => double(to_title: 'bacon')}
129
+ instance = klass.new(attributes)
130
+ expect(instance.title).to eq('bacon')
131
+ expect(instance[:title]).to eq('bacon')
132
+ end
73
133
  end
74
134
 
75
135
  context "an instance of this class when initialized with a block" do
@@ -7,7 +7,7 @@ RSpec.describe SmartProperties, 'default values' do
7
7
  context "an instance of this class" do
8
8
  it "should evaluate the lambda in its own scope and thus differ from every other instance" do
9
9
  first_instance, second_instance = klass.new, klass.new
10
- expect(klass.new.id).to_not eq(klass.new.id)
10
+ expect(first_instance.id).to_not eq(second_instance.id)
11
11
  end
12
12
  end
13
13
  end
@@ -17,14 +17,16 @@ RSpec.describe SmartProperties, 'intheritance' do
17
17
  context 'when modeling the following class hiearchy: Base > Section > SectionWithSubtitle' do
18
18
  let!(:base) do
19
19
  Class.new do
20
- attr_reader :content
21
- def initialize(content = nil)
20
+ attr_reader :content, :options
21
+ def initialize(content = nil, options = {})
22
22
  @content = content
23
+ @options = options
23
24
  end
24
25
  end
25
26
  end
26
27
  let!(:section) { DummyClass.new(base) { property :title } }
27
28
  let!(:subsection) { DummyClass.new(section) { property :subtitle } }
29
+ let!(:subsubsection) { DummyClass.new(subsection) { property :subsubtitle } }
28
30
 
29
31
  context 'the base class' do
30
32
  it('should not respond to #properties') { expect(base).to_not respond_to(:properties) }
@@ -47,6 +49,11 @@ RSpec.describe SmartProperties, 'intheritance' do
47
49
  subject { section.new }
48
50
  it { is_expected.to have_smart_property(:title) }
49
51
  it { is_expected.to_not have_smart_property(:subtitle) }
52
+
53
+ it 'should forward keyword arguments that do not correspond to a property to the super class constructor' do
54
+ instance = section.new('some content', answer: 42)
55
+ expect(instance.options).to eq({answer: 42})
56
+ end
50
57
  end
51
58
 
52
59
  context 'an instance of this class when initialized with content' do
@@ -89,6 +96,68 @@ RSpec.describe SmartProperties, 'intheritance' do
89
96
  expect(instance.title).to eq('some title')
90
97
  expect(instance.subtitle).to eq('some subtitle')
91
98
  end
99
+
100
+ it 'should forward keyword arguments that do not correspond to a property to the super class constructor' do
101
+ instance = subsection.new('some content', answer: 42)
102
+ expect(instance.options).to eq({answer: 42})
103
+ end
104
+ end
105
+ end
106
+
107
+ context 'the subsubsectionclass' do
108
+ it "should expose the names of the properties through its property collection" do
109
+ expect(subsubsection.properties.keys).to eq([:title, :subtitle, :subsubtitle])
110
+ end
111
+
112
+ it "should expose the the properties through its property collection" do
113
+ properties = subsubsection.properties.values
114
+
115
+ expect(properties[0]).to be_kind_of(SmartProperties::Property)
116
+ expect(properties[0].name).to eq(:title)
117
+
118
+ expect(properties[1]).to be_kind_of(SmartProperties::Property)
119
+ expect(properties[1].name).to eq(:subtitle)
120
+
121
+ expect(properties[2]).to be_kind_of(SmartProperties::Property)
122
+ expect(properties[2].name).to eq(:subsubtitle)
123
+ end
124
+
125
+ context 'an instance of this class' do
126
+ subject(:instance) { subsubsection.new }
127
+ it { is_expected.to have_smart_property(:title) }
128
+ it { is_expected.to have_smart_property(:subtitle) }
129
+ it { is_expected.to have_smart_property(:subsubtitle) }
130
+
131
+ it 'should have content, a title, and a subtile when initialized with these parameters' do
132
+ instance = subsubsection.new('some content', title: 'some title', subtitle: 'some subtitle', subsubtitle: 'some subsubtitle')
133
+ expect(instance.content).to eq('some content')
134
+ expect(instance.title).to eq('some title')
135
+ expect(instance.subtitle).to eq('some subtitle')
136
+ expect(instance.subsubtitle).to eq('some subsubtitle')
137
+
138
+ instance = subsubsection.new('some content') do |s|
139
+ s.title, s.subtitle, s.subsubtitle = 'some title', 'some subtitle', 'some subsubtitle'
140
+ end
141
+ expect(instance.content).to eq('some content')
142
+ expect(instance.title).to eq('some title')
143
+ expect(instance.subtitle).to eq('some subtitle')
144
+ expect(instance.subsubtitle).to eq('some subsubtitle')
145
+ end
146
+
147
+ it 'should not accidentally forward attributes as options because the keys where strings' do
148
+ attributes = {'title' => 'some title', 'subtitle' => 'some subtitle', 'subsubtitle' => "some subsubtitle", "answer" => 42}
149
+ instance = subsubsection.new('some content', attributes)
150
+ expect(instance.content).to eq('some content')
151
+ expect(instance.title).to eq('some title')
152
+ expect(instance.subtitle).to eq('some subtitle')
153
+ expect(instance.subsubtitle).to eq('some subsubtitle')
154
+ expect(instance.options).to eq({"answer" => 42})
155
+ end
156
+
157
+ it 'should forward keyword arguments that do not correspond to a property to the super class constructor' do
158
+ instance = subsubsection.new('some content', answer: 42)
159
+ expect(instance.options).to eq({answer: 42})
160
+ end
92
161
  end
93
162
  end
94
163
 
@@ -0,0 +1,29 @@
1
+ require 'spec_helper'
2
+
3
+ RSpec.describe SmartProperties, "property collection caching:" do
4
+ specify "SmartProperty enabled objects should be extendable at runtime" do
5
+ base_class = DummyClass.new { property :title }
6
+ subclass = DummyClass.new(base_class) { property :body }
7
+ subsubclass = DummyClass.new(subclass) { property :attachment }
8
+
9
+ expect(base_class.new).to have_smart_property(:title)
10
+ expect(subclass.new).to have_smart_property(:title)
11
+ expect(subclass.new).to have_smart_property(:body)
12
+
13
+ base_class.class_eval { property :severity }
14
+ expect(base_class.new).to have_smart_property(:severity)
15
+ expect(subclass.new).to have_smart_property(:severity)
16
+ expect(subsubclass.new).to have_smart_property(:severity)
17
+
18
+ expected_names = [:title, :body, :attachment, :severity]
19
+
20
+ names = subsubclass.properties.map(&:first) # Using enumerable
21
+ expect(names - expected_names).to be_empty
22
+
23
+ names = subsubclass.properties.each.map(&:first) # Using enumerator
24
+ expect(names - expected_names).to be_empty
25
+
26
+ expect(subsubclass.properties.keys - expected_names).to be_empty
27
+ expect(subsubclass.properties.to_hash.keys - expected_names).to be_empty
28
+ end
29
+ end
data/spec/spec_helper.rb CHANGED
@@ -1,9 +1,84 @@
1
1
  require 'bundler/setup'
2
2
  require 'rspec'
3
+ require 'pry'
3
4
 
4
5
  require 'smart_properties'
5
6
 
6
7
  Dir[File.join(File.dirname(__FILE__), 'support', '**', '*.rb')].each { |f| require f }
7
8
 
9
+ # See http://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration
8
10
  RSpec.configure do |config|
11
+ # rspec-expectations config goes here. You can use an alternate
12
+ # assertion/expectation library such as wrong or the stdlib/minitest
13
+ # assertions if you prefer.
14
+ config.expect_with :rspec do |expectations|
15
+ # This option will default to `true` in RSpec 4. It makes the `description`
16
+ # and `failure_message` of custom matchers include text for helper methods
17
+ # defined using `chain`, e.g.:
18
+ # be_bigger_than(2).and_smaller_than(4).description
19
+ # # => "be bigger than 2 and smaller than 4"
20
+ # ...rather than:
21
+ # # => "be bigger than 2"
22
+ expectations.include_chain_clauses_in_custom_matcher_descriptions = true
23
+ end
24
+
25
+ # rspec-mocks config goes here. You can use an alternate test double
26
+ # library (such as bogus or mocha) by changing the `mock_with` option here.
27
+ config.mock_with :rspec do |mocks|
28
+ # Prevents you from mocking or stubbing a method that does not exist on
29
+ # a real object. This is generally recommended, and will default to
30
+ # `true` in RSpec 4.
31
+ mocks.verify_partial_doubles = true
32
+ end
33
+
34
+ # These two settings work together to allow you to limit a spec run
35
+ # to individual examples or groups you care about by tagging them with
36
+ # `:focus` metadata. When nothing is tagged with `:focus`, all examples
37
+ # get run.
38
+ config.filter_run :focus
39
+ config.run_all_when_everything_filtered = true
40
+
41
+ # Allows RSpec to persist some state between runs in order to support
42
+ # the `--only-failures` and `--next-failure` CLI options. We recommend
43
+ # you configure your source control system to ignore this file.
44
+ #
45
+ # config.example_status_persistence_file_path = "spec/examples.txt"
46
+
47
+ # Limits the available syntax to the non-monkey patched syntax that is
48
+ # recommended. For more details, see:
49
+ # - http://myronmars.to/n/dev-blog/2012/06/rspecs-new-expectation-syntax
50
+ # - http://www.teaisaweso.me/blog/2013/05/27/rspecs-new-message-expectation-syntax/
51
+ # - http://myronmars.to/n/dev-blog/2014/05/notable-changes-in-rspec-3#new__config_option_to_disable_rspeccore_monkey_patching
52
+ config.disable_monkey_patching!
53
+
54
+ # This setting enables warnings. It's recommended, but in some cases may
55
+ # be too noisy due to issues in dependencies.
56
+ config.warnings = true
57
+
58
+ # Many RSpec users commonly either run the entire suite or an individual
59
+ # file, and it's useful to allow more verbose output when running an
60
+ # individual spec file.
61
+ if config.files_to_run.one?
62
+ # Use the documentation formatter for detailed output,
63
+ # unless a formatter has already been configured
64
+ # (e.g. via a command-line flag).
65
+ config.default_formatter = 'doc'
66
+ end
67
+
68
+ # Print the 10 slowest examples and example groups at the
69
+ # end of the spec run, to help surface which specs are running
70
+ # particularly slow.
71
+ config.profile_examples = 10
72
+
73
+ # Run specs in random order to surface order dependencies. If you find an
74
+ # order dependency and want to debug it, you can fix the order by providing
75
+ # the seed, which is printed after each run.
76
+ # --seed 1234
77
+ config.order = :random
78
+
79
+ # Seed global randomization in this process using the `--seed` CLI option.
80
+ # Setting this allows you to use `--seed` to deterministically reproduce
81
+ # test failures related to randomization by passing the same `--seed` value
82
+ # as the one that triggered the failure.
83
+ Kernel.srand config.seed
9
84
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: smart_properties
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.9.0
4
+ version: 1.10.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Konstantin Tennhard
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2015-09-25 00:00:00.000000000 Z
11
+ date: 2015-10-11 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rspec
@@ -38,6 +38,20 @@ dependencies:
38
38
  - - "~>"
39
39
  - !ruby/object:Gem::Version
40
40
  version: '10.0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: pry
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
41
55
  description: |2
42
56
  SmartProperties are a more flexible and feature-rich alternative to
43
57
  traditional Ruby accessors. They provide support for input conversion,
@@ -56,6 +70,10 @@ files:
56
70
  - README.md
57
71
  - Rakefile
58
72
  - lib/smart_properties.rb
73
+ - lib/smart_properties/errors.rb
74
+ - lib/smart_properties/property.rb
75
+ - lib/smart_properties/property_collection.rb
76
+ - lib/smart_properties/version.rb
59
77
  - smart_properties.gemspec
60
78
  - spec/acceptance_checking_spec.rb
61
79
  - spec/base_spec.rb
@@ -63,6 +81,7 @@ files:
63
81
  - spec/conversion_spec.rb
64
82
  - spec/default_values_spec.rb
65
83
  - spec/inheritance_spec.rb
84
+ - spec/property_collection_caching_spec.rb
66
85
  - spec/required_values_spec.rb
67
86
  - spec/spec_helper.rb
68
87
  - spec/support/dummy_class.rb
@@ -97,6 +116,7 @@ test_files:
97
116
  - spec/conversion_spec.rb
98
117
  - spec/default_values_spec.rb
99
118
  - spec/inheritance_spec.rb
119
+ - spec/property_collection_caching_spec.rb
100
120
  - spec/required_values_spec.rb
101
121
  - spec/spec_helper.rb
102
122
  - spec/support/dummy_class.rb