├── .circleci
    └── config.yml
├── .gitignore
├── Gemfile
├── Gemfile.lock
├── LICENSE
├── README.markdown
├── Rakefile
├── docs
    └── validations.markdown
├── examples
    ├── baseball.rb
    ├── person.rb
    ├── phone_number.rb
    ├── rails_presenter.rb
    ├── search-medium.rb
    └── search-simple.rb
├── lib
    ├── valuable.rb
    └── valuable
    │   └── utils.rb
├── test
    ├── alias_test.rb
    ├── bad_attributes_test.rb
    ├── collection_test.rb
    ├── custom_formatter_test.rb
    ├── custom_initializer_test.rb
    ├── default_values_from_anon_methods.rb
    ├── deprecated_test.rb
    ├── extending_test.rb
    ├── inheritance_test.rb
    ├── parse_with_test.rb
    ├── typical_test.rb
    ├── valuable_test.rb
    └── write_and_read_attribute_test.rb
├── valuable.gemspec
└── valuable.version
/.circleci/config.yml:
--------------------------------------------------------------------------------
 1 | # Ruby CircleCI 2.0 configuration file
 2 | #
 3 | # Check https://circleci.com/docs/2.0/language-ruby/ for more details
 4 | #
 5 | version: 2
 6 | jobs:
 7 |   build:
 8 |     docker:
 9 |       # specify the version you desire here
10 |        - image: circleci/ruby:2.4.1-node-browsers
11 | 
12 |       # Specify service dependencies here if necessary
13 |       # CircleCI maintains a library of pre-built images
14 |       # documented at https://circleci.com/docs/2.0/circleci-images/
15 |       # - image: circleci/postgres:9.4
16 | 
17 |     working_directory: ~/repo
18 | 
19 |     steps:
20 |       - checkout
21 | 
22 |       # Download and cache dependencies
23 |       - restore_cache:
24 |           keys:
25 |           - v1-dependencies-{{ checksum "Gemfile.lock" }}
26 |           # fallback to using the latest cache if no exact match is found
27 |           - v1-dependencies-
28 | 
29 |       - run:
30 |           name: install dependencies
31 |           command: |
32 |             bundle install --jobs=4 --retry=3 --path vendor/bundle
33 | 
34 |       - save_cache:
35 |           paths:
36 |             - ./venv
37 |           key: v1-dependencies-{{ checksum "Gemfile.lock" }}
38 | 
39 |       # run tests!
40 |       - run:
41 |           name: run tests
42 |           command: |
43 |             mkdir /tmp/test-results
44 |             #TEST_FILES="$(circleci tests glob "spec/**/*_spec.rb" | circleci tests split --split-by=timings)"
45 | 
46 |             bundle exec rake test > /tmp/test-results/test-unit.txt
47 | 
48 |       # collect reports
49 |       - store_test_results:
50 |           path: /tmp/test-results
51 |       - store_artifacts:
52 |           path: /tmp/test-results
53 |           destination: test-results
54 | 
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
 1 | *.*~
 2 | *.swp
 3 | .bundle
 4 | .rvmrc
 5 | .rbx
 6 | .irbrc
 7 | .svn
 8 | pkg
 9 | vendor
10 | *.gem
11 | 
--------------------------------------------------------------------------------
/Gemfile:
--------------------------------------------------------------------------------
 1 | source 'http://rubygems.org'
 2 | 
 3 | # Specify your gem's dependencies in sketch.gemspec
 4 | gemspec
 5 | 
 6 | gem 'rake'
 7 | 
 8 | group :test do
 9 |   gem 'rspec', '2.14.1'
10 |   gem 'test-unit'
11 |   gem 'mocha'
12 | end
13 | 
14 | 
--------------------------------------------------------------------------------
/Gemfile.lock:
--------------------------------------------------------------------------------
 1 | PATH
 2 |   remote: .
 3 |   specs:
 4 |     valuable (0.9.13)
 5 | 
 6 | GEM
 7 |   remote: http://rubygems.org/
 8 |   specs:
 9 |     diff-lcs (1.2.5)
10 |     metaclass (0.0.1)
11 |     mocha (0.13.2)
12 |       metaclass (~> 0.0.1)
13 |     rake (10.0.3)
14 |     rspec (2.14.1)
15 |       rspec-core (~> 2.14.0)
16 |       rspec-expectations (~> 2.14.0)
17 |       rspec-mocks (~> 2.14.0)
18 |     rspec-core (2.14.8)
19 |     rspec-expectations (2.14.5)
20 |       diff-lcs (>= 1.1.3, < 2.0)
21 |     rspec-mocks (2.14.6)
22 |     test-unit (2.5.4)
23 | 
24 | PLATFORMS
25 |   ruby
26 | 
27 | DEPENDENCIES
28 |   mocha
29 |   rake
30 |   rspec (= 2.14.1)
31 |   test-unit
32 |   valuable!
33 | 
34 | BUNDLED WITH
35 |    1.13.0.rc.2
36 | 
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
 1 | Copyright (c) 2009 Johnathon Wright 
 2 | 
 3 | MIT License
 4 | 
 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
 6 | 
 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
 8 | 
 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
