├── .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 | --------------------------------------------------------------------------------