smart_properties 1.9.0 → 1.10.0

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