10 | 
11 | 
--------------------------------------------------------------------------------
/README.markdown:
--------------------------------------------------------------------------------
  1 | # Introducing Valuable
  2 | 
  3 | Valuable enables quick modeling... it's `attr_accessor` on steroids.  Its simple interface allows you to build, change and discard models without hassles, so you can get on with the logic specific to your application.
  4 | 
  5 | When working with Rails, Sinatra etc., I find myself creating non-Active-Record classes to create testable classes for:
  6 | 
  7 | * reports
  8 | * events (model interactions between classes; this code does not belong in either a controller or an ORM model.)
  9 | * view helpers ( very hard to test in Rails unless they're in a class like EmployeePresenter or DashboardPresenter )
 10 | * incoming / outgoing API handlers (ie MapQuest::GeoCoder or LocalCache::GeoCoder )
 11 | * search ( `Search` but also `EmployeeSearch`, `EmployeeSearch.new(company: co).incomplete_pto_for(year)`, etc.
 12 | * factories
 13 |     
 14 | Here's an example of modeling an event, logic that doesn't belong in either a controller or a model:
 15 | 
 16 | ```ruby
 17 | class EmployeeHireAide < Valuable
 18 |     has_value :employee, klass: Employee
 19 |     has_value :hire_date, klass: :date
 20 |     has_value :current_user               
 21 | 
 22 |     def fire
 23 |       employee.save.tap do |success|
 24 |         if success
 25 |           add_note_about_hiring
 26 |           create_documentation_checklist
 27 |           create_user_account
 28 |         end
 29 |       end
 30 |     end
 31 | 
 32 |     def add_note_about_hiring
 33 |       Note.create(notable: employee, author: current_user, event: 'Hire', body: "Hired employee on #{hire_date.to_s(:mdy)}")
 34 |     end
 35 | 
 36 |     def create_documentation_checklist
 37 |       ChecklistTemplate.find_by_name('employee_documentation').create_checklist(reference: employee)
 38 |     end
 39 | 
 40 |     def create_user_account
 41 |       ... etc ...
 42 |     end
 43 | end
 44 | ```
 45 | 
 46 | Then in your controller:
 47 | 
 48 | ```ruby
 49 | class EmployeeController
 50 |   def create
 51 |     aide = EmployeeHireAide.new(employee: params[:employee], current_user: current_user, hire_date: params[:hire_date])
 52 | 
 53 |     if !current_user.can_create?(:employee)
 54 |       go_away
 55 |     elsif aide.fire
 56 |       redirect_to aide.employee
 57 |     else
 58 |       render action: :new
 59 |     end
 60 |   end
 61 | end
 62 | ```
 63 | 
 64 | Valuable provides DRY decoration like `attr_accessor`, but includes default values and other formatting (like, `"2" => 2`), and a constructor that accepts an attributes hash. It provides a class-level list of attributes, an instance-level attributes hash, and more.
 65 | 
 66 | Tested with [Rubinius](http://www.rubini.us "Rubinius"), `1.8.7`, `1.9.1`, `1.9.2`, `1.9.3`
 67 | 
 68 | Version `0.9.x` is considered stable.
 69 | 
 70 | Valuable was originally created to avoid the repetition of writing the constructor-accepts-a-hash method. It has evolved, but at its core are still the same concepts.
 71 | 
 72 | ## Contents
 73 | 
 74 | - [Frequent Uses](#frequent-uses)
 75 | - [Methods](#methods) ( [Class-Level](#class-level-methods), [Instance-Level](#instance-level-methods) )
 76 | - [Installation](#installation)
 77 | - [Usage & Examples](#usage--examples)
 78 |     - [Constructor Accepts an Attributes Hash](#constructor-accepts-an-attributes-hash)
 79 |     - [Default Values](#default-values)
 80 |     - [Nil Values](#nil-values)
 81 |     - [Aliases](#aliases)
 82 |     - [Formatting Input](#formatting-input)
 83 |     - [Pre-Defined Formatters](#pre-defined-formatters)
 84 |     - [Extending Values](#extending-values)
 85 |     - [Collections](#collections)
 86 |     - [Formatting Collections](#formatting-collections)
 87 |     - [Extending Collections](#extending-collections)
 88 |     - [Registering Formatters](#registering-formatters)
 89 |     - [More about Attributes](#more-about-attributes)
 90 |     - [Advanced Input Parsing](#advanced-input-parsing)
 91 |     - [Advanced Defaults](#advanced-defaults)
 92 |     - [Advanced Collection Formatting](#advanced-collection-formatting)
 93 | - Other Examples
 94 |     - [Validations](/docs/validations.markdown)
 95 | 
 96 | ## Frequent Uses
 97 | 
 98 | Valuable was created to help you quickly model things. Things I find myself modeling:
 99 | 
100 | + **data imported from JSON, XML, etc**
101 | + **the result of an API call**
102 | + **a subset of some data in an ORM class** say you have a class Person with street, city, state and zip. It might not make sense to store this in a separate table, but you can still create an Address model to hold address-related logic and state like geocode, post_office_box? and Address#==
103 | + **as a presenter that wraps a model** This way you keep view-specific methods out of views and models.
104 | + **as a presenter that aggregates several models** Generating a map might involve coordinating several different collections of data. Create a valuable class to handle that integration.
105 | + **to model search forms** - Use Valuable to model an advanced search form. Create an attribute for each drop-down, check-box, and text field, and constants to store options. Integrates easily with Rails via @search = CustomerSearch.new(params[:search]) and form_for(@search, :url => ...)
106 | + **to model reports** like search forms, reports can be stateful when they have critiera that can be selected via form.
107 | + **as a query builder** ie, "I need to create an (Arel or SQL) query based off of form input." (see previous two points)
108 | + **experiments / spikes**
109 | + **factories** factories need well-defined input, so valuable is a great fit.
110 | 
111 | ## Methods
112 | 
113 | ### Class-Level Methods
114 | 
115 | #### `has_value(field_name, options = {})`
116 | 
117 | creates a getter and setter named field_name
118 | 
119 | options:
120 | + **`default`** - provide a default value
121 | 
122 | ```ruby
123 | class Task < Valuable
124 |   has_value :status, :default => 'Active'
125 | end
126 |           
127 | >> Task.new.status
128 | => 'Active'
129 | ```
130 | 
131 | + **`alias`** - create setters and getters with the name of the attribute and _also_ with the alias. See [Aliases](#aliases) for more information.
132 | 
133 | + **`klass`** - pre-format the input with one of the [predefined formatters](#pre-defined-formatters), as a class, or with your [custom formatter](#registering-formatters). See [Formatting Input](#formatting-input) for more information.
134 | 
135 | ```ruby
136 | class Person < Valuable
137 |   has_value :age, :klass => :integer
138 |   has_value :phone_number, :klass => PhoneNumber
139 | end
140 | 
141 | >> Person.new(:age => '15').age.class
142 | => Fixnum
143 | 
144 | >> jenny = Person.new(:phone_number => '2018675309')
145 | 
146 | >> jenny.phone_number == PhoneNumber.new('2018675309')
147 | => true
148 | ```
149 | 
150 | 
151 | + **`parse_with`** - Sometimes you want to instantiate with a method other than `new`... one example being `Date.parse`
152 | 
153 | ```ruby
154 | class Person
155 |   has_value :dob, :klass => Date, :parse_with => :parse
156 | end
157 | 
158 | # this will call Date.parse('1976-07-26')
159 | Person.new(:dob => '1976-07-26')
160 | ```
161 | 
162 | #### `has_collection(field_name, options = {})`
163 | 
164 | like `has_value`, this creates a getter and setter. The default value is an array.
165 | 
166 | options:
167 | + **`klass`** - apply pre-defined or custom formatters to each element of the array.
168 | + **`alias`** - create additional getters and setters under this name.
169 | + **`extend`** - extend the collection with the provided module or modules.
170 | 
171 | ```ruby
172 | class Person
173 |   has_collection :friends
174 | end
175 | 
176 | >> Person.new.friends
177 | =>   []
178 | ```
179 | 
180 | #### `attributes`
181 | 
182 | an array of attributes you have defined on a model.
183 | 
184 | ```ruby
185 | class Person < Valuable
186 |   has_value :first_name
187 |   has_value :last_name
188 | end
189 | 
190 | >> Person.attributes
191 | => [:first_name, :last_name]
192 | ```
193 | 
194 | #### `defaults`
195 | 
196 | A hash of the attributes with their default values. Attributes defined without default values do not appear in this list.
197 | 
198 | ```ruby
199 | class Pastry < Valuable
200 |   has_value :primary_ingredient, :default => :sugar
201 |   has_value :att_with_no_default
202 | end
203 | 
204 | >> Pastry.defaults
205 | => {:primary_ingredient => :sugar}
206 | ```
207 | 
208 | #### `register_formatter(name, &block)`
209 | 
210 | Allows you to provide custom code to pre-format attributes, if the included ones are not sufficient. For instance, you might wish to register an 'orientation' formatter that accepts either angles or 'N', 'S', 'E', 'W', and converts those to angles. See [registering formatters](#registering-formatters) for details and examples.
211 |   
212 | **Note:** as with other formatters, `nil` values will not be passed to the formatter. The attribute will simply be set to `nil`. See [nil values](#nil-values). If this is an issue, let me know.
213 | 
214 | #### `acts_as_permissive`
215 | 
216 | Valuable classes typically raise an error if you instantiate them with attributes that have not been predefined. This method makes Valuable ignore any unknown attributes.
217 | 
218 | ### Instance-Level Methods
219 | 
220 | #### `attributes`
221 | 
222 | provides a hash of the attributes and their values.
223 | 
224 | ```ruby
225 | class Party < Valuable
226 |   has_value :host
227 |   has_value :theme
228 |   has_value :time, :default => '6pm'
229 | end
230 | 
231 | >> party = Party.new(:theme => 'Black and Whitle')
232 | 
233 | >> party.attributes
234 | => {:theme => 'Black and White', :time => '6pm'}
235 | 
236 | # note that the 'host' attribute was not set by default, at
237 | # instantiation, or via the setter method party.host=, so 
238 | # it does not appear in the attributes hash.
239 | ```
240 | 
241 | #### `update_attributes(atts={})`
242 | 
243 | Accepts a hash of `:attribute => :value` and updates each associated attributes. Will raise an exception if any of the keys isn't already set up in the class, unless you call `acts_as_permissive`.
244 |   
245 | ```ruby
246 | class Tomatoe
247 |   has_value :color
248 | end
249 | 
250 | >> t = Tomatoe.new(:color => 'green')
251 | >> t.color
252 | => 'green'
253 | >> t.update_attributes(:color => 'red')
254 | >> t.color
255 | => 'red'
256 | ```
257 | 
258 | #### `write_attribute(att_name, value)`
259 | 
260 | this method is called by all the setters and, obviously, `update_attributes`.  Using a formatter (if specified), it updates the attributes hash.
261 | 
262 | ```ruby
263 | class Chicken
264 |   has_value :gender
265 | end
266 | 
267 | >> c = Chicken.new
268 | 
269 | >> c.gender
270 | => nil
271 | 
272 | >> c.write_attribute(:gender, 'F')
273 | 
274 | >> c.gender
275 | => 'F'
276 | ```
277 | 
278 | ## Installation
279 | 
280 | if using `bundler`, add this to your `Gemfile`:
281 | 
282 | ```Gemfile
283 | gem 'valuable'
284 | ```
285 | 
286 | and the examples below should work.
287 | 
288 | ## Usage & Examples
289 | 
290 | ```ruby
291 | class Person < Valuable
292 |   has_value :name
293 |   has_value :age, :klass => :integer
294 |   has_value :phone_number, :klass => PhoneNumber
295 |           # see /examples/phone_number.rb
296 | end
297 | 
298 | params = 
299 | {
300 |   'person' =>
301 |   {
302 |     'name' => 'Mr. Freud',
303 |     'age' => "344",
304 |     'phone_number' => '8002195642',
305 |     'specialization_code' => "2106"
306 |   }
307 | }
308 | 
309 | >> p = Person.new(params[:person])
310 | 
311 | >> p.age
312 | => 344
313 | 
314 | >> p.phone_number
315 | => (337) 326-3121
316 | 
317 | >> p.phone_number.class
318 | => PhoneNumber
319 | ```
320 | 
321 | "Yeah, I could have just done that myself."
322 | 
323 | "Right, but now you don't have to."
324 | 
325 | 
326 | ### Constructor Accepts an Attributes Hash
327 | 
328 | ```ruby
329 | >> apple = Fruit.new(:name => 'Apple')
330 | 
331 | >> apple.name
332 | => 'Apple'
333 | 
334 | >> apple.vitamins
335 | => []
336 | ```
337 | 
338 | ### Default Values
339 | 
340 | Default values are... um... you know.
341 | 
342 | ```ruby
343 | class Developer
344 |   has_value :name
345 |   has_value :nickname, :default => 'mort'
346 | end
347 | 
348 | >> dev = Developer.new(:name => 'zk')
349 | 
350 | >> dev.name
351 | => 'zk'
352 | 
353 | >> dev.nickname
354 | => 'mort'
355 | ```
356 | 
357 | If there is no default value, the result will be `nil`, _EVEN_ if type casting is provided. Thus, a field typically cast as an `Integer` can be `nil`. See calculation of average example.
358 | 
359 | See also:
360 | + [nil values](#nil-values)
361 | + [Advanced Defaults](#advanced-defaults) 
362 | 
363 | **Note:** When a default value and a `klass` are specified, the default value will _NOT_ be cast to type `klass` -- you must do it. Example:
364 | 
365 | ```ruby
366 | class Person
367 | 
368 |   # WRONG!
369 |   has_value :dob, :klass => Date, :default => '2012-07-26'
370 | 
371 |   # Correct
372 |   has_value :dob, :klass => Date, :default => Date.parse('2012-07-26')
373 | 
374 | end
375 | ```
376 | 
377 | 
378 | ### Nil Values
379 | 
380 | Setting an attribute to `nil` always results in it being `nil`. [Default values](#default-values), [pre-defined formatters](#pre-defined-formatters), and [custom formatters](#registering-formatters) have no effect.
381 | 
382 | ```ruby
383 | class Account
384 |   has_value :logins, :klass => :integer, :default => 0
385 | end
386 | 
387 | >> Account.new(:logins => nil).loginx
388 | => nil 
389 | 
390 | # note this is not the same as
391 | >> nil.to_i
392 | => 0
393 | ```
394 | 
395 | ### Aliases
396 | 
397 | Set additional getters and setters. Useful when outside data sources have odd field names.
398 | 
399 | ```ruby
400 | # This example requires active_support because of Hash.from_xml
401 | 
402 | class Software < Valuable
403 |   has_value :name, :alias => 'Title'
404 | end
405 | 
406 | >> xml = 'Windows XP'
407 | 
408 | >> xp = Software.new(Hash.from_xml(xml)['software'])
409 | 
410 | >> xp.name
411 | => "Windows XP"
412 | ```
413 | 
414 | ### Formatting Input
415 | 
416 | The purpose of Valuable's attribute formatting is to ensure that a model's input is "corrected" and ready for use as soon as the class is instantiated. Valuable provides several formatters by default -- `:integer`, `:boolean`, and `:date` are a few of them. You can optionally write your own formatters -- see [Registering Formatters](#registering-formatters)
417 | 
418 | ```ruby
419 | class BaseballPlayer < Valuable
420 | 
421 |   has_value :at_bats, :klass => :integer
422 |   has_value :hits, :klass => :integer
423 | 
424 |   def average
425 |     hits/at_bats.to_f if hits && at_bats
426 |   end
427 | end
428 | 
429 | >> joe = BaseballPlayer.new(:hits => '5', :at_bats => '20', :on_drugs => '0' == '1')
430 | 
431 | >> joe.at_bats
432 | => 20
433 | 
434 | >> joe.average
435 | => 0.25
436 | ```
437 | 
438 | ### Pre-Defined Formatters
439 | 
440 | see also [Registering Formatters](#registering-formatters)
441 | - `integer` ( see [nil values](#nil-values) )
442 | - `decimal` ( casts to `BigDecimal`. see [nil values](#nil-values) )
443 | - `date`    ( see [nil values](#nil-values) )
444 | - `string`  
445 | - `boolean` ( NOTE: `'0'` casts to `false`... I'm not sure whether this is intuitive, but I would be fascinated to know when this is not the correct behavior. )
446 | - or any class ( formats as `SomeClass.new( ) unless value.is_a?( SomeClass )` )
447 | 
448 | ### Extending Values
449 | 
450 | As with `has_value`, you can do something like:
451 | 
452 | ```ruby
453 | module PirateTranslator
454 |   def to_pirate
455 |     "#{self} AAARRRRRGgghhhh!"
456 |   end
457 | end
458 | 
459 | class Envelope < Valuable
460 |   has_value :message, :extend => PirateTranslator
461 | end
462 | 
463 | >> Envelope.new(:message => 'contrived').message.to_pirate
464 | => "contrived AAARRRRRGgghhhh!"
465 | ```
466 | 
467 | ### Collections
468 | 
469 | ```ruby
470 | has_collection :codez
471 | ```
472 | 
473 | is similar to:
474 | 
475 | ```ruby
476 | has_value :codez, :default => []
477 | ```
478 | 
479 | except 
480 |   * it reads better
481 |   * that the formatter is applied to the collection's members, not (obviously) the collection. See [Formatting Collections](#formatting-collections) for more details.
482 | 
483 | ```ruby
484 | class MailingList < Valuable
485 |   has_collection :emails
486 |   has_collection :messages, :klass => BulkMessage
487 | end
488 | 
489 | >> m = MailingList.new
490 | 
491 | >> m.emails
492 | => []
493 | 
494 | >> m = MailingList.new(:emails => [ 'johnathon.e.wright@nasa.gov', 'other.people@wherever.com' ])
495 | 
496 | => m.emails
497 | >> [ 'johnathon.e.wright@nasa.gov', 'other.people@wherever.com' ]
498 | ```
499 | 
500 | ### Formatting Collections
501 | 
502 | If a `klass` is specified, members of the collection will be formatted appropriately:
503 | 
504 | ```ruby
505 | >> m.messages << "Houston, we have a problem"
506 | 
507 | >> m.messages.first.class
508 | => BulkMessage
509 | ```
510 | 
511 | see [Advanced Collection Formatting](#advanced-collection-formatting) for more complex examples.
512 | 
513 | ### Extending Collections
514 | 
515 | As with `has_value`, you can do something like:
516 | 
517 | ```ruby
518 | module PirateTranslator
519 |   def to_pirate
520 |     "#{self} AAARRRRRGgghhhh!"
521 |   end
522 | end
523 | 
524 | class Envelope < Valuable
525 |   has_value :message, :extend => PirateTranslator
526 | end
527 | 
528 | >> Envelope.new(:message => 'contrived').message.to_pirate
529 | => "contrived AAARRRRRGgghhhh!"
530 | ```
531 | 
532 | ### Registering Formatters
533 | 
534 | If the default formatters don't suit your needs, Valuable allows you to write your own formatting code via `register_formatter`. You can even override the predefined formatters simply by registering a formatter with the same name.
535 | 
536 | ```ruby
537 | # In honor of NASA's Curiosity rover, let's say you were modeling
538 | # a rover. Here's the valuable class:
539 | 
540 | class Rover < Valuable
541 |   has_value :orientation
542 | end
543 | 
544 | Sometimes orientation comes in as 'N', 'E', 'S' or 'W', sometimes it comes in as an orientation in degrees as a string ("92"), and sometimes it comes in as an integer. Let's create a formatter that makes sure everything is formatted in degrees. Notice that we're registering this formatter on Valuable, not on Rover. It will be available to every Valuable model.
545 | 
546 | Valuable.register_formatter(:orientation) do |value|
547 |   case value
548 |   when Numeric
549 |     value
550 |   when /^\d{1,3}$/
551 |     value.to_i
552 |   when 'N', 'North'
553 |     0
554 |   when 'E', 'East'
555 |     90
556 |   when 'S', 'South'
557 |     180
558 |   when 'W', 'West'
559 |     270
560 |   else
561 |     nil
562 |   end
563 | end
564 | ```
565 |  
566 | and then we update rover to use the new formatter:
567 | 
568 | ```ruby
569 | class Rover < Valuable
570 |   has_value :orientation, :klass => :orientation
571 | end
572 | 
573 | >> Rover.new(:orientation => 90).orientation
574 | => 90
575 | 
576 | >> Rover.new(:orientation => '282').orientation
577 | >> 282
578 | 
579 | >> Rover.new(:orientation => 'S').orientation
580 | => 180
581 | ```
582 | 
583 | ### More about Attributes
584 | 
585 | Access the attributes via the `attributes` hash. Only default and specified attributes will have entries here.
586 | 
587 | ```ruby
588 | class Person < Valuable
589 |   has_value :name
590 |   has_value :is_developer, :default => false
591 |   has_value :ssn
592 | end
593 | 
594 | >> elvis = Person.new(:name => 'The King')
595 | 
596 | >> elvis.attributes
597 | => {:name=>"The King", :is_developer=>false}
598 | 
599 | >> elvis.attributes[:name]
600 | => "The King"
601 | 
602 | >> elvis.ssn
603 | => nil
604 | 
605 | >> elvis.attributes.has_key?(:ssn)
606 | => false
607 | 
608 | >> elvis.ssn = '409-52-2002'  # allegedly
609 | 
610 | >> elvis.attributes[:ssn]
611 | => "409-52-2002"
612 | ```
613 | 
614 | You _can_ write directly to the `attributes` hash. As far as I know, Valuable will not care. However, formatters will not be applied.
615 | 
616 | Get a list of all the defined attributes from the class:
617 | 
618 | ```ruby
619 | >> Person.attributes
620 | => [:name, :is_developer, :ssn]
621 | ```
622 | 
623 | ### Advanced Input Parsing
624 | 
625 | When you specify a `klass`, Valuable will pass any input (that isn't already that `klass`) to the constructor. If you want to use a class-level method other than the constructor, pass the method name to `parse_with`. Perhaps it should have been called `construct_with`. :)
626 | 
627 | Default behavior:
628 | 
629 | ```ruby
630 | class Customer
631 |   has_value :payment_method, :klass => PaymentMethod
632 | end
633 | 
634 | # this will call PaymentMethod.new('1232123')
635 | Customer.new(:payment_method => '1232123')
636 | ```
637 | 
638 | using `parse_with`:
639 | 
640 | ```ruby
641 | require 'date'
642 | 
643 | class Person < Valuable
644 |   has_value :date_of_birth, :alias => :dob, :klass => Date, :parse_with => :parse
645 | 
646 |   def age_in_days
647 |     Date.today - dob
648 |   end
649 | end
650 | 
651 | >> sammy = Person.new(:dob => '2012-02-17')
652 | >> sammy.age_in_days
653 | => Rational(8, 1)
654 | ```
655 | 
656 | example using a lookup method:
657 | 
658 | ```ruby
659 | class Person < ActiveRecord::Base
660 |   def find_by_full_name( full_name )
661 |     #some finder code
662 |   end
663 | end
664 | 
665 | class Photograph < Valuable
666 |   has_value :photographer, :klass => Person
667 | end
668 | ```
669 | 
670 | use it to load associated data from an exising set...
671 | 
672 | ```ruby
673 | class Planet < Valuable
674 |   has_value :name
675 |   has_value :spaceport
676 | 
677 |   def Planet.list
678 |     @list ||= []
679 |   end
680 | 
681 |   def Planet.find_by_name( needle )
682 |     list.find{|i| i.name == needle }
683 |   end
684 | end
685 | 
686 | class Spaceship < Valuable
687 |   has_value :name
688 |   has_value :home, :klass => Planet, :parse_with => :find_by_name
689 | end
690 | 
691 | Planet.list << Planet.new(:name => 'Earth', :spaceport => 'KSC')
692 | Planet.list << Planet.new(:name => 'Mars', :spaceport => 'Olympus Mons')
693 | 
694 | >> vger = Spaceship.new( :name => "V'ger", :home => 'Earth')
695 | >> vger.home.spaceport
696 | => 'KSC'
697 | ```
698 | 
699 | You can also provide a lambda. This is similar to specifying a custom formatter, except that it only applies to this attribute and can not be re-used.
700 | 
701 | ```ruby
702 | require 'active_support'
703 | 
704 | class Movie < Valuable
705 |   has_value :title, :parse_with => lambda{|x| x.titleize}
706 | end
707 | 
708 | >> best_movie_ever = Movie.new(:title => 'the usual suspects')
709 | 
710 | >> best_movie_ever.title
711 | => "The Usual Suspects"
712 | ```
713 | 
714 | ### Advanced Defaults
715 | 
716 | The `:default` option will accept a lambda and call it on instantiation.
717 | 
718 | ```ruby
719 | class Borg < Valuable
720 |   cattr_accessor :count
721 |   has_value :position, :default => lambda { Borg.count += 1 }
722 | 
723 |   def designation
724 |     "#{self.position} of #{Borg.count}"
725 |   end
726 | end
727 | 
728 | >> Borg.count = 6
729 | >> seven = Borg.new
730 | >> Borg.count = 9
731 | >> seven.designation
732 | => '7 of 9'
733 | ```
734 | 
735 | **Caution** -- if you overwrite the constructor, you should call `initialize_attributes`. Otherwise, your default values won't be set up until the first time the `attributes` hash is called -- in theory, this could be well after initialization, and could cause unknowable gremlins. Trivial example:
736 | 
737 | ```ruby
738 | class Person
739 |   has_value :created_at, :default => lambda { Time.now }
740 | 
741 |   def initialize(atts)
742 |   end
743 | end
744 | 
745 | >> p = Person.new 
746 | >> # wait 10 minutes
747 | >> p.created_at == Time.now  # attributes initialized on first use
748 | => true
749 | ```
750 | 
751 | ### Advanced Collection Formatting
752 | 
753 | see [Collections](#collections) and [Formatting Collections](#formatting-collections) for basic examples. A more complex example involves nested Valuable models:
754 |         
755 | ```ruby
756 | class Team < Valuable
757 |   has_value :name
758 |   has_value :long_name
759 | 
760 |   has_collection :players, :klass => Player
761 | end
762 | 
763 | class Player < Valuable
764 |   has_value :first_name
765 |   has_value :last_name
766 |   has_value :salary
767 | end
768 | 
769 | t = Team.new(:name => 'Toronto', :long_name => 'The Toronto Blue Jays', 
770 |           'players' => [
771 |               {'first_name' => 'Chad', 'last_name' => 'Beck', :salary => 'n/a'},
772 |               {'first_name' => 'Shawn', 'last_name' => 'Camp', :salary => '2250000'},
773 |               {'first_name' => 'Brett', 'last_name' => 'Cecil', :salary => '443100'},
774 |               Player.new(:first_name => 'Travis', :last_name => 'Snider', :salary => '435800')
775 |             ])
776 | 
777 | >> t.players.first
778 | => #"n/a", :first_name=>"Chad", :last_name=>"Beck"}>
779 | 
780 | >> t.players.last
781 | => #"435800", :first_name=>"Travis", :last_name=>"Snider"}>
782 | ```
783 | 
784 | `parse_with` parses each item in a collection...
785 | 
786 | ```ruby
787 | class Roster < Valuable
788 |   has_collection :players, :klass => Player, :parse_with => :find_by_name
789 | end
790 | ```
791 | 
--------------------------------------------------------------------------------
/Rakefile:
--------------------------------------------------------------------------------
 1 | #!/usr/bin/env rake
 2 | 
 3 | require 'bundler'
 4 | Bundler::GemHelper.install_tasks
 5 | 
 6 | require 'rake'
 7 | require 'rake/testtask'
 8 | 
 9 | desc "Run unit tests"
10 | Rake::TestTask.new("test") { |t|
11 |   t.libs << 'test'
12 |   t.pattern = 'test/*.rb'
13 |   t.verbose = true
14 |   t.warning = true
15 | }
16 | 
17 | 
18 | desc 'Generate HTML readme file'
19 | task :readme do
20 |   `markdown README.markdown > README.html`
21 | end
22 | 
23 | desc 'clean temporary files, rdoc, and gem package'
24 | task :clean => [:clobber_package, :clobber_rdoc] do
25 |   temp_filenames = File.join('**', '*.*~')
26 |   temp_files = Dir.glob(temp_filenames)
27 | 
28 |   File.delete(*temp_files)
29 | end
30 | 
31 | task :default => [:test]
32 | 
--------------------------------------------------------------------------------
/docs/validations.markdown:
--------------------------------------------------------------------------------
 1 | Validations via ActiveModel::Validations
 2 | ========================================
 3 | 
 4 | Valuable doesn't support validations because other people are already doing that well. Here are examples of using the ActiveModel gem for validations:
 5 | 
 6 |       class Entity < Valuable
 7 |         include ActiveModel::Validations
 8 |       
 9 |         has_value :name
10 |         has_value :avatar
11 |       
12 |         validates_presence_of :name
13 |         validates_presence_of :avatar
14 |       end
15 |       
16 |       >> entity = Entity.new(:name => 'Crystaline Entity')
17 |       
18 |       >> entity.valid?
19 |       => false
20 |       
21 |       >> entity.errors.full_messages
22 |       => ["Avatar can't be blank"]
23 | 
24 | Example using validators
25 | ------------------------
26 | 
27 | less talk; more code:
28 | 
29 |       class BorgValidator < ActiveModel::Validator
30 |         def validate( entity )
31 |           if( entity.name.to_s == "" )
32 |             entity.errors[:name] << 'is blank and will be assimilated.'
33 |           elsif( entity.name !~ /(\d+) of (\d+)/ )
34 |             entity.errors[:name] << 'does not conform and will be assimilated.'
35 |           end
36 |         end
37 |       end
38 |       
39 |       class Entity < Valuable
40 |         include ActiveModel::Validations
41 |         validates_with BorgValidator
42 |       
43 |         has_value :name
44 |       
45 |         validates_presence_of :name
46 |       end
47 |       
48 |       >> hugh = Entity.new(:name => 'Hugh')
49 |       
50 |       >> hugh.valid?
51 |       => false
52 |       
53 |       >> hugh.errors.full_messages
54 |       => ["Name does not conform and will be assimilated"]
55 |       
56 |       >> high = Entity.new(:name => '3 of 7')
57 |       
58 |       >> hugh.valid?
59 |       => true
60 | 
61 | 
--------------------------------------------------------------------------------
/examples/baseball.rb:
--------------------------------------------------------------------------------
 1 | class Jersey < String
 2 |   def initialize(object)
 3 |     super "Jersey Number #{object})"
 4 |   end
 5 | end
 6 | 
 7 | class BaseballPlayer < Valuable
 8 | 
 9 |   has_value :at_bats, :klass => Integer
10 |   has_value :hits, :klass => Integer
11 |   has_value :league, :default => 'unknown'
12 |   has_value :name
13 |   has_value :jersey, :klass => Jersey, :default => 'Unknown'
14 |   has_value :active, :klass => Boolean
15 |   
16 |   has_collection :teammates
17 | 
18 |   def average
19 |     hits/at_bats.to_f if hits && at_bats
20 |   end
21 | end
22 | 
--------------------------------------------------------------------------------
/examples/person.rb:
--------------------------------------------------------------------------------
1 | require File.dirname(__FILE__) + '/phone_number'
2 | 
3 | class Person < Valuable
4 |   has_value :name
5 |   has_value :age, :klass => :integer
6 |   has_value :phone_number, :klass => PhoneNumber
7 | end
8 | 
9 | 
--------------------------------------------------------------------------------
/examples/phone_number.rb:
--------------------------------------------------------------------------------
 1 | class PhoneNumber < String
 2 |   def initialize(value)
 3 |     super(value.to_s)
 4 |   end
 5 | 
 6 |   def valid?
 7 |     has_ten_digits?  
 8 |   end
 9 |   
10 |   def has_ten_digits?
11 |     self =~ /\d{9}/
12 |   end
13 | 
14 |   def inspect
15 |     self.to_s
16 |   end
17 |   
18 |   def to_s
19 |     "(#{self[0..2]}) #{self[3..5]}-#{self[6..9]}" if valid?
20 |   end
21 | end
22 | 
--------------------------------------------------------------------------------
/examples/rails_presenter.rb:
--------------------------------------------------------------------------------
 1 | #  This class might appear in a controller like this:
 2 | #  
 3 | #  class CalendarController < ApplicationController
 4 | #    def show
 5 | #      @presenter = CalendarPresenter.new(params[:calendar])
 6 | #    end
 7 | #  end
 8 | #
 9 | #  but in documentation, makes more sense this way :)  
10 | #  
11 | #  >> @presenter = CalendarPresenter.new # first pageload
12 | #  
13 | #  >> @presenter.start_date
14 | #  => Tue, 01 Dec 2009
15 | #  
16 | #  >> @presenter.end_date
17 | #  => Thu, 31 Dec 2009
18 | #  
19 | #  >> # User selects some other month and year; the next request looks like...
20 | #  
21 | #  >> @presenter = CalendarPresenter.new({:month => '2', :year => '2002'})
22 | #  
23 | #  >> @presenter.start_date
24 | #  => Fri, 01 Feb 2002
25 | #  
26 | #  >> @presenter.end_date
27 | #  => Thu, 28 Feb 2002
28 | #  
29 | #  ...
30 | #  
31 | class CalenderPresenter < Valuable
32 |   has_value :month, :klass => Integer, :default => Time.now.month
33 |   has_value :year, :klass => Integer, :default => Time.now.year
34 | 
35 |   def start_date
36 |     Date.civil( year, month, 1)
37 |   end
38 | 
39 |   def end_date
40 |     Date.civil( year, month, -1) #strange I know
41 |   end
42 | 
43 |   def events
44 |     Event.find(:all, :conditions => event_conditions)
45 |   end
46 | 
47 |   def event_conditions
48 |     ['starts_at between ? and ?', start_date, end_date]
49 |   end
50 | end
51 | 
52 | 
--------------------------------------------------------------------------------
/examples/search-medium.rb:
--------------------------------------------------------------------------------
 1 | class CustomerSearch < Valuable
 2 |   # BE AWARE OF SQL INJECTION!!!
 3 | 
 4 |   has_value :last_name
 5 |   has_value :first_name
 6 |   has_value :zipcode
 7 |   has_value :partner_id, :klass => :integer
 8 | 
 9 |   def terms
10 |     # With truly simple cases, you can just use `attributes` for this
11 | 
12 |     terms = {}
13 |     terms[:zipcode] = self.zipcode
14 |     terms[:last_name_like] = "%#{self.last_name}%" if self.last_name
15 |     terms[:first_name_like] = "%#{self.first_name}%" if self.first_name
16 |     terms[:partner_id] = self.partner_id if self.partner_id
17 |     terms
18 |   end
19 | 
20 |   def joins
21 |     out = []
22 |     out << [:location] if self.zipcode
23 |     out << [:identifiers] if self.partner_id
24 |     out
25 |   end
26 | 
27 |   def conditions
28 |     out = []
29 | 
30 |     unless self.last_name.blank?
31 |       out << "customers.last_name like :last_name_like"
32 |     end
33 | 
34 |     unless self.first_name.blank?
35 |       out << "customers.first_name like :first_name_like";
36 |     end
37 | 
38 |     unless self.zipcode.blank?
39 |       out << "locations.zipcode = :zipcode"
40 |     end
41 | 
42 |     unless self.partner_id.blank?
43 |       out << "customer_identifiers.partner_id = :partner_id"
44 |     end
45 | 
46 |     if( out.not.empty? )
47 |       [out.join(' and '), terms]
48 |     else
49 |       nil
50 |     end
51 |   end
52 | 
53 |   def results
54 |     Customer.joins(joins).where(conditions).includes([:location]).order('customers.id
55 |  desc')
56 |   end
57 | end
58 | 
--------------------------------------------------------------------------------
/examples/search-simple.rb:
--------------------------------------------------------------------------------
 1 | class CustomerHistorySearch < Valuable
 2 |   has_value :customer_id, klass: :integer
 3 |   has_value :client_id, klass: :integer
 4 | 
 5 |   def results
 6 |     if client_id && customer_id
 7 |       (
 8 |         ServiceOrder.where(
 9 |            customer_id: customer_id,
10 |            client_id: client_id
11 |         ) + 
12 |         SalesOrder.where(
13 |           customer_id: customer_id,
14 |           client_id: client_id
15 |         )
16 |       ).sort_by(&:created_at)
17 |     elsif customer_id
18 |       (
19 |         ServiceOrder.where(
20 |           customer_id: customer_id
21 |         ) +
22 |         PreQ.where(
23 |           customer_id: customer_id
24 |         )
25 |       ).sort_by(&:created_at)
26 |     end
27 |   end
28 | end
29 | 
30 | 
--------------------------------------------------------------------------------
/lib/valuable.rb:
--------------------------------------------------------------------------------
  1 | # Valuable is the class from which all classes (who are so inclined)
  2 | # should inherit.
  3 | #
  4 | # ==Example:
  5 | # 
  6 | #   class Bus < Valuable
  7 | #     
  8 | #     has_value :number, :klass => :integer
  9 | #     has_value :color, :default => 'yellow'
 10 | #     has_collection :riders, :alias => 'Passengers'
 11 | #   
 12 | #   end
 13 | #
 14 | #   >> Bus.attributes
 15 | #   => [:number, :color, :riders]
 16 | #   >> bus = Bus.new(:number => '3', :Passengers => ['GOF', 'Fowler', 'Mort']
 17 | #   >> bus.attributes
 18 | #   => {:number => 3, :riders => ['GOF', 'Fowler', 'Mort'], :color => 'yellow'}
 19 | #   
 20 | class Valuable
 21 | 
 22 |   # Returns a Hash representing all known values. Values are set four ways:
 23 |   # 
 24 |   #   (1) Default values are set on instanciation, ie Person.new
 25 |   #   (2) they were passed to the constructor
 26 |   #          Bus.new(:color => 'green')
 27 |   #   (3) they were set via their namesake setter or alias setter
 28 |   #          bus.color = 'green'
 29 |   #          bus.Passengers = ['bill', 'steve']
 30 |   #   (4) the write_attributes(key, value) method
 31 |   # 
 32 |   # Values that have not been set and have no default not appear in this
 33 |   # collection. Their namesake attribute methods will respond with nil.
 34 |   # Always use symbols to access these values, ie:
 35 |   #   Person.attributes[:color]
 36 |   # not
 37 |   #   Person.attributes['color']
 38 |   #
 39 |   # basic usage: 
 40 |   #   >> bus = Bus.new(:number => 16) # color has default value 'yellow'
 41 |   #   >> bus.attributes
 42 |   #   => {:color => 'yellow', :number => 16}
 43 |   def attributes
 44 |     @attributes ||= Valuable::Utils.initial_copy_of_attributes(self.class.defaults) 
 45 |   end
 46 |   alias_method :initialize_attributes, :attributes 
 47 |   # alias is for readability in constructor
 48 | 
 49 |   # accepts an optional hash that will be used to populate the 
 50 |   # predefined attributes for this class.
 51 |   #
 52 |   # Note: You are free to overwrite the constructor, but you should call
 53 |   # initialize_attributes OR make sure at least one value is stored.
 54 |   def initialize(atts = nil)
 55 |     initialize_attributes
 56 |     self.update_attributes(atts || {})
 57 |   end
 58 | 
 59 |   # mass assign attributes. This method will not clear any existing attributes.
 60 |   #
 61 |   # class Shoe
 62 |   #   has_value :size
 63 |   #   has_value :owner
 64 |   #   has_value :color, :default => 'red'
 65 |   #
 66 |   #   def big_feet?
 67 |   #     size && size > 15
 68 |   #   end
 69 |   # end
 70 |   #
 71 |   # >> shoe = Shoe.new
 72 |   # >> shoe.update_attributes(:size => 16, :owner => 'MJ')
 73 |   # >> shoe.attributes
 74 |   # => {:size => 16, :owner => 'MJ', :color => 'red'}
 75 |   #
 76 |   # can be method-chained
 77 |   #
 78 |   # >> Shoe.new.update_attributes(:size => 16).big_feet?
 79 |   # => true
 80 |   def update_attributes(atts)
 81 |     atts.each{|name, value| __send__("#{name}=", value )}
 82 |     self
 83 |   end
 84 | 
 85 |   def permissive?
 86 |     self.class.permissive_constructor?
 87 |   end
 88 | 
 89 |   def method_missing(method_name, *args)
 90 |     if method_name.to_s =~ /(\w+)=/
 91 |       raise( ArgumentError, "#{self.class.to_s} does not have an attribute or alias '#{$1}'", caller) unless self.permissive?
 92 |     else
 93 |       super 
 94 |     end
 95 |   end
 96 | 
 97 |   def write_attribute(name, value)
 98 |     attribute = Valuable::Utils.find_attribute_for( name, self.class._attributes )
 99 | 
100 |     if attribute
101 |       self.attributes[attribute] = Valuable::Utils.format(attribute, value, self.class._attributes) 
102 |     else
103 |       raise( ArgumentError, "#{self.class.to_s} does not have an attribute or alias '#{name}'", caller) unless self.permissive?
104 |     end
105 |   end
106 | 
107 |   class << self
108 | 
109 |     # Returns an array of the attributes available on this object.
110 |     def attributes
111 |       _attributes.keys
112 |     end 
113 | 
114 |     def _attributes
115 |       @_attributes ||= {}
116 |     end
117 | 
118 |     # Returns a name/value set of the values that will be used on
119 |     # instanciation unless new values are provided.
120 |     #
121 |     #   >> Bus.defaults
122 |     #   => {:color => 'yellow'}
123 |     def defaults
124 |       out = {}
125 |       _attributes.each{|n, atts| out[n] = atts[:default] unless atts[:default].nil?}
126 |       out
127 |     end 
128 | 
129 |     # Decorator method that lets you specify the attributes for your
130 |     # model. It accepts an attribute name (a symbol) and an options 
131 |     # hash. Valid options are :default, :klass and (when :klass is 
132 |     # Boolean) :negative.
133 |     #
134 |     #   :default - for the given attribute, use this value if no other
135 |     #   is provided.
136 |     #
137 |     #   :klass - light weight type casting. Use :integer, :string or
138 |     #   :boolean. Alternately, supply a class. 
139 |     #
140 |     #   :alias - creates an alias for getter and setter with the new name.
141 |     #
142 |     # When a :klassified attribute is set to some new value, if the value
143 |     # is not nil and is not already of that class, the value will be cast
144 |     # to the specified klass. In the case of :integer, it wil be done via
145 |     # .to_i. In the case of a random other class, it will be done via
146 |     # Class.new(value). If the value is nil, it will not be cast.
147 |     #
148 |     # A good example: PhoneNumber < String is useful if you
149 |     # want numbers to come out the other end properly formatted, when your
150 |     # input may come in as an integer, or string without formatting, or
151 |     # string with bad formatting.
152 |     # 
153 |     # IMPORTANT EXCEPTION
154 |     #
155 |     # Due to the way Rails handles checkboxes, '0' resolves to FALSE,
156 |     # though it would normally resolve to TRUE.
157 |     def has_value(name, options={})
158 |       Valuable::Utils.check_options_validity(self.class.name, name, options)
159 | 
160 |       options[:extend] = [options[:extend]].flatten.compact
161 |       options[:allow_blank] = options.has_key?(:allow_blank) ? options[:allow_blank] : true
162 | 
163 |       name = name.to_sym
164 |       _attributes[name] = options 
165 |      
166 |       create_accessor_for(name, options[:extend])
167 | 
168 |       create_question_for(name) if options[:klass] == :boolean
169 |       create_negative_question_for(name, options[:negative]) if options[:klass] == :boolean && options[:negative]
170 |       
171 |       create_setter_for(name, allow_blank: options[:allow_blank] )
172 | 
173 |       sudo_alias options[:alias], name if options[:alias]
174 |       sudo_alias "#{options[:alias]}=", "#{name}=" if options[:alias]
175 |     end
176 | 
177 |     # Creates the method that sets the value of an attribute.
178 |     # The setter calls write_attribute, which handles typicification.
179 |     # It is called by the constructor (rather than using
180 |     # write attribute, which would render any custom setters
181 |     # ineffective.)
182 |     #
183 |     # Setting values via the attributes hash avoids typification,
184 |     # ie:
185 |     # >> player.phone = "8778675309"
186 |     # >> player.phone
187 |     # => "(877) 867-5309"
188 |     #
189 |     # >> player.attributes[:phone] = "8778675309"
190 |     # >> player.phone
191 |     # => "8778675309"
192 |     def create_setter_for(attribute, options)
193 |       setter_method = "#{attribute}="
194 | 
195 |       define_method setter_method do |value|
196 |         if options[:allow_blank] || value != ""
197 |           write_attribute(attribute, value)
198 |         end
199 |       end
200 |     end
201 | 
202 |     def sudo_alias( alias_name, method_name )
203 |       define_method alias_name do |*atts|
204 |         send(method_name, *atts)
205 |       end
206 |     end
207 | 
208 |     # creates an accessor method named after the 
209 |     # attribute... can be used as a chained setter, 
210 |     # as in:
211 |     #
212 |     #     whitehouse.windows(5).doors(4).oval_rooms(1)
213 |     #
214 |     # If NOT used as a setter, returns the value,
215 |     # extended by the modules listed in the second 
216 |     # parameter.
217 |     def create_accessor_for(name, extensions)
218 |       define_method name do |*args|
219 |         if args.length == 0
220 |           attributes[name].tap do |out|
221 |             extensions.each do |extension|
222 |               out.extend( extension )
223 |             end
224 |           end
225 |         else
226 |           send("#{name}=", *args)
227 |           self
228 |         end 
229 |       end
230 |     end
231 | 
232 |     # In addition to the normal getter and setter, boolean attributes
233 |     # get a method appended with a ?.
234 |     #
235 |     #   class Player < Valuable
236 |     #     has_value :free_agent, :klass => Boolean
237 |     #   end
238 |     #
239 |     #   juan = Player.new(:free_agent => true)
240 |     #   >> juan.free_agent?
241 |     #   => true
242 |     def create_question_for(name)
243 |       define_method "#{name}?" do
244 |         attributes[name]
245 |       end
246 |     end
247 | 
248 |     # In some situations, the opposite of a value may be just as interesting.
249 |     #
250 |     #   class Coder < Valuable
251 |     #     has_value :agilist, :klass => Boolean, :negative => :waterfaller
252 |     #   end
253 |     #
254 |     #   monkey = Coder.new(:agilist => false)
255 |     #   >> monkey.waterfaller?
256 |     #   => true
257 |     def create_negative_question_for(name, negative)
258 |       define_method "#{negative}?" do
259 |         !attributes[name]
260 |       end
261 |     end
262 | 
263 |     # this is a more intuitive way of marking an attribute as holding a
264 |     # collection. 
265 |     #
266 |     #   class Bus < Valuable
267 |     #     has_value :riders, :default => [] # meh...
268 |     #     has_collection :riders # better!
269 |     #   end
270 |     #
271 |     #   >> bus = Bus.new
272 |     #   >> bus.riders << 'jack'
273 |     #   >> bus.riders
274 |     #   => ['jack']
275 |     #
276 |     #   class Person
277 |     #     has_collection :phone_numbers, :klass => PhoneNumber
278 |     #   end
279 |     #
280 |     #   >> jenny = Person.new(:phone_numbers => ['8008675309'] )
281 |     #   >> jenny.phone_numbers.first.class
282 |     #   => PhoneNumber
283 |     def has_collection(name, options = {})
284 |       Utils.check_options_validity( self.class.name, name, options)
285 |       name = name.to_sym
286 |       options[:item_klass] = options[:klass] if options[:klass]
287 |       options[:klass] = :collection
288 |       options[:default] ||= []
289 |       options[:extend] = [options[:extend]].flatten.compact
290 | 
291 |       _attributes[name] = options 
292 |       
293 |       create_accessor_for(name, options[:extend])
294 |       create_setter_for(name, allow_blank: false)
295 | 
296 |       sudo_alias options[:alias], name if options[:alias]
297 |       sudo_alias "#{options[:alias]}=", "#{name}=" if options[:alias]
298 |     end 
299 | 
300 |     # Register custom formatters. Not happy with the default behavior?
301 |     # Custom formatters override all pre-defined formatters. However,
302 |     # remember that formatters are defined globally, rather than 
303 |     # per-class.
304 |     #
305 |     # Valuable.register_formatter(:orientation) do |value|
306 |     #   case value
307 |     #   case Numeric
308 |     #     value
309 |     #   when 'N', 'North'
310 |     #     0 
311 |     #   when 'E', 'East'
312 |     #     90 
313 |     #   when 'S', 'South'
314 |     #     180 
315 |     #   when 'W', 'West'
316 |     #     270 
317 |     #   else
318 |     #     nil 
319 |     #   end
320 |     # end
321 |     #
322 |     # class MarsRover < Valuable
323 |     #   has_value :orientation, :klass => :orientation
324 |     # end
325 |     # 
326 |     # >> curiosity = MarsRover.new(:orientation => 'S')
327 |     # >> curiosity.orientation
328 |     # => 180
329 |     def register_formatter(name, &block)
330 |       Valuable::Utils.formatters[name] = block
331 |     end
332 | 
333 | 
334 |     # Instructs the class NOT to complain if any attributes are set
335 |     # that haven't been declared.
336 |     #
337 |     # class Sphere < Valuable
338 |     #   has_value :material
339 |     # end
340 |     #
341 |     # >> Sphere.new(:radius => 3, :material => 'water')
342 |     # EXCEPTION! OH NOS!
343 |     #
344 |     # class Box < Valuable
345 |     #   acts_as_permissive
346 |     #
347 |     #   has_value :material
348 |     # end
349 |     #
350 |     # >> box = Box.new(:material => 'wood', :size => '36 x 40')
351 |     # >> box.attributes
352 |     # => {:material => 'wood'}
353 |     def acts_as_permissive
354 |       self.permissive_constructor=true
355 |     end
356 | 
357 |     def permissive_constructor=(value)
358 |       @_permissive_constructor = value
359 |     end
360 | 
361 |     def permissive_constructor?
362 |       !!(@_permissive_constructor ||= false)
363 |     end
364 | 
365 |     private
366 | 
367 |     def inherited(child)
368 |       _attributes.each {|n, atts| child._attributes[n] = atts }
369 |     end
370 |   end
371 | end
372 | 
373 | require 'valuable/utils'
374 | 
--------------------------------------------------------------------------------
/lib/valuable/utils.rb:
--------------------------------------------------------------------------------
  1 | # Trying to extract as much logic as possible to minimize the memory
  2 | # footprint of individual instances. Feedback welcome.
  3 | require 'bigdecimal'
  4 | require 'date'
  5 | 
  6 | module Valuable::Utils
  7 |   class << self
  8 | 
  9 |     def find_attribute_for( name, attributes )
 10 |       name = name.to_sym
 11 | 
 12 |       if attributes.keys.include?( name )
 13 |         name
 14 |       elsif found=attributes.find{|n, v| v[:alias].to_sym == name }
 15 |         found[0]
 16 |       end
 17 |     end
 18 | 
 19 |     def initial_copy_of_attributes(atts)
 20 |       out = {}
 21 |       atts.each do |name, value|
 22 |         case value
 23 |         when Proc
 24 |           out[name] = value.call
 25 |         else
 26 |           out[name] = deep_duplicate_of( value )
 27 |         end
 28 |       end
 29 | 
 30 |       out
 31 |     end
 32 | 
 33 |     def deep_duplicate_of(value)
 34 |       Marshal.load(Marshal.dump(value))
 35 |     end
 36 | 
 37 |     def format( name, value, attributes, collection_item = false )
 38 |       klass = collection_item ? attributes[name][:item_klass] : attributes[name][:klass]
 39 | 
 40 |       case klass
 41 |       when *formatters.keys
 42 |         formatters[klass].call(value)
 43 | 
 44 |       when NilClass
 45 | 
 46 |         if Proc === attributes[name][:parse_with]
 47 |           attributes[name][:parse_with].call(value)
 48 |         else
 49 |           value
 50 |         end
 51 | 
 52 |       when :collection
 53 |         value.map do |item|
 54 |           Valuable::Utils.format( name, item, attributes, true )
 55 |         end
 56 | 
 57 |       when :date
 58 | 
 59 |         case value.class.to_s
 60 |         when "Date"
 61 |           value
 62 |         when "ActiveSupport::TimeWithZone", "Time", "DateTime"
 63 |           value.to_date
 64 |         when "String"
 65 |           value && begin; Date.parse(value); rescue; end
 66 |         else
 67 |           value
 68 |         end
 69 | 
 70 |       when :integer
 71 | 
 72 |         value.to_i if value && value.to_s =~ /^\d{1,}$/
 73 | 
 74 |       when :decimal
 75 | 
 76 |         case value
 77 |         when NilClass
 78 |           nil
 79 |         when BigDecimal
 80 |           value
 81 |         else
 82 |           BigDecimal.new( value.to_s )
 83 |         end
 84 | 
 85 |       when :string
 86 | 
 87 |         value && value.to_s
 88 | 
 89 |       when :boolean
 90 | 
 91 |         value == '0' ? false : !!value
 92 | 
 93 |       else
 94 | 
 95 |         if value.nil?
 96 |           nil
 97 |         elsif value.is_a? klass
 98 |           value
 99 |         elsif Proc === attributes[name][:parse_with]
100 |           attributes[name][:parse_with].call(value)
101 |         else
102 |           klass.send( attributes[name][:parse_with] || :new, value)
103 |         end
104 | 
105 |       end unless value.nil?
106 | 
107 |     end
108 | 
109 |     def formatters
110 |       @formatters ||= {}
111 |     end
112 | 
113 |     def klass_options
114 |       [NilClass, :integer, Class, :date, :decimal, :string, :boolean] + formatters.keys
115 |     end
116 | 
117 |     def known_options
118 |       [:klass, :default, :negative, :alias, :parse_with, :extend, :allow_blank]
119 |     end
120 | 
121 |     def can_be_duplicated?( item )
122 |       Marshal.dump(item)
123 |       true
124 |     rescue
125 |       false
126 |     end
127 | 
128 |     # this helper raises an exception if the options passed to has_value
129 |     # are wrong. Mostly written because I occasionally used :class instead
130 |     # of :klass and, being a moron, wasted time trying to find the issue.
131 |     def check_options_validity( class_name, attribute, options )
132 |       invalid_options = options.keys - known_options
133 | 
134 |       raise ArgumentError, "#{class_name}##{attribute} has a default value that must be set using a lambda. Use :default => lambda { Thing.new }." if options[:default] && !options[:default].kind_of?(Proc) && !can_be_duplicated?( options[:default] )
135 | 
136 |       raise ArgumentError, "has_value did not know how to respond to option(s) #{invalid_options.join(', ')}. Valid (optional) arguments are: #{known_options.join(', ')}" unless invalid_options.empty?
137 | 
138 |       raise ArgumentError, "#{class_name} doesn't know how to format #{attribute} with :klass => #{options[:klass].inspect}" unless klass_options.any?{|klass| klass === options[:klass]}
139 | 
140 |       raise( ArgumentError, "#{class_name} can't promise to return a(n) #{options[:klass]} when using :parse_with" ) if options[:klass].is_a?( Symbol ) && options[:parse_with]
141 |     end
142 |   end
143 | end
144 | 
145 | 
--------------------------------------------------------------------------------
/test/alias_test.rb:
--------------------------------------------------------------------------------
 1 | $: << File.expand_path(File.dirname(__FILE__) + '/../lib')
 2 | 
 3 | require 'test/unit'
 4 | require 'valuable.rb'
 5 | 
 6 | class Software < Valuable
 7 |   has_value :name, :alias => :title
 8 |   has_value :enterprise_namespace, :alias => 'EnterpriseNamespace'
 9 | end
10 | 
11 | class BackwardDay < Valuable
12 |   has_value :name, :alias => 'nickname'
13 |   has_value :crazies, :alias => 'funkitated'
14 | 
15 |   def name=(value)
16 |     attributes[:name] = value.reverse
17 |   end
18 | 
19 |   def crazies=(value, value2)
20 |     attributes[:crazies] = "#{value2.reverse} #{value1.reverse}"
21 |   end
22 | end
23 | 
24 | class AliasTest < Test::Unit::TestCase
25 | 
26 |   def test_that_values_can_be_set_using_their_alias
27 |     software = Software.new(:title => 'PostIt')
28 |     assert_equal 'PostIt', software.name
29 |   end
30 | 
31 |   def test_that_aliases_can_be_strings
32 |     software = Software.new('EnterpriseNamespace' => 'Enterprisey')
33 |     assert_equal 'Enterprisey', software.enterprise_namespace
34 |   end
35 | 
36 |   def test_that_aliases_work_for_getters
37 |     software = Software.new(:title => 'ObtrusiveJavascriptComponent')
38 |     assert_equal 'ObtrusiveJavascriptComponent', software.name
39 |   end
40 | 
41 |   def test_that_overridden_setters_are_not_overlooked
42 |     assert_equal 'rabuf', BackwardDay.new(:nickname => 'fubar').name
43 |   end
44 | end
45 | 
46 | 
--------------------------------------------------------------------------------
/test/bad_attributes_test.rb:
--------------------------------------------------------------------------------
 1 | $: << File.expand_path(File.dirname(__FILE__) + '/../lib')
 2 | 
 3 | require 'test/unit'
 4 | require 'valuable.rb'
 5 | 
 6 | class Infrastructure < Valuable
 7 | end
 8 | 
 9 | class BadAttributesTest < Test::Unit::TestCase
10 | 
11 |   def test_that_has_value_grumbles_when_it_gets_bad_attributes
12 |     assert_raises ArgumentError do
13 |       Infrastructure.has_value :fu, :invalid => 'shut your mouth'
14 |     end
15 |   end
16 | 
17 |   def test_that_valid_arguments_cause_no_grumbling
18 |     assert_nothing_raised do
19 |       Infrastructure.has_value :bar, :klass => Integer
20 |     end
21 |   end
22 | 
23 |   def test_that_invalid_attributes_raise
24 |     assert_raises ArgumentError do
25 |       model = Class.new(Valuable)
26 |       model.new(:invalid => 'should not be allowed')
27 |     end
28 |   end
29 | 
30 |   def test_that_invalid_attributes_can_be_ignored
31 |     assert_nothing_raised do
32 |       model = Class.new(Valuable) do
33 |         acts_as_permissive 
34 |       end
35 |       model.new(:invalid => 'should be ignored')
36 |     end
37 |   end
38 | 
39 |   def test_that_we_provide_a_better_error_when_objects_can_not_be_marhsaled
40 |     assert_raises ArgumentError do
41 |       Class.new(Valuable) do
42 |         has_value :invalid, :default => StringIO.new
43 |       end
44 |     end
45 |   end
46 | 
47 |   def test_that_Strings_are_not_numbers
48 |     player = Class.new(Valuable) do
49 |       has_value :number, :klass => :integer
50 |     end
51 | 
52 |     assert_equal nil, player.new(number: 'abc').number
53 |   end
54 | end
55 | 
56 | 
--------------------------------------------------------------------------------
/test/collection_test.rb:
--------------------------------------------------------------------------------
 1 | $: << File.expand_path(File.dirname(__FILE__) + '/../lib')
 2 | 
 3 | require 'test/unit'
 4 | require 'valuable.rb'
 5 | require 'mocha/setup'
 6 | 
 7 | class Album < Valuable
 8 |   has_collection :concepts, default: -> {['a', 'b', 'c']}
 9 | end
10 | 
11 | class BaseTest < Test::Unit::TestCase
12 |   def test_that_collection_can_have_a_default_value
13 |     album = Album.new
14 |     assert_equal ['a', 'b', 'c'], album.concepts
15 |   end
16 | end
17 | 
--------------------------------------------------------------------------------
/test/custom_formatter_test.rb:
--------------------------------------------------------------------------------
 1 | $: << File.expand_path(File.dirname(__FILE__) + '/../lib')
 2 | 
 3 | require 'test/unit'
 4 | require 'valuable.rb'
 5 | 
 6 | Valuable.register_formatter(:point) do |latitude, longitude|
 7 |   :perfect
 8 | end
 9 | 
10 | Valuable.register_formatter(:temperature) do |input|
11 |   if input.nil?
12 |     'unknown'
13 |   else
14 |     'very hot'
15 |   end
16 | end
17 | 
18 | class MarsLander < Valuable 
19 |   has_value :position, :klass => :point
20 |   has_value :core_temperature, :klass => :temperature
21 | end
22 | 
23 | class CustomFormatterTest < Test::Unit::TestCase
24 | 
25 |   def test_that_formatter_keys_are_added_to_the_klass_options_list
26 |     assert Valuable::Utils.klass_options.include?( :point )
27 |   end
28 |  
29 |   def test_that_custom_formatters_are_used_to_set_attributes
30 |     expected = :perfect
31 |     actual = MarsLander.new(:position => [10, 20]).position
32 |     assert_equal expected, actual
33 |   end
34 | 
35 |   def test_that_nil_values_are_not_passed_to_custom_formatter
36 |     expected = nil
37 |     actual = MarsLander.new(:core_temperature => nil).core_temperature
38 |     assert_equal expected, actual
39 |   end
40 | end
41 | 
42 | 
--------------------------------------------------------------------------------
/test/custom_initializer_test.rb:
--------------------------------------------------------------------------------
 1 | $: << File.expand_path(File.dirname(__FILE__) + '/../lib')
 2 | 
 3 | require 'rubygems'
 4 | require 'test/unit'
 5 | require 'valuable.rb'
 6 | require 'mocha/setup'
 7 | 
 8 | class Person < Valuable
 9 |   has_value :first_name
10 |   has_value :last_name
11 | 
12 |   def initialize(atts={})
13 |     self.first_name = "Joe"
14 |     super(atts)
15 |   end
16 | end
17 | 
18 | class ParseWithTest < Test::Unit::TestCase
19 |  
20 |   def test_that_attributes_are_accessible_in_custom_constructor
21 |     assert_nothing_raised do
22 |       Person.new(:last_name => 'Smith')
23 |     end
24 |   end
25 | end
26 | 
27 | 
--------------------------------------------------------------------------------
/test/default_values_from_anon_methods.rb:
--------------------------------------------------------------------------------
 1 | $: << File.expand_path(File.dirname(__FILE__) + '/../lib')
 2 | 
 3 | require 'rubygems'
 4 | require 'test/unit'
 5 | require 'valuable.rb'
 6 | 
 7 | class Borg < Valuable
 8 |   class << self
 9 |     attr_accessor :count
10 |   end
11 |   has_value :position, :default => lambda { Borg.count += 1 } 
12 |   has_value :name
13 | 
14 |   def designation
15 |     "#{self.position} of #{Borg.count}"
16 |   end
17 | end
18 | 
19 | class DefaultValueFromAnonMethodsTest < Test::Unit::TestCase
20 | 
21 |   def test_that_children_inherit_their_parents_attributes
22 |     Borg.count = 6
23 |     seven = Borg.new
24 |     Borg.count = 9
25 |     assert_equal '7 of 9', seven.designation  
26 |   end	  
27 | 
28 | end
29 | 
30 | 
--------------------------------------------------------------------------------
/test/deprecated_test.rb:
--------------------------------------------------------------------------------
 1 | $: << File.expand_path(File.dirname(__FILE__) + '/../lib')
 2 | 
 3 | require 'test/unit'
 4 | require 'valuable.rb'
 5 | require 'mocha/setup'
 6 | 
 7 | class Signature < String
 8 | end
 9 | 
10 | class Cube < String
11 |   def initialize(number)
12 |     super "Lives in Cube #{number}"
13 |   end
14 | end
15 | 
16 | class DevCertifications < Valuable
17 |   has_value :a_plus, :default => false
18 |   has_value :mcts, :default => false
19 |   has_value :hash_rocket, :default => false
20 | end
21 | 
22 | class Dev < Valuable
23 |   has_value :has_exposure_to_sunlight, :default => false
24 |   has_value :mindset
25 |   has_value :name, :default => 'DHH Jr.', :klass => String
26 |   has_value :signature, :klass => Signature
27 |   has_value :cubical, :klass => Cube
28 |   has_value :hacker, :default => true
29 |   has_value :certifications, :default => DevCertifications.new
30 |   has_value :quote
31 | 
32 |   has_collection :favorite_gems  
33 | 
34 | end
35 | 
36 | # Previously, we used :klass => Klass instead of :klass => :klass.
37 | # I decided it was just plain dirty. On refactoring, I realized that
38 | # most it would continue to work. Other stuff, unfortunately, would
39 | # break horribly. (Integer.new, for instance, makes Ruby very angry.)
40 | # The purpose of these tests is to verify that everything _either_ 
41 | # breaks horribly or works, where the third option is fails silently
42 | # and mysteriously.
43 | class DeprecatedTest < Test::Unit::TestCase
44 |   
45 |   def test_that_attributes_can_be_klassified
46 |     dev = Dev.new(:signature => 'brah brah')
47 |     assert_equal Signature, dev.signature.class
48 |   end
49 | 
50 |   def test_that_randomly_classed_attributes_persist_nils
51 |     assert_equal nil, Dev.new.signature
52 |   end
53 | 
54 |   def test_that_randomly_classed_attributes_respect_defaults
55 |     assert_equal 'DHH Jr.', Dev.new.name
56 |   end
57 | 
58 |   def test_that_constructor_casts_attributes
59 |     assert_equal 'Lives in Cube 20', Dev.new(:cubical => 20).cubical
60 |   end
61 | 
62 |   def test_that_setter_casts_attributes
63 |     golden_boy = Dev.new
64 |     golden_boy.cubical = 20
65 |     
66 |     assert_equal 'Lives in Cube 20', golden_boy.cubical
67 |   end
68 | 
69 |   def test_that_properly_klassed_values_are_not_rekast
70 |     why_hammer = Signature.new('go ask your mom')
71 |     Signature.expects(:new).with(why_hammer).never
72 |     hammer = Dev.new(:signature => why_hammer)
73 |   end
74 | 
75 |   def test_that_default_values_can_be_set_to_nothing
76 |     assert_equal nil, Dev.new(:hacker => nil).hacker
77 |   end
78 | 
79 | end
80 | 
81 | 
--------------------------------------------------------------------------------
/test/extending_test.rb:
--------------------------------------------------------------------------------
 1 | $: << File.expand_path(File.dirname(__FILE__) + '/../lib')
 2 | 
 3 | require 'test/unit'
 4 | require 'valuable.rb'
 5 | 
 6 | module BookCollection
 7 | end
 8 | 
 9 | module PirateFormatter
10 |   def to_pirate
11 |     "#{self}, ARRRGGGhhhhh!"
12 |   end
13 | end
14 | 
15 | class Series < Valuable
16 |   has_collection :books, :extend => BookCollection
17 |   has_value :name, :extend => PirateFormatter
18 | end
19 | 
20 | class ExtendingTest < Test::Unit::TestCase
21 |   def test_that_collections_are_extended
22 |     assert Series.new.books.is_a?(BookCollection)
23 |   end
24 | 
25 |   def test_that_values_are_extended
26 |     assert_equal 'Walk The Plank, ARRRGGGhhhhh!', Series.new(:name => 'Walk The Plank').name.to_pirate
27 |   end
28 | 
29 | end
30 | 
31 | 
--------------------------------------------------------------------------------
/test/inheritance_test.rb:
--------------------------------------------------------------------------------
 1 | $: << File.expand_path(File.dirname(__FILE__) + '/../lib')
 2 | 
 3 | require 'rubygems'
 4 | require 'test/unit'
 5 | require 'valuable.rb'
 6 | require 'mocha/setup'
 7 | 
 8 | class Parent < Valuable
 9 |   has_value :name, :default => 'unknown'
10 | end
11 | 
12 | class Child < Parent
13 |   has_value :age
14 | end
15 | 
16 | class InheritanceTest < Test::Unit::TestCase
17 | 
18 |   def test_that_children_inherit_their_parents_attributes
19 |     assert Child.attributes.include?(:name)
20 |   end	  
21 | 
22 |   def test_that_children_have_distinctive_attributes
23 |     assert Child.attributes.include?(:age)
24 |   end
25 | 
26 |   def test_that_parents_do_not_inherit_things_from_children
27 |     assert_equal [:name], Parent.attributes
28 |   end
29 | end
30 | 
--------------------------------------------------------------------------------
/test/parse_with_test.rb:
--------------------------------------------------------------------------------
 1 | $: << File.expand_path(File.dirname(__FILE__) + '/../lib')
 2 | 
 3 | require 'test/unit'
 4 | require 'valuable.rb'
 5 | require 'mocha/setup'
 6 | 
 7 | class Person < Valuable
 8 |   has_value :first_name
 9 |   has_value :last_name
10 | 
11 |   def Person.load( name )
12 |     f, l = name.split(' ') # trivial case
13 |     new(:first_name => f, :last_name => l)
14 |   end
15 | end
16 | 
17 | class RailsApp < Valuable
18 |   has_value :tech_lead, :klass => Person, :parse_with => :load
19 |   has_collection :devs, :klass => Person, :parse_with => :load
20 |   has_value :name, :parse_with => lambda{|x| x == 'IA' ? 'Information Architecture' : x}
21 |   has_value :overlord, :klass => Person, :parse_with => lambda{|name| Person.load(name) }
22 | end
23 | 
24 | class ParseWithTest < Test::Unit::TestCase
25 |  
26 |   def test_that_parse_with_calls_target_classes_parse_method
27 |     ia = RailsApp.new(:tech_lead => 'Adam Dalton')
28 |     assert_equal 'Adam', ia.tech_lead.first_name
29 |   end
30 | 
31 |   def test_that_collections_are_parsed
32 |     ia = RailsApp.new(:devs => ['Dennis Camp', 'Richard Hoblitzell', 'Paul Kuracz', 'Magda Lueiro', 'George Meyer', 'David Moyer', 'Bill Snoddy'])
33 |     expected = ['Dennis', 'Richard', 'Paul', 'Magda', 'George', 'David', 'Bill']
34 |     actual = ia.devs.map(&:first_name)
35 |     assert_equal expected, actual
36 |   end
37 | 
38 |   def test_that_lambdas_can_be_used_as_parsers
39 |     assert_equal 'Information Architecture', RailsApp.new(:name => 'IA').name
40 |   end
41 | 
42 |   def test_that_it_raises_an_error_when_passed_a_class_and_a_proc
43 |     animal = Class.new(Valuable)
44 |     assert_raises ArgumentError, "Class can't promise to return a(n) :integer when using the option :parse_with" do
45 |       animal.has_value :invalid, :klass => :integer, :parse_with => :method
46 |     end
47 |   end
48 | 
49 |   def test_that_lambdas_can_be_combined_with_a_class
50 |     assert_equal 'vader', RailsApp.new(:overlord => 'darth vader').overlord.last_name
51 |   end
52 | end
53 | 
--------------------------------------------------------------------------------
/test/typical_test.rb:
--------------------------------------------------------------------------------
 1 | $: << File.expand_path(File.dirname(__FILE__) + '/../lib')
 2 | 
 3 | require 'test/unit'
 4 | require 'valuable.rb'
 5 | require 'date'
 6 | require File.expand_path(File.dirname(__FILE__) + '/../examples/phone_number')
 7 | class Person < Valuable
 8 |   has_value :dob, :klass => :date
 9 |   has_collection :dreams, :default => [:happiness, :respect]
10 | end
11 | 
12 | class Chemical < Valuable
13 |   has_value :ph, :klass => :decimal
14 | end
15 | 
16 | class TypicalTest < Test::Unit::TestCase
17 | 
18 |   def test_that_dates_can_be_set_directly
19 |     born_on = Date.civil(1976, 07, 26)
20 |     me = Person.new( :dob => born_on )
21 |     assert_equal( born_on, me.dob )
22 |   end
23 | 
24 |   def test_that_date_do_not_flip_out
25 |     me = Person.new( :dob => "" )
26 |     assert_equal( nil, me.dob )
27 |   end
28 | 
29 |   def test_that_dates_are_parsed_from_strings
30 |     neil_born_on = 'August 5, 1930'
31 |     neil = Person.new( :dob => neil_born_on )
32 |     assert_equal( Date.civil( 1930, 8, 5 ), neil.dob )
33 |   end
34 | 
35 |   def test_that_a_date_might_not_be_set_yet_and_that_can_be_ok
36 |     dr_who = Person.new( :dob => nil )
37 |     assert_nil( dr_who.dob )
38 |   end
39 | 
40 |   def test_that_collections_are_typified
41 |     people = Class.new(Valuable)
42 |     people.has_collection( :phones, :klass => PhoneNumber )
43 | 
44 |     person = people.new(:phones => ['8668675309'])
45 |     assert_kind_of( Array, person.phones )
46 |     assert_kind_of( PhoneNumber, person.phones.first )
47 |   end
48 | 
49 |   def test_that_it_discovers_an_invalid_klass
50 |     animal = Class.new(Valuable)
51 |     assert_raises ArgumentError, "Animal doesn't know how to format species with :klass => 'invalid'" do
52 |       animal.has_value :species, :klass => :invalid
53 |     end
54 |   end
55 | 
56 |   def test_that_decimals_typified
57 |     lemon_juice = Chemical.new(:ph => 1.8)
58 |     assert_kind_of BigDecimal, lemon_juice.ph
59 |   end
60 | 
61 |   def test_that_nil_input_is_preserved_for_decimals
62 |     lemon_juice = Chemical.new(:ph => nil)
63 |     assert_equal nil, lemon_juice.ph
64 |   end
65 | 
66 |   def test_that_it_uses_the_default_collection
67 |     assert_equal Person.new.dreams, [:happiness, :respect]
68 |   end
69 | 
70 |   def test_that_we_can_prevent_blanks
71 |     device = Class.new(Valuable)
72 |     device.has_value( :battery_percent, :allow_blank => false )
73 | 
74 |     cell = device.new(:battery_percent => '')
75 |     assert_equal( nil, cell.battery_percent )
76 |   end
77 | end
78 | 
79 | 
--------------------------------------------------------------------------------
/test/valuable_test.rb:
--------------------------------------------------------------------------------
  1 | $: << File.expand_path(File.dirname(__FILE__) + '/../lib')
  2 | 
  3 | require 'test/unit'
  4 | require 'valuable.rb'
  5 | require 'mocha/setup'
  6 | 
  7 | class Cubical < String
  8 |   def initialize(number)
  9 |     super "Lives in Cubical #{number}"
 10 |   end
 11 | end
 12 | 
 13 | class DevCertifications < Valuable
 14 |   has_value :a_plus, :default => false
 15 |   has_value :mcts, :default => false
 16 |   has_value :hash_rocket, :default => false
 17 | end
 18 | 
 19 | class Developer < Valuable
 20 |   has_value :experience, :klass => :integer
 21 |   has_value :has_exposure_to_sunlight, :default => false
 22 |   has_value :mindset
 23 |   has_value :name, :default => 'DHH Jr.', :klass => :string
 24 |   has_value :snacks_per_day, :klass => :integer, :default => 7
 25 |   has_value :cubical, :klass => Cubical
 26 |   has_value :hacker, :default => true
 27 |   has_value :certifications, :klass => DevCertifications, :default => DevCertifications.new
 28 |   has_value :quote
 29 |   has_value :employed, :klass => :boolean, :negative => 'unemployed'
 30 | 
 31 |   has_collection :favorite_gems  
 32 | 
 33 | end
 34 | 
 35 | class BaseTest < Test::Unit::TestCase
 36 |  
 37 |   def test_that_an_attributes_hash_is_available
 38 |     assert_kind_of(Hash, Developer.new.attributes)
 39 |   end
 40 | 
 41 |   def test_that_static_defaults_hash_is_available  
 42 |     assert_equal 'DHH Jr.', Developer.defaults[:name]
 43 |   end
 44 |   
 45 |   def test_that_an_accessor_is_created
 46 |     dev = Developer.new(:mindset => :agile)
 47 |     assert_equal :agile, dev.mindset
 48 |   end
 49 |   
 50 |   def test_that_setter_is_created
 51 |     dev = Developer.new
 52 |     dev.mindset = :enterprisey
 53 |     assert_equal :enterprisey, dev.mindset
 54 |   end
 55 |   
 56 |   def test_that_attributes_can_be_cast_as_integer
 57 |     dev = Developer.new(:experience => 9.2)
 58 |     assert_equal 9, dev.experience
 59 |   end
 60 |   
 61 |   def test_that_integer_attributes_respect_default
 62 |     assert_equal 7, Developer.new.snacks_per_day
 63 |   end
 64 | 
 65 |   def test_that_an_integer_attribute_with_no_value_results_in_nil
 66 |     assert_equal nil, Developer.new.experience
 67 |   end
 68 | 
 69 |   def test_that_integer_attributes_ignore_blanks
 70 |     assert_equal nil, Developer.new(:experience => '').experience
 71 |   end
 72 | 
 73 |   def test_that_attributes_can_be_klassified
 74 |     dev = Developer.new(:cubical => 12)
 75 |     assert_equal Cubical, dev.cubical.class
 76 |   end
 77 | 
 78 |   def test_that_defaults_appear_in_attributes_hash
 79 |     assert_equal false, Developer.new.attributes[:has_exposure_to_sunlight]
 80 |   end
 81 |   
 82 |   def test_that_attributes_can_have_default_values
 83 |     assert_equal false, Developer.new.has_exposure_to_sunlight
 84 |   end
 85 | 
 86 |   def test_that_randomly_classed_attributes_persist_nils
 87 |     assert_equal nil, Developer.new.cubical
 88 |   end
 89 | 
 90 |   def test_that_randomly_classed_attributes_respect_defaults
 91 |     assert_equal 'DHH Jr.', Developer.new.name
 92 |   end
 93 | 
 94 |   def test_that_constructor_casts_attributes
 95 |     assert_equal 'Lives in Cubical 20', Developer.new(:cubical => 20).cubical
 96 |   end
 97 | 
 98 |   def test_that_setter_casts_attributes
 99 |     golden_boy = Developer.new
100 |     golden_boy.cubical = 20
101 |     
102 |     assert_equal 'Lives in Cubical 20', golden_boy.cubical
103 |   end
104 |   
105 |   def test_that_attributes_are_available_as_class_method
106 |     assert Developer.attributes.include?(:cubical)
107 |   end
108 | 
109 |   def test_that_a_model_can_have_a_collection
110 |     assert_equal [], Developer.new.favorite_gems
111 |   end
112 | 
113 |   def test_that_values_do_not_mysteriously_jump_instances
114 |     panda = Developer.new
115 |     panda.mindset = 'geek'
116 | 
117 |     hammer = Developer.new
118 |     
119 |     assert_not_equal 'geek', hammer.mindset
120 |   end
121 |   
122 |   def test_that_collection_values_do_not_roll_across_instances
123 |     jim = Developer.new
124 |     jim.favorite_gems << 'Ruby'
125 | 
126 |     clark = Developer.new
127 | 
128 |     assert_equal [], clark.favorite_gems
129 |   end
130 | 
131 |   def test_that_attributes_are_cast
132 |     panda = Developer.new(:name => 'Code Panda', :experience => '8')
133 |     assert_kind_of Integer, panda.attributes[:experience]
134 |   end
135 | 
136 |   def test_that_stringy_keys_are_tried_in_absence_of_symbolic_keys
137 |     homer = Developer.new('quote' => "D'oh!")
138 |     assert_equal "D'oh!", homer.quote 
139 |   end
140 | 
141 |   def test_that_default_values_from_seperate_instances_are_not_references_to_the_default_value_for_that_field
142 |     assert_not_equal Developer.new.favorite_gems.object_id, Developer.new.favorite_gems.object_id
143 |   end
144 | 
145 |   def test_that_properly_klassed_values_are_not_rekast
146 |     stapler = Cubical.new('in sub-basement')
147 |     Cubical.expects(:new).with(stapler).never
148 |     Developer.new(:cubical => stapler)
149 |   end
150 | 
151 |   def test_that_values_can_be_set_to_false
152 |     assert_equal false, Developer.new(:hacker => false).hacker
153 |   end
154 | 
155 |   def test_that_default_values_needing_deep_duplication_get_it
156 |     a = Developer.new
157 |     b = Developer.new
158 | 
159 |     a.certifications.hash_rocket = true
160 |     assert_equal false, b.certifications.hash_rocket    
161 |   end
162 | 
163 |   def test_that_default_values_can_be_set_to_nothing
164 |     assert_equal nil, Developer.new(:hacker => nil).hacker
165 |   end
166 | 
167 |   def test_that_values_are_cast_to_boolean
168 |     assert_equal true, Developer.new(:employed => 'true').employed
169 |   end
170 | 
171 |   def test_that_string_zero_becomes_false
172 |     assert_equal false, Developer.new(:employed => '0').employed
173 |   end
174 | 
175 |   def test_that_boolean_values_get_questionmarked_methods
176 |     assert Developer.instance_methods.map(&:to_sym).include?(:employed?)
177 |   end
178 | 
179 |   def test_that_boolean_values_get_negative_methods
180 |     assert Developer.instance_methods.map(&:to_sym).include?(:unemployed?)
181 |   end
182 | 
183 |   def test_that_negative_methods_are_negative
184 |     assert_equal true, Developer.new(:employed => false).unemployed?
185 |   end
186 | 
187 |   def test_that_constructor_can_handle_an_instance_of_nothing
188 |     assert_nothing_raised do
189 |       Developer.new(nil)
190 |     end
191 |   end
192 | 
193 |   def test_that_klassification_does_not_break_when_stringified
194 |     assert_nothing_raised do
195 |       Developer.new(:experience => '2')
196 |     end
197 |   end
198 | end
199 | 
--------------------------------------------------------------------------------
/test/write_and_read_attribute_test.rb:
--------------------------------------------------------------------------------
 1 | $: << File.expand_path(File.dirname(__FILE__) + '/../lib')
 2 | 
 3 | require 'test/unit'
 4 | require 'valuable.rb'
 5 | 
 6 | class Beer < Valuable
 7 |   has_value :name
 8 |   has_value :brewery
 9 | end
10 | 
11 | class WriteAndReadAttributeTest < Test::Unit::TestCase
12 | 
13 |   def test_that_values_can_be_set_using_write_attribute
14 |     beer = Beer.new
15 |     beer.write_attribute(:name, 'Red Stripe')
16 |     assert_equal 'Red Stripe', beer.name
17 |   end
18 | 
19 |   def test_that_values_can_be_set_using_stringified_attribute
20 |     beer = Beer.new
21 |     beer.write_attribute('name', 'Fosters')
22 |     assert_equal 'Fosters', beer.name
23 |   end
24 | 
25 |   def test_that_values_can_be_set_using_newfangled_way
26 |     beer = Beer.new
27 |     beer.name('Abita Amber')
28 |     assert_equal 'Abita Amber', beer.name
29 |   end
30 | 
31 |   def test_newfangled_fluid_chaining
32 |     beer = Beer.new
33 |     beer.name('Amber').brewery('Abita')
34 |     assert_equal 'Abita', beer.brewery
35 |   end
36 | 
37 | end
38 | 
39 | 
--------------------------------------------------------------------------------
/valuable.gemspec:
--------------------------------------------------------------------------------
 1 | $:.push File.expand_path("../lib", __FILE__)  
 2 | version = File.read(File.expand_path("../valuable.version",__FILE__)).strip
 3 | 
 4 | spec = Gem::Specification.new do |s|
 5 |   s.name = 'valuable'
 6 |   s.version = version 
 7 |   s.summary = "attr_accessor on steroids with defaults, attribute formatting, alias methods, etc."
 8 |   s.description = "Valuable is a ruby base class that is essentially attr_accessor on steroids. A simple and intuitive interface allows you to get on with modeling in your app."
 9 |   s.license = 'MIT'
10 | 
11 |   s.require_path = 'lib'
12 | 
13 |   s.files         = `git ls-files`.split("\n")
14 |   s.test_files    = `git ls-files -- {test,spec,features}/*`.split("\n")
15 |   s.require_paths = ["lib"]
16 | 
17 |   s.has_rdoc = true
18 | 
19 |   s.authors = ["Johnathon Wright"]
20 |   s.email = "jw@mustmodify.com"
21 |   s.homepage = "http://valuable.mustmodify.com/"
22 | end
23 | 
24 | 
--------------------------------------------------------------------------------
/valuable.version:
--------------------------------------------------------------------------------
1 | 0.9.14
2 | 
--------------------------------------------------------------------------------