├── .gitignore ├── .travis.yml ├── CHANGELOG ├── Gemfile ├── Gemfile.lock ├── LICENSE ├── README.md ├── Rakefile ├── app └── app_delegate.rb ├── lib └── motion_model.rb ├── motion ├── adapters │ ├── array_finder_query.rb │ ├── array_model_adapter.rb │ └── array_model_persistence.rb ├── date_parser.rb ├── ext.rb ├── input_helpers.rb ├── model │ ├── column.rb │ ├── formotion.rb │ ├── model.rb │ ├── model_casts.rb │ └── transaction.rb ├── validatable.rb └── version.rb ├── motion_model.gemspec ├── resources └── StoredTasks.dat └── spec ├── adapter_spec.rb ├── array_model_persistence_spec.rb ├── cascading_delete_spec.rb ├── column_options_spec.rb ├── date_spec.rb ├── ext_spec.rb ├── finder_spec.rb ├── formotion_spec.rb ├── has_one_as_object_spec.rb ├── kvo_config_clone_spec.rb ├── model_casting_spec.rb ├── model_hook_spec.rb ├── model_spec.rb ├── notification_spec.rb ├── proc_defaults_spec.rb ├── relation_spec.rb ├── transaction_spec.rb └── validation_spec.rb /.gitignore: -------------------------------------------------------------------------------- 1 | .repl_history 2 | build 3 | resources/*.nib 4 | resources/*.momd 5 | resources/*.storyboardc 6 | .DS_Store 7 | doc/**/*.* 8 | doc 9 | *.gem 10 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: objective-c 2 | before_install: 3 | - ruby --version 4 | - sudo chown -R travis ~/Library/RubyMotion 5 | # Figure out if we have latest RubyMotion 6 | - motion --version 7 | - sudo motion update 8 | - motion --version 9 | install: 10 | - ruby -S bundle install 11 | # rvm: 12 | # - "1.9.3" 13 | script: 14 | - bundle install 15 | - bundle exec rake clean 16 | - bundle exec rake spec 17 | 18 | # before_install: 19 | # - (ruby --version) 20 | # - sudo chown -R travis ~/Library/RubyMotion 21 | # - mkdir -p ~/Library/RubyMotion/build 22 | # - sudo motion update 23 | # script: 24 | # - bundle install 25 | # - bundle exec rake clean 26 | # - bundle exec rake spec 27 | # - bundle exec rake clean 28 | # - bundle exec rake spec osx=true 29 | -------------------------------------------------------------------------------- /CHANGELOG: -------------------------------------------------------------------------------- 1 | 2013-05-21: Added different method of converting from String to date using 2 | iOS DataDetector instead of dateFromNaturalLanguageString. 3 | 4 | 2013-04-13: WARNING: Possible breaking change. Hook methods changed to send 5 | affected object. So, if you have: 6 | 7 | def after_create 8 | 9 | You will get an error about expecting 1 argument, got 0 10 | 11 | The correct signature is: 12 | 13 | def after_create(sender) 14 | 15 | 2013-03-15: Fixed bug where created_at and updated_at were being incorrectly 16 | when restored from persistence (thanks Justin McPherson for finding 17 | that). 18 | 19 | Moved all NSCoder stuff out of Model to ArrayModelAdapter. 20 | 21 | 2013-02-19: Included Doug Puchalski's great refactoring of Model that provides an 22 | adapter for ArrayModelAdapter. WARNING!!! This is a breaking change 23 | since version 0.3.8. You will have to include: 24 | 25 | MotionModel::ArrayModelAdapter 26 | 27 | after including MotionModel::Model to get the same functionality. 28 | Failure to include an adapter (note: spelling counts :) will result 29 | in an exception so this will not quietly fail. 30 | 31 | 2013-01-24: Added block-structured transactions. 32 | 33 | 2013-01-14: Fixed problem where data returned from forms was of type NSString, which 34 | confused some monkey-patching code. 35 | Changed before_ hooks such that handlers returning false would terminate 36 | the process. So, if before_save returns anything other than false, the 37 | save continues. Only if before_save returns false does the save get 38 | interrupted. 39 | Fixed immutable string issue in validations. 40 | 41 | 2013-01-09: Added automatic date/timestamp support for created_at and updated_at columns 42 | Added Hash extension except, Array introspection methods has_hash_key? and 43 | has_hash_value? 44 | Commit of Formotion module including optional inclusion/suppression of the 45 | auto-date fields 46 | Specs 47 | 48 | 49 | 2012-12-30: Added Formotion module. This allows for tighter integration with Formotion 50 | Changed options for columns such that any arbitrary values can be inserted 51 | allowing for future expansion. 52 | 53 | 2012-12-14: Added lots of framework to validations 54 | Added validations for length, format, email, presence 55 | Added array type (thanks justinmcp) 56 | 57 | 2012-12-07: Added MIT license file. 58 | InputHelpers: Whitespace cleanup. Fixed keyboard show/hide to scroll to correct position. 59 | MotionModel::Column: Whitespace cleanup, added code to support cascading delete (:dependent => :destroy) 60 | MotionModel::Model: Whitespace cleanup, added code to support cascading destroy, destroy_all, and cascading if specified. 61 | relation_spec.rb: removed delete tests into cascading_delete_spec.rb 62 | 63 | 2012-12-06: Work on has_many to add cascading delete 64 | 65 | MotionModel: POTENTIAL CODE-BREAKING CHANGE. has_many now takes two arguments 66 | only. Previously, it would allow a list of symbols or strings, 67 | now it conforms more to the Rails way of one call per relation. 68 | E.g.: 69 | 70 | has_many :pets 71 | 72 | -or- 73 | 74 | has_many :pets, :delete => :destroy # cascade delete. 75 | 76 | 2012-10-14: Primary New Feature: Notifications 77 | 78 | MotionModel: Added bulk update, which suppresses notifications and added it to delete_all. 79 | MotionModel: Added notifications of type MotionModelDataDidChangeNotification on data change. 80 | MotionModel: Added classification code to save to differentiate between save-new and update 81 | MotionModel: Added notification calls to save and delete 82 | 83 | 84 | 2012-09-05: Basically rewrote how the data is stored. 85 | 86 | The API remains consistent, but a certain amount of 87 | efficiency is added by adding hashes to map column names 88 | to the column metadata. 89 | 90 | * Type casting now works, and is a function of initialization 91 | and of assignment. 92 | 93 | * Default values have been added to fill in values 94 | if not specified in new or create. 95 | 96 | 2012-09-06: Added block-style finders. Added delete method. 97 | 98 | 2012-09-07: IMPORTANT! PLEASE READ! Two new methods were added 99 | to MotionModel to support persistence: 100 | 101 | Task#serialize_to_file(file_name) 102 | Task.deserialize_from_file(file_name) 103 | 104 | Note that serialize operates on an instance and 105 | deserialize is a class method that creates an 106 | instance. 107 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gem "rake" 4 | gem "bubble-wrap" 5 | gem "motion-stump", '~>0.2' 6 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: https://rubygems.org/ 3 | specs: 4 | bubble-wrap (1.9.5) 5 | motion-stump (0.3.2) 6 | rake (11.1.0) 7 | 8 | PLATFORMS 9 | ruby 10 | 11 | DEPENDENCIES 12 | bubble-wrap 13 | motion-stump (~> 0.2) 14 | rake 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2012 Steve Ross 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Code Climate](https://codeclimate.com/github/sxross/MotionModel.png)](https://codeclimate.com/github/sxross/MotionModel)[![Build Status](https://travis-ci.org/sxross/MotionModel.png)](https://travis-ci.org/sxross]/MotionModel) 2 | 3 | MotionModel: Models, Relations, and Validation for RubyMotion 4 | ================ 5 | 6 | MotionModel is a DSL for cases where Core Data is too heavy to lift but you are 7 | still intending to work with your data, its types, and its relations. It also provides for 8 | data validation and actually quite a bit more. 9 | 10 | File | Module | Description 11 | ---------------------|---------------------------|------------------------------------ 12 | **ext.rb** | N/A | Core Extensions that provide a few Rails-like niceties. Nothing new here, moving on... 13 | **model.rb** | MotionModel::Model | You should read about it in "[What Model Can Do](#what-model-can-do)". Model is the raison d'etre and the centerpiece of MotionModel. 14 | **validatable.rb** | MotionModel::Validatable | Provides a basic validation framework for any arbitrary class. You can also create custom validations to suit your app's unique needs. 15 | **input_helpers** | MotionModel::InputHelpers | Helps hook a collection up to a data form, populate the form, and retrieve the data afterwards. Note: *MotionModel supports Formotion for input handling as well as these input helpers*. 16 | **formotion.rb** | MotionModel::Formotion | Provides an interface between MotionModel and Formotion 17 | **transaction.rb** | MotionModel::Model::Transactions | Provides transaction support for model modifications 18 | 19 | MotionModel is MIT licensed, which means you can pretty much do whatever 20 | you like with it. See the LICENSE file in this project. 21 | 22 | * [Getting Going](#getting-going) 23 | * [Bugs, Features, and Issues, Oh My!](#bugs-features-and-issues-oh-my) 24 | * [What Model Can Do](#what-model-can-do) 25 | * [Model Data Types](#model-data-types) 26 | * [Validation Methods](#validation-methods) 27 | * [Model Instances and Unique IDs](#model-instances-and-unique-ids) 28 | * [Using MotionModel](#using-motionmodel) 29 | * [Transactions and Undo/Cancel](#transactions-and-undocancel) 30 | * [Notifications](#notifications) 31 | * [Core Extensions](#core-extensions) 32 | * [Formotion Support](#formotion-support) 33 | * [Problems/Comments](#problemscomments) 34 | * [Submissions/Patches](#submissionspatches) 35 | 36 | ## Bugs, Features, and Issues, Oh My! 37 | 38 | The reason this is up front here is that in order to respond to your issues 39 | we need you to help us out by reading these guidelines. You can also look at 40 | [Submissions/Patches](#submissionspatches) near the bottom of this README 41 | which restates a bit of this. 42 | 43 | That said, all software has bugs, and anyone who thinks otherwise probably is smarter than I am. There are going to be edge cases or cases that our tests don't cover. And that's why open source is great: other people will run into issues we can fix. Other people will have needs we don't have but that are of general utility. And so on. 44 | 45 | But… fair is fair. We would make the following requests of you: 46 | 47 | * Debug the code as far as you can. Obviously, there are times when you just won't be able to see what's wrong or where there's some squirrely interaction with RubyMotion. 48 | * If you are comfortable with the MotionModel code, please try to write a spec that makes it fail and submit a pull request with that failing spec. The isolated test case helps us narrow down what changed and to know when we have the issue fixed. Two things make this even better: 49 | 1. Our specs become more comprehensive; and 50 | 2. If the issue is an interaction between MotionModel and RubyMotion, it's easier to pass along to HipByte and have a spec they can use for a quick hitting test case. Even better, fix the bug and submit that fix *and* the spec in a pull request. 51 | * If you are not comfortable with the MotionModel code, then go ahead and describe the issue in as much detail as possible, including backtraces from the debugger, if appropriate. 52 | 53 | Now, I've belabored the point about bug reporting enough. The point is, if you possibly can, write a spec. 54 | 55 | Issues: Please mark your issues as questions or feature requests, depending on which they are. We'll do all we can to review them and answer questions as quickly as possible. For feature requests, you really can implement the feature in many cases and then submit a pull request. If not, we'll leave it open for consideration in future releases. 56 | 57 | ### Summary 58 | 59 | Bugs: Please write a failing spec 60 | 61 | Issues: Please mark them as question or request 62 | 63 | Changes for Existing Users to Be Aware Of 64 | ================= 65 | 66 | Please see the CHANGELOG for update on changes. 67 | 68 | Version 0.4.4 is the first version to be gem-compatible with RubyMotion 2.0 69 | 70 | Version 0.3.8 to 0.4.0 is a minor version bump, not a patch version. Upgrading 71 | to 0.4.0 *will break existing code*. To update your code, simply insert the following line: 72 | 73 | ```ruby 74 | class ModelWithAdapter 75 | include MotionModel::Model 76 | include MotionModel::ArrayModelAdapter # <== Here! 77 | 78 | columns :name 79 | end 80 | ``` 81 | 82 | This change lays the foundation for using other persistence adapters. 83 | If you don't want to update all your models, install the gem: 84 | 85 | ``` 86 | $ gem install motion_model -v 0.3.8 87 | ``` 88 | 89 | or if you are using bundler: 90 | 91 | ``` 92 | gem motion_model, "0.3.8" 93 | ``` 94 | 95 | Version 0.3.8 was the last that did not separate the model and persistence concerns. 96 | 97 | Getting Going 98 | ================ 99 | 100 | If you are using Bundler, put this in your Gemfile: 101 | 102 | ```ruby 103 | gem 'motion_model' 104 | ``` 105 | 106 | then do: 107 | 108 | ``` 109 | bundle install 110 | ``` 111 | 112 | If you are not using Bundler: 113 | 114 | ``` 115 | gem install motion_model 116 | ``` 117 | 118 | then put this in your Rakefile after requiring `motion/project`: 119 | 120 | ``` 121 | require 'motion_model' 122 | ``` 123 | 124 | If you want to use Bundler from `master`, put this in your Gemfile: 125 | 126 | ``` 127 | gem 'motion_model', :git => 'git@github.com:sxross/MotionModel.git' 128 | ``` 129 | 130 | Note that in the above construct, Ruby 1.8.x hash keys are used. That's because Apple's System Ruby is 1.8.7 and won't recognize keen new 1.9.x hash syntax. 131 | 132 | What MotionModel Can Do 133 | ================ 134 | 135 | You can define your models and their schemas in Ruby. For example: 136 | 137 | ```ruby 138 | class Task 139 | include MotionModel::Model 140 | include MotionModel::ArrayModelAdapter 141 | 142 | columns :name => :string, 143 | :long_name => :string, 144 | :due_date => :date 145 | end 146 | 147 | class MyCoolController 148 | def some_method 149 | @task = Task.create :name => 'walk the dog', 150 | :long_name => 'get plenty of exercise. pick up the poop', 151 | :due_date => '2012-09-15' 152 | end 153 | end 154 | ``` 155 | 156 | Side note: The original documentation on this used `description` for the column that is now `long_name`. It turns out Apple reserves `description` so MotionModel saves you the trouble of finding that particular bug by not allowing you to use it for a column name. 157 | 158 | Models support default values, so if you specify your model like this, you get defaults: 159 | 160 | ```ruby 161 | class Task 162 | include MotionModel::Model 163 | include MotionModel::ArrayModelAdapter 164 | 165 | columns :name => :string, 166 | :due_date => {:type => :date, :default => '2012-09-15'} 167 | end 168 | ``` 169 | 170 | A note on defaults, you can specify a proc, block or symbol for your default if you want to get fancy. The most obvious use case for this is that Ruby will optimize the assignment of an array so that a default of `[]` always points to the same object. Not exactly what is intended. Wrapping this in a proc causes a new array to be created. Here's an example: 171 | 172 | ``` 173 | class Foo 174 | include MotionModel::Model 175 | include MotionModel::ArrayModelAdapter 176 | columns subject: { type: :array, default: ->{ [] } } 177 | end 178 | ``` 179 | 180 | This is not constrained to initializing arrays. You can 181 | initialize pretty much anything using a proc or block. 182 | If you are specifying a block, make sure to use begin/end 183 | instead of do/end because it makes Ruby happy. 184 | 185 | Here's a different example: 186 | 187 | ``` 188 | class Timely 189 | include MotionModel::Model 190 | include MotionModel::ArrayModelAdapter 191 | columns ended_run_at: { type: :time, default: ->{ Time.now } } 192 | end 193 | ``` 194 | Note that this uses the "stubby proc" syntax. That is pretty much equivalent 195 | to: 196 | 197 | ``` 198 | columns ended_run_at: { type: :time, default: lambda { Time.now } } 199 | ``` 200 | 201 | for the previous example. 202 | 203 | If you want to use a block, use the begin/end syntax: 204 | 205 | ``` 206 | columns ended_run_at: { type: :time, default: 207 | begin 208 | Time.now 209 | end 210 | } 211 | ``` 212 | Finally, you can have the default call some class method as follows: 213 | 214 | ``` 215 | class Timely 216 | include MotionModel::Model 217 | include MotionModel::ArrayModelAdapter 218 | columns unique_thingie: { type: :integer, default: :randomize } 219 | 220 | def self.randomize 221 | rand 1_000_000 222 | end 223 | end 224 | ``` 225 | 226 | You can also include the `Validatable` module to get field validation. For example: 227 | 228 | ```ruby 229 | class Task 230 | include MotionModel::Model 231 | include MotionModel::ArrayModelAdapter 232 | include MotionModel::Validatable 233 | 234 | columns :name => :string, 235 | :long_name => :string, 236 | :due_date => :date 237 | validates :name, :presence => true 238 | end 239 | 240 | class MyCoolController 241 | def some_method 242 | @task = Task.new :name => 'walk the dog', 243 | :long_name => 'get plenty of exercise. pick up the poop', 244 | :due_date => '2012-09-15' 245 | 246 | show_scary_warning unless @task.valid? 247 | end 248 | end 249 | ``` 250 | 251 | *Important Note*: Type casting occurs at initialization and on assignment. That means 252 | If you have a field type `int`, it will be changed from a string to an integer when you 253 | initialize the object of your class type or when you assign to the integer field in your class. 254 | 255 | ```ruby 256 | a_task = Task.create(:name => 'joe-bob', :due_date => '2012-09-15') # due_date is cast to NSDate 257 | 258 | a_task.due_date = '2012-09-19' # due_date is cast to NSDate 259 | ``` 260 | 261 | Model Data Types 262 | ----------- 263 | 264 | Currently supported types are: 265 | 266 | * `:string` 267 | * `:text` 268 | * `:boolean`, `:bool` 269 | * `:int`, `:integer` 270 | * `:float`, `:double` 271 | * `:date` 272 | * `:array` 273 | 274 | You are really not encouraged to stuff big things in your models, which is why a blob type 275 | is not implemented. The smaller your data, the less overhead involved in saving/loading. 276 | 277 | ### Special Columns 278 | 279 | The two column names, `created_at` and `updated_at` will be adjusted automatically if they 280 | are declared. They need to be of type `:date`. The `created_at` column will be set only when 281 | the object is created (i.e., on first save). The `updated_at` column will change every time 282 | the object is saved. 283 | 284 | Validation Methods 285 | ----------------- 286 | 287 | To use validations in your model, declare your model as follows: 288 | 289 | ```ruby 290 | class MyValidatableModel 291 | include MotionModel::Model 292 | include MotionModel::ArrayModelAdapter 293 | include MotionModel::Validatable 294 | 295 | # All other model-y stuff here 296 | end 297 | ``` 298 | 299 | Here are some sample validations: 300 | 301 | validate :field_name, :presence => true 302 | validate :field_name, :length => 5..8 # specify a range 303 | validate :field_name, :email => true 304 | validate :field_name, :format => /\A\d?\d-\d?\d-\d\d\Z/ # expected string format would be like '12-12-12' 305 | 306 | The framework is sufficiently flexible that you can add in custom validators like so: 307 | 308 | ```ruby 309 | module MotionModel 310 | module Validatable 311 | def validate_foo(field, value, setting) 312 | # do whatever you need to make sure that the value 313 | # denoted by *value* for the field corresponds to 314 | # whatever is passed in setting. 315 | end 316 | end 317 | end 318 | 319 | validate :my_field, :foo => 42 320 | ``` 321 | 322 | In the above example, your new `validate_foo` method will get the arguments 323 | pretty much as you expect. The value of the 324 | last hash is passed intact via the `settings` argument. 325 | 326 | You are responsible for adding an error message using: 327 | 328 | add_message(field, "incorrect value foo #{the_foo} -- should be something else.") 329 | 330 | You must return `true` from your validator if the value passes validation otherwise `false`. 331 | 332 | An important note about `save` once you include `Validatable`, you have two flavors 333 | of save: 334 | 335 | Method | Meaning 336 | -----------------------|--------------------------- 337 | `save(options)` |Just saves the data if it is valid (passes validations) or if you have specified `:validate => false` for `options` 338 | `save!` |Saves the data if it is valid, otherwise raises a `MotionModel::Validatable::RecordInvalid` exception 339 | 340 | Model Instances and Unique IDs 341 | ----------------- 342 | 343 | It is assumed that models can be created from an external source (JSON from a Web 344 | application or `NSCoder` from the device) or simply be a stand-alone data store. 345 | To identify rows properly, the model tracks a special field called `:id`. If it's 346 | already present, it's left alone. If it's missing, then it is created for you. 347 | Each row id is guaranteed to be unique, so you can use this when communicating 348 | with a server or syncing your rowset to a UITableView. 349 | 350 | Using MotionModel 351 | ----------------- 352 | 353 | * Your data in a model is accessed in a very ActiveRecord (or Railsey) way. 354 | This should make transitioning from Rails or any ORM that follows the 355 | ActiveRecord pattern pretty easy. Some of the finder syntactic sugar is 356 | similar to that of Sequel or DataMapper. 357 | 358 | * Finders are implemented using chaining. Here is an examples: 359 | 360 | ```ruby 361 | @tasks = Task.where(:assigned_to).eq('bob').and(:location).contains('seattle') 362 | @tasks.all.each { |task| do_something_with(task) } 363 | ``` 364 | 365 | You can use a block with find: 366 | 367 | ```ruby 368 | @tasks = Task.find{|task| task.name =~ /dog/i && task.assigned_to == 'Bob'} 369 | ``` 370 | 371 | Note that finders always return a proxy (`FinderQuery`). You must use `first`, `last`, or `all` 372 | to get useful results. 373 | 374 | ```ruby 375 | @tasks = Task.where(:owner).eq('jim') # => A FinderQuery. 376 | @tasks.all # => An array of matching results. 377 | @tasks.first # => The first result 378 | ``` 379 | 380 | You can perform ordering using either a field name or block syntax. Here's an example: 381 | 382 | ```ruby 383 | @tasks = Task.order(:name).all # Get tasks ordered ascending by :name 384 | @tasks = Task.order{|one, two| two.details <=> one.details}.all # Get tasks ordered descending by :details 385 | ``` 386 | 387 | You can implement some aggregate functions using map/reduce: 388 | 389 | ```ruby 390 | @task.all.map{|task| task.number_of_items}.reduce(:+) # implements sum 391 | @task.all.map{|task| task.number_of_items}.reduce(:+) / @task.count #implements average 392 | ``` 393 | 394 | * Serialization is part of MotionModel. So, in your `AppDelegate` you might do something like this: 395 | 396 | ```ruby 397 | @tasks = Task.deserialize_from_file('tasks.dat') 398 | ``` 399 | 400 | and of course on the "save" side: 401 | 402 | ```ruby 403 | Task.serialize_to_file('tasks.dat') 404 | ``` 405 | After the first serialize or deserialize, your model will remember the file 406 | name so you can call these methods without the filename argument. 407 | 408 | Implementation note: that the this serialization of any arbitrarily complex set of relations 409 | is automatically handled by `NSCoder` provided you conform to the coding 410 | protocol (which MotionModel does). When you declare your columns, `MotionModel` understands how to 411 | serialize your data so you need take no specific action. 412 | 413 | Persistence will serialize only one 414 | model at a time and not your entire data store. 415 | This is to allow you to decide what data is 416 | serialized when. 417 | 418 | * Relations 419 | 420 | ```ruby 421 | class Task 422 | include MotionModel::Model 423 | include MotionModel::ArrayModelAdapter 424 | columns :name => :string 425 | has_many :assignees 426 | end 427 | 428 | class Assignee 429 | include MotionModel::Model 430 | include MotionModel::ArrayModelAdapter 431 | columns :assignee_name => :string 432 | belongs_to :task 433 | end 434 | 435 | # Create a task, then create an assignee as a 436 | # related object on that task 437 | a_task = Task.create(:name => "Walk the Dog") 438 | a_task.assignees.create(:assignee_name => "Howard") 439 | 440 | # See? It works. 441 | a_task.assignees.assignee_name # => "Howard" 442 | Task.first.assignees.assignee_name # => "Howard" 443 | 444 | # Create another assignee but don't save 445 | # Add to assignees collection. Both objects 446 | # are saved. 447 | another_assignee = Assignee.new(:name => "Douglas") 448 | a_task.assignees << another_assignee # adds to relation and saves both objects 449 | 450 | # The count of assignees accurately reflects current state 451 | a_task.assignees.count # => 2 452 | 453 | # And backreference access through belongs_to works. 454 | Assignee.first.task.name # => "Walk the Dog" 455 | ``` 456 | 457 | There are four ways to delete objects from your data store: 458 | 459 | * `object.delete #` just deletes the object and ignores all relations 460 | * `object.destroy #` deletes the object and honors any cascading declarations 461 | * `Class.delete_all #` just deletes all objects of this class and ignores all relations 462 | * `Class.destroy_all #` deletes all objects of this class and honors any cascading declarations 463 | 464 | The key to how the `destroy` variants work in how the relation is declared. You can declare: 465 | 466 | ```ruby 467 | class Task 468 | include MotionModel::Model 469 | include MotionModel::ArrayModelAdapter 470 | columns :name => :string 471 | has_many :assignees 472 | end 473 | ``` 474 | 475 | and `assignees` will *not be considered* when deleting `Task`s. However, by modifying the `has_many`, 476 | 477 | ```ruby 478 | has_many :assignees, :dependent => :destroy 479 | ``` 480 | 481 | When you `destroy` an object, all of the objects related to it, and only those related 482 | to that object, are also destroyed. So, if you call `task.destroy` and there are 5 483 | `assignees` related to that task, they will also be destroyed. Any other `assignees` 484 | are left untouched. 485 | 486 | You can also specify: 487 | 488 | ```ruby 489 | has_many :assignees, :dependent => :delete 490 | ``` 491 | 492 | The difference here is that the cascade stops as the `assignees` are deleted so anything 493 | related to the assignees remains intact. 494 | 495 | Note: This syntax is modeled on the Rails `:dependent => :destroy` options in `ActiveRecord`. 496 | 497 | ## Hook Methods 498 | 499 | During a save or delete operation, hook methods are called to allow you a chance to modify the 500 | object at that point. These hook methods are: 501 | 502 | ```ruby 503 | before_save(sender) 504 | after_save(sender) 505 | before_delete(sender) 506 | after_delete(sender) 507 | ``` 508 | 509 | MotionModel makes no distinction between destroy and delete when calling hook methods, as it only 510 | calls them when the actual object is deleted. In a destroy operation, during the cascading delete, 511 | the delete hooks are called (again) at the point of object deletion. 512 | 513 | Note that the method signatures may be different from previous implementations. No longer can you 514 | declare a hook method without the `sender` argument. 515 | 516 | Finally, contrasting hook methods with notifications, the hook methods `before_save` and `after_save` 517 | are called before the save operation begins and after it completes. However, the notification (covered 518 | below) is only issued after the save operation. However... the notification understands whether the 519 | operation was a save or update. Rule of thumb: If you want to catch an operation before it begins, 520 | use the hook. If you just want to know about it when it happens, use the notification. 521 | 522 | The delete hooks happen around the delete operation and, again, allow you the option to mess with the 523 | object before you allow the process to go forward (pretty much, the `before_delete` hook does this). 524 | 525 | *IMPORTANT*: Returning false in a before hook stops the rest of the operation. So, for example, you 526 | could prevent the deletion of the last admin by writing something like this: 527 | 528 | ```ruby 529 | def before_delete(sender) 530 | return false if sender.find(:privilege_level).eq('admin').count < 2 531 | end 532 | ``` 533 | 534 | ## Transactions and Undo/Cancel 535 | 536 | MotionModel is not ActiveRecord. MotionModel is not a database-backed mapper. The bottom line is that when you change a field in a model, even if you don't save it, you are partying on the central object store. In part, this is because Ruby copies objects by reference, so when you do a find, you get a reference to the object *in the central object store*. 537 | 538 | The upshot of this is that MotionModel can be wicked fast because it isn't moving much more than pointers around in memory when you do assignments. However, it can be surprising if you are used to a database-backed mapper. 539 | 540 | You could easily build an app and never run across a problem with this, but in the case where you present a dialog with a cancel button, you will need a way to back out. Here's how: 541 | 542 | ```ruby 543 | # in your form presentation view... 544 | include MotionModel::Model::Transactions 545 | 546 | person.transaction do 547 | result = do_something_that_changes_person 548 | person.rollback unless result 549 | end 550 | 551 | def do_something_that_changes_person 552 | # stuff 553 | return it_worked 554 | end 555 | ``` 556 | 557 | You can have nested transactions and each has its own context so you don't wind up rolling back to the wrong state. However, everything that you wrap in a transaction must be wrapped in the `transaction` block. That means you need to have some outer calling method that can wrap a series of delegated changes. Explained differently, you can't start a transaction, have a delegate method handle a cancel button click and roll back the transaction from inside the delegate method. When the block is exited, the transaction context is removed. 558 | 559 | Notifications 560 | ------------- 561 | 562 | Notifications are issued on object save, update, and delete. They work like this: 563 | 564 | ```ruby 565 | def viewDidAppear(animated) 566 | super 567 | # other stuff here to set up your view 568 | 569 | NSNotificationCenter.defaultCenter.addObserver(self, selector:'dataDidChange:', 570 | name:'MotionModelDataDidChangeNotification', 571 | object:nil) 572 | end 573 | 574 | def viewWillDisappear(animated) 575 | super 576 | NSNotificationCenter.defaultCenter.removeObserver self 577 | end 578 | 579 | # ... more stuff ... 580 | 581 | def dataDidChange(notification) 582 | # code to update or refresh your view based on the object passed back 583 | # and the userInfo. userInfo keys are: 584 | # action 585 | # 'add' 586 | # 'update' 587 | # 'delete' 588 | end 589 | ``` 590 | 591 | In your `dataDidChange` notification handler, you can respond to the `MotionModelDataDidChangeNotification` notification any way you like, 592 | but in the instance of a tableView, you might want to use the id of the object passed back to locate 593 | the correct row in the table and act upon it instead of doing a wholesale `reloadData`. 594 | 595 | Note that if you do a delete_all, no notifications are issued because there is no single object 596 | on which to report. You pretty much know what you need to do: Refresh your view. 597 | 598 | This is implemented as a notification and not a delegate so you can dispatch something 599 | like a remote synch operation but still be confident you will be updating the UI only on the main thread. 600 | MotionModel does not currently send notification messages that differentiate by class, so if your 601 | UI presents `Task`s and you get a notification that an `Assignee` has changed: 602 | 603 | ```ruby 604 | class Task 605 | include MotionModel::Model 606 | include MotionModel::ArrayModelAdapter 607 | has_many :assignees 608 | # etc 609 | end 610 | 611 | class Assignee 612 | include MotionModel::Model 613 | include MotionModel::ArrayModelAdapter 614 | belongs_to :task 615 | # etc 616 | end 617 | 618 | # ... 619 | 620 | task = Task.create :name => 'Walk the dog' # Triggers notification with a task object 621 | task.assignees.create :name => 'Adam' # Triggers notification with an assignee object 622 | 623 | # ... 624 | 625 | # We set up observers for `MotionModelDataDidChangeNotification` someplace and: 626 | def dataDidChange(notification) 627 | if notification.object is_a?(Task) 628 | # Update our UI 629 | else 630 | # This notification is not for us because 631 | # We don't display anything other than tasks 632 | end 633 | ``` 634 | 635 | The above example implies you are only presenting, say, a list of tasks in the current 636 | view. If, however, you are presenting a list of tasks along with their assignees and 637 | the assignees could change as a result of a background sync, then your code could and 638 | should recognize the change to assignee objects. 639 | 640 | Core Extensions 641 | ---------------- 642 | 643 | - String#humanize 644 | - String#titleize 645 | - String#empty? 646 | - String#singularize 647 | - String#pluralize 648 | - NilClass#empty? 649 | - Array#empty? 650 | - Hash#empty? 651 | - Symbol#titleize 652 | 653 | Also in the extensions is a `Debug` class to log stuff to the console. 654 | It uses NSLog so you will have a separate copy in your application log. 655 | This may be preferable to `puts` just because it's easier to spot in 656 | your code and it gives you the exact level and file/line number of the 657 | info/warning/error in your console output: 658 | 659 | - Debug.info(message) 660 | - Debug.warning(message) 661 | - Debug.error(message) 662 | - Debug.silence / Debug.resume to turn on and off logging 663 | - Debug.colorize (true/false) for pretty console display 664 | 665 | Finally, there is an inflector singleton class based around the one 666 | Rails has implemented. You don't need to dig around in this class 667 | too much, as its core functionality is exposed through two methods: 668 | 669 | String#singularize 670 | String#pluralize 671 | 672 | These work, with the caveats that 1) The inflector is English-language 673 | based; 2) Irregular nouns are not handled; 3) Singularizing a singular 674 | or pluralizing a plural makes for good cocktail-party stuff, but in 675 | code, it mangles things pretty badly. 676 | 677 | You may want to get into customizing your inflections using: 678 | 679 | - Inflector.inflections.singular(rule, replacement) 680 | - Inflector.inflections.plural(rule, replacement) 681 | - Inflector.inflections.irregular(rule, replacement) 682 | 683 | These allow you to add to the list of rules the inflector uses when 684 | processing singularize and pluralize. For each singular rule, you will 685 | probably want to add a plural one. Note that order matters for rules, 686 | so if your inflection is getting chewed up in one of the baked-in 687 | inflections, you may have to use Inflector.inflections.reset to empty 688 | them all out and build your own. 689 | 690 | Of particular note is Inflector.inflections.irregular. This is for words 691 | that defy regular rules such as 'man' => 'men' or 'person' => 'people'. 692 | Again, a reversing rule is required for both singularize and 693 | pluralize to work properly. 694 | 695 | Serialization 696 | ---------------------- 697 | 698 | The `ArrayModelAdapter` does not, by default perform any serialization. That's 699 | because how often which parts of your object graph are serialized can affect 700 | application performance. However, you *will* want to use the serialization 701 | features. Here they are: 702 | 703 | YourModel.deserialize_from_file(file_name = nil) 704 | 705 | YourModel.serialize_to_file(file_name = nil) 706 | 707 | What happens here? When you want to save a model, you call `serialize_to_file`. 708 | Each model's data must be saved to a different file so name them accordingly. 709 | If you have a model that contains related model objects, you may want to save 710 | both models. But you have complete say over that and *the responsibility to 711 | handle it*. 712 | 713 | When you call `deserialize_from_file`, your model is populated from the file 714 | previously serialized. 715 | 716 | Formotion Support 717 | ---------------------- 718 | 719 | ### Background 720 | 721 | MotionModel has support for the cool [Formotion gem](https://github.com/clayallsopp/formotion). 722 | Note that the Formotion project on GitHub appears to be way ahead of the gem on Rubygems, so you 723 | might want to build it yourself if you want the latest gee-whiz features (like `:picker_type`, as 724 | I've shown in the first example). 725 | 726 | ### High-Level View 727 | 728 | ```ruby 729 | class Event 730 | include MotionModel::Model 731 | include MotionModel::Formotion # <== Formotion support 732 | 733 | columns :name => :string, 734 | :date => {:type => :date, :formotion => {:picker_type => :date_time}}, 735 | :location => :string 736 | end 737 | ``` 738 | 739 | This declares the class. The only difference is that you include `MotionModel::Formotion`. 740 | If you want to pass additional information on to Formotion, simply include it in the 741 | `:formotion` hash as shown above. 742 | 743 | > Note: the `:formation` stuff in the `columns` specification is something I'm still thinking about. Read on to find out about the two alternate syntaxes for `to_formotion`. 744 | 745 | ### Details About `to_formotion` 746 | 747 | There are two alternate syntaxes for calling this. The initial, or "legacy" syntax is as follows: 748 | 749 | ```ruby 750 | to_formotion(form_title, expose_auto_date_fields, first_section_title) 751 | ``` 752 | 753 | In the legacy syntax, all arguments are optional and sensible defaults are chosen. However, when you want to tune how your form is presented, the syntax gets a bit burdensome. The alternate syntax is: 754 | 755 | ```ruby 756 | to_formotion(options) 757 | ``` 758 | 759 | The options hash looks a lot like a Formotion hash might, except without the data. Here is an example: 760 | 761 | ```ruby 762 | {title: 'A very fine form', 763 | sections: [ 764 | {title: 'First Section', 765 | fields: [:name, :gender] 766 | }, 767 | {title: 'Second Section', 768 | fields: [:address, :city, :state] 769 | } 770 | ]} 771 | ``` 772 | 773 | Note that in this syntax, you can specify a button in the fields array: 774 | 775 | ```ruby 776 | {title: 'A very fine form', 777 | sections: [ 778 | {title: 'First Section', 779 | fields: [:name, :gender] 780 | }, 781 | {title: 'Second Section', 782 | fields: [:address, :city, :state, {type: :submit, title: 'Ok'}] 783 | } 784 | ]} 785 | ``` 786 | 787 | This specifies exactly what titles and fields appear where and in what order. 788 | 789 | Finally, you can specify a button: 790 | 791 | ```ruby 792 | {title: 'A very fine form', 793 | sections: [ 794 | {title: 'First Section', 795 | fields: [:name, :gender] 796 | }, 797 | {title: 'Second Section', 798 | fields: [:address, :city, :state, {type: :submit, title: 'Ok'}], 799 | {type: :button, title: 'add now!!!'} 800 | } 801 | ]} 802 | ``` 803 | 804 | ### How Values Are Produced for Formotion 805 | 806 | MotionModel has sensible defaults for each type supported, so any field of `:date` 807 | type will default to a date picker in the Formotion form. However, if you want it 808 | to be a string for some reason, just specify this in `columns`: 809 | 810 | ```ruby 811 | :date => {:type => :date, :formotion => {:type => :string}} 812 | ``` 813 | 814 | To initialize a form from a model in your controller: 815 | 816 | ```ruby 817 | @form = Formotion::Form.new(@event.to_formotion('event details')) # Legacy syntax 818 | @form_controller = MyFormController.alloc.initWithForm(@form) 819 | ``` 820 | 821 | The magic is in: `MotionModel::Model#to_formotion(form_title)`. 822 | 823 | The auto_date fields `created_at` and `updated_at` are not sent to 824 | Formotion by default. If you want them sent to Formotion, set the 825 | second argument to true. E.g., 826 | 827 | ```ruby 828 | @form = Formotion::Form.new(@event.to_formotion('event details', true)) 829 | ``` 830 | 831 | On the flip side you do something like this in your Formotion submit handler: 832 | 833 | ```ruby 834 | @event.from_formotion!(data) 835 | ``` 836 | 837 | This performs sets on each field. You'll, of course, want to check your 838 | validations before dismissing the form. 839 | 840 | Moreover, Formotion support allows you to split one model fields in sections. 841 | By default all fields are put in a single untitled section. Here is a complete 842 | example: 843 | 844 | ```ruby 845 | class Event 846 | include MotionModel::Model 847 | include MotionModel::Formotion # <== Formotion support 848 | 849 | columns :name => :string, 850 | :date => {:type => :date, :formotion => {:picker_type => :date_time}}, 851 | :location => {:type => :string, :formotion => {:section => :address}} 852 | 853 | has_formotion_sections :address => {:title => "Address"} 854 | end 855 | ``` 856 | 857 | This will create a form with the `name` and `date` fields presented first, then a 858 | section titled 'Address' will contain the `location` field. 859 | 860 | If you want to add a title to the first section, provide a :first_section_title 861 | argument to `to_formotion`: 862 | 863 | ```ruby 864 | @form = Formotion::Form.new(@event.to_formotion('event details', true, 'First Section Title')) 865 | ``` 866 | 867 | Problems/Comments 868 | ------------------ 869 | 870 | Please **raise an issue** on GitHub if you find something that doesn't work, some 871 | syntax that smells, etc. 872 | 873 | If you want to stay on the bleeding edge, clone yourself a copy (or better yet, fork 874 | one). 875 | 876 | Then be sure references to motion_model are commented out or removed from your Gemfile 877 | and/or Rakefile and put this in your Rakefile: 878 | 879 | ```ruby 880 | require "~/github/local/MotionModel/lib/motion_model.rb" 881 | ``` 882 | 883 | The `~/github/local` is where I cloned it, but you can put it anyplace. Next, make 884 | sure you are following the project on GitHub so you know when there are changes. 885 | 886 | Submissions/Patches/Bug Reports 887 | ------------------ 888 | 889 | For a submission, do this: 890 | 891 | 1. Fork it 892 | 2. Create your feature branch (git checkout -b my-new-feature) 893 | 3. Commit your changes (git commit -am 'Add some feature') 894 | 4. Push to the branch (git push origin my-new-feature) 895 | 5. Create new Pull Request 896 | 897 | For a bug report, the best bet is follow the above steps, but for #2 and 4, 898 | use the issue number in the branch. Once you have created the pull request, 899 | reference it in the issue. 900 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | require "bundler/gem_tasks" 3 | $:.unshift("/Library/RubyMotion/lib") 4 | require 'motion/project/template/ios' 5 | require 'bundler' 6 | Bundler.require 7 | 8 | $:.unshift(File.expand_path('../lib', __FILE__)) 9 | require 'motion_model' 10 | 11 | Motion::Project::App.setup do |app| 12 | # Use `rake config' to see complete project settings. 13 | app.name = 'MotionModel' 14 | app.delegate_class = 'FakeDelegate' 15 | app.sdk_version = "8.1" 16 | app.deployment_target = "8.1" 17 | app.files = (app.files + Dir.glob('./app/**/*.rb')).uniq 18 | end 19 | -------------------------------------------------------------------------------- /app/app_delegate.rb: -------------------------------------------------------------------------------- 1 | class FakeDelegate 2 | end 3 | -------------------------------------------------------------------------------- /lib/motion_model.rb: -------------------------------------------------------------------------------- 1 | Motion::Project::App.setup do |app| 2 | Dir.glob(File.join(File.expand_path('../../motion/**/*.rb', __FILE__))).each do |file| 3 | app.files.unshift(file) 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /motion/adapters/array_finder_query.rb: -------------------------------------------------------------------------------- 1 | module MotionModel 2 | class ArrayFinderQuery 3 | attr_accessor :field_name 4 | 5 | def initialize(*args)#nodoc 6 | @field_name = args[0] if args.length > 1 7 | @collection = args.last 8 | end 9 | 10 | def belongs_to(obj, klass = nil) #nodoc 11 | @related_object = obj 12 | @klass = klass 13 | self 14 | end 15 | 16 | # Conjunction to add conditions to query. 17 | # 18 | # Task.find(:name => 'bob').and(:gender).eq('M') 19 | # Task.asignees.where(:assignee_name).eq('bob') 20 | def and(field_name) 21 | @field_name = field_name 22 | self 23 | end 24 | alias_method :where, :and 25 | 26 | # Specifies how to sort. only ascending sort is supported in the short 27 | # form. For descending, implement the block form. 28 | # 29 | # Task.where(:name).eq('bob').order(:pay_grade).all => array of bobs ascending by pay grade 30 | # Task.where(:name).eq('bob').order(:pay_grade){|o1, o2| o2 <=> o1} => array of bobs descending by pay grade 31 | def order(field = nil, &block) 32 | if block_given? 33 | @collection = @collection.sort{|o1, o2| yield(o1, o2)} 34 | else 35 | raise ArgumentError.new('you must supply a field name to sort unless you supply a block.') if field.nil? 36 | @collection = @collection.sort{|o1, o2| o1.send(field) <=> o2.send(field)} 37 | end 38 | self 39 | end 40 | 41 | def translate_case(item, case_sensitive)#nodoc 42 | item = item.downcase if case_sensitive === false && item.respond_to?(:downcase) 43 | item 44 | end 45 | 46 | def do_comparison(query_string, options = {:case_sensitive => false})#nodoc 47 | query_string = translate_case(query_string, options[:case_sensitive]) 48 | @collection = @collection.collect do |item| 49 | comparator = item.send(@field_name.to_sym) 50 | comparator = translate_case(comparator, options[:case_sensitive]) 51 | item if yield query_string, comparator 52 | end.compact 53 | self 54 | end 55 | 56 | # performs a "like" query. 57 | # 58 | # Task.find(:work_group).contain('dev') => ['UI dev', 'Core dev', ...] 59 | def contain(query_string, options = {:case_sensitive => false}) 60 | do_comparison(query_string) do |comparator, item| 61 | if options[:case_sensitive] 62 | item =~ Regexp.new(comparator, Regexp::MULTILINE) 63 | else 64 | item =~ Regexp.new(comparator, Regexp::IGNORECASE | Regexp::MULTILINE) 65 | end 66 | end 67 | end 68 | alias_method :contains, :contain 69 | alias_method :like, :contain 70 | 71 | # performs a set-inclusion test. 72 | # 73 | # Task.find(:id).in([3, 5, 9]) 74 | def in(set) 75 | @collection = @collection.collect do |item| 76 | item if set.include?(item.send(@field_name.to_sym)) 77 | end.compact 78 | end 79 | 80 | # performs strict equality comparison. 81 | # 82 | # If arguments are strings, they are, by default, 83 | # compared case-insensitive, if case-sensitivity 84 | # is required, use: 85 | # 86 | # eq('something', :case_sensitive => true) 87 | def eq(query_string, options = {:case_sensitive => false}) 88 | do_comparison(query_string, options) do |comparator, item| 89 | comparator == item 90 | end 91 | end 92 | alias_method :==, :eq 93 | alias_method :equal, :eq 94 | 95 | # performs greater-than comparison. 96 | # 97 | # see `eq` for notes on case sensitivity. 98 | def gt(query_string, options = {:case_sensitive => false}) 99 | do_comparison(query_string, options) do |comparator, item| 100 | comparator < item 101 | end 102 | end 103 | alias_method :>, :gt 104 | alias_method :greater_than, :gt 105 | 106 | # performs less-than comparison. 107 | # 108 | # see `eq` for notes on case sensitivity. 109 | def lt(query_string, options = {:case_sensitive => false}) 110 | do_comparison(query_string, options) do |comparator, item| 111 | comparator > item 112 | end 113 | end 114 | alias_method :<, :lt 115 | alias_method :less_than, :lt 116 | 117 | # performs greater-than-or-equal comparison. 118 | # 119 | # see `eq` for notes on case sensitivity. 120 | def gte(query_string, options = {:case_sensitive => false}) 121 | do_comparison(query_string, options) do |comparator, item| 122 | comparator <= item 123 | end 124 | end 125 | alias_method :>=, :gte 126 | alias_method :greater_than_or_equal, :gte 127 | 128 | # performs less-than-or-equal comparison. 129 | # 130 | # see `eq` for notes on case sensitivity. 131 | def lte(query_string, options = {:case_sensitive => false}) 132 | do_comparison(query_string, options) do |comparator, item| 133 | comparator >= item 134 | end 135 | end 136 | alias_method :<=, :lte 137 | alias_method :less_than_or_equal, :lte 138 | 139 | # performs inequality comparison. 140 | # 141 | # see `eq` for notes on case sensitivity. 142 | def ne(query_string, options = {:case_sensitive => false}) 143 | do_comparison(query_string, options) do |comparator, item| 144 | comparator != item 145 | end 146 | end 147 | alias_method :!=, :ne 148 | alias_method :not_equal, :ne 149 | 150 | ########### accessor methods ######### 151 | 152 | # returns first element or count elements that matches. 153 | def first(*args) 154 | to_a.send(:first, *args) 155 | end 156 | 157 | # returns last element or count elements that matches. 158 | def last(*args) 159 | to_a.send(:last, *args) 160 | end 161 | 162 | # returns all elements that match as an array. 163 | def all 164 | to_a 165 | end 166 | alias_method :array, :all 167 | 168 | # returns all elements that match as an array. 169 | def to_a 170 | @collection || [] 171 | end 172 | 173 | # each is a shortcut method to turn a query into an iterator. It allows 174 | # you to write code like: 175 | # 176 | # Task.where(:assignee).eq('bob').each{ |assignee| do_something_with(assignee) } 177 | def each(&block) 178 | raise ArgumentError.new("each requires a block") unless block_given? 179 | @collection.each{|item| yield item} 180 | end 181 | 182 | # returns length of the result set. 183 | def length 184 | @collection.length 185 | end 186 | alias_method :count, :length 187 | 188 | ################ relation support ############## 189 | 190 | # task.assignees.create(:name => 'bob') 191 | # creates a new Assignee object on the Task object task 192 | def create(options) 193 | raise ArgumentError.new("Creating on a relation requires the parent be saved first.") if @related_object.nil? 194 | obj = new(options) 195 | obj.save 196 | obj 197 | end 198 | 199 | # task.assignees.new(:name => 'BoB') 200 | # creates a new unsaved Assignee object on the Task object task 201 | def new(options = {}) 202 | raise ArgumentError.new("Creating on a relation requires the parent be saved first.") if @related_object.nil? 203 | 204 | id_field = (@related_object.class.to_s.underscore + '_id').to_sym 205 | new_obj = @klass.new(options.merge(id_field => @related_object.id)) 206 | 207 | new_obj 208 | end 209 | 210 | # Returns number of objects (rows) in collection 211 | def length 212 | @collection.length 213 | end 214 | alias_method :count, :length 215 | 216 | # Pushes an object onto an association. For e.g.: 217 | # 218 | # Task.find(3).assignees.push(assignee) 219 | # 220 | # This both establishes the relation and saves the related 221 | # object, so make sure the related object is valid. 222 | def push(object) 223 | id_field = (@related_object.class.to_s.underscore + '_id=').to_sym 224 | object.send(id_field, @related_object.id) 225 | result = object.save 226 | result ||= @related_object.save 227 | result 228 | end 229 | alias_method :<<, :push 230 | end 231 | end 232 | -------------------------------------------------------------------------------- /motion/adapters/array_model_adapter.rb: -------------------------------------------------------------------------------- 1 | module MotionModel 2 | module ArrayModelAdapter 3 | def adapter 4 | 'Array Model Adapter' 5 | end 6 | 7 | def self.included(base) 8 | base.extend(PrivateClassMethods) 9 | base.extend(PublicClassMethods) 10 | base.instance_eval do 11 | _reset_next_id 12 | end 13 | end 14 | 15 | module PublicClassMethods 16 | def collection 17 | @collection ||= [] 18 | end 19 | 20 | def insert(object) 21 | collection << object 22 | end 23 | alias :<< :insert 24 | 25 | def length 26 | collection.length 27 | end 28 | alias_method :count, :length 29 | 30 | # Deletes all rows in the model -- no hooks are called and 31 | # deletes are not cascading so this does not affected related 32 | # data. 33 | def delete_all 34 | # Do each delete so any on_delete and 35 | # cascades are called, then empty the 36 | # collection and compact the array. 37 | bulk_update { collection.pop.delete until collection.empty? } 38 | _reset_next_id 39 | end 40 | 41 | # Finds row(s) within the data store. E.g., 42 | # 43 | # @post = Post.find(1) # find a specific row by ID 44 | # 45 | # or... 46 | # 47 | # @posts = Post.find(:author).eq('bob').all 48 | def find(*args, &block) 49 | if block_given? 50 | matches = collection.collect do |item| 51 | item if yield(item) 52 | end.compact 53 | return ArrayFinderQuery.new(matches) 54 | end 55 | 56 | unless args[0].is_a?(Symbol) || args[0].is_a?(String) 57 | target_id = args[0].to_i 58 | return collection.select{|element| element.id == target_id}.first 59 | end 60 | 61 | ArrayFinderQuery.new(args[0].to_sym, collection) 62 | end 63 | alias_method :where, :find 64 | 65 | def find_by_id(id) 66 | find(:id).eq(id).first 67 | end 68 | 69 | # Returns query result as an array 70 | def all 71 | collection 72 | end 73 | alias_method :array, :all 74 | 75 | def order(field_name = nil, &block) 76 | ArrayFinderQuery.new(collection).order(field_name, &block) 77 | end 78 | 79 | end 80 | 81 | module PrivateClassMethods 82 | private 83 | 84 | # Returns next available id 85 | def _next_id #nodoc 86 | @_next_id 87 | end 88 | 89 | def _reset_next_id 90 | @_next_id = 1 91 | end 92 | 93 | # Increments next available id 94 | def increment_next_id(other_id) #nodoc 95 | @_next_id = [@_next_id, other_id.to_i].max + 1 96 | end 97 | 98 | end 99 | 100 | def before_initialize(options) 101 | assign_id(options) 102 | end 103 | 104 | def increment_next_id(other_id) 105 | self.class.send(:increment_next_id, other_id) 106 | end 107 | 108 | # Undelete does pretty much as its name implies. However, 109 | # the natural sort order is not preserved. IMPORTANT: If 110 | # you are trying to undo a cascading delete, this will not 111 | # work. It only undeletes the object you still own. 112 | 113 | def undelete 114 | collection << self 115 | issue_notification(:action => 'add') 116 | end 117 | 118 | def collection #nodoc 119 | self.class.collection 120 | end 121 | 122 | # This adds to the ArrayStore without the magic date 123 | # and id manipulation stuff 124 | def add_to_store(*) 125 | do_insert 126 | @dirty = @new_record = false 127 | end 128 | 129 | # Count of objects in the current collection 130 | def length 131 | collection.length 132 | end 133 | alias_method :count, :length 134 | 135 | private 136 | 137 | def _next_id 138 | self.class.send(:_next_id) 139 | end 140 | 141 | def assign_id(options) #nodoc 142 | options[:id] ||= _next_id 143 | increment_next_id(options[:id]) 144 | end 145 | 146 | def belongs_to_relation(col) # nodoc 147 | col.classify.find_by_id(_get_attr(col.foreign_key)) 148 | end 149 | 150 | def has_many_relation(col) # nodoc 151 | _has_many_has_one_relation(col) 152 | end 153 | 154 | def has_one_relation(col) # nodoc 155 | _has_many_has_one_relation(col) 156 | end 157 | 158 | def _has_many_has_one_relation(col) # nodoc 159 | related_klass = col.classify 160 | related_klass.find(col.inverse_column.foreign_key).belongs_to(self, related_klass).eq(_get_attr(:id)) 161 | end 162 | 163 | def do_insert(options = {}) 164 | collection << self 165 | end 166 | 167 | def do_update(options = {}) 168 | self 169 | end 170 | 171 | def do_delete 172 | target_index = collection.index{|item| item.id == self.id} 173 | collection.delete_at(target_index) unless target_index.nil? 174 | issue_notification(:action => 'delete') 175 | end 176 | 177 | end 178 | end 179 | -------------------------------------------------------------------------------- /motion/adapters/array_model_persistence.rb: -------------------------------------------------------------------------------- 1 | module MotionModel 2 | module ArrayModelAdapter 3 | class PersistFileError < Exception; end 4 | class VersionNumberError < ArgumentError; end 5 | 6 | module PublicClassMethods 7 | 8 | def validate_schema_version(version_number) 9 | raise MotionModel::ArrayModelAdapter::VersionNumberError.new('version number must be a string') unless version_number.is_a?(String) 10 | if version_number !~ /^[\d.]+$/ 11 | raise MotionModel::ArrayModelAdapter::VersionNumberError.new('version number string must contain only numbers and periods') 12 | end 13 | end 14 | 15 | # Declare a version number for this schema. For example: 16 | # 17 | # class Task 18 | # include MotionModel::Model 19 | # include MotionModel::ArrayModelAdapter 20 | # 21 | # version_number 1.0.1 22 | # end 23 | # 24 | # When a version number mismatch occurs as an individual row is loaded 25 | # from persistent storage, the migrate method is invoked, allowing 26 | # you to programmatically migrate on a per-row basis. 27 | 28 | def schema_version(*version_number) 29 | if version_number.empty? 30 | return @schema_version 31 | else 32 | validate_schema_version(version_number[0]) 33 | @schema_version = version_number[0] 34 | end 35 | end 36 | 37 | def migrate 38 | end 39 | 40 | 41 | # Returns the unarchived object if successful, otherwise false 42 | # 43 | # Note that subsequent calls to serialize/deserialize methods 44 | # will remember the file name, so they may omit that argument. 45 | # 46 | # Raises a +MotionModel::PersistFileFailureError+ on failure. 47 | def deserialize_from_file(file_name = nil, directory = nil) 48 | if schema_version != '1.0.0' 49 | migrate 50 | end 51 | 52 | @file_name = file_name if file_name 53 | @file_path = 54 | if directory.nil? 55 | documents_file(@file_name) 56 | else 57 | File.join(directory, @file_name) 58 | end 59 | 60 | 61 | if File.exist? @file_path 62 | error_ptr = Pointer.new(:object) 63 | 64 | data = NSData.dataWithContentsOfFile(@file_path, options:NSDataReadingMappedIfSafe, error:error_ptr) 65 | 66 | if data.nil? 67 | error = error_ptr[0] 68 | raise MotionModel::PersistFileError.new "Error when reading the data: #{error}" 69 | else 70 | bulk_update do 71 | NSKeyedUnarchiver.unarchiveObjectWithData(data) 72 | end 73 | 74 | # ensure _next_id is in sync with deserialized model 75 | max_id = self.all.map { |o| o.id }.max 76 | increment_next_id(max_id) unless max_id.nil? || _next_id > max_id 77 | 78 | return self 79 | end 80 | else 81 | return false 82 | end 83 | end 84 | # Serializes data to a persistent store (file, in this 85 | # terminology). Serialization is synchronous, so this 86 | # will pause your run loop until complete. 87 | # 88 | # +file_name+ is the name of the persistent store you 89 | # want to use. If you omit this, it will use the last 90 | # remembered file name. 91 | # 92 | # Raises a +MotionModel::PersistFileError+ on failure. 93 | def serialize_to_file(file_name = nil, directory = nil) 94 | @file_name = file_name if file_name 95 | @file_path = 96 | if directory.nil? 97 | documents_file(@file_name) 98 | else 99 | File.join(directory, @file_name) 100 | end 101 | 102 | error_ptr = Pointer.new(:object) 103 | 104 | data = NSKeyedArchiver.archivedDataWithRootObject collection 105 | unless data.writeToFile(@file_path, options: NSDataWritingAtomic, error: error_ptr) 106 | # De-reference the pointer. 107 | error = error_ptr[0] 108 | 109 | # Now we can use the `error' object. 110 | raise MotionModel::PersistFileError.new "Error when writing data: #{error}" 111 | end 112 | end 113 | 114 | def documents_file(file_name) 115 | file_path = File.join NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, true), file_name 116 | file_path 117 | end 118 | end 119 | 120 | def initWithCoder(coder) 121 | self.init 122 | 123 | new_tag_id = 1 124 | columns.each do |attr| 125 | next if has_relation?(attr) 126 | # If a model revision has taken place, don't try to decode 127 | # something that's not there. 128 | if coder.containsValueForKey(attr.to_s) 129 | value = coder.decodeObjectForKey(attr.to_s) 130 | self.send("#{attr}=", value) 131 | else 132 | self.send("#{attr}=", nil) 133 | end 134 | 135 | # re-issue tags to make sure they are unique 136 | @tag = new_tag_id 137 | new_tag_id += 1 138 | end 139 | add_to_store 140 | 141 | self 142 | end 143 | 144 | # Follow Apple's recommendation not to encode missing 145 | # values. 146 | def encodeWithCoder(coder) 147 | columns.each do |attr| 148 | # Serialize attributes except the proxy has_many and belongs_to ones. 149 | unless [:belongs_to, :has_many, :has_one].include? column(attr).type 150 | value = self.send(attr) 151 | unless value.nil? 152 | coder.encodeObject(value, forKey: attr.to_s) 153 | end 154 | end 155 | end 156 | end 157 | 158 | end 159 | end 160 | -------------------------------------------------------------------------------- /motion/date_parser.rb: -------------------------------------------------------------------------------- 1 | module DateParser 2 | @@isoDateFormatter = nil 3 | @@detector = nil 4 | 5 | # Parse a date string: E.g.: 6 | # 7 | # DateParser.parse_date "There is a date in here tomorrow at 9:00 AM" 8 | # 9 | # => 2013-02-20 09:00:00 -0800 10 | def self.parse_date(date_string) 11 | if date_string.match(/\d{2}T\d{2}/) 12 | return fractional_date(date_string) if date_string =~ /\.\d{3}Z$/ 13 | return Time.iso8601(date_string) 14 | end 15 | 16 | detect(date_string).first.date 17 | end 18 | 19 | # Parse time zone from date 20 | # 21 | # DateParser.parse_date "There is a date in here tomorrow at 9:00 AM EDT" 22 | # 23 | # Caveat: This is implemented per Apple documentation. I've never really 24 | # seen it work. 25 | def self.parse_time_zone(date_string) 26 | detect(date_string).first.timeZone 27 | end 28 | 29 | # Parse a date string: E.g.: 30 | # 31 | # SugarCube::DateParser.parse_date "You have a meeting from 9:00 AM to 3:00 PM" 32 | # 33 | # => 21600.0 34 | # 35 | # Divide by 3600.0 to get number of hours duration. 36 | def self.parse_duration(date_string) 37 | detect(date_string).first.send(:duration) 38 | end 39 | 40 | # Parse a date into a raw match array for further processing 41 | def self.match(date_string) 42 | detect(date_string) 43 | end 44 | 45 | private 46 | def self.detect(date_string) 47 | error = Pointer.new(:object) 48 | detector = NSDataDetector.dataDetectorWithTypes(NSTextCheckingTypeDate, error:error) 49 | matches = detector.matchesInString(date_string, options:0, range:NSMakeRange(0, date_string.length)) 50 | end 51 | 52 | def self.allocate_date_formatter 53 | @@isoDateFormatter = NSDateFormatter.alloc.init 54 | @@isoDateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSZ'" 55 | end 56 | 57 | def self.fractional_date(date_string) 58 | allocate_date_formatter if @@isoDateFormatter.nil? 59 | date = @@isoDateFormatter.dateFromString date_string 60 | return date 61 | end 62 | 63 | def self.allocate_data_detector 64 | error = Pointer.new(:object) 65 | @@detector = NSDataDetector.dataDetectorWithTypes(NSTextCheckingTypeDate, error:error) 66 | end 67 | 68 | def self.detector 69 | allocate_data_detector if @@detector.nil? 70 | return @@detector 71 | end 72 | end 73 | 74 | 75 | class String 76 | # Use NSDataDetector to parse a string containing a date 77 | # or duration. These can be of the form: 78 | # 79 | # "tomorrow at 7:30 PM" 80 | # "11.23.2013" 81 | # "from 7:30 to 10:00 AM" 82 | # 83 | # etc. 84 | def to_date 85 | DateParser.parse_date(self) 86 | end 87 | 88 | def to_timezone 89 | DateParser.parse_time_zone(self) 90 | end 91 | 92 | def to_duration 93 | DateParser.parse_duration(self) 94 | end 95 | end 96 | -------------------------------------------------------------------------------- /motion/ext.rb: -------------------------------------------------------------------------------- 1 | # The reason the String extensions are wrapped in 2 | # conditional blocks is to reduce the likelihood 3 | # of a namespace collision with other libraries. 4 | 5 | class String 6 | unless String.instance_methods.include?(:humanize) 7 | def humanize 8 | self.split(/_|-| /).join(' ') 9 | end 10 | end 11 | 12 | unless String.instance_methods.include?(:titleize) 13 | def titleize 14 | self.split(/_|-| /).each{|word| word[0...1] = word[0...1].upcase}.join(' ') 15 | end 16 | end 17 | 18 | unless String.instance_methods.include?(:empty?) 19 | def empty? 20 | self.length < 1 21 | end 22 | end 23 | 24 | unless String.instance_methods.include?(:pluralize) 25 | def pluralize 26 | Inflector.inflections.pluralize self 27 | end 28 | end 29 | 30 | unless String.instance_methods.include?(:singularize) 31 | def singularize 32 | Inflector.inflections.singularize self 33 | end 34 | end 35 | 36 | unless String.instance_methods.include?(:camelize) 37 | def camelize(uppercase_first_letter = true) 38 | string = self.dup 39 | string.gsub!(/(?:_|(\/))([a-z\d]*)/i) do 40 | new_word = $2.downcase 41 | new_word[0] = new_word[0].upcase 42 | new_word = "/#{new_word}" if $1 == '/' 43 | new_word 44 | end 45 | if uppercase_first_letter && uppercase_first_letter != :lower 46 | string[0] = string[0].upcase 47 | else 48 | string[0] = string[0].downcase 49 | end 50 | string.gsub!('/', '::') 51 | string 52 | end 53 | end 54 | 55 | unless String.instance_methods.include?(:underscore) 56 | def underscore 57 | word = self.dup 58 | word.gsub!(/::/, '/') 59 | word.gsub!(/([A-Z\d]+)([A-Z][a-z])/,'\1_\2') 60 | word.gsub!(/([a-z\d])([A-Z])/,'\1_\2') 61 | word.tr!("-", "_") 62 | word.downcase! 63 | word 64 | end 65 | end 66 | end 67 | 68 | # Inflector is a singleton class that helps 69 | # singularize, pluralize and other-thing-ize 70 | # words. It is very much based on the Rails 71 | # ActiveSupport implementation or Inflector 72 | class Inflector 73 | def self.instance #nodoc 74 | @__instance__ ||= new 75 | end 76 | 77 | def initialize #nodoc 78 | reset 79 | end 80 | 81 | def reset 82 | # Put singular-form to plural form transformations here 83 | @plurals = [ 84 | [/^person$/, 'people'], 85 | [/^man$/, 'men'], 86 | [/^child$/, 'children'], 87 | [/^sex$/, 'sexes'], 88 | [/^move$/, 'moves'], 89 | [/^cow$/, 'kine'], 90 | [/^zombie$/, 'zombies'], 91 | [/(quiz)$/i, '\1zes'], 92 | [/^(oxen)$/i, '\1'], 93 | [/^(ox)$/i, '\1en'], 94 | [/^(m|l)ice$/i, '\1ice'], 95 | [/^(m|l)ouse$/i, '\1ice'], 96 | [/(matr|vert|ind)(?:ix|ex)$/i, '\1ices'], 97 | [/(x|ch|ss|sh)$/i, '\1es'], 98 | [/([^aeiouy]|qu)y$/i, '\1ies'], 99 | [/(hive)$/i, '\1s'], 100 | [/(?:([^f])fe|([lr])f)$/i, '\1\2ves'], 101 | [/sis$/i, 'ses'], 102 | [/([ti])a$/i, '\1a'], 103 | [/([ti])um$/i, '\1a'], 104 | [/(buffal|tomat)o$/i, '\1oes'], 105 | [/(bu)s$/i, '\1ses'], 106 | [/(alias|status)$/i, '\1es'], 107 | [/(octop|vir)i$/i, '\1i'], 108 | [/(octop|vir|alumn)us$/i, '\1i'], 109 | [/^(ax|test)is$/i, '\1es'], 110 | [/s$/i, 's'], 111 | [/$/, 's'] 112 | ] 113 | 114 | # Put plural-form to singular form transformations here 115 | @singulars = [ 116 | [/^people$/, 'person'], 117 | [/^men$/, 'man'], 118 | [/^children$/, 'child'], 119 | [/^sexes$/, 'sex'], 120 | [/^moves$/, 'move'], 121 | [/^kine$/, 'cow'], 122 | [/^zombies$/, 'zombie'], 123 | [/(database)s$/i, '\1'], 124 | [/(quiz)zes$/i, '\1'], 125 | [/(matr)ices$/i, '\1ix'], 126 | [/(vert|ind)ices$/i, '\1ex'], 127 | [/^(ox)en/i, '\1'], 128 | [/(alias|status)(es)?$/i, '\1'], 129 | [/(octop|vir|alumn)(us|i)$/i, '\1us'], 130 | [/^(a)x[ie]s$/i, '\1xis'], 131 | [/(cris|test)(is|es)$/i, '\1is'], 132 | [/(shoe)s$/i, '\1'], 133 | [/(o)es$/i, '\1'], 134 | [/(bus)(es)?$/i, '\1'], 135 | [/^(m|l)ice$/i, '\1ouse'], 136 | [/(x|ch|ss|sh)es$/i, '\1'], 137 | [/(m)ovies$/i, '\1ovie'], 138 | [/(s)eries$/i, '\1eries'], 139 | [/([^aeiouy]|qu)ies$/i, '\1y'], 140 | [/([lr])ves$/i, '\1f'], 141 | [/(tive)s$/i, '\1'], 142 | [/(hive)s$/i, '\1'], 143 | [/([^f])ves$/i, '\1fe'], 144 | [/(^analy)(sis|ses)$/i, '\1sis'], 145 | [/((a)naly|(b)a|(d)iagno|(p)arenthe|(p)rogno|(s)ynop|(t)he)(sis|ses)$/i, '\1sis'], 146 | [/([ti])a$/i, '\1um'], 147 | [/(n)ews$/i, '\1ews'], 148 | [/(ss)$/i, '\1'], 149 | [/s$/i, ''] 150 | ] 151 | 152 | @irregulars = [ 153 | ] 154 | 155 | @uncountables = [ 156 | 'equipment', 157 | 'information', 158 | 'rice', 159 | 'money', 160 | 'species', 161 | 'series', 162 | 'fish', 163 | 'sheep', 164 | 'jeans', 165 | 'police' 166 | ] 167 | end 168 | 169 | attr_reader :plurals, :singulars, :uncountables, :irregulars 170 | 171 | def self.inflections 172 | if block_given? 173 | yield Inflector.instance 174 | else 175 | Inflector.instance 176 | end 177 | end 178 | 179 | def uncountable(word) 180 | @uncountables << word 181 | end 182 | 183 | def singular(rule, replacement) 184 | @singulars << [rule, replacement] 185 | end 186 | 187 | def plural(rule, replacement) 188 | @plurals << [rule, replacement] 189 | end 190 | 191 | def irregular(rule, replacement) 192 | @irregulars << [rule, replacement] 193 | end 194 | 195 | def uncountable?(word) 196 | return word if @uncountables.include?(word.downcase) 197 | false 198 | end 199 | 200 | def inflect(word, direction) #nodoc 201 | return word if uncountable?(word) 202 | 203 | subject = word.dup 204 | 205 | @irregulars.each do |rule| 206 | return subject if subject.gsub!(rule.first, rule.last) 207 | end 208 | 209 | sense_group = direction == :singularize ? @singulars : @plurals 210 | sense_group.each do |rule| 211 | return subject if subject.gsub!(rule.first, rule.last) 212 | end 213 | subject 214 | end 215 | 216 | def singularize(word) 217 | inflect word, :singularize 218 | end 219 | 220 | def pluralize(word) 221 | inflect word, :pluralize 222 | end 223 | end 224 | 225 | class NilClass 226 | def empty? 227 | true 228 | end 229 | end 230 | 231 | class Array 232 | def empty? 233 | self.length < 1 234 | end 235 | 236 | # If any item in the array has the key == `key` true, otherwise false. 237 | # Of good use when writing specs. 238 | def has_hash_key?(key) 239 | self.each do |entity| 240 | return true if entity.has_key? key 241 | end 242 | return false 243 | end 244 | 245 | # If any item in the array has the value == `key` true, otherwise false 246 | # Of good use when writing specs. 247 | def has_hash_value?(key) 248 | self.each do |entity| 249 | entity.each_pair{|hash_key, value| return true if value == key} 250 | end 251 | return false 252 | end 253 | end 254 | 255 | 256 | 257 | class Hash 258 | def empty? 259 | self.length < 1 260 | end 261 | 262 | # Returns the contents of the hash, with the exception 263 | # of the keys specified in the keys array. 264 | def except(*keys) 265 | self.dup.reject{|k, v| keys.include?(k)} 266 | end 267 | end 268 | 269 | class Symbol 270 | def titleize 271 | self.to_s.titleize 272 | end 273 | end 274 | 275 | class Ansi 276 | ESCAPE = "\033" 277 | 278 | def self.color(color_constant) 279 | "#{ESCAPE}[#{color_constant}m" 280 | end 281 | 282 | def self.reset_color 283 | color 0 284 | end 285 | 286 | def self.yellow_color 287 | color 33 288 | end 289 | 290 | def self.green_color 291 | color 32 292 | end 293 | 294 | def self.red_color 295 | color 31 296 | end 297 | end 298 | 299 | class Debug 300 | @@silent = false 301 | @@colorize = true 302 | 303 | class << self 304 | # Use silence if you want to keep messages from being echoed 305 | # to the console. 306 | def silence 307 | @@silent = true 308 | end 309 | 310 | def colorize 311 | @@colorize 312 | end 313 | 314 | def colorize=(value) 315 | @@colorize = value == true 316 | end 317 | 318 | # Use resume when you want messages that were silenced to 319 | # resume displaying. 320 | def resume 321 | @@silent = false 322 | end 323 | 324 | def put_message(type, message, color = Ansi.reset_color) 325 | open_color = @@colorize ? color : '' 326 | close_color = @@colorize ? Ansi.reset_color : '' 327 | 328 | NSLog("#{open_color}#{type} #{caller[1]}: #{message}#{close_color}") unless @@silent 329 | end 330 | 331 | def info(msg) 332 | put_message 'INFO', msg, Ansi.green_color 333 | end 334 | alias :log :info 335 | 336 | def warning(msg) 337 | put_message 'WARNING', msg, Ansi.yellow_color 338 | end 339 | 340 | def error(msg) 341 | put_message 'ERROR', msg, Ansi.red_color 342 | end 343 | end 344 | end 345 | 346 | # These are C macros in iOS SDK. Not workable for Ruby. 347 | # def UIInterfaceOrientationIsLandscape(orientation) 348 | # orientation == UIInterfaceOrientationLandscapeLeft || 349 | # orientation == UIInterfaceOrientationLandscapeRight 350 | # end 351 | # 352 | # def UIInterfaceOrientationIsPortrait(orientation) 353 | # orientation == UIInterfaceOrientationPortrait || 354 | # orientation == UIInterfaceOrientationPortraitUpsideDown 355 | # end 356 | 357 | class Module 358 | # Retrieve a constant within its scope 359 | def deep_const_get(const) 360 | if Symbol === const 361 | const = const.to_s 362 | else 363 | const = const.to_str.dup 364 | end 365 | if const.sub!(/^::/, '') 366 | base = Object 367 | else 368 | base = self 369 | end 370 | const.split(/::/).inject(base) { |mod, name| mod.const_get(name) } 371 | end 372 | end 373 | 374 | class Object 375 | def try(*a, &b) 376 | if a.empty? && block_given? 377 | yield self 378 | else 379 | public_send(*a, &b) if respond_to?(a.first) 380 | end 381 | end 382 | end 383 | -------------------------------------------------------------------------------- /motion/input_helpers.rb: -------------------------------------------------------------------------------- 1 | module MotionModel 2 | module InputHelpers 3 | class ModelNotSetError < RuntimeError; end 4 | 5 | # FieldBindingMap contains a simple label to model 6 | # field binding, and is decorated by a tag to be 7 | # used on the UI control. 8 | class FieldBindingMap 9 | attr_accessor :label, :name, :tag 10 | 11 | def initialize(options = {}) 12 | @name = options[:name] 13 | @label = options[:label] 14 | end 15 | end 16 | 17 | def self.included(base) 18 | base.extend(ClassMethods) 19 | base.instance_variable_set('@binding_data', []) 20 | end 21 | 22 | module ClassMethods 23 | # +field+ is a declarative macro that specifies 24 | # the field name (i.e., the model field name) 25 | # and the label. In the absence of a label, 26 | # +field+ attempts to synthesize one from the 27 | # model field name. YMMV. 28 | # 29 | # Usage: 30 | # 31 | # class MyInputSheet < UIViewController 32 | # include InputHelpers 33 | # 34 | # field 'event_name', :label => 'name' 35 | # field 'event_location', :label => 'location 36 | # 37 | # Only one field mapping may be supplied for 38 | # a given class. 39 | def field(field, options = {}) 40 | label = options[:label] || field.humanize 41 | @binding_data << FieldBindingMap.new(:label => label, :name => field) 42 | end 43 | end 44 | 45 | # +model+ is a mandatory method in which you 46 | # specify the instance of the model to which 47 | # your fields are bound. 48 | 49 | def model(model_instance) 50 | @model = model_instance 51 | end 52 | 53 | # +field_count+ specifies how many fields have 54 | # been bound. 55 | # 56 | # Usage: 57 | # 58 | # def tableView(table, numberOfRowsInSection: section) 59 | # field_count 60 | # end 61 | 62 | def field_count 63 | self.class.instance_variable_get('@binding_data'.to_sym).length 64 | end 65 | 66 | # +field_at+ retrieves the field at a given index. 67 | # 68 | # Usage: 69 | # 70 | # field = field_at(indexPath.row) 71 | # label_view = subview(UILabel, :label_frame, text: field.label) 72 | 73 | def field_at(index) 74 | data = self.class.instance_variable_get('@binding_data'.to_sym) 75 | data[index].tag = index + 1 76 | data[index] 77 | end 78 | 79 | # +value_at+ retrieves the value from the form that corresponds 80 | # to the name of the field. 81 | # 82 | # Usage: 83 | # 84 | # value_edit_view = subview(UITextField, :input_value_frame, text: value_at(field)) 85 | 86 | def value_at(field) 87 | @model.send(field.name) 88 | end 89 | 90 | # +fields+ is the iterator for all fields 91 | # mapped for this class. 92 | # 93 | # Usage: 94 | # 95 | # fields do |field| 96 | # do_something_with field.label, field.value 97 | # end 98 | 99 | def fields 100 | self.class.instance_variable_get('@binding_data'.to_sym).each{|datum| yield datum} 101 | end 102 | 103 | # +bind+ fetches all mapped fields from 104 | # any subview of the current +UIView+ 105 | # and transfers the contents to the 106 | # corresponding fields of the model 107 | # specified by the +model+ method. 108 | def bind 109 | raise ModelNotSetError.new("You must set the model before binding it.") unless @model 110 | 111 | fields do |field| 112 | view_obj = self.view.viewWithTag(field.tag) 113 | @model.send("#{field.name}=".to_sym, view_obj.text) if view_obj.respond_to?(:text) 114 | end 115 | end 116 | 117 | # Handle hiding the keyboard if the user 118 | # taps "return". If you don't want this behavior, 119 | # define the function as empty in your class. 120 | def textFieldShouldReturn(textField) 121 | textField.resignFirstResponder 122 | end 123 | 124 | # Keyboard show/hide handlers do this: 125 | # 126 | # * Reset the table insets so that the 127 | # UITableView knows how large its real 128 | # visible area. 129 | # * Scroll the UITableView to reveal the 130 | # cell that has the +firstResponder+ 131 | # if it is not already showing. 132 | # 133 | # Of course, the process is exactly reversed 134 | # when the keyboard hides. 135 | # 136 | # An instance variable +@table+ is assumed to 137 | # be the table to affect; if this is missing, 138 | # this code will simply no-op. 139 | # 140 | # Rejigger everything under the sun when the 141 | # keyboard slides up. 142 | # 143 | # You *must* handle the +UIKeyboardWillShowNotification+ and 144 | # when you receive it, call this method to handle the keyboard 145 | # showing. 146 | def handle_keyboard_will_show(notification) 147 | return unless @table 148 | 149 | animationCurve = notification.userInfo.valueForKey(UIKeyboardAnimationCurveUserInfoKey) 150 | animationDuration = notification.userInfo.valueForKey(UIKeyboardAnimationDurationUserInfoKey) 151 | keyboardEndRect = notification.userInfo.valueForKey(UIKeyboardFrameEndUserInfoKey) 152 | keyboardEndRect = view.convertRect(keyboardEndRect.CGRectValue, fromView:App.delegate.window) 153 | 154 | UIView.beginAnimations "changeTableViewContentInset", context:nil 155 | UIView.setAnimationDuration animationDuration 156 | UIView.setAnimationCurve animationCurve 157 | 158 | intersectionOfKeyboardRectAndWindowRect = CGRectIntersection(App.delegate.window.frame, keyboardEndRect) 159 | bottomInset = intersectionOfKeyboardRectAndWindowRect.size.height; 160 | 161 | @table.contentInset = UIEdgeInsetsMake(0, 0, bottomInset, 0) 162 | 163 | UIView.commitAnimations 164 | 165 | 166 | @table.scrollToRowAtIndexPath(owner_cell_index_path, 167 | atScrollPosition:UITableViewScrollPositionMiddle, 168 | animated: true) 169 | end 170 | 171 | def owner_cell_index_path 172 | # Find active cell 173 | indexPathOfOwnerCell = nil 174 | numberOfCells = @table.dataSource.tableView(@table, numberOfRowsInSection:0) 175 | 0.upto(numberOfCells) do |index| 176 | indexPath = NSIndexPath.indexPathForRow(index, inSection:0) 177 | cell = @table.cellForRowAtIndexPath(indexPath) 178 | return indexPath if find_first_responder(cell) 179 | end 180 | 181 | # By default use the first section, first row. 182 | NSIndexPath.indexPathForRow 0, inSection: 0 183 | end 184 | 185 | # Undo all the rejiggering when the keyboard slides 186 | # down. 187 | # 188 | # You *must* handle the +UIKeyboardWillHideNotification+ and 189 | # when you receive it, call this method to handle the keyboard 190 | # hiding. 191 | def handle_keyboard_will_hide(notification) 192 | return unless @table 193 | 194 | if UIEdgeInsetsEqualToEdgeInsets(@table.contentInset, UIEdgeInsetsZero) 195 | return 196 | end 197 | 198 | animationCurve = notification.userInfo.valueForKey(UIKeyboardAnimationCurveUserInfoKey) 199 | animationDuration = notification.userInfo.valueForKey(UIKeyboardAnimationDurationUserInfoKey) 200 | 201 | UIView.beginAnimations("changeTableViewContentInset", context:nil) 202 | UIView.setAnimationDuration(animationDuration) 203 | UIView.setAnimationCurve(animationCurve) 204 | 205 | @table.contentInset = UIEdgeInsetsZero; 206 | 207 | UIView.commitAnimations 208 | end 209 | 210 | def find_first_responder(parent) 211 | return parent if parent.isFirstResponder 212 | 213 | parent.subviews.each do |subview| 214 | first_responder = find_first_responder(subview) 215 | return first_responder if first_responder 216 | end 217 | 218 | return false 219 | end 220 | end 221 | end 222 | -------------------------------------------------------------------------------- /motion/model/column.rb: -------------------------------------------------------------------------------- 1 | module MotionModel 2 | module Model 3 | class Column 4 | attr_reader :name 5 | attr_reader :owner 6 | attr_reader :type 7 | attr_reader :options 8 | 9 | OPTION_ATTRS = [:as, :conditions, :default, :dependent, :foreign_key, :inverse_of, :joined_class_name, 10 | :polymorphic, :symbolize, :through] 11 | 12 | OPTION_ATTRS.each do |key| 13 | define_method(key) { @options[key] } 14 | end 15 | 16 | def initialize(owner, name = nil, type = nil, options = {}) 17 | raise RuntimeError.new "columns need a type declared." if type.nil? 18 | @owner = owner 19 | @name = name 20 | @type = type 21 | @klass = options.delete(:class) 22 | @options = options 23 | end 24 | 25 | def class_name 26 | joined_class_name || name 27 | end 28 | 29 | def primary_key 30 | :id 31 | end 32 | 33 | def foreign_name 34 | as || name 35 | end 36 | 37 | def foreign_polymorphic_type 38 | "#{foreign_name}_type".to_sym 39 | end 40 | 41 | def foreign_key 42 | @options[:foreign_key] || "#{foreign_name.to_s.singularize}_id".to_sym 43 | end 44 | 45 | def classify 46 | fail "Column#classify indeterminate for polymorphic associations" if type == :belongs_to && polymorphic 47 | if @klass 48 | @klass 49 | else 50 | case @type 51 | when :belongs_to 52 | @klass ||= Object.const_get(class_name.to_s.camelize) 53 | when :has_many, :has_one 54 | @klass ||= Object.const_get(class_name.to_s.singularize.camelize) 55 | else 56 | raise "#{@name} is not a relation. This isn't supposed to happen." 57 | end 58 | end 59 | end 60 | 61 | def class_const_get 62 | Kernel::const_get(classify) 63 | end 64 | 65 | def through_class 66 | Kernel::const_get(through.to_s.classify) 67 | end 68 | 69 | def inverse_foreign_key 70 | inverse_column.foreign_key 71 | end 72 | 73 | def inverse_name 74 | if as 75 | as 76 | elsif inverse_of 77 | inverse_of 78 | elsif type == :belongs_to 79 | # Check for a singular and a plural relationship 80 | name = owner.name.singularize.underscore 81 | col = classify.column(name) 82 | col ||= classify.column(name.pluralize) 83 | col.name 84 | else 85 | owner.name.singularize.underscore.to_sym 86 | end 87 | end 88 | 89 | def inverse_column 90 | classify.column(inverse_name) 91 | end 92 | 93 | end 94 | end 95 | end 96 | -------------------------------------------------------------------------------- /motion/model/formotion.rb: -------------------------------------------------------------------------------- 1 | module MotionModel 2 | module Formotion 3 | def self.included(base) 4 | base.extend(PublicClassMethods) 5 | end 6 | module PublicClassMethods 7 | def has_formotion_sections(sections = {}) 8 | define_method( "formotion_sections") do 9 | sections 10 | end 11 | end 12 | end 13 | FORMOTION_MAP = { 14 | :string => :string, 15 | :date => :date, 16 | :time => :date, 17 | :int => :number, 18 | :integer => :number, 19 | :float => :number, 20 | :double => :number, 21 | :bool => :check, 22 | :boolean => :check, 23 | :text => :text 24 | } 25 | 26 | def should_return(column) #nodoc 27 | skippable = [:id] 28 | skippable += [:created_at, :updated_at] unless @expose_auto_date_fields 29 | !skippable.include?(column) && !relation_column?(column) 30 | end 31 | 32 | def returnable_columns #nodoc 33 | columns.select{|column| should_return(column)} 34 | end 35 | 36 | def default_hash_for(column, value) 37 | value = value.to_f if is_date_time?(column) 38 | 39 | {:key => column.to_sym, 40 | :title => column.to_s.humanize, 41 | :type => FORMOTION_MAP[column_type(column)], 42 | :placeholder => column.to_s.humanize, 43 | :value => value 44 | } 45 | end 46 | 47 | def is_date_time?(column) 48 | column_type = column_type(column) 49 | [:date, :time].include?(column_type) 50 | end 51 | 52 | def value_for(column) #nodoc 53 | value = self.send(column) 54 | value = value.to_f if value && is_date_time?(column) 55 | value 56 | end 57 | 58 | def combine_options(column, hash) #nodoc 59 | options = column(column).options[:formotion] 60 | options ? hash.merge(options) : hash 61 | end 62 | 63 | # to_formotion maps a MotionModel into a hash suitable for creating 64 | # a Formotion form. By default, the auto date fields, created_at 65 | # and updated_at are suppressed. If you want these shown in 66 | # your Formotion form, set expose_auto_date_fields to true 67 | # 68 | # If you want a title for your Formotion form, set the form_title 69 | # argument to a string that will become that title. 70 | def to_formotion(form_title = nil, expose_auto_date_fields = false, first_section_title = nil) 71 | return new_to_formotion(form_title) if form_title.is_a? Hash 72 | 73 | @expose_auto_date_fields = expose_auto_date_fields 74 | 75 | sections = { 76 | default: {rows: []} 77 | } 78 | if respond_to? 'formotion_sections' 79 | formotion_sections.each do |k,v| 80 | sections[k] = v 81 | sections[k][:rows] = [] 82 | end 83 | end 84 | sections[:default][:title] ||= first_section_title 85 | 86 | returnable_columns.each do |column| 87 | value = value_for(column) 88 | h = default_hash_for(column, value) 89 | s = column(column).options[:formotion] ? column(column).options[:formotion][:section] : nil 90 | if s 91 | sections[s] ||= {} 92 | sections[s][:rows].push(combine_options(column,h)) 93 | else 94 | sections[:default][:rows].push(combine_options(column, h)) 95 | end 96 | end 97 | 98 | form = { 99 | sections: [] 100 | } 101 | form[:title] ||= form_title 102 | sections.each do |k,section| 103 | form[:sections] << section 104 | end 105 | form 106 | end 107 | 108 | # new_to_formotion maps a MotionModel into a hash in a user-definable 109 | # manner, according to options. 110 | # 111 | # form_title: String for form title 112 | # sections: Array of sections 113 | # 114 | # Within sections, use these keys: 115 | # 116 | # title: String for section title 117 | # field: Name of field in your model (Symbol) 118 | # 119 | # Hash looks something like this: 120 | # 121 | # {sections: [ 122 | # {title: 'First Section', # First section 123 | # fields: [:name, :gender] # contains name and gender 124 | # }, 125 | # {title: 'Second Section', 126 | # fields: [:address, :city, :state], # Second section, address 127 | # {title: 'Submit', type: :submit} # city, state add submit button 128 | # } 129 | # ]} 130 | def new_to_formotion(options = {form_title: nil, sections: []}) 131 | form = {} 132 | 133 | @expose_auto_date_fields = options[:auto_date_fields] 134 | 135 | fields = returnable_columns 136 | form[:title] = options[:form_title] unless options[:form_title].nil? 137 | fill_from_options(form, options) if options[:sections] 138 | form 139 | end 140 | 141 | def fill_from_options(form, options) 142 | form[:sections] ||= [] 143 | 144 | options[:sections].each do |section| 145 | form[:sections] << fill_section(section) 146 | end 147 | form 148 | end 149 | 150 | def fill_section(section) 151 | new_section = {} 152 | 153 | section.each_pair do |key, value| 154 | case key 155 | when :title 156 | new_section[:title] = value unless value.nil? 157 | when :fields 158 | new_section[:rows] ||= [] 159 | value.each do |field_or_hash| 160 | new_section[:rows].push(fill_row(field_or_hash)) 161 | end 162 | end 163 | end 164 | new_section 165 | end 166 | 167 | def fill_row(field_or_hash) 168 | case field_or_hash 169 | when Hash 170 | return field_or_hash unless field_or_hash.keys.detect{|key| key =~ /^formotion_/} 171 | else 172 | combine_options field_or_hash, default_hash_for(field_or_hash, self.send(field_or_hash)) 173 | end 174 | end 175 | 176 | # from_formotion takes the information rendered from a Formotion 177 | # form and stuffs it back into a MotionModel. This data is not saved until 178 | # you say so, offering you the opportunity to validate your form data. 179 | def from_formotion!(data) 180 | self.returnable_columns.each{|column| 181 | if data[column] && column_type(column) == :date || column_type(column) == :time 182 | data[column] = Time.at(data[column]) unless data[column].nil? 183 | end 184 | value = self.send("#{column}=", data[column]) 185 | } 186 | end 187 | end 188 | end 189 | -------------------------------------------------------------------------------- /motion/model/model.rb: -------------------------------------------------------------------------------- 1 | # MotionModel encapsulates a pattern for synthesizing a model 2 | # out of thin air. The model will have attributes, types, 3 | # finders, ordering, ... the works. 4 | # 5 | # As an example, consider: 6 | # 7 | # class Task 8 | # include MotionModel 9 | # 10 | # columns :task_name => :string, 11 | # :details => :string, 12 | # :due_date => :date 13 | # 14 | # # any business logic you might add... 15 | # end 16 | # 17 | # Now, you can write code like: 18 | # 19 | # 20 | # Recognized types are: 21 | # 22 | # * :string 23 | # * :text 24 | # * :date (must be in YYYY-mm-dd form) 25 | # * :time 26 | # * :integer 27 | # * :float 28 | # * :boolean 29 | # * :array 30 | # 31 | # Assuming you have a bunch of tasks in your data store, you can do this: 32 | # 33 | # tasks_this_week = Task.where(:due_date).ge(beginning_of_week).and(:due_date).le(end_of_week).order(:due_date) 34 | # 35 | # Partial queries are supported so you can do: 36 | # 37 | # tasks_this_week = Task.where(:due_date).ge(beginning_of_week).and(:due_date).le(end_of_week) 38 | # ordered_tasks_this_week = tasks_this_week.order(:due_date) 39 | # 40 | module MotionModel 41 | class PersistFileError < Exception; end 42 | class RelationIsNilError < Exception; end 43 | class AdapterNotFoundError < Exception; end 44 | class RecordNotSaved < Exception; end 45 | 46 | module Model 47 | def self.included(base) 48 | base.extend(PrivateClassMethods) 49 | base.extend(PublicClassMethods) 50 | 51 | base.instance_eval do 52 | unless self.respond_to?(:id) 53 | add_field(:id, :integer) 54 | end 55 | end 56 | end 57 | 58 | module PublicClassMethods 59 | 60 | def new(options = {}) 61 | object_class = options[:inheritance_type] ? Kernel.const_get(options[:inheritance_type]) : self 62 | object_class.allocate.instance_eval do 63 | initialize(options) 64 | self 65 | end 66 | end 67 | 68 | # Use to do bulk insertion, updating, or deleting without 69 | # making repeated calls to a delegate. E.g., when syncing 70 | # with an external data source. 71 | def bulk_update(&block) 72 | self._issue_notifications = false 73 | class_eval &block 74 | self._issue_notifications = true 75 | end 76 | 77 | # Macro to define names and types of columns. It can be used in one of 78 | # two forms: 79 | # 80 | # Pass a hash, and you define columns with types. E.g., 81 | # 82 | # columns :name => :string, :age => :integer 83 | # 84 | # Pass a hash of hashes and you can specify defaults such as: 85 | # 86 | # columns :name => {:type => :string, :default => 'Joe Bob'}, :age => :integer 87 | # 88 | # Pass an array, and you create column names, all of which have type +:string+. 89 | # 90 | # columns :name, :age, :hobby 91 | 92 | def columns(*fields) 93 | return _columns.map{|c| c.name} if fields.empty? 94 | 95 | case fields.first 96 | when Hash 97 | column_from_hash fields 98 | when String, Symbol 99 | column_from_string_or_sym fields 100 | else 101 | raise ArgumentError.new("arguments to `columns' must be a symbol, a hash, or a hash of hashes -- was #{fields.first}.") 102 | end 103 | 104 | unless columns.include?(:id) 105 | add_field(:id, :integer) 106 | end 107 | end 108 | 109 | # Use at class level, as follows: 110 | # 111 | # class Task 112 | # include MotionModel::Model 113 | # include MotionModel::ArrayModelAdapter 114 | # 115 | # columns :name, :details, :assignees, :created_at, :updated_at 116 | # has_many :assignees 117 | # protects_remote_timestamps 118 | # 119 | # In this case, creating or updating will not alter the values of the 120 | # timestamps, preferring to allow the server to be the only authority 121 | # for assigning timestamp information. 122 | 123 | def protect_remote_timestamps 124 | @_protect_remote_timestamps = true 125 | end 126 | 127 | def protect_remote_timestamps? 128 | @_protect_remote_timestamps == true 129 | end 130 | 131 | # Use at class level, as follows: 132 | # 133 | # class Task 134 | # include MotionModel::Model 135 | # 136 | # columns :name, :details, :assignees 137 | # has_many :assignees 138 | # 139 | # Note that :assignees must be declared as a virtual attribute on the 140 | # model before you can has_many on it. 141 | # 142 | # This enables code like: 143 | # 144 | # Task.find(:due_date).gt(Time.now).first.assignees 145 | # 146 | # to get the people assigned to first task that is due after right now. 147 | # 148 | # This must be used with a belongs_to macro in the related model class 149 | # if you want to be able to access the inverse relation. 150 | 151 | def has_many(relation, options = {}) 152 | raise ArgumentError.new("arguments to has_many must be a symbol or string.") unless [Symbol, String].include? relation.class 153 | add_field relation, :has_many, options # Relation must be plural 154 | end 155 | 156 | def has_one(relation, options = {}) 157 | raise ArgumentError.new("arguments to has_one must be a symbol or string.") unless [Symbol, String].include? relation.class 158 | add_field relation, :has_one, options # Relation must be plural 159 | end 160 | 161 | # Use at class level, as follows 162 | # 163 | # class Assignee 164 | # include MotionModel::Model 165 | # 166 | # columns :assignee_name, :department 167 | # belongs_to :task 168 | # 169 | # Allows code like this: 170 | # 171 | # Assignee.find(:assignee_name).like('smith').first.task 172 | def belongs_to(relation, options = {}) 173 | add_field relation, :belongs_to, options 174 | end 175 | 176 | # Returns true if a column exists on this model, otherwise false. 177 | def column?(col) 178 | !column(col).nil? 179 | end 180 | 181 | # Returns type of this column. 182 | def column_type(col) 183 | column(col).type || nil 184 | end 185 | 186 | def column(col) 187 | col.is_a?(Column) ? col : _column_hashes[col.to_sym] 188 | end 189 | 190 | def has_many_columns 191 | _column_hashes.select { |name, col| col.type == :has_many} 192 | end 193 | 194 | def has_one_columns 195 | _column_hashes.select { |name, col| col.type == :has_one} 196 | end 197 | 198 | def belongs_to_columns 199 | _column_hashes.select { |name, col| col.type == :belongs_to} 200 | end 201 | 202 | def association_columns 203 | _column_hashes.select { |name, col| [:belongs_to, :has_many, :has_one].include?(col.type)} 204 | end 205 | 206 | # returns default value for this column or nil. 207 | def default(col) 208 | _col = column(col) 209 | _col.nil? ? nil : _col.default 210 | end 211 | 212 | # Build an instance that represents a saved object from the persistence layer. 213 | def read(attrs) 214 | new(attrs).instance_eval do 215 | @new_record = false 216 | @dirty = false 217 | self 218 | end 219 | end 220 | 221 | def create!(options) 222 | result = create(options) 223 | raise RecordNotSaved unless result 224 | result 225 | end 226 | 227 | # Creates an object and saves it. E.g.: 228 | # 229 | # @bob = Person.create(:name => 'Bob', :hobby => 'Bird Watching') 230 | # 231 | # returns the object created or false. 232 | def create(options = {}) 233 | row = self.new(options) 234 | row.save 235 | row 236 | end 237 | 238 | # Destroys all rows in the model -- before_delete and after_delete 239 | # hooks are called and deletes are not cascading if declared with 240 | # :dependent => :destroy in the has_many macro. 241 | def destroy_all 242 | ids = self.all.map{|item| item.id} 243 | bulk_update do 244 | ids.each do |item| 245 | find_by_id(item).destroy 246 | end 247 | end 248 | # Note collection is not emptied, and next_id is not reset. 249 | end 250 | 251 | # Retrieves first row or count rows of query 252 | def first(*args) 253 | all.send(:first, *args) 254 | end 255 | 256 | # Retrieves last row or count rows of query 257 | def last(*args) 258 | all.send(:last, *args) 259 | end 260 | 261 | def each(&block) 262 | raise ArgumentError.new("each requires a block") unless block_given? 263 | all.each{|item| yield item} 264 | end 265 | 266 | def empty? 267 | all.empty? 268 | end 269 | end 270 | 271 | module PrivateClassMethods 272 | 273 | private 274 | 275 | attr_accessor :abstract_class 276 | 277 | def config 278 | @config ||= begin 279 | if !superclass.ancestors.include?(MotionModel::Model) || superclass.abstract_class 280 | {} 281 | else 282 | superclass.send(:config).dup 283 | end 284 | end 285 | end 286 | 287 | # Hashes to for quick column lookup 288 | def _column_hashes 289 | config[:column_hashes] ||= {} 290 | end 291 | 292 | # BUGBUG: This appears not to be executed, therefore @_issue_notifications is always nil to begin with. 293 | @_issue_notifications = true 294 | def _issue_notifications 295 | @_issue_notifications = true if @_issue_notifications.nil? 296 | @_issue_notifications 297 | end 298 | 299 | def _issue_notifications=(value) 300 | @_issue_notifications = value 301 | end 302 | 303 | def _columns 304 | _column_hashes.values 305 | end 306 | 307 | # This populates a column from something like: 308 | # 309 | # columns :name => :string, :age => :integer 310 | # 311 | # or 312 | # 313 | # columns :name => {:type => :string, :default => 'Joe Bob'}, :age => :integer 314 | 315 | def column_from_hash(hash) #nodoc 316 | hash.first.each_pair do |name, options| 317 | raise ArgumentError.new("you cannot use `description' as a column name because of a conflict with Cocoa.") if name.to_s == 'description' 318 | 319 | case options 320 | when Symbol, String, Class 321 | add_field(name, options) 322 | when Hash 323 | add_field(name, options.delete(:type), options) 324 | else 325 | raise ArgumentError.new("arguments to `columns' must be a symbol, a hash, or a hash of hashes.") 326 | end 327 | end 328 | end 329 | 330 | # This populates a column from something like: 331 | # 332 | # columns :name, :age, :hobby 333 | 334 | def column_from_string_or_sym(string) #nodoc 335 | string.each do |name| 336 | add_field(name.to_sym, :string) 337 | end 338 | end 339 | 340 | def issue_notification(object, info) #nodoc 341 | if _issue_notifications == true && !object.nil? 342 | NSNotificationCenter.defaultCenter.postNotificationName('MotionModelDataDidChangeNotification', object: object, userInfo: info) 343 | end 344 | end 345 | 346 | def define_accessor_methods(name, type, options = {}) #nodoc 347 | define_method(name.to_sym) { _get_attr(name) } unless allocate.respond_to?(name) 348 | define_method("#{name}=".to_sym) { |v| _set_attr(name, v) } 349 | end 350 | 351 | def define_belongs_to_methods(name) #nodoc 352 | col = column(name) 353 | define_method(name) { get_belongs_to_attr(col) } 354 | define_method("#{name}=") { |owner| set_belongs_to_attr(col, owner) } 355 | 356 | # TODO also define #{name}+id= methods.... 357 | 358 | if col.polymorphic 359 | add_field col.foreign_polymorphic_type, :belongs_to_type 360 | add_field col.foreign_key, :belongs_to_id 361 | else 362 | add_field col.foreign_key, :belongs_to_id # a relation is singular. 363 | end 364 | end 365 | 366 | def define_has_many_methods(name) #nodoc 367 | col = column(name) 368 | define_method(name) { get_has_many_attr(col) } 369 | define_method("#{name}=") { |collection| set_has_many_attr(col, *collection) } 370 | end 371 | 372 | def define_has_one_methods(name) #nodoc 373 | col = column(name) 374 | define_method(name) { get_has_one_attr(col) } 375 | define_method("#{name}=") { |instance| set_has_one_attr(col, instance) } 376 | end 377 | 378 | def add_field(name, type, options = {:default => nil}) #nodoc 379 | name = name.to_sym 380 | col = Column.new(self, name, type, options) 381 | 382 | _column_hashes[col.name] = col 383 | 384 | case type 385 | when :has_many then define_has_many_methods(name) 386 | when :has_one then define_has_one_methods(name) 387 | when :belongs_to then define_belongs_to_methods(name) 388 | else define_accessor_methods(name, type, options) 389 | end 390 | end 391 | 392 | # Returns the column that has the name as its :as option 393 | def column_as(col) #nodoc 394 | _col = column(col) 395 | _column_hashes.values.find{ |c| c.as == _col.name } 396 | end 397 | 398 | # All relation columns, including type and id columns for polymorphic associations 399 | def relation_column?(col) #nodoc 400 | _col = column(col) 401 | [:belongs_to, :belongs_to_id, :belongs_to_type, :has_many, :has_one].include?(_col.type) 402 | end 403 | 404 | # Polymorphic association columns that are not stored in DB 405 | def virtual_polymorphic_relation_column?(col) #nodoc 406 | _col = column(col) 407 | [:belongs_to, :has_many, :has_one].include?(_col.type) 408 | end 409 | 410 | def has_relation?(col) #nodoc 411 | return false if col.nil? 412 | _col = column(col) 413 | [:has_many, :has_one, :belongs_to].include?(_col.type) 414 | end 415 | 416 | end 417 | 418 | def initialize(options = {}) 419 | raise AdapterNotFoundError.new("You must specify a persistence adapter.") unless self.respond_to? :adapter 420 | 421 | @data ||= {} 422 | before_initialize(options) if respond_to?(:before_initialize) 423 | 424 | # Gather defaults 425 | columns.each do |col| 426 | next if options.has_key?(col) 427 | next if relation_column?(col) 428 | default = self.class.default(col) 429 | options[col] = default unless default.nil? 430 | end 431 | 432 | options.each do |col, value| 433 | initialize_data_columns col, value 434 | end 435 | 436 | @dirty = true 437 | @new_record = true 438 | end 439 | 440 | # String uniquely identifying a saved model instance in memory 441 | def object_identifier 442 | ["#{self.class.name}", (id.nil? ? nil : "##{id}"), ":0x#{self.object_id.to_s(16)}"].join 443 | end 444 | 445 | # String uniquely identifying a saved model instance 446 | def model_identifier 447 | raise 'Invalid' unless id 448 | "#{self.class.name}##{id}" 449 | end 450 | 451 | def motion_model? 452 | true 453 | end 454 | 455 | def new_record? 456 | @new_record 457 | end 458 | 459 | # Returns true if +comparison_object+ is the same exact object, or +comparison_object+ 460 | # is of the same type and +self+ has an ID and it is equal to +comparison_object.id+. 461 | # 462 | # Note that new records are different from any other record by definition, unless the 463 | # other record is the receiver itself. Besides, if you fetch existing records with 464 | # +select+ and leave the ID out, you're on your own, this predicate will return false. 465 | # 466 | # Note also that destroying a record preserves its ID in the model instance, so deleted 467 | # models are still comparable. 468 | def ==(comparison_object) 469 | super || 470 | comparison_object.instance_of?(self.class) && 471 | !id.nil? && 472 | comparison_object.id == id 473 | end 474 | alias :eql? :== 475 | 476 | def attributes 477 | @data 478 | end 479 | 480 | def attributes=(attrs) 481 | attrs.each { |k, v| set_attr(k, v) } 482 | end 483 | 484 | def update_attributes(attrs) 485 | self.attributes = attrs 486 | save 487 | end 488 | 489 | def read_attribute(name) 490 | @data[name] 491 | end 492 | 493 | def write_attribute(attr_name, value) 494 | @data[attr_name] = value 495 | @dirty = true 496 | end 497 | 498 | # Default to_i implementation returns value of id column, much as 499 | # in Rails. 500 | 501 | def to_i 502 | @data[:id].to_i 503 | end 504 | 505 | # Default inspect implementation returns identifier and ID 506 | # Need to keep this short, i.e. for running specs as the output could be very large 507 | def inspect 508 | object_identifier 509 | end 510 | 511 | def to_s 512 | columns.each{|c| "#{c}: #{get_attr(c)}\n"} 513 | end 514 | 515 | def save!(options = {}) 516 | result = save(options) 517 | raise RecordNotSaved unless result 518 | result 519 | end 520 | 521 | # Save current object. Speaking from the context of relational 522 | # databases, this inserts a row if it's a new one, or updates 523 | # in place if not. 524 | def save(options = {}) 525 | save_without_transaction(options) 526 | end 527 | 528 | # Performs the save. 529 | # This is separated to allow #save to do any transaction handling that might be necessary. 530 | def save_without_transaction(options = {}) 531 | return false if @deleted 532 | call_hooks 'save' do 533 | # Existing object implies update in place 534 | action = 'add' 535 | set_auto_date_field 'updated_at' 536 | if new_record? 537 | set_auto_date_field 'created_at' 538 | result = do_insert(options) 539 | else 540 | result = do_update(options) 541 | action = 'update' 542 | end 543 | @new_record = false 544 | @dirty = false 545 | issue_notification(:action => action) 546 | result 547 | end 548 | end 549 | 550 | # Set created_at and updated_at fields 551 | def set_auto_date_field(field_name) 552 | unless self.class.protect_remote_timestamps? 553 | method = "#{field_name}=" 554 | self.send(method, Time.now) if self.respond_to?(method) 555 | end 556 | end 557 | 558 | # Stub methods for hook protocols 559 | def before_save(sender); end 560 | def after_save(sender); end 561 | def before_delete(sender); end 562 | def after_delete(sender); end 563 | def before_destroy(sender); end 564 | def after_destroy(sender); end 565 | 566 | def call_hook(hook_name, postfix) 567 | hook = "#{hook_name}_#{postfix}" 568 | self.send(hook, self) 569 | end 570 | 571 | def call_hooks(hook_name, &block) 572 | result = call_hook('before', hook_name) 573 | # returning false from a before_ hook stops the process 574 | result = block.call if result != false && block_given? 575 | call_hook('after', hook_name) if result 576 | result 577 | end 578 | 579 | def delete(options = {}) 580 | return if @deleted 581 | call_hooks('delete') do 582 | options = options.dup 583 | options[:omit_model_identifiers] ||= {} 584 | options[:omit_model_identifiers][model_identifier] = self 585 | do_delete 586 | @deleted = true 587 | end 588 | end 589 | 590 | # Destroys the current object. The difference between delete 591 | # and destroy is that destroy calls before_delete 592 | # and after_delete hooks. As well, it will cascade 593 | # into related objects, deleting them if they are related 594 | # using :dependent => :destroy in the has_many 595 | # and has_one> declarations 596 | # 597 | # Note: lifecycle hooks are only called when individual objects 598 | # are deleted. 599 | def destroy(options = {}) 600 | call_hooks 'destroy' do 601 | options = options.dup 602 | options[:omit_model_identifiers] ||= {} 603 | options[:omit_model_identifiers][model_identifier] = self 604 | self.class.association_columns.each do |name, col| 605 | delete_candidates = get_attr(name) 606 | Array(delete_candidates).each do |candidate| 607 | next if options[:omit_model_identifiers][candidate.model_identifier] 608 | if col.dependent == :destroy 609 | candidate.destroy(options) 610 | elsif col.dependent == :delete 611 | candidate.delete(options) 612 | end 613 | end 614 | end 615 | delete 616 | end 617 | self 618 | end 619 | 620 | # True if the column exists, otherwise false 621 | def column?(col) 622 | self.class.column?(col) 623 | end 624 | 625 | def column(col) 626 | self.class.column(col) 627 | end 628 | 629 | # Returns list of column names as an array 630 | def columns 631 | self.class.columns 632 | end 633 | 634 | # Type of a given column 635 | def column_type(col) 636 | self.class.column_type(col) 637 | end 638 | 639 | # Options hash for column, excluding the core 640 | # options such as type, default, etc. 641 | # 642 | # Options are completely arbitrary so you can 643 | # stuff anything in this hash you want. For 644 | # example: 645 | # 646 | # columns :date => {:type => :date, :formotion => {:picker_type => :date_time}} 647 | def options(col) 648 | column(col).options 649 | end 650 | 651 | def dirty? 652 | @dirty 653 | end 654 | 655 | def set_dirty 656 | @dirty = true 657 | end 658 | 659 | def get_attr(name) 660 | send(name) 661 | end 662 | 663 | def _attr_present?(name) 664 | @data.has_key?(name) 665 | end 666 | 667 | def _get_attr(col) 668 | _col = column(col) 669 | return nil if @data[_col.name].nil? 670 | if _col.symbolize 671 | @data[_col.name].to_sym 672 | else 673 | @data[_col.name] 674 | end 675 | end 676 | 677 | def set_attr(name, value) 678 | method = "#{name}=".to_sym 679 | respond_to?(method) ? send(method, value) : _set_attr(name, value) 680 | end 681 | 682 | def _set_attr(name, value) 683 | name = name.to_sym 684 | old_value = @data[name] 685 | new_value = !column(name) || relation_column?(name) ? value : cast_to_type(name, value) 686 | if new_value != old_value 687 | @data[name] = new_value 688 | @dirty = true 689 | end 690 | end 691 | 692 | def get_belongs_to_attr(col) 693 | belongs_to_relation(col) 694 | end 695 | 696 | def get_has_many_attr(col) 697 | _has_many_has_one_relation(col) 698 | end 699 | 700 | def get_has_one_attr(col) 701 | has_one_attr = _has_many_has_one_relation(col) 702 | has_one_attr = has_one_attr.first if has_one_attr.is_a?(ArrayFinderQuery) && !has_one_attr.first.nil? 703 | has_one_attr 704 | end 705 | 706 | # Associate the owner but without rebuilding the inverse assignment 707 | def set_belongs_to_attr(col, owner, options = {}) 708 | _col = column(col) 709 | unless belongs_to_synced?(_col, owner) 710 | _set_attr(_col.name, owner) 711 | rebuild_relation(_col, owner, set_inverse: options[:set_inverse]) 712 | if _col.polymorphic 713 | set_polymorphic_attr(_col.name, owner) 714 | else 715 | _set_attr(_col.foreign_key, owner ? owner.id : nil) 716 | end 717 | end 718 | 719 | owner 720 | end 721 | 722 | # Determine if the :belongs_to relationship is synchronized. Checks the instance and the DB column attributes. 723 | def belongs_to_synced?(col, owner) 724 | # The :belongs_to that points to the instance has changed 725 | return false if get_belongs_to_attr(col) != owner 726 | 727 | # The polymorphic reference (_type, _id) columns do not match, maybe it was just saved 728 | return false if col.polymorphic && !polymorphic_attr_matches?(col, owner) 729 | 730 | # The key reference (_id) column does not match, maybe it was just saved 731 | return false if _get_attr(col.foreign_key) != owner.try(:id) 732 | 733 | true 734 | end 735 | 736 | def push_has_many_attr(col, *instances) 737 | _col = column(col) 738 | collection = get_has_many_attr(_col) 739 | _collection = [] 740 | instances.each do |instance| 741 | next if collection.include?(instance) 742 | _collection << instance 743 | end 744 | push_relation(_col, *_collection) 745 | instances 746 | end 747 | 748 | # TODO clean up existing reference, check rails 749 | def set_has_many_attr(col, *instances) 750 | _col = column(col) 751 | unload_relation(_col) 752 | push_has_many_attr(_col, *instances) 753 | instances 754 | end 755 | 756 | def set_has_one_attr(col, instance) 757 | get_has_one_attr(col).push(instance) 758 | instance 759 | end 760 | 761 | def get_polymorphic_attr(col) 762 | _col = column(col) 763 | owner_class = nil 764 | id = _get_attr(_col.foreign_key) 765 | unless id.nil? 766 | owner_class_name = _get_attr(_col.foreign_polymorphic_type) 767 | owner_class_name = String(owner_class_name) # RubyMotion issue, String#classify might fail otherwise 768 | owner_class = Kernel::deep_const_get(owner_class_name.classify) 769 | end 770 | [owner_class, id] 771 | end 772 | 773 | 774 | def polymorphic_attr_matches?(col, instance) 775 | klass, id = get_polymorphic_attr(col) 776 | klass == instance.class && id == instance.id 777 | end 778 | 779 | def set_polymorphic_attr(col, instance) 780 | _col = column(col) 781 | _set_attr(_col.foreign_polymorphic_type, instance.class.name) 782 | _set_attr(_col.foreign_key, instance.id) 783 | instance 784 | end 785 | 786 | def foreign_column_name(col) 787 | if col.polymorphic 788 | col.as || col.name 789 | elsif col.foreign_key 790 | col.foreign_key 791 | else 792 | self.class.name.underscore.to_sym 793 | end 794 | end 795 | 796 | private 797 | 798 | def _column_hashes 799 | self.class.send(:_column_hashes) 800 | end 801 | 802 | def relation_column?(col) 803 | self.class.send(:relation_column?, col) 804 | end 805 | 806 | def virtual_polymorphic_relation_column?(col) 807 | self.class.send(:virtual_polymorphic_relation_column?, col) 808 | end 809 | 810 | def has_relation?(col) #nodoc 811 | self.class.send(:has_relation?, col) 812 | end 813 | 814 | def rebuild_relation(col, instance_or_collection, options = {}) # nodoc 815 | end 816 | 817 | def unload_relation(col) 818 | end 819 | 820 | def evaluate_default_value(column, value) 821 | default = self.class.default(column) 822 | 823 | case default 824 | when NilClass 825 | {column => value} 826 | when Proc 827 | begin 828 | {column => default.call} 829 | rescue Exception => ex 830 | Debug.error "\n\nProblem initializing #{self.class} : #{column} with default and proc.\nException: #{ex.message}\nSorry, your app is pretty much crashing.\n" 831 | exit 832 | end 833 | when Symbol 834 | {column => self.send(column)} 835 | else 836 | {column => (value.nil? ? default : value)} 837 | end 838 | end 839 | 840 | # issue #113. added ability to specify a proc or block 841 | # for the default value. This allows for arrays to be 842 | # created as unique. E.g.: 843 | # 844 | # class Foo 845 | # include MotionModel::Model 846 | # include MotionModel::ArrayModelAdapter 847 | # columns subject: { type: :array, default: ->{ [] } } 848 | # end 849 | # 850 | # ... 851 | # 852 | # This is not constrained to initializing arrays. You can 853 | # initialize pretty much anything using a proc or block. 854 | # If you are specifying a block, make sure to use begin/end 855 | # instead of do/end because it makes Ruby happy. 856 | 857 | def initialize_data_columns(column, value) #nodoc 858 | self.attributes = evaluate_default_value(column, value) 859 | end 860 | 861 | def column_as(col) #nodoc 862 | self.class.send(:column_as, col) 863 | end 864 | 865 | def issue_notification(info) #nodoc 866 | self.class.send(:issue_notification, self, info) 867 | end 868 | 869 | def method_missing(sym, *args, &block) 870 | return @data[sym] if sym.to_s[-1] != '=' && @data && @data.has_key?(sym) 871 | super 872 | end 873 | 874 | end 875 | end 876 | -------------------------------------------------------------------------------- /motion/model/model_casts.rb: -------------------------------------------------------------------------------- 1 | module MotionModel 2 | class Model 3 | def cast_to_bool(arg) 4 | case arg 5 | when NilClass then false 6 | when TrueClass, FalseClass then arg 7 | when Integer then arg != 0 8 | when String then (arg =~ /^true/i) != nil 9 | else raise ArgumentError.new("type #{column_name} : #{column_type(column_name)} is not possible to cast.") 10 | end 11 | end 12 | 13 | def cast_to_integer(arg) 14 | arg.is_a?(Integer) ? arg : arg.to_i 15 | end 16 | 17 | def cast_to_float(arg) 18 | arg.is_a?(Float) ? arg : arg.to_f 19 | end 20 | 21 | def cast_to_date(arg) 22 | case arg 23 | when String 24 | return DateParser::parse_date(arg) 25 | # return NSDate.dateWithNaturalLanguageString(arg.gsub('-','/'), locale:NSUserDefaults.standardUserDefaults.dictionaryRepresentation) 26 | when Time, NSDate 27 | return arg 28 | # return NSDate.dateWithNaturalLanguageString(arg.strftime('%Y/%m/%d %H:%M:%S'), locale:NSUserDefaults.standardUserDefaults.dictionaryRepresentation) 29 | else 30 | return arg 31 | end 32 | end 33 | 34 | def cast_to_array(arg) 35 | array=*arg 36 | array 37 | end 38 | 39 | def cast_to_hash(arg) 40 | arg.is_a?(String) ? BW::JSON.parse(String(arg)) : arg 41 | end 42 | 43 | def cast_to_string(arg) 44 | String(arg) 45 | end 46 | 47 | def cast_to_arbitrary_class(arg) 48 | # This little oddity is because a number of built-in 49 | # Ruby classes cannot be dup'ed. Not only that, they 50 | # respond_to?(:dup) but raise an exception when you 51 | # actually do it. Not only that, the behavior can be 52 | # different depending on architecture (32- versus 64-bit). 53 | # 54 | # This is Ruby, folks, not just RubyMotion. 55 | # 56 | # We don't have to worry if it's a MotionModel, because 57 | # using a reference to the data is ok. The by-reference 58 | # copy is fine. 59 | 60 | return arg if arg.respond_to?(:motion_model?) 61 | 62 | # But if it is not a MotionModel, we either need to dup 63 | # it (for most cases), or just assign it (for built-in 64 | # types like Integer, Fixnum, Float, NilClass, etc.) 65 | 66 | result = nil 67 | begin 68 | result = arg.dup 69 | rescue 70 | result = arg 71 | end 72 | 73 | result 74 | end 75 | 76 | def cast_to_type(column_name, arg) #nodoc 77 | return nil if arg.nil? && ![ :boolean, :bool ].include?(column_type(column_name)) 78 | 79 | return case column_type(column_name) 80 | when :string, :belongs_to_type then cast_to_string(arg) 81 | when :boolean, :bool then cast_to_bool(arg) 82 | when :int, :integer, :belongs_to_id then cast_to_integer(arg) 83 | when :float, :double then cast_to_float(arg) 84 | when :date, :time, :datetime then cast_to_date(arg) 85 | when :text then cast_to_string(arg) 86 | when :array then cast_to_array(arg) 87 | when :hash then cast_to_hash(arg) 88 | when Class then cast_to_arbitrary_class(arg) 89 | else 90 | raise ArgumentError.new("type #{column_name} : #{column_type(column_name)} is not possible to cast.") 91 | end 92 | end 93 | end 94 | end 95 | -------------------------------------------------------------------------------- /motion/model/transaction.rb: -------------------------------------------------------------------------------- 1 | module MotionModel 2 | module Model 3 | module Transactions 4 | def transaction(&block) 5 | if block_given? 6 | @savepoints = [] if @savepoints.nil? 7 | @savepoints.push self.duplicate 8 | yield 9 | @savepoints.pop 10 | else 11 | raise ArgumentError.new("transaction must have a block") 12 | end 13 | end 14 | 15 | def rollback 16 | unless @savepoints.empty? 17 | restore_attributes 18 | else 19 | NSLog "No savepoint, so rollback not performed." 20 | end 21 | end 22 | 23 | def columns_without_relations 24 | columns.select{|col| ![:has_many, :belongs_to].include?(column_type(col))} 25 | end 26 | 27 | def restore_attributes 28 | savepoint = @savepoints.last 29 | if savepoint.nil? 30 | NSLog "No savepoint, so rollback not performed." 31 | else 32 | columns_without_relations.each do |col| 33 | self.send("#{col}=", savepoint.send(col)) 34 | end 35 | end 36 | end 37 | 38 | def duplicate 39 | Marshal.load(Marshal.dump(self)) 40 | end 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /motion/validatable.rb: -------------------------------------------------------------------------------- 1 | module MotionModel 2 | module Validatable 3 | class ValidationSpecificationError < RuntimeError; end 4 | class RecordInvalid < RuntimeError; end 5 | 6 | def self.included(base) 7 | base.extend(ClassMethods) 8 | end 9 | 10 | module ClassMethods 11 | def validate(field = nil, validation_type = {}) 12 | if field.nil? || field.to_s == '' 13 | ex = ValidationSpecificationError.new('field not present in validation call') 14 | raise ex 15 | end 16 | 17 | unless validation_type.is_a?(Hash) 18 | ex = ValidationSpecificationError.new('validation_type is not a hash') 19 | raise ex 20 | end 21 | 22 | if validation_type == {} 23 | ex = ValidationSpecificationError.new('validation_type hash is empty') 24 | raise ex 25 | end 26 | 27 | validations << {field => validation_type} 28 | end 29 | alias_method :validates, :validate 30 | 31 | def validations 32 | @validations ||= [] 33 | end 34 | end 35 | 36 | def do_save?(options = {}) 37 | _valid = true 38 | if options[:validate] != false 39 | call_hooks 'validation' do 40 | _valid = valid? 41 | end 42 | end 43 | _valid 44 | end 45 | private :do_save? 46 | 47 | def do_insert(options = {}) 48 | return false unless do_save?(options) 49 | super 50 | end 51 | 52 | def do_update(options = {}) 53 | return false unless do_save?(options) 54 | super 55 | end 56 | 57 | # it fails loudly 58 | def save! 59 | raise RecordInvalid.new('failed validation') unless valid? 60 | save 61 | end 62 | 63 | # This has two functions: 64 | # 65 | # * First, it triggers validations. 66 | # 67 | # * Second, it returns the result of performing the validations. 68 | def before_validation(sender); end 69 | def after_validation(sender); end 70 | 71 | def valid? 72 | call_hooks 'validation' do 73 | @messages = [] 74 | @valid = true 75 | self.class.validations.each do |validations| 76 | validate_each(validations) 77 | end 78 | end 79 | @valid 80 | end 81 | 82 | # Raw array of hashes of error messages. 83 | def error_messages 84 | @messages 85 | end 86 | 87 | # Array of messages for a given field. Results are always an array 88 | # because a field can fail multiple validations. 89 | def error_messages_for(field) 90 | key = field.to_sym 91 | error_messages.select{|message| message.has_key?(key)}.map{|message| message[key]} 92 | end 93 | 94 | def validate_each(validations) #nodoc 95 | validations.each_pair do |field, validation| 96 | result = validate_one field, validation 97 | @valid &&= result 98 | end 99 | end 100 | 101 | def validation_method(validation_type) #nodoc 102 | validation_method = "validate_#{validation_type}".to_sym 103 | end 104 | 105 | def each_validation_for(field) #nodoc 106 | self.class.validations.select{|validation| validation.has_key?(field)}.each do |validation| 107 | validation.each_pair do |field, validation_hash| 108 | yield validation_hash 109 | end 110 | end 111 | end 112 | 113 | # Validates an arbitrary string against a specific field's validators. 114 | # Useful before setting the value of a model's field. I.e., you get data 115 | # from a form, do a validate_for(:my_field, that_data) and 116 | # if it succeeds, you do obj.my_field = that_data. 117 | def validate_for(field, value) 118 | @messages = [] 119 | key = field.to_sym 120 | result = true 121 | each_validation_for(key) do |validation| 122 | validation.each_pair do |validation_type, setting| 123 | method = validation_method(validation_type) 124 | if self.respond_to? method 125 | value.strip! if value.is_a?(String) 126 | result &&= self.send(method, field, value, setting) 127 | end 128 | end 129 | end 130 | result 131 | end 132 | 133 | def validate_one(field, validation) #nodoc 134 | result = true 135 | validation.each_pair do |validation_type, setting| 136 | if self.respond_to? validation_method(validation_type) 137 | value = self.send(field) 138 | result &&= self.send(validation_method(validation_type), field, value.is_a?(String) ? value.strip : value, setting) 139 | else 140 | ex = ValidationSpecificationError.new("unknown validation type :#{validation_type.to_s}") 141 | end 142 | end 143 | result 144 | end 145 | 146 | # Validates that something has been endntered in a field. 147 | # Should catch Fixnums, Bignums and Floats. Nils and Strings should 148 | # be handled as well, Arrays, Hashes and other datatypes will not. 149 | def validate_presence(field, value, setting) 150 | if(value.is_a?(Numeric)) 151 | return true 152 | elsif value.is_a?(String) || value.nil? 153 | result = value.nil? || ((value.length == 0) == setting) 154 | additional_message = setting ? "non-empty" : "non-empty" 155 | add_message(field, "incorrect value supplied for #{field.to_s} -- should be #{additional_message}.") if result 156 | return !result 157 | end 158 | return false 159 | end 160 | 161 | # Validates that the length is in a given range of characters. E.g., 162 | # 163 | # validate :name, :length => 5..8 164 | def validate_length(field, value, setting) 165 | if value.is_a?(String) || value.nil? 166 | result = value.nil? || (value.length < setting.first || value.length > setting.last) 167 | add_message(field, "incorrect value supplied for #{field.to_s} -- should be between #{setting.first} and #{setting.last} characters long.") if result 168 | return !result 169 | end 170 | return false 171 | end 172 | 173 | def validate_email(field, value, setting) 174 | if value.is_a?(String) || value.nil? 175 | result = value.nil? || value.match(/\A([^@\s]+)@((?:[-a-z0-9]+\.)+[a-z]{2,})\Z/i).nil? 176 | add_message(field, "#{field.to_s} does not appear to be an email address.") if result 177 | end 178 | return !result 179 | end 180 | 181 | # Validates contents of field against a given Regexp. This can be tricky because you need 182 | # to anchor both sides in most cases using \A and \Z to get a reliable match. 183 | def validate_format(field, value, setting) 184 | result = value.nil? || setting.match(value).nil? 185 | add_message(field, "#{field.to_s} does not appear to be in the proper format.") if result 186 | return !result 187 | end 188 | 189 | # Add a message for field to the messages collection. 190 | def add_message(field, message) 191 | @messages.push({field.to_sym => message}) 192 | end 193 | 194 | # Stub methods for hook protocols 195 | def before_validation(sender); end 196 | def after_validation(sender); end 197 | 198 | end 199 | end 200 | -------------------------------------------------------------------------------- /motion/version.rb: -------------------------------------------------------------------------------- 1 | # 0.3.8 is the last version without adapters. 2 | # for backward compatibility, users should 3 | # specify that version in their gem command 4 | # or forward port their code to take advantage 5 | # of adapters. 6 | module MotionModel 7 | VERSION = "0.6.2" 8 | end 9 | -------------------------------------------------------------------------------- /motion_model.gemspec: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | require File.expand_path('../motion/version', __FILE__) 3 | 4 | Gem::Specification.new do |gem| 5 | gem.authors = ["Steve Ross"] 6 | gem.email = ["sxross@gmail.com"] 7 | gem.description = "Simple model and validation mixins for RubyMotion" 8 | gem.summary = "Simple model and validation mixins for RubyMotion" 9 | gem.homepage = "https://github.com/sxross/MotionModel" 10 | 11 | gem.files = `git ls-files`.split($\) 12 | gem.test_files = gem.files.grep(%r{^(test|spec|features)/}) 13 | gem.name = "motion_model" 14 | gem.require_paths = ["lib"] 15 | gem.add_dependency 'bubble-wrap', '>= 1.3.0' 16 | gem.add_dependency 'motion-support', '>=0.1.0' 17 | gem.version = MotionModel::VERSION 18 | end 19 | -------------------------------------------------------------------------------- /resources/StoredTasks.dat: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sxross/MotionModel/37bf447b6c9bdc2158f320cef40714f41132c542/resources/StoredTasks.dat -------------------------------------------------------------------------------- /spec/adapter_spec.rb: -------------------------------------------------------------------------------- 1 | class ModelWithAdapter 2 | include MotionModel::Model 3 | include MotionModel::ArrayModelAdapter 4 | 5 | columns :name 6 | end 7 | 8 | describe 'adapters with adapter method defined' do 9 | it "does not raise an exception" do 10 | lambda{ModelWithAdapter.create(:name => 'bob')}.should.not.raise 11 | end 12 | 13 | it "provides humanized string representation of the current adapter" do 14 | ModelWithAdapter.create(:name => 'bob').adapter.should == 'Array Model Adapter' 15 | end 16 | end 17 | 18 | class ModelWithoutAdapter 19 | include MotionModel::Model 20 | 21 | columns :name 22 | end 23 | 24 | describe 'adapters without adapter method defined' do 25 | it "raises an exception" do 26 | lambda{ 27 | ModelWithoutAdapter.new 28 | }.should.raise(MotionModel::AdapterNotFoundError) 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /spec/array_model_persistence_spec.rb: -------------------------------------------------------------------------------- 1 | class PersistTask 2 | include MotionModel::Model 3 | include MotionModel::ArrayModelAdapter 4 | columns :name => :string, 5 | :desc => :string, 6 | :created_at => :date, 7 | :updated_at => :date 8 | end 9 | 10 | describe 'persistence' do 11 | before do 12 | PersistTask.delete_all 13 | %w(one two three).each do |task| 14 | @tasks = PersistTask.create(:name => "name #{task}") 15 | end 16 | end 17 | 18 | it "serializes data" do 19 | lambda{PersistTask.serialize_to_file('test.dat')}.should.not.raise 20 | end 21 | 22 | it 'reads persisted model data' do 23 | PersistTask.serialize_to_file('test.dat') 24 | 25 | PersistTask.delete_all 26 | 27 | PersistTask.count.should == 0 28 | 29 | tasks = PersistTask.deserialize_from_file('test.dat') 30 | 31 | PersistTask.count.should == 3 32 | PersistTask.first.name.should == 'name one' 33 | PersistTask.last.name.should == 'name three' 34 | end 35 | 36 | it "does not change created or updated date on load" do 37 | created_at = PersistTask.first.created_at 38 | updated_at = PersistTask.first.updated_at 39 | 40 | PersistTask.serialize_to_file('test.dat') 41 | tasks = PersistTask.deserialize_from_file('test.dat') 42 | PersistTask.first.created_at.should == created_at 43 | PersistTask.first.updated_at.should == updated_at 44 | end 45 | 46 | describe 'model change resiliency' do 47 | it 'column addition' do 48 | Object.send(:remove_const, :Foo) if defined?(Foo) 49 | class Foo 50 | include MotionModel::Model 51 | include MotionModel::ArrayModelAdapter 52 | columns :name => :string 53 | end 54 | @foo = Foo.create(:name=> 'Bob') 55 | Foo.serialize_to_file('test.dat') 56 | 57 | @foo.should.not.respond_to :address 58 | 59 | Foo.delete_all 60 | class Foo 61 | columns :address => :string 62 | end 63 | Foo.deserialize_from_file('test.dat') 64 | 65 | @foo = Foo.first 66 | 67 | @foo.name.should == 'Bob' 68 | @foo.address.should == nil 69 | @foo.should.respond_to :address 70 | Foo.length.should == 1 71 | end 72 | 73 | it "column removal" do 74 | Object.send(:remove_const, :Foo) if defined?(Foo) 75 | class Foo 76 | include MotionModel::Model 77 | include MotionModel::ArrayModelAdapter 78 | columns :name => :string, :desc => :string 79 | end 80 | 81 | @foo = Foo.create(:name=> 'Bob', :desc => 'who cares anyway?') 82 | Foo.serialize_to_file('test.dat') 83 | 84 | @foo.should.respond_to :desc 85 | 86 | Object.send(:remove_const, :Foo) if defined?(Foo) 87 | class Foo 88 | include MotionModel::Model 89 | include MotionModel::ArrayModelAdapter 90 | columns :name => :string, 91 | :address => :string 92 | end 93 | Foo.deserialize_from_file('test.dat') 94 | end 95 | end 96 | 97 | describe "array model migrations" do 98 | class TestForColumnAddition 99 | include MotionModel::Model 100 | include MotionModel::ArrayModelAdapter 101 | columns :name => :string, :desc => :string 102 | end 103 | 104 | it "column addition should call migrate first as a test" do 105 | TestForColumnAddition.mock!(:migrate) 106 | TestForColumnAddition.deserialize_from_file('dfca.dat') 107 | 1.should == 1 108 | end 109 | 110 | it "this example should pass" do 111 | 1.should == 1 112 | end 113 | 114 | it "accepts properly formatted version strings" do 115 | lambda{TestForColumnAddition.schema_version("3.1")}.should.not.raise 116 | end 117 | 118 | it "rejects non-string versions" do 119 | lambda{TestForColumnAddition.schema_version(3)}.should.raise(MotionModel::ArrayModelAdapter::VersionNumberError) 120 | end 121 | 122 | it "rejects improperly formated version strings" do 123 | lambda{TestForColumnAddition.schema_version("3/1/1")}.should.raise(MotionModel::ArrayModelAdapter::VersionNumberError) 124 | end 125 | 126 | it "returns the version number if no arguments supplied" do 127 | TestForColumnAddition.schema_version("3.1") 128 | TestForColumnAddition.schema_version.should == "3.1" 129 | end 130 | end 131 | 132 | describe "remembering filename" do 133 | class Foo 134 | include MotionModel::Model 135 | include MotionModel::ArrayModelAdapter 136 | columns :name => :string 137 | end 138 | 139 | before do 140 | Foo.delete_all 141 | @foo = Foo.create(:name => 'Bob') 142 | end 143 | 144 | it "deserializes from last file if no filename given (previous method serialize)" do 145 | Foo.serialize_to_file('test.dat') 146 | Foo.delete_all 147 | Foo.count.should == 0 148 | Foo.deserialize_from_file 149 | Foo.count.should == 1 150 | end 151 | 152 | it "deserializes from last file if no filename given (previous method deserialize)" do 153 | Foo.serialize_to_file('test.dat') 154 | Foo.serialize_to_file('bogus.dat') # serialize sets default filename to something bogus 155 | File.delete Foo.documents_file('bogus.dat') # and we get rid of that file 156 | Foo.deserialize_from_file('test.dat') # so we'll be sure the default filename last was set by deserialize 157 | Foo.delete_all 158 | Foo.count.should == 0 159 | Foo.deserialize_from_file 160 | Foo.count.should == 1 161 | end 162 | 163 | it "serializes to last file if no filename given (previous method serialize)" do 164 | Foo.serialize_to_file('test.dat') 165 | Foo.create(:name => 'Ted') 166 | Foo.serialize_to_file 167 | Foo.delete_all 168 | Foo.count.should == 0 169 | Foo.deserialize_from_file('test.dat') 170 | Foo.count.should == 2 171 | end 172 | 173 | it "serializes to last file if no filename given (previous method deserialize)" do 174 | Foo.serialize_to_file('test.dat') 175 | Foo.delete_all 176 | Foo.serialize_to_file('bogus.dat') # serialize sets default filename to something bogus 177 | File.delete Foo.documents_file('bogus.dat') # and we get rid of that file 178 | Foo.deserialize_from_file('test.dat') # so we'll be sure the default filename was last set by deserialize 179 | Foo.create(:name => 'Ted') 180 | Foo.serialize_to_file 181 | Foo.delete_all 182 | Foo.count.should == 0 183 | Foo.deserialize_from_file('test.dat') 184 | Foo.count.should == 2 185 | end 186 | 187 | end 188 | end 189 | 190 | class Parent 191 | include MotionModel::Model 192 | include MotionModel::ArrayModelAdapter 193 | columns :name 194 | has_many :children 195 | has_one :dog 196 | end 197 | 198 | class Child 199 | include MotionModel::Model 200 | include MotionModel::ArrayModelAdapter 201 | columns :name 202 | belongs_to :parent 203 | end 204 | 205 | class Dog 206 | include MotionModel::Model 207 | include MotionModel::ArrayModelAdapter 208 | columns :name 209 | belongs_to :parent 210 | end 211 | 212 | describe "serialization of relations" do 213 | before do 214 | parent = Parent.create(:name => 'BoB') 215 | parent.children.create :name => 'Fergie' 216 | parent.children.create :name => 'Will I Am' 217 | parent.dog.create :name => 'Fluffy' 218 | end 219 | 220 | it "is wired up right" do 221 | Parent.first.name.should == 'BoB' 222 | Parent.first.children.count.should == 2 223 | Parent.first.dog.count.should == 1 224 | end 225 | 226 | it "serializes and deserializes properly" do 227 | Parent.serialize_to_file('parents.dat') 228 | Child.serialize_to_file('children.dat') 229 | Dog.serialize_to_file('dogs.dat') 230 | Parent.delete_all 231 | Child.delete_all 232 | Dog.delete_all 233 | Parent.deserialize_from_file('parents.dat') 234 | Child.deserialize_from_file('children.dat') 235 | Dog.deserialize_from_file('dogs.dat') 236 | Parent.first.name.should == 'BoB' 237 | Parent.first.children.count.should == 2 238 | Parent.first.children.first.name.should == 'Fergie' 239 | Parent.first.dog.first.name.should == 'Fluffy' 240 | end 241 | 242 | it "allows to serialize and eserialize from directories" do 243 | directory_path = '/Library/Caches' 244 | Parent.serialize_to_file('parents.dat', directory_path) 245 | Child.serialize_to_file('children.dat', directory_path) 246 | Dog.serialize_to_file('dogs.dat', directory_path) 247 | Parent.delete_all 248 | Child.delete_all 249 | Dog.delete_all 250 | Parent.deserialize_from_file('parents.dat', directory_path) 251 | Child.deserialize_from_file('children.dat', directory_path) 252 | Dog.deserialize_from_file('dogs.dat', directory_path) 253 | Parent.first.name.should == 'BoB' 254 | Parent.first.children.count.should == 2 255 | Parent.first.children.first.name.should == 'Fergie' 256 | Parent.first.dog.first.name.should == 'Fluffy' 257 | end 258 | 259 | class StoredTask 260 | include MotionModel::Model 261 | include MotionModel::ArrayModelAdapter 262 | columns :name 263 | end 264 | 265 | describe "reloading correct ids" do 266 | before do 267 | # # StoredTasks.dat was built with the following 268 | # t1 = StoredTask.create(name: "One") # id: 1 269 | # t2 = StoredTask.create(name: "Two") # id: 2 270 | # t3 = StoredTask.create(name: "Three") # id: 3 271 | # t2.destroy 272 | 273 | # # StoredTasks.all => [id: 1, id:3] 274 | # StoredTask.serialize_to_file('StoredTasks.dat') 275 | StoredTask.deserialize_from_file('StoredTasks.dat', NSBundle.mainBundle.resourcePath) 276 | end 277 | 278 | it "creates a new task with the correct id after deserialization" do 279 | StoredTask.count.should == 2 280 | StoredTask.first.id.should == 1 281 | StoredTask.last.id.should == 3 282 | 283 | t4 = StoredTask.create(name: "Four") 284 | t4.id.should == 4 285 | end 286 | end 287 | end 288 | -------------------------------------------------------------------------------- /spec/cascading_delete_spec.rb: -------------------------------------------------------------------------------- 1 | class Assignee 2 | include MotionModel::Model 3 | include MotionModel::ArrayModelAdapter 4 | columns :assignee_name => :string 5 | belongs_to :task 6 | end 7 | 8 | class Task 9 | include MotionModel::Model 10 | include MotionModel::ArrayModelAdapter 11 | columns :name => :string, 12 | :details => :string, 13 | :some_day => :date 14 | has_many :assignees 15 | end 16 | 17 | class CascadingTask 18 | include MotionModel::Model 19 | include MotionModel::ArrayModelAdapter 20 | columns :name => :string, 21 | :details => :string, 22 | :some_day => :date 23 | has_many :cascaded_assignees, :dependent => :delete 24 | end 25 | 26 | class CascadedAssignee 27 | include MotionModel::Model 28 | include MotionModel::ArrayModelAdapter 29 | columns :assignee_name => :string 30 | belongs_to :cascading_task 31 | has_many :employees 32 | end 33 | 34 | class Employee 35 | include MotionModel::Model 36 | include MotionModel::ArrayModelAdapter 37 | columns :name 38 | belongs_to :cascaded_assignee 39 | end 40 | 41 | describe "cascading deletes" do 42 | # describe "when not marked for destruction" do 43 | # it "leaves assignees alone when they are not marked for destruction" do 44 | # Task.delete_all 45 | # Assignee.delete_all 46 | 47 | # task = Task.create :name => 'Walk the dog' 48 | # task.assignees.create :assignee_name => 'Joe' 49 | # lambda{task.destroy}.should.not.change{Assignee.length} 50 | # end 51 | # end 52 | 53 | describe "when marked for destruction" do 54 | before do 55 | CascadingTask.delete_all 56 | CascadedAssignee.delete_all 57 | end 58 | 59 | it "deletes assignees that belong to a destroyed task" do 60 | task = CascadingTask.create(:name => 'cascading') 61 | task.cascaded_assignees.create(:assignee_name => 'joe') 62 | task.cascaded_assignees.create(:assignee_name => 'bill') 63 | 64 | CascadingTask.count.should == 1 65 | CascadedAssignee.count.should == 2 66 | 67 | task.destroy 68 | 69 | CascadingTask.count.should == 0 70 | CascadedAssignee.count.should == 0 71 | end 72 | 73 | it "deletes all assignees when all tasks are destroyed" do 74 | 1.upto(3) do |item| 75 | task = CascadingTask.create :name => "Task #{item}" 76 | 1.upto(3) do |assignee| 77 | task.cascaded_assignees.create :assignee_name => "assignee #{assignee} for task #{task}" 78 | end 79 | end 80 | CascadingTask.count.should == 3 81 | CascadedAssignee.count.should == 9 82 | 83 | CascadingTask.destroy_all 84 | 85 | CascadingTask.count.should == 0 86 | CascadedAssignee.count.should == 0 87 | end 88 | 89 | it "deletes only one level when a task is destroyed but dependent is delete" do 90 | task = CascadingTask.create :name => 'dependent => :delete' 91 | assignee = task.cascaded_assignees.create :assignee_name => 'deletable assignee' 92 | assignee.employees.create :name => 'person who sticks around' 93 | 94 | CascadingTask.count.should == 1 95 | CascadedAssignee.count.should == 1 96 | Employee.count.should == 1 97 | 98 | task.destroy 99 | 100 | CascadingTask.count.should == 0 101 | CascadedAssignee.count.should == 0 102 | Employee.count.should == 1 103 | end 104 | end 105 | end 106 | -------------------------------------------------------------------------------- /spec/column_options_spec.rb: -------------------------------------------------------------------------------- 1 | class ModelWithOptions 2 | include MotionModel::Model 3 | include MotionModel::ArrayModelAdapter 4 | 5 | columns :date => {:type => :date, :formotion => {:picker_type => :date_time}} 6 | end 7 | 8 | describe "column options" do 9 | it "accepts the hash form of column declaration" do 10 | lambda{ModelWithOptions.new}.should.not.raise 11 | end 12 | 13 | it "retrieves non-nil options for a column declaration" do 14 | instance = ModelWithOptions.new 15 | instance.options(:date).should.not.be.nil 16 | end 17 | 18 | it "retrieves correct options for a column declaration" do 19 | instance = ModelWithOptions.new 20 | instance.options(:date)[:formotion].should.not.be.nil 21 | instance.options(:date)[:formotion][:picker_type].should == :date_time 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /spec/date_spec.rb: -------------------------------------------------------------------------------- 1 | describe "time conversions" do 2 | it "NSDate and Time should agree on minutes since epoch" do 3 | t = Time.new 4 | d = NSDate.dateWithTimeIntervalSince1970(t.to_f) 5 | (t.to_f - d.timeIntervalSince1970).abs.should. < 0.001 6 | end 7 | 8 | it "Parsing '3/18/12 @ 7:00 PM' With Natural Language should work right" do 9 | NSDate.dateWithNaturalLanguageString('3/18/12 @ 7:00 PM'.gsub('-','/'), locale:NSUserDefaults.standardUserDefaults.dictionaryRepresentation). 10 | strftime("%m-%d-%Y | %I:%M %p"). 11 | should == "03-18-2012 | 07:00 PM" 12 | end 13 | 14 | describe "auto_date_fields" do 15 | 16 | class Creatable 17 | include MotionModel::Model 18 | include MotionModel::ArrayModelAdapter 19 | columns :name => :string, 20 | :created_at => :date 21 | end 22 | 23 | class Updateable 24 | include MotionModel::Model 25 | include MotionModel::ArrayModelAdapter 26 | columns :name => :string, 27 | :updated_at => :date 28 | end 29 | 30 | class ProtectedUpdateable 31 | include MotionModel::Model 32 | include MotionModel::ArrayModelAdapter 33 | columns :name => :string, 34 | :updated_at => :date 35 | protect_remote_timestamps 36 | end 37 | 38 | it "Sets created_at when an item is created" do 39 | c = Creatable.new(:name => 'test') 40 | lambda{c.save}.should.change{c.created_at} 41 | end 42 | 43 | it "Sets updated_at when an item is created" do 44 | c = Updateable.new(:name => 'test') 45 | lambda{c.save}.should.change{c.updated_at} 46 | end 47 | 48 | it "Doesn't update created_at when an item is updated" do 49 | c = Creatable.create(:name => 'test') 50 | c.name = 'test 1' 51 | lambda{c.save}.should.not.change{c.created_at} 52 | end 53 | 54 | it "Updates updated_at when an item is updated" do 55 | c = Updateable.create(:name => 'test') 56 | sleep 1 57 | c.name = 'test 1' 58 | lambda{ c.save }.should.change{c.updated_at} 59 | end 60 | 61 | it "Honors (protects) server side timestamps" do 62 | c = ProtectedUpdateable.create(:name => 'test') 63 | sleep 1 64 | c.name = 'test 1' 65 | lambda{ c.save }.should.not.change{c.updated_at} 66 | end 67 | end 68 | 69 | describe "date parser data detector reuse" do 70 | it "creates a data detector if none is present" do 71 | DateParser.class_variable_get(:@@detector).should.be.nil 72 | DateParser.detector.class.should == NSDataDetector 73 | end 74 | end 75 | 76 | describe "parsing ISO8601 date formats" do 77 | class Model 78 | include MotionModel::Model 79 | include MotionModel::ArrayModelAdapter 80 | columns :test_date => :date, 81 | end 82 | 83 | it 'parses ISO8601 format variant #1 (RoR default)' do 84 | m = Model.new(test_date: '2012-04-23T18:25:43Z') 85 | m.test_date.should.not.be.nil 86 | end 87 | 88 | it 'parses ISO8601 variant #2, 3DP Accuracy (RoR4), JavaScript built-in JSON object' do 89 | m = Model.new(test_date: '2012-04-23T18:25:43.511Z') 90 | m.test_date.should.not.be.nil 91 | end 92 | 93 | it 'parses ISO8601 variant #3' do 94 | m = Model.new(test_date: '2012-04-23 18:25:43 +0000') 95 | m.test_date.should.not.be.nil 96 | m.test_date.utc.to_s.should.eql '2012-04-23 18:25:43 UTC' 97 | end 98 | 99 | it "does not discard fractional portion of ISO8601 dates" do 100 | m = Model.new(test_date: '2012-04-23T18:25:43.511Z') 101 | m.test_date.should.not.be.nil 102 | m.test_date.utc.to_s.should.eql '2012-04-23 18:25:43 UTC' 103 | m.test_date.utc.to_s.should.not.eql '2012-04-23 18:25:51 UTC' 104 | end 105 | end 106 | end 107 | -------------------------------------------------------------------------------- /spec/ext_spec.rb: -------------------------------------------------------------------------------- 1 | 2 | 3 | describe 'Extensions' do 4 | describe 'Pluralization' do 5 | it 'pluralizes a normal word: dog' do 6 | Inflector.inflections.pluralize('dog').should == 'dogs' 7 | end 8 | 9 | it 'pluralizes words that end in "s": pass' do 10 | Inflector.inflections.pluralize('pass').should == 'passes' 11 | end 12 | 13 | it "pluralizes words that end in 'us'" do 14 | Inflector.inflections.pluralize('alumnus').should == 'alumni' 15 | end 16 | 17 | it "pluralizes words that end in 'ee'" do 18 | Inflector.inflections.pluralize('attendee').should == 'attendees' 19 | end 20 | 21 | it "pluralizes words that end in 'e'" do 22 | Inflector.inflections.pluralize('article').should == 'articles' 23 | end 24 | end 25 | 26 | describe 'Singularization' do 27 | it 'singularizes a normal word: "dogs"' do 28 | Inflector.inflections.singularize('dogs').should == 'dog' 29 | end 30 | 31 | it "singualarizes a word that ends in 's': passes" do 32 | Inflector.inflections.singularize('passes').should == 'pass' 33 | end 34 | 35 | it "singualarizes a word that ends in 'ee': assignees" do 36 | Inflector.inflections.singularize('assignees').should == 'assignee' 37 | end 38 | 39 | it "singualarizes words that end in 'us'" do 40 | Inflector.inflections.singularize('alumni').should == 'alumnus' 41 | end 42 | 43 | it "singualarizes words that end in 'es'" do 44 | Inflector.inflections.singularize('articles').should == 'article' 45 | end 46 | end 47 | 48 | describe 'Irregular Patterns' do 49 | it "handles person to people singularizing" do 50 | Inflector.inflections.singularize('people').should == 'person' 51 | end 52 | 53 | it "handles person to people pluralizing" do 54 | Inflector.inflections.pluralize('person').should == 'people' 55 | end 56 | end 57 | 58 | describe 'Adding Rules to Inflector' do 59 | it 'accepts new rules' do 60 | Inflector.inflections.irregular /^foot$/, 'feet' 61 | Inflector.inflections.irregular /^feet$/, 'foot' 62 | Inflector.inflections.pluralize('foot').should == 'feet' 63 | Inflector.inflections.singularize('feet').should == 'foot' 64 | end 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /spec/finder_spec.rb: -------------------------------------------------------------------------------- 1 | class Task 2 | include MotionModel::Model 3 | include MotionModel::ArrayModelAdapter 4 | columns :name => :string, 5 | :details => :string, 6 | :some_day => :date 7 | end 8 | 9 | describe 'finders' do 10 | before do 11 | Task.delete_all 12 | 1.upto(10) {|i| Task.create(:name => "task #{i}", :id => i)} 13 | end 14 | 15 | describe 'find' do 16 | it 'finds elements within the collection' do 17 | Task.count.should == 10 18 | Task.find(3).name.should.equal("task 3") 19 | end 20 | 21 | it 'returns nil if find by id is not found' do 22 | Task.find(999).should.be.nil 23 | end 24 | 25 | it 'looks into fields if field name supplied' do 26 | Task.create(:name => 'find me') 27 | tasks = Task.find(:name).eq('find me') 28 | tasks.count.should.equal(1) 29 | tasks.first.name.should == 'find me' 30 | end 31 | 32 | it "provides an array of valid model instances when doing a find" do 33 | Task.create(:name => 'find me') 34 | tasks = Task.find(:name).eq('find me') 35 | tasks.first.name.should.eql 'find me' 36 | end 37 | 38 | it 'allows for multiple (chained) query parameters' do 39 | Task.create(:name => 'find me', :details => "details 1") 40 | Task.create(:name => 'find me', :details => "details 2") 41 | tasks = Task.find(:name).eq('find me').and(:details).like('2') 42 | tasks.first.details.should.equal('details 2') 43 | tasks.all.length.should.equal(1) 44 | end 45 | 46 | it 'where should respond to finder methods' do 47 | Task.where(:details).should.respond_to(:contain) 48 | end 49 | 50 | it 'returns a FinderQuery object' do 51 | Task.where(:details).should.is_a(MotionModel::ArrayFinderQuery) 52 | end 53 | 54 | it 'using where instead of find' do 55 | atask = Task.create(:name => 'find me', :details => "details 1") 56 | found_task = Task.where(:details).contain("s 1").first.details.should == 'details 1' 57 | end 58 | 59 | it 'should returns first 5 results for where call' do 60 | Task.where(:name).contains('task').first(5).length.should == 5 61 | end 62 | 63 | it 'should returns last 5 results for where call' do 64 | Task.where(:name).contains('task').last(5).length.should == 5 65 | end 66 | 67 | it 'should returns first element for where call' do 68 | Task.where(:name).contains('task').first.should.is_a Task 69 | end 70 | 71 | it 'should returns last element for where call' do 72 | Task.where(:name).contains('task').last.should.is_a Task 73 | end 74 | 75 | it "performs set inclusion(in) queries" do 76 | class InTest 77 | include MotionModel::Model 78 | include MotionModel::ArrayModelAdapter 79 | columns :name 80 | end 81 | 82 | 1.upto(10) do |i| 83 | InTest.create(:id => i, :name => "test #{i}") 84 | end 85 | 86 | results = InTest.find(:id).in([3, 5, 7]) 87 | results.length.should == 3 88 | end 89 | 90 | it 'handles case-insensitive queries as default' do 91 | task = Task.create :name => 'camelCase' 92 | Task.find(:name).eq('camelcase').all.length.should == 1 93 | end 94 | 95 | it 'handles case-sensitive queries' do 96 | task = Task.create :name => 'Bob' 97 | Task.find(:name).eq('bob', :case_sensitive => true).all.length.should == 0 98 | end 99 | 100 | it 'all returns all members of the collection as an array' do 101 | Task.all.each { |t| puts t } 102 | Task.all.length.should.equal(10) 103 | end 104 | 105 | it 'each yields each row in sequence' do 106 | task_id = nil 107 | Task.each do |task| 108 | task_id.should.<(task.id) if task_id 109 | task_id = task.id 110 | end 111 | end 112 | 113 | it 'should returns first 5 members of the collection as an array' do 114 | Task.first(5).length.should.equal(5) 115 | end 116 | 117 | it 'should returns last 5 members of the collection as an array' do 118 | Task.last(5).length.should.equal(5) 119 | end 120 | 121 | it 'should be a difference between first and last element' do 122 | first_records = Task.first(5) 123 | last_records = Task.last(5) 124 | 125 | first_records[0].should.not == last_records[0] 126 | end 127 | 128 | it 'should returns first element' do 129 | Task.first.should.is_a Task 130 | end 131 | 132 | it 'should returns last element' do 133 | Task.last.should.is_a Task 134 | end 135 | 136 | describe 'comparison finders' do 137 | 138 | it 'returns elements with id greater than 5' do 139 | tasks = Task.where(:id).gt(5).all 140 | tasks.length.should.equal(5) 141 | tasks.reject{|t| [6,7,8,9,10].include?(t.id)}.should.be.empty 142 | end 143 | 144 | it 'returns elements with id greater than or equal to 7' do 145 | tasks = Task.where(:id).gte(7).all 146 | tasks.length.should.equal(4) 147 | tasks.reject{|t| [7,8,9,10].include?(t.id)}.should.be.empty 148 | end 149 | 150 | it 'returns elements with id less than 5' do 151 | tasks = Task.where(:id).lt(5).all 152 | tasks.length.should.equal(4) 153 | tasks.reject{|t| [1,2,3,4].include?(t.id)}.should.be.empty 154 | end 155 | 156 | it 'returns elements with id less than or equal to 3' do 157 | tasks = Task.where(:id).lte(3).all 158 | tasks.length.should.equal(3) 159 | tasks.reject{|t| [1,2,3].include?(t.id)}.should.be.empty 160 | end 161 | 162 | end 163 | 164 | describe 'block-style finders' do 165 | before do 166 | @items_less_than_5 = Task.find{|item| item.name.split(' ').last.to_i < 5} 167 | end 168 | 169 | it 'returns a FinderQuery' do 170 | @items_less_than_5.should.is_a MotionModel::ArrayFinderQuery 171 | end 172 | 173 | it 'handles block-style finders' do 174 | @items_less_than_5.length.should == 4 175 | end 176 | 177 | it 'deals with any arbitrary block finder' do 178 | @even_items = Task.find do |item| 179 | test_item = item.name.split(' ').last.to_i 180 | test_item % 2 == 0 && test_item <= 6 181 | end 182 | @even_items.each{|item| item.name.split(' ').last.to_i.should.even?} 183 | @even_items.length.should == 3 # [2, 4, 6] 184 | end 185 | end 186 | end 187 | 188 | describe 'sorting' do 189 | before do 190 | Task.delete_all 191 | Task.create(:name => 'Task 3', :details => 'detail 3') 192 | Task.create(:name => 'Task 1', :details => 'detail 1') 193 | Task.create(:name => 'Task 2', :details => 'detail 6') 194 | Task.create(:name => 'Random Task', :details => 'another random task') 195 | end 196 | 197 | it 'sorts by field' do 198 | tasks = Task.order(:name).all 199 | tasks[0].name.should.equal('Random Task') 200 | tasks[1].name.should.equal('Task 1') 201 | tasks[2].name.should.equal('Task 2') 202 | tasks[3].name.should.equal('Task 3') 203 | end 204 | 205 | it 'sorts observing block syntax' do 206 | tasks = Task.order{|one, two| two.details <=> one.details}.all 207 | tasks[0].details.should.equal('detail 6') 208 | tasks[1].details.should.equal('detail 3') 209 | tasks[2].details.should.equal('detail 1') 210 | tasks[3].details.should.equal('another random task') 211 | end 212 | end 213 | 214 | end 215 | 216 | -------------------------------------------------------------------------------- /spec/formotion_spec.rb: -------------------------------------------------------------------------------- 1 | Object.send(:remove_const, :ModelWithOptions) if defined?(ModelWithOptions) 2 | class ModelWithOptions 3 | include MotionModel::Model 4 | include MotionModel::ArrayModelAdapter 5 | include MotionModel::Formotion 6 | 7 | columns :name => :string, 8 | :date => {:type => :date, :formotion => {:picker_type => :date_time}}, 9 | :location => {:type => :string, :formotion => {:section => :address}}, 10 | :created_at => :date, 11 | :updated_at => :date 12 | 13 | has_many :related_models 14 | 15 | has_formotion_sections :address => { title: "Address" } 16 | 17 | end 18 | 19 | class RelatedModel 20 | include MotionModel::Model 21 | include MotionModel::ArrayModelAdapter 22 | 23 | columns :name => :string 24 | belongs_to :model_with_options 25 | end 26 | 27 | def section(subject) 28 | subject[:sections] 29 | end 30 | 31 | def rows(subject) 32 | section(subject).first[:rows] 33 | end 34 | 35 | def first_row(subject) 36 | rows(subject).first 37 | end 38 | 39 | describe "formotion" do 40 | before do 41 | @subject = ModelWithOptions.create(:name => 'get together', :date => '12-11-13 @ 9:00 PM', :location => 'my house') 42 | end 43 | 44 | it "generates a formotion hash" do 45 | @subject.to_formotion.should.not.be.nil 46 | end 47 | 48 | it "has the correct form title" do 49 | @subject.to_formotion('test form')[:title].should == 'test form' 50 | end 51 | 52 | it "has two sections" do 53 | @subject.to_formotion[:sections].length.should == 2 54 | end 55 | 56 | it "has 2 rows in default section" do 57 | @subject.to_formotion[:sections].first[:rows].length.should == 2 58 | end 59 | 60 | it "does not include title in the default section" do 61 | @subject.to_formotion[:sections].first[:title].should == nil 62 | end 63 | 64 | it "does include title in the :address section" do 65 | @subject.to_formotion[:sections][1][:title].should == 'Address' 66 | end 67 | 68 | it "has 1 row in :address section" do 69 | @subject.to_formotion[:sections][1][:rows].length.should == 1 70 | end 71 | 72 | it "value of location row in :address section is 'my house'" do 73 | @subject.to_formotion[:sections][1][:rows].first[:value].should == 'my house' 74 | end 75 | 76 | it "value of name row is 'get together'" do 77 | first_row(@subject.to_formotion)[:value].should == 'get together' 78 | end 79 | 80 | it "binds data from rendered form into model fields" do 81 | @subject.from_formotion!({:name => '007 Reunion', :date => 1358197323, :location => "Q's Lab"}) 82 | @subject.name.should == '007 Reunion' 83 | @subject.date.utc.strftime("%Y-%m-%d %H:%M").should == '2013-01-14 21:02' 84 | @subject.location.should == "Q's Lab" 85 | end 86 | 87 | it "does not include auto date fields in the hash by default" do 88 | @subject.to_formotion[:sections].first[:rows].has_hash_key?(:created_at).should == false 89 | @subject.to_formotion[:sections].first[:rows].has_hash_key?(:updated_at).should == false 90 | end 91 | 92 | it "can optionally include auto date fields in the hash" do 93 | result = @subject.to_formotion(nil, true)[:sections].first[:rows].has_hash_value?(:created_at).should == true 94 | result = @subject.to_formotion(nil, true)[:sections].first[:rows].has_hash_value?(:updated_at).should == true 95 | end 96 | 97 | it "does not include related columns in the collection" do 98 | result = @subject.to_formotion[:sections].first[:rows].has_hash_value?(:related_models).should == false 99 | end 100 | 101 | describe "new syntax" do 102 | it "generates a formotion hash" do 103 | @subject.new_to_formotion.should.not.be.nil 104 | end 105 | 106 | it "has the correct form title" do 107 | @subject.new_to_formotion(form_title: 'test form')[:title].should == 'test form' 108 | end 109 | 110 | it "has two sections" do 111 | s = @subject.new_to_formotion( 112 | sections: [ 113 | {title: 'one'}, 114 | {title: 'two'} 115 | ] 116 | )[:sections].length.should == 2 117 | end 118 | 119 | it "does not include title in the default section" do 120 | @subject.new_to_formotion( 121 | sections: [ 122 | {fields: [:name]}, 123 | {title: 'two'} 124 | ] 125 | )[:sections].first[:title].should == nil 126 | end 127 | 128 | it "does include address in the second section" do 129 | @subject.new_to_formotion( 130 | sections: [ 131 | {fields: [:name]}, 132 | {title: 'two'} 133 | ] 134 | )[:sections][1][:title].should.not == nil 135 | end 136 | 137 | it "has two rows in the first section" do 138 | @subject.new_to_formotion( 139 | sections: [ 140 | {fields: [:name, :date]}, 141 | {title: 'two'} 142 | ] 143 | )[:sections][0][:rows].length.should == 2 144 | end 145 | 146 | it "has two rows in the first section" do 147 | @subject.new_to_formotion( 148 | sections: [ 149 | {fields: [:name, :date]}, 150 | {title: 'two'} 151 | ] 152 | )[:sections][0][:rows].length.should == 2 153 | end 154 | 155 | it "value of location row in :address section is 'my house'" do 156 | @subject.new_to_formotion( 157 | sections: [ 158 | {title: 'name', fields: [:name, :date]}, 159 | {title: 'address', fields: [:location]} 160 | ] 161 | )[:sections][1][:rows].first[:value].should == 'my house' 162 | end 163 | it "value of name row is 'get together'" do 164 | @subject.new_to_formotion( 165 | sections: [ 166 | {title: 'name', fields: [:name, :date]}, 167 | {title: 'address', fields: [:location]} 168 | ] 169 | )[:sections][1][:rows].first[:value].should == 'my house' 170 | end 171 | it "allows you to place buttons in your form" do 172 | result = @subject.new_to_formotion( 173 | sections: [ 174 | {title: 'name', fields: [:name, :date, {title: 'Submit', type: :submit}]}, 175 | {title: 'address', fields: [:location]} 176 | ] 177 | ) 178 | 179 | result[:sections][0][:rows][2].should.is_a? Hash 180 | result[:sections][0][:rows][2].should.has_key?(:type) 181 | result[:sections][0][:rows][2][:type].should == :submit 182 | end 183 | 184 | it "creates date as a float in the formotion hash" do 185 | result = @subject.new_to_formotion( 186 | sections: [ 187 | {title: 'name', fields: [:name, :date, {title: 'Submit', type: :submit}]}, 188 | {title: 'address', fields: [:location]} 189 | ] 190 | ) 191 | date_row = result[:sections][0][:rows][1] 192 | date_row.should.has_key?(:type) 193 | date_row[:type].should == :date 194 | date_row[:value].class.should == Float 195 | end 196 | end 197 | end 198 | -------------------------------------------------------------------------------- /spec/has_one_as_object_spec.rb: -------------------------------------------------------------------------------- 1 | class User 2 | include MotionModel::Model 3 | include MotionModel::ArrayModelAdapter 4 | 5 | columns :name => :string 6 | 7 | has_one :profile 8 | end 9 | 10 | class Profile 11 | include MotionModel::Model 12 | include MotionModel::ArrayModelAdapter 13 | 14 | columns :email => :string 15 | 16 | belongs_to :user 17 | 18 | end 19 | 20 | describe 'has_one behaviors' do 21 | before do 22 | User.destroy_all 23 | Profile.destroy_all 24 | end 25 | 26 | it 'can create a has_one relation' do 27 | user = User.create(name: 'Sam') 28 | profile = user.profile.create(email: 'ss@gmail.com') 29 | 30 | User.first.profile.should.is_a?(Profile) 31 | User.first.profile.email.should == 'ss@gmail.com' 32 | end 33 | 34 | it 'can assign a has_one relation' do 35 | user = User.create(name: 'Sam') 36 | user.profile = Profile.create(email: 'ss@gmail.com') 37 | 38 | User.first.profile.should.is_a?(Profile) 39 | User.first.profile.email.should == 'ss@gmail.com' 40 | end 41 | 42 | it 'can get parent from a has_one create relation' do 43 | user = User.create(name: 'Sam') 44 | profile = user.profile.create(email: 'ss@gmail.com') 45 | 46 | Profile.first.user.should.is_a?(User) 47 | Profile.first.user.name.should == 'Sam' 48 | 49 | User.first.profile.user.should.is_a?(User) 50 | User.first.profile.user.name.should == 'Sam' 51 | end 52 | 53 | it 'can get parent from a has_one assigned relation' do 54 | user = User.create(name: 'Sam') 55 | user.profile = Profile.create(email: 'ss@gmail.com') 56 | 57 | Profile.first.user.should.is_a?(User) 58 | Profile.first.user.name.should == 'Sam' 59 | 60 | User.first.profile.user.should.is_a?(User) 61 | User.first.profile.user.name.should == 'Sam' 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /spec/kvo_config_clone_spec.rb: -------------------------------------------------------------------------------- 1 | describe "cloning configuration data for KVO" do 2 | class KVOObservable 3 | include MotionModel::Model 4 | include MotionModel::ArrayModelAdapter 5 | 6 | columns :name, :nickname 7 | end 8 | 9 | class KVOWatcher 10 | attr_reader :o 11 | 12 | include BW::KVO 13 | 14 | def initialize(o) 15 | @o = o 16 | observe(o, :name) do |old_value, new_value| 17 | end 18 | end 19 | end 20 | 21 | before do 22 | @observable = KVOObservable.create!(name: 'Jim', nickname: 'Jimmy') 23 | @watcher = KVOWatcher.new(@observable) 24 | end 25 | 26 | it "is a KVO anonymous class" do 27 | @watcher.o.class.to_s.should.match(/^NSKVO/) 28 | @watcher.o.class.should.not == KVOObservable 29 | end 30 | 31 | it "retrieves attribute values correctly" do 32 | @watcher.o.name.should == @observable.name 33 | @watcher.o.nickname.should == @observable.nickname 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /spec/model_casting_spec.rb: -------------------------------------------------------------------------------- 1 | class TypeCast 2 | include MotionModel::Model 3 | include MotionModel::ArrayModelAdapter 4 | columns :a_boolean => :boolean, 5 | :an_int => {:type => :int, :default => 3}, 6 | :an_integer => :integer, 7 | :a_float => :float, 8 | :a_double => :double, 9 | :a_date => :date, 10 | :a_time => :time, 11 | :an_array => :array 12 | end 13 | 14 | describe 'Type casting' do 15 | before do 16 | @convertible = TypeCast.new 17 | @convertible.a_boolean = 'false' 18 | @convertible.an_int = '1' 19 | @convertible.an_integer = '2' 20 | @convertible.a_float = '3.7' 21 | @convertible.a_double = '3.41459' 22 | @convertible.a_date = '2012-09-15' 23 | @convertible.an_array = 1..10 24 | end 25 | 26 | it 'does the type casting on instantiation' do 27 | @convertible.a_boolean.should.is_a FalseClass 28 | @convertible.an_int.should.is_a Integer 29 | @convertible.an_integer.should.is_a Integer 30 | @convertible.a_float.should.is_a Float 31 | @convertible.a_double.should.is_a Float 32 | @convertible.a_date.should.is_a NSDate 33 | @convertible.an_array.should.is_a Array 34 | end 35 | 36 | it 'returns a boolean for a boolean field' do 37 | @convertible.a_boolean.should.is_a(FalseClass) 38 | end 39 | 40 | it 'the boolean field should be the same as it was in string form' do 41 | @convertible.a_boolean.to_s.should.equal('false') 42 | end 43 | 44 | it 'the boolean field accepts a non-zero integer as true' do 45 | @convertible.a_boolean = 1 46 | @convertible.a_boolean.should.is_a(TrueClass) 47 | end 48 | 49 | it 'the boolean field accepts a zero valued integer as false' do 50 | @convertible.a_boolean = 0 51 | @convertible.a_boolean.should.is_a(FalseClass) 52 | end 53 | 54 | it 'the boolean field accepts a string that starts with "true" as true' do 55 | @convertible.a_boolean = 'true' 56 | @convertible.a_boolean.should.is_a(TrueClass) 57 | end 58 | 59 | it 'the boolean field treats a string with "true" not at the start as false' do 60 | @convertible.a_boolean = 'something true' 61 | @convertible.a_boolean.should.is_a(FalseClass) 62 | end 63 | 64 | it 'the boolean field accepts a string that does not contain "true" as false' do 65 | @convertible.a_boolean = 'something' 66 | @convertible.a_boolean.should.is_a(FalseClass) 67 | end 68 | 69 | it 'the boolean field accepts nil as false' do 70 | @convertible.a_boolean = nil 71 | @convertible.a_boolean.should.is_a(FalseClass) 72 | end 73 | 74 | it 'returns an integer for an int field' do 75 | @convertible.an_int.should.is_a(Integer) 76 | end 77 | 78 | it 'the int field should be the same as it was in string form' do 79 | @convertible.an_int.to_s.should.equal('1') 80 | end 81 | 82 | it 'returns an integer for an integer field' do 83 | @convertible.an_integer.should.is_a(Integer) 84 | end 85 | 86 | it 'the integer field should be the same as it was in string form' do 87 | @convertible.an_integer.to_s.should.equal('2') 88 | end 89 | 90 | it 'returns a float for a float field' do 91 | @convertible.a_float.should.is_a(Float) 92 | end 93 | 94 | it 'the float field should be the same as it was in string form' do 95 | @convertible.a_float.should.>(3.6) 96 | @convertible.a_float.should.<(3.8) 97 | end 98 | 99 | it 'returns a double for a double field' do 100 | @convertible.a_double.should.is_a(Float) 101 | end 102 | 103 | it 'the double field should be the same as it was in string form' do 104 | @convertible.a_double.should.>(3.41458) 105 | @convertible.a_double.should.<(3.41460) 106 | end 107 | 108 | it 'returns a NSDate for a date field' do 109 | @convertible.a_date.should.is_a(NSDate) 110 | end 111 | 112 | it 'the date field should be the same as it was in string form' do 113 | @convertible.a_date.to_s.should.match(/^2012-09-15/) 114 | end 115 | 116 | it 'returns an Array for an array field' do 117 | @convertible.an_array.should.is_a(Array) 118 | end 119 | it 'returns proper array for parsed json data using bubble wrap' do 120 | parsed_json = BW::JSON.parse('{"menu_categories":["Lunch"]}') 121 | @convertible.an_array = parsed_json["menu_categories"] 122 | @convertible.an_array.count.should == 1 123 | @convertible.an_array.include?("Lunch").should == true 124 | end 125 | it 'the array field should be the same as the range form' do 126 | (@convertible.an_array.first..@convertible.an_array.last).should.equal(1..10) 127 | end 128 | 129 | describe 'can cast to an arbitrary type' do 130 | class HasArbitraryTypes 131 | include MotionModel::Model 132 | include MotionModel::ArrayModelAdapter 133 | columns name: String, 134 | properties: Hash 135 | end 136 | 137 | class EmbeddedAddress 138 | include MotionModel::Model 139 | include MotionModel::ArrayModelAdapter 140 | columns street: String, 141 | city: String, 142 | state: String, 143 | zip: Integer, 144 | pets: Array 145 | # attr_accessor :street 146 | # attr_accessor :city 147 | # attr_accessor :state 148 | # attr_accessor :zip 149 | # attr_accessor :pets 150 | 151 | # def initialize(options = {}) 152 | # @street = options[:street] if options[:street] 153 | # @city = options[:city] if options[:city] 154 | # @state = options[:state] if options[:state] 155 | # @zip = options[:zip] if options[:zip] 156 | # @pets = options[:pets] if options[:pets] 157 | # end 158 | end 159 | 160 | class EmbeddingClass 161 | include MotionModel::Model 162 | include MotionModel::ArrayModelAdapter 163 | columns name: String, 164 | address: EmbeddedAddress, 165 | pets: Array 166 | end 167 | 168 | before do 169 | EmbeddingClass.delete_all 170 | HasArbitraryTypes.delete_all 171 | end 172 | 173 | it "creation works" do 174 | arb = HasArbitraryTypes.create(name: 'A Name', properties: {address: '123 Main Street', city: 'Seattle', state: 'WA'}) 175 | arb.name.should == 'A Name' 176 | arb.properties.class.should == Hash 177 | arb.properties[:address].should == '123 Main Street' 178 | end 179 | 180 | it "updating works" do 181 | HasArbitraryTypes.create(name: 'Another Name', properties: {address: '123 Main Street', city: 'Seattle', state: 'WA'}) 182 | arb = HasArbitraryTypes.first 183 | arb.properties[:address] = '234 Main Street' 184 | arb.save 185 | arb.properties[:address].should == '234 Main Street' 186 | arb = HasArbitraryTypes.find(:name).eq('Another Name').first 187 | arb.properties[:address].should == '234 Main Street' 188 | end 189 | 190 | it "creating objects with embedded documents works" do 191 | addr = EmbeddedAddress.new(street: '2211 First', city: 'Seattle', state: 'WA', zip: 98104) 192 | emb = EmbeddingClass.create(name: 'On Class', address: addr) 193 | emb.address.class.should == EmbeddedAddress 194 | emb.address.street.should == '2211 First' 195 | end 196 | 197 | it "copies embedded types" do 198 | addr = EmbeddedAddress.new(street: '2211 First', city: 'Seattle', state: 'WA', zip: 98104, pets: ['rover', 'fido', 'barney']) 199 | emb = EmbeddingClass.create(name: 'On Class', address: addr) 200 | emb.address.pets.class.should == Array 201 | emb.address.pets.should.include?('barney') 202 | EmbeddingClass.first.address.pets.should.include?('barney') 203 | end 204 | 205 | it "updates embedded types" do 206 | addr = EmbeddedAddress.new(street: '3322 First', city: 'Seattle', state: 'WA', zip: 98104, pets: ['rover', 'fido', 'barney']) 207 | emb = EmbeddingClass.create(name: 'On Class', address: addr) 208 | emb.address.pets.should.include?('barney') 209 | found = EmbeddingClass.find(:name).eq('On Class').first 210 | found.address.pets.should.include?('barney') 211 | found.address.pets.delete('barney') 212 | found.save 213 | EmbeddingClass.find(:name).eq('On Class').first.address.pets.should.not.include?('barney') 214 | end 215 | 216 | it "serializes with arbitrary Ruby types without error" do 217 | HasArbitraryTypes.create(name: 'A Name', properties: {address: '123 Main Street', city: 'Seattle', state: 'WA'}) 218 | lambda{HasArbitraryTypes.serialize_to_file('test.dat')}.should.not.raise 219 | end 220 | 221 | it "deserializes arbitrary Ruby types with correct values" do 222 | HasArbitraryTypes.create(name: 'A Name', properties: {address: '123 Main Street', city: 'Seattle', state: 'WA'}) 223 | HasArbitraryTypes.serialize_to_file('test.dat') 224 | HasArbitraryTypes.deserialize_from_file('test.dat') 225 | result = HasArbitraryTypes.find(:name).eq('A Name').first 226 | result.should.not.be.nil 227 | result.properties.class.should == Hash 228 | result.properties[:city].should == 'Seattle' 229 | end 230 | 231 | it "serializes arbitrary user-defined classes without error" do 232 | addr = EmbeddedAddress.new(street: '2211 First', city: 'Seattle', state: 'WA', zip: 98104) 233 | emb = EmbeddingClass.create(name: 'On Class', address: addr) 234 | lambda{EmbeddingClass.serialize_to_file('test.dat')}.should.not.raise 235 | end 236 | 237 | it "deserializes arbitrary user-defined classes with correct values" do 238 | addr = EmbeddedAddress.new(street: '2211 First', city: 'Seattle', state: 'WA', zip: 98104, pets: ['Katniss', 'Peeta']) 239 | emb = EmbeddingClass.create(name: 'On Class', address: addr) 240 | lambda{EmbeddingClass.serialize_to_file('test.dat')}.should.not.raise 241 | EmbeddingClass.deserialize_from_file('test.dat') 242 | result = EmbeddingClass.find(:name).eq('On Class').first 243 | result.should.not.be.nil 244 | result.address.class.should == EmbeddedAddress 245 | result.address.city.should == 'Seattle' 246 | result.address.pets.should.include?('Katniss') 247 | end 248 | end 249 | end 250 | -------------------------------------------------------------------------------- /spec/model_hook_spec.rb: -------------------------------------------------------------------------------- 1 | Object.send(:remove_const, :Task) if defined?(Task) 2 | class Task 3 | attr_reader :before_delete_called, :after_delete_called 4 | attr_reader :before_save_called, :after_save_called 5 | 6 | include MotionModel::Model 7 | include MotionModel::ArrayModelAdapter 8 | columns :name => :string, 9 | :details => :string, 10 | :some_day => :date 11 | 12 | def before_delete(sender) 13 | @before_delete_called = true 14 | end 15 | 16 | def after_delete(sender) 17 | @after_delete_called = true 18 | end 19 | 20 | def before_save(sender) 21 | @before_save_called = true 22 | end 23 | 24 | def after_save(sender) 25 | @after_save_called = true 26 | end 27 | 28 | end 29 | 30 | describe "lifecycle hooks" do 31 | describe "delete and destroy" do 32 | before{@task = Task.create(:name => 'joe')} 33 | 34 | it "calls the before delete hook when delete is called" do 35 | lambda{@task.delete}.should.change{@task.before_delete_called} 36 | end 37 | 38 | it "calls the after delete hook when delete is called" do 39 | lambda{@task.delete}.should.change{@task.after_delete_called} 40 | end 41 | 42 | it "calls the before delete hook when destroy is called" do 43 | lambda{@task.destroy}.should.change{@task.before_delete_called} 44 | end 45 | 46 | it "calls the after delete hook when destroy is called" do 47 | lambda{@task.destroy}.should.change{@task.after_delete_called} 48 | end 49 | end 50 | 51 | describe "create and save" do 52 | before{@task = Task.new(:name => 'joe')} 53 | 54 | it "calls before_save hook on save" do 55 | lambda{@task.save}.should.change{@task.before_save_called} 56 | end 57 | 58 | it "calls after_save hook on save" do 59 | lambda{@task.save}.should.change{@task.after_save_called} 60 | end 61 | 62 | it "calls after_save hook on update" do 63 | task = Task.last 64 | task.instance_variable_set("@after_save_called", false) 65 | lambda{task.save}.should.change{task.after_save_called} 66 | end 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /spec/model_spec.rb: -------------------------------------------------------------------------------- 1 | class ModelSpecTask 2 | include MotionModel::Model 3 | include MotionModel::ArrayModelAdapter 4 | columns :name => :string, 5 | :details => :string, 6 | :some_day => :date, 7 | :enabled => {:type => :boolean, :default => false} 8 | 9 | def custom_attribute_by_method 10 | "#{name} - #{details}" 11 | end 12 | end 13 | 14 | class AModelSpecTask 15 | include MotionModel::Model 16 | include MotionModel::ArrayModelAdapter 17 | columns :name, :details, :some_day 18 | end 19 | 20 | class BModelSpecTask 21 | include MotionModel::Model 22 | include MotionModel::ArrayModelAdapter 23 | columns :name, :details 24 | def details=(value) 25 | write_attribute(:details, "overridden") 26 | end 27 | end 28 | 29 | class TypeCast 30 | include MotionModel::Model 31 | include MotionModel::ArrayModelAdapter 32 | columns :a_boolean => :boolean, 33 | :an_int => {:type => :int, :default => 3}, 34 | :an_integer => :integer, 35 | :a_float => :float, 36 | :a_double => :double, 37 | :a_date => :date, 38 | :a_time => :time, 39 | :an_array => :array 40 | end 41 | 42 | describe "Creating a model" do 43 | describe 'column macro behavior' do 44 | before do 45 | ModelSpecTask.delete_all 46 | end 47 | 48 | it 'succeeds when creating a valid model from attributes' do 49 | a_task = ModelSpecTask.new(:name => 'name', :details => 'details') 50 | a_task.name.should.equal('name') 51 | end 52 | 53 | it 'creates a model with all attributes even if some omitted' do 54 | atask = ModelSpecTask.create(:name => 'bob') 55 | atask.should.respond_to(:details) 56 | end 57 | 58 | it "adds a default value if none supplied" do 59 | a_type_test = TypeCast.new 60 | a_type_test.an_int.should.equal(3) 61 | end 62 | 63 | it "on initialization uses supplied value instead of default value, if supplied" do 64 | a_task = ModelSpecTask.new(:enabled => true) 65 | a_task.enabled.should.be.true 66 | end 67 | 68 | it "on creation uses supplied value instead of default value, if supplied" do 69 | a_task = ModelSpecTask.create(:enabled => true) 70 | a_task.enabled.should.be.true 71 | end 72 | 73 | it "can check for a column's existence on a model" do 74 | ModelSpecTask.column?(:name).should.be.true 75 | end 76 | 77 | it "can check for a column's existence on an instance" do 78 | a_task = ModelSpecTask.new(:name => 'name', :details => 'details') 79 | a_task.column?(:name).should.be.true 80 | end 81 | 82 | it "gets a list of columns on a model" do 83 | cols = ModelSpecTask.columns 84 | cols.should.include(:name) 85 | cols.should.include(:details) 86 | end 87 | 88 | it "gets a list of columns on an instance" do 89 | a_task = ModelSpecTask.new 90 | cols = a_task.columns 91 | cols.should.include(:name) 92 | cols.should.include(:details) 93 | end 94 | 95 | it "columns can be specified as a Hash" do 96 | lambda{ModelSpecTask.new}.should.not.raise 97 | ModelSpecTask.new.column?(:name).should.be.true 98 | end 99 | 100 | it "columns can be specified as an Array" do 101 | lambda{AModelSpecTask.new}.should.not.raise 102 | ModelSpecTask.new.column?(:name).should.be.true 103 | end 104 | 105 | it "the type of a column can be retrieved" do 106 | ModelSpecTask.new.column_type(:some_day).should.equal(:date) 107 | end 108 | 109 | end 110 | 111 | describe "ID handling" do 112 | before do 113 | ModelSpecTask.delete_all 114 | end 115 | 116 | 117 | it 'creates an id if none present' do 118 | task = ModelSpecTask.create 119 | task.should.respond_to(:id) 120 | end 121 | 122 | it 'does not overwrite an existing ID' do 123 | task = ModelSpecTask.create(:id => 999) 124 | task.id.should.equal(999) 125 | end 126 | 127 | it 'creates multiple objects with unique ids' do 128 | ModelSpecTask.create.id.should.not.equal(ModelSpecTask.create.id) 129 | end 130 | 131 | end 132 | 133 | describe 'count and length methods' do 134 | before do 135 | ModelSpecTask.delete_all 136 | end 137 | 138 | it 'has a length method' do 139 | ModelSpecTask.should.respond_to(:length) 140 | end 141 | 142 | it 'has a count method' do 143 | ModelSpecTask.should.respond_to(:count) 144 | end 145 | 146 | it 'when there is one element, length returns 1' do 147 | task = ModelSpecTask.create 148 | ModelSpecTask.length.should.equal(1) 149 | end 150 | 151 | it 'when there is one element, count returns 1' do 152 | task = ModelSpecTask.create 153 | ModelSpecTask.count.should.equal(1) 154 | end 155 | 156 | it 'instance variables have access to length and count' do 157 | task = ModelSpecTask.create 158 | task.length.should.equal(1) 159 | task.count.should.equal(1) 160 | end 161 | 162 | it 'when there is more than one element, length returned is correct' do 163 | 10.times { ModelSpecTask.create } 164 | ModelSpecTask.length.should.equal(10) 165 | end 166 | 167 | end 168 | 169 | describe 'adding or updating' do 170 | before do 171 | ModelSpecTask.delete_all 172 | end 173 | 174 | it 'adds to the collection when a new task is saved' do 175 | task = ModelSpecTask.new 176 | lambda{task.save}.should.change{ModelSpecTask.count} 177 | end 178 | 179 | it 'does not add to the collection when an existing task is saved' do 180 | task = ModelSpecTask.create(:name => 'updateable') 181 | task.name = 'updated' 182 | lambda{task.save}.should.not.change{ModelSpecTask.count} 183 | end 184 | 185 | it 'updates data properly' do 186 | task = ModelSpecTask.create(:name => 'updateable') 187 | task.name = 'updated' 188 | ModelSpecTask.where(:name).eq('updated').should == 0 189 | lambda{task.save}.should.change{ModelSpecTask.where(:name).eq('updated')} 190 | end 191 | end 192 | 193 | describe 'deleting' do 194 | before do 195 | ModelSpecTask.delete_all 196 | ModelSpecTask.bulk_update do 197 | 1.upto(10) {|i| ModelSpecTask.create(:name => "task #{i}")} 198 | end 199 | end 200 | 201 | it 'deletes a row' do 202 | target = ModelSpecTask.find(:name).eq('task 3').first 203 | target.should.not == nil 204 | target.delete 205 | ModelSpecTask.find(:name).eq('task 3').count.should.equal 0 206 | end 207 | 208 | it 'deleting a row changes length' do 209 | target = ModelSpecTask.find(:name).eq('task 2').first 210 | lambda{target.delete}.should.change{ModelSpecTask.length} 211 | end 212 | 213 | it 'undeleting a row restores it' do 214 | target = ModelSpecTask.find(:name).eq('task 3').first 215 | target.should.not == nil 216 | target.delete 217 | target.undelete 218 | ModelSpecTask.find(:name).eq('task 3').count.should.equal 1 219 | end 220 | end 221 | 222 | describe 'Handling Attribute Implementation' do 223 | it 'raises a NoMethodError exception when an unknown attribute it referenced' do 224 | task = ModelSpecTask.new 225 | lambda{task.bar}.should.raise(NoMethodError) 226 | end 227 | 228 | it 'raises a NoMethodError exception when an unknown attribute receives an assignment' do 229 | task = ModelSpecTask.new 230 | lambda{task.bar = 'foo'}.should.raise(NoMethodError) 231 | end 232 | 233 | it 'successfully retrieves by attribute' do 234 | task = ModelSpecTask.create(:name => 'my task') 235 | task.name.should == 'my task' 236 | end 237 | 238 | describe "dirty" do 239 | before do 240 | @new_task = ModelSpecTask.new 241 | end 242 | 243 | it 'marks a new object as dirty' do 244 | @new_task.should.be.dirty 245 | end 246 | 247 | it 'marks a saved object as clean' do 248 | lambda{@new_task.save}.should.change{@new_task.dirty?} 249 | end 250 | 251 | it 'marks a modified object as dirty' do 252 | @new_task.save 253 | lambda{@new_task.name = 'now dirty'}.should.change{@new_task.dirty?} 254 | end 255 | 256 | it 'marks an updated object as clean' do 257 | @new_task.save 258 | @new_task.should.not.be.dirty 259 | @new_task.name = 'now updating task' 260 | @new_task.should.be.dirty 261 | @new_task.save 262 | @new_task.should.not.be.dirty 263 | end 264 | end 265 | end 266 | 267 | describe 'defining custom attributes' do 268 | before do 269 | ModelSpecTask.delete_all 270 | @task = ModelSpecTask.create :name => 'Feed the Cat', :details => 'Get food, pour out' 271 | end 272 | 273 | it 'uses a custom attribute by method' do 274 | @task.custom_attribute_by_method.should == 'Feed the Cat - Get food, pour out' 275 | end 276 | end 277 | 278 | describe 'overloading accessors using write_attribute' do 279 | before do 280 | BModelSpecTask.delete_all 281 | end 282 | 283 | it 'updates the attribute on creation' do 284 | @task = BModelSpecTask.create :name => 'foo', :details => 'bar' 285 | @task.details.should.equal('overridden') 286 | @task.should.not.be.dirty 287 | end 288 | 289 | it 'updates the attribute but does not save a new instance' do 290 | @task = BModelSpecTask.new :name => 'foo', :details => 'bar' 291 | @task.details.should.equal('overridden') 292 | @task.should.be.dirty 293 | end 294 | 295 | end 296 | 297 | describe 'protecting timestamps' do 298 | class NoTimestamps 299 | include MotionModel::Model 300 | include MotionModel::ArrayModelAdapter 301 | columns name: :string 302 | protect_remote_timestamps 303 | end 304 | 305 | class AutoTimeable 306 | include MotionModel::Model 307 | include MotionModel::ArrayModelAdapter 308 | columns name: :string, 309 | created_at: :date, 310 | updated_at: :date 311 | end 312 | 313 | class ProtectedTimestamps 314 | include MotionModel::Model 315 | include MotionModel::ArrayModelAdapter 316 | columns name: :string, 317 | created_at: :date, 318 | updated_at: :date 319 | protect_remote_timestamps 320 | end 321 | 322 | it 'does nothing to break classes with no timestamps' do 323 | lambda{NoTimestamps.create!(name: 'no timestamps')}.should.not.raise 324 | end 325 | 326 | it "changes the timestamps if they are not protected" do 327 | auto_timeable = AutoTimeable.new(name: 'auto timeable') 328 | lambda{auto_timeable.name = 'changed auto timeable'; auto_timeable.save!}.should.change{auto_timeable.updated_at} 329 | end 330 | 331 | it "does not change created_at if timestamps are protected" do 332 | protected_times = ProtectedTimestamps.new(name: 'auto timeable', created_at: Time.now, updated_at: Time.now) 333 | lambda{protected_times.name = 'changed created at'; protected_times.save!}.should.not.change{protected_times.created_at} 334 | end 335 | 336 | it "does not change updated_at if timestamps are protected" do 337 | protected_times = ProtectedTimestamps.new(name: 'auto timeable', created_at: Time.now, updated_at: Time.now) 338 | lambda{protected_times.name = 'changed updated at'; protected_times.save!}.should.not.change{protected_times.updated_at} 339 | end 340 | end 341 | end 342 | -------------------------------------------------------------------------------- /spec/notification_spec.rb: -------------------------------------------------------------------------------- 1 | class NotifiableTask 2 | include MotionModel::Model 3 | include MotionModel::ArrayModelAdapter 4 | columns :name 5 | @@notification_called = false 6 | @@notification_details = :none 7 | 8 | def notification_called; @@notification_called; end 9 | def notification_called=(value); @@notification_called = value; end 10 | def notification_details; @@notification_details; end 11 | def notification_details=(value); @@notification_details = value; end 12 | 13 | def hookup_events 14 | @notification_id = NSNotificationCenter.defaultCenter.addObserverForName('MotionModelDataDidChangeNotification', object:self, queue:NSOperationQueue.mainQueue, 15 | usingBlock:lambda{|notification| 16 | @@notification_called = true 17 | @@notification_details = notification.userInfo 18 | } 19 | ) 20 | end 21 | 22 | def dataDidChange(notification) 23 | @notification_called = true 24 | @notification_details = notification.userInfo 25 | end 26 | 27 | def teardown_events 28 | NSNotificationCenter.defaultCenter.removeObserver @notification_id 29 | end 30 | end 31 | 32 | describe 'data change notifications' do 33 | before do 34 | NotifiableTask.delete_all 35 | @task = NotifiableTask.new(:name => 'bob') 36 | @task.notification_called = false 37 | @task.notification_details = :nothing 38 | @task.hookup_events 39 | end 40 | 41 | after do 42 | @task.teardown_events 43 | end 44 | 45 | it "fires a change notification when an item is added" do 46 | @task.save 47 | @task.notification_called.should == true 48 | end 49 | 50 | it "contains an add notification for new objects" do 51 | @task.save 52 | @task.notification_details[:action].should == 'add' 53 | end 54 | 55 | it "contains an update notification for an updated object" do 56 | @task.save 57 | @task.name = "Bill" 58 | @task.save 59 | @task.notification_details[:action].should == 'update' 60 | end 61 | 62 | it "does not get a delete notification for delete_all" do 63 | @task.save 64 | @task.notification_called = false 65 | NotifiableTask.delete_all 66 | @task.notification_called.should == false 67 | end 68 | 69 | it "contains a delete notification for a deleted object" do 70 | @task.save 71 | @task.delete 72 | @task.notification_details[:action].should == 'delete' 73 | end 74 | end 75 | 76 | -------------------------------------------------------------------------------- /spec/proc_defaults_spec.rb: -------------------------------------------------------------------------------- 1 | describe "proc for defaults" do 2 | describe "accepts a proc or block for default" do 3 | describe "accepts proc" do 4 | class AcceptsProc 5 | include MotionModel::Model 6 | include MotionModel::ArrayModelAdapter 7 | columns subject: { type: :array, default: ->{ [] } } 8 | end 9 | 10 | before do 11 | @test1 = AcceptsProc.create 12 | @test2 = AcceptsProc.create 13 | end 14 | 15 | it "initializes array type using proc call" do 16 | @test1.subject.should.be == @test2.subject 17 | end 18 | end 19 | 20 | describe "accepts block" do 21 | class AcceptsBlock 22 | include MotionModel::Model 23 | include MotionModel::ArrayModelAdapter 24 | columns subject: { 25 | type: :array, default: begin 26 | [] 27 | end 28 | } 29 | end 30 | 31 | before do 32 | @test1 = AcceptsBlock.create 33 | @test2 = AcceptsBlock.create 34 | end 35 | 36 | it "initializes array type using begin/end block call" do 37 | @test1.subject.should.be == @test2.subject 38 | end 39 | end 40 | 41 | describe "accepts symbol" do 42 | class AcceptsSym 43 | include MotionModel::Model 44 | include MotionModel::ArrayModelAdapter 45 | columns subject: { type: :integer, default: :randomize } 46 | 47 | def self.randomize 48 | rand 1_000_000 49 | end 50 | end 51 | 52 | before do 53 | @test1 = AcceptsSym.create 54 | @test2 = AcceptsSym.create 55 | end 56 | 57 | it "initializes column by calling a method" do 58 | @test1.subject.should.be == @test2.subject 59 | end 60 | end 61 | 62 | describe "scalar defaults still work" do 63 | class AcceptsScalars 64 | include MotionModel::Model 65 | include MotionModel::ArrayModelAdapter 66 | columns subject: { type: :integer, default: 42 } 67 | end 68 | 69 | before do 70 | @test1 = AcceptsScalars.create 71 | end 72 | 73 | it "initializes column as normal" do 74 | @test1.subject.should == 42 75 | end 76 | end 77 | end 78 | end 79 | -------------------------------------------------------------------------------- /spec/relation_spec.rb: -------------------------------------------------------------------------------- 1 | class Assignee 2 | include MotionModel::Model 3 | include MotionModel::ArrayModelAdapter 4 | columns :assignee_name => :string 5 | belongs_to :task 6 | end 7 | 8 | class Task 9 | include MotionModel::Model 10 | include MotionModel::ArrayModelAdapter 11 | columns :name => :string, 12 | :details => :string, 13 | :some_day => :date 14 | has_many :assignees 15 | end 16 | 17 | class User 18 | include MotionModel::Model 19 | include MotionModel::ArrayModelAdapter 20 | columns :name => :string 21 | 22 | has_many :email_accounts 23 | end 24 | 25 | class EmailAccount 26 | include MotionModel::Model 27 | include MotionModel::ArrayModelAdapter 28 | columns :name => :string 29 | belongs_to :user 30 | end 31 | 32 | class BelongsToUser 33 | include MotionModel::Model 34 | include MotionModel::ArrayModelAdapter 35 | 36 | columns :name => :string, 37 | :id => :string 38 | 39 | has_one :belongs_to_profile 40 | end 41 | 42 | class BelongsToProfile 43 | include MotionModel::Model 44 | include MotionModel::ArrayModelAdapter 45 | 46 | columns :email => :string, 47 | :id => :string 48 | 49 | belongs_to :belongs_to_user 50 | end 51 | 52 | describe 'related objects' do 53 | describe "supporting belongs_to and has_many with camelcased relations" do 54 | before do 55 | EmailAccount.delete_all 56 | User.delete_all 57 | end 58 | 59 | it "camelcased style" do 60 | t = User.create(:name => "Arkan") 61 | t.email_accounts.create(:name => "Gmail") 62 | EmailAccount.first.user.name.should == "Arkan" 63 | User.last.email_accounts.last.name.should == "Gmail" 64 | end 65 | end 66 | 67 | describe 'has_many' do 68 | before do 69 | Task.delete_all 70 | Assignee.delete_all 71 | end 72 | 73 | it "is wired up right" do 74 | lambda {Task.new}.should.not.raise 75 | lambda {Task.new.assignees}.should.not.raise 76 | end 77 | 78 | it 'relation objects are empty on initialization' do 79 | a_task = Task.create 80 | a_task.assignees.all.should.be.empty 81 | end 82 | 83 | it "supports creating related objects directly on parents" do 84 | a_task = Task.create(:name => 'Walk the Dog') 85 | a_task.assignees.create(:assignee_name => 'bob') 86 | a_task.assignees.count.should == 1 87 | a_task.assignees.first.assignee_name.should == 'bob' 88 | Assignee.count.should == 1 89 | end 90 | 91 | describe "supporting has_many" do 92 | before do 93 | Task.delete_all 94 | Assignee.delete_all 95 | 96 | @tasks = [] 97 | @assignees = [] 98 | 1.upto(3) do |task| 99 | t = Task.create(:name => "task #{task}", :id => task) 100 | assignee_index = 1 101 | @tasks << t 102 | 1.upto(task * 2) do |assignee| 103 | @assignees << t.assignees.create(:assignee_name => "employee #{assignee_index}_assignee_for_task_#{t.id}") 104 | assignee_index += 1 105 | end 106 | end 107 | end 108 | 109 | it "is wired up right" do 110 | Task.count.should == 3 111 | Assignee.count.should == 12 112 | end 113 | 114 | it "has 2 assignees for the first task" do 115 | Task.first.assignees.count.should == 2 116 | end 117 | 118 | it "the first assignee for the second task is employee 7" do 119 | Task.find(2).name.should == @tasks[1].name 120 | Task.find(2).assignees.first.assignee_name.should == @assignees[2].assignee_name 121 | end 122 | 123 | it 'supports adding related objects to parents' do 124 | assignee = Assignee.new(:assignee_name => 'Zoe') 125 | Task.count.should == 3 126 | assignee_count = Task.find(3).assignees.count 127 | Task.find(3).assignees.push(assignee) 128 | Task.find(3).assignees.count.should == assignee_count + 1 129 | end 130 | 131 | end 132 | 133 | it "supports creating blank (empty) scratchpad associated objects" do 134 | task = Task.create :name => 'watch a movie' 135 | assignee = task.assignees.new # TODO per Rails convention, this should really be #build, not #new 136 | assignee.assignee_name = 'Chloe' 137 | assignee.save 138 | task.assignees.count.should == 1 139 | task.assignees.first.assignee_name.should == 'Chloe' 140 | end 141 | end 142 | 143 | describe "supporting belongs_to" do 144 | before do 145 | Task.delete_all 146 | Assignee.delete_all 147 | end 148 | 149 | it "allows a child to back-reference its parent" do 150 | t = Task.create(:name => "Walk the Dog") 151 | t.assignees.create(:assignee_name => "Rihanna") 152 | Assignee.first.task.name.should == "Walk the Dog" 153 | end 154 | 155 | describe "belongs_to reassignment" do 156 | before do 157 | Task.delete_all 158 | @t1 = Task.create(:name => "Walk the Dog") 159 | @t2 = Task.create :name => "Feed the cat" 160 | @a1 = Assignee.create :assignee_name => "Jim" 161 | end 162 | 163 | describe "basic wiring" do 164 | before do 165 | @t1.assignees << @a1 166 | end 167 | 168 | it "pushing a created assignee gives a task count of 1" do 169 | @t1.assignees.count.should == 1 170 | end 171 | 172 | it "pushing a created assignee gives a cascaded assignee name" do 173 | @t1.assignees.first.assignee_name.should == "Jim" 174 | end 175 | 176 | it "pushing a created assignee enables back-referencing a task" do 177 | @a1.task.name.should == "Walk the Dog" 178 | end 179 | end 180 | 181 | describe "when pushing assignees onto two different tasks" do 182 | before do 183 | @t2.assignees << @a1 184 | end 185 | 186 | it "pushing assignees to two different tasks lets the last task have the assignee (count)" do 187 | @t2.assignees.count.should == 1 188 | end 189 | 190 | it "pushing assignees to two different tasks removes the assignee from the first task (count)" do 191 | @t1.assignees.count.should == 0 192 | end 193 | 194 | it "pushing assignees to two different tasks lets the last task have the assignee (assignee name)" do 195 | @t2.assignees.first.assignee_name.should == "Jim" 196 | end 197 | 198 | it "pushing assignees to two different tasks lets the last task have the assignee (back reference)" do 199 | @a1.task.name.should == "Feed the cat" 200 | end 201 | end 202 | 203 | describe "directly assigning to child" do 204 | it "directly assigning a different task to an assignee changes the assignee's task" do 205 | @a1.task_id = @t1.id 206 | @a1.save 207 | @t1.assignees.count.should == 1 208 | @t1.assignees.first.assignee_name.should == @a1.assignee_name 209 | end 210 | 211 | it "directly assigning an instance of a task to an assignee changes the assignee's task" do 212 | @a1.task = @t1 213 | @a1.save 214 | @t1.assignees.count.should == 1 215 | @t1.assignees.first.assignee_name.should == @a1.assignee_name 216 | end 217 | 218 | it "directly assigning the assignee a nil task twice doesn't change anything" do 219 | @a1.task.should == nil 220 | @a1.task = nil 221 | @a1.dirty?.should == false 222 | end 223 | 224 | it "directly assigning the existing task to an assignee doesn't change anything" do 225 | @a1.task = @t1 226 | @a1.save 227 | @a1.task = @t1 228 | @a1.dirty?.should == false 229 | end 230 | 231 | it "directly assigning the assignee a nil task twice doesn't change anything" do 232 | @a1.task.should == nil 233 | @a1.task = nil 234 | @a1.dirty?.should == false 235 | end 236 | end 237 | end 238 | end 239 | 240 | it 'can get parent from a has_one create relation with a custom ID' do 241 | user = BelongsToUser.create(name: 'Sam', id: "") 242 | profile = user.belongs_to_profile.create(email: 'ss@gmail.com') 243 | 244 | BelongsToProfile.first.belongs_to_user.should.is_a?(BelongsToUser) 245 | BelongsToProfile.first.belongs_to_user.name.should == 'Sam' 246 | 247 | BelongsToUser.first.belongs_to_profile.first.belongs_to_user.should.is_a?(BelongsToUser) 248 | BelongsToUser.first.belongs_to_profile.first.belongs_to_user.name.should == 'Sam' 249 | end 250 | end 251 | 252 | -------------------------------------------------------------------------------- /spec/transaction_spec.rb: -------------------------------------------------------------------------------- 1 | class TransactClass 2 | include MotionModel::Model 3 | include MotionModel::ArrayModelAdapter 4 | include MotionModel::Model::Transactions 5 | columns :name, :age 6 | has_many :transaction_things 7 | end 8 | 9 | class TransactionThing 10 | include MotionModel::Model 11 | include MotionModel::ArrayModelAdapter 12 | include MotionModel::Model::Transactions 13 | columns :thingie_description 14 | belongs_to :transact_class 15 | end 16 | 17 | describe "transactions" do 18 | before{TransactClass.destroy_all} 19 | 20 | it "wraps a transaction but auto-commits" do 21 | item = TransactClass.create(:name => 'joe', :age => 22) 22 | item.transaction do 23 | item.name = 'Bob' 24 | end 25 | item.name.should == 'Bob' 26 | TransactClass.find(:name).eq('Bob').count.should == 1 27 | end 28 | 29 | it "wraps a transaction but can rollback to a savepoint" do 30 | item = TransactClass.create(:name => 'joe', :age => 22) 31 | item.transaction do 32 | item.name = 'Bob' 33 | item.rollback 34 | end 35 | item.name.should == 'joe' 36 | TransactClass.find(:name).eq('joe').count.should == 1 37 | TransactClass.find(:name).eq('Bob').count.should == 0 38 | end 39 | 40 | it "allows multiple savepoints -- inside one not exercised" do 41 | item = TransactClass.create(:name => 'joe', :age => 22) 42 | item.transaction do 43 | item.transaction do 44 | item.name = 'Bob' 45 | end 46 | item.rollback 47 | item.name.should == 'joe' 48 | TransactClass.find(:name).eq('joe').count.should == 1 49 | TransactClass.find(:name).eq('Bob').count.should == 0 50 | end 51 | end 52 | 53 | it "allows multiple savepoints -- inside one exercised" do 54 | item = TransactClass.create(:name => 'joe', :age => 22) 55 | item.transaction do 56 | item.transaction do 57 | item.name = 'Ralph' 58 | item.rollback 59 | end 60 | item.name.should == 'joe' 61 | TransactClass.find(:name).eq('joe').count.should == 1 62 | TransactClass.find(:name).eq('Bob').count.should == 0 63 | end 64 | end 65 | 66 | it "allows multiple savepoints -- set in outside context rollback in inside" do 67 | item = TransactClass.create(:name => 'joe', :age => 22) 68 | item.transaction do 69 | item.name = 'Ralph' 70 | item.transaction do 71 | item.rollback 72 | end 73 | item.name.should == 'Ralph' 74 | TransactClass.find(:name).eq('Ralph').count.should == 1 75 | end 76 | end 77 | 78 | it "allows multiple savepoints -- multiple savepoints exercised" do 79 | item = TransactClass.create(:name => 'joe', :age => 22) 80 | item.transaction do 81 | item.name = 'Ralph' 82 | item.transaction do 83 | item.name = 'Paul' 84 | item.rollback 85 | item.name.should == 'Ralph' 86 | TransactClass.find(:name).eq('Ralph').count.should == 1 87 | end 88 | item.rollback 89 | item.name.should == 'joe' 90 | TransactClass.find(:name).eq('joe').count.should == 1 91 | end 92 | end 93 | end 94 | -------------------------------------------------------------------------------- /spec/validation_spec.rb: -------------------------------------------------------------------------------- 1 | class ValidatableTask 2 | include MotionModel::Model 3 | include MotionModel::ArrayModelAdapter 4 | include MotionModel::Validatable 5 | columns :name => :string, 6 | :email => :string, 7 | :some_day => :string, 8 | :some_float => :float, 9 | :some_int => :int 10 | 11 | validate :name, :presence => true 12 | validate :name, :length => 2..10 13 | validate :email, :email => true 14 | validate :some_day, :format => /\A\d?\d-\d?\d-\d\d\Z/ 15 | validate :some_day, :length => 8..10 16 | validate :some_float, :presence => true 17 | validate :some_int, :presence => true 18 | end 19 | 20 | describe "validations" do 21 | before do 22 | @valid_tasks = { 23 | :name => 'bob', 24 | :email => 'bob@domain.com', 25 | :some_day => '12-12-12', 26 | :some_float => 1.080, 27 | :some_int => 99 28 | } 29 | end 30 | 31 | describe "presence" do 32 | it "is initially false if name is blank" do 33 | task = ValidatableTask.new(@valid_tasks.except(:name)) 34 | task.valid?.should === false 35 | end 36 | 37 | it "contains correct error message if name is blank" do 38 | task = ValidatableTask.new(@valid_tasks.except(:name)) 39 | task.valid? 40 | task.error_messages_for(:name).first.should == 41 | "incorrect value supplied for name -- should be non-empty." 42 | end 43 | 44 | it "is true if name is filled in" do 45 | task = ValidatableTask.create(@valid_tasks.except(:name)) 46 | task.name = 'bob' 47 | task.valid?.should === true 48 | end 49 | 50 | it "is false if the float is nil" do 51 | task = ValidatableTask.new(@valid_tasks.except(:some_float)) 52 | task.valid?.should === false 53 | end 54 | 55 | it "contains multiple error messages if name and some_float are blank" do 56 | task = ValidatableTask.new(@valid_tasks.except(:name, :some_float)) 57 | task.valid? 58 | task.error_messages.length.should == 3 59 | task.error_messages_for(:name).length.should == 2 60 | task.error_messages_for(:some_float).length.should == 1 61 | 62 | task.error_messages_for(:name).should.include 'incorrect value supplied for name -- should be non-empty.' 63 | task.error_messages_for(:name).should.include "incorrect value supplied for name -- should be between 2 and 10 characters long." 64 | task.error_messages_for(:some_float).should.include "incorrect value supplied for some_float -- should be non-empty." 65 | end 66 | 67 | it "is true if the float is filled in" do 68 | task = ValidatableTask.new(@valid_tasks) 69 | task.valid?.should === true 70 | end 71 | 72 | it "is false if the integer is nil" do 73 | task = ValidatableTask.new(@valid_tasks.except(:some_int)) 74 | task.valid?.should === false 75 | end 76 | 77 | it "is true if the integer is filled in" do 78 | task = ValidatableTask.new(@valid_tasks) 79 | task.valid?.should === true 80 | end 81 | 82 | it "is true if the Numeric datatypes are zero" do 83 | task = ValidatableTask.new(@valid_tasks) 84 | task.some_float = 0 85 | task.some_int = 0 86 | task.valid?.should === true 87 | end 88 | end 89 | 90 | describe "length" do 91 | it "succeeds when in range of 2-10 characters" do 92 | task = ValidatableTask.create(@valid_tasks.except(:name)) 93 | task.name = '123456' 94 | task.valid?.should === true 95 | end 96 | 97 | it "fails when length less than two characters" do 98 | task = ValidatableTask.create(@valid_tasks.except(:name)) 99 | task.name = '1' 100 | task.valid?.should === false 101 | task.error_messages_for(:name).first.should == 102 | "incorrect value supplied for name -- should be between 2 and 10 characters long." 103 | end 104 | 105 | it "fails when length greater than 10 characters" do 106 | task = ValidatableTask.create(@valid_tasks.except(:name)) 107 | task.name = '123456709AB' 108 | task.valid?.should === false 109 | task.error_messages_for(:name).first.should == 110 | "incorrect value supplied for name -- should be between 2 and 10 characters long." 111 | end 112 | end 113 | 114 | describe "email" do 115 | it "succeeds when a valid email address is supplied" do 116 | ValidatableTask.new(@valid_tasks).should.be.valid? 117 | end 118 | 119 | it "fails when an empty email address is supplied" do 120 | ValidatableTask.new(@valid_tasks.except(:email)).should.not.be.valid? 121 | end 122 | 123 | it "fails when a bogus email address is supplied" do 124 | ValidatableTask.new(@valid_tasks.except(:email).merge({:email => 'bogus'})).should.not.be.valid? 125 | end 126 | end 127 | 128 | describe "format" do 129 | it "succeeds when date is in the correct format" do 130 | ValidatableTask.new(@valid_tasks).should.be.valid? 131 | end 132 | 133 | it "fails when date is in incorrect format" do 134 | ValidatableTask.new(@valid_tasks.except(:some_day).merge({:some_day => 'a-12-12'})).should.not.be.valid? 135 | end 136 | end 137 | 138 | describe "validating one element" do 139 | it "validates any properly formatted arbitrary string and succeeds" do 140 | task = ValidatableTask.new 141 | task.validate_for(:some_day, '12-12-12').should == true 142 | end 143 | 144 | it "validates any improperly formatted arbitrary string and fails" do 145 | task = ValidatableTask.new 146 | task.validate_for(:some_day, 'a-12-12').should == false 147 | end 148 | end 149 | 150 | describe "validation syntax" do 151 | it "validates correctly when the expected hash syntax is used" do 152 | task = ValidatableTask.new(@valid_tasks) 153 | task.valid?.should == true 154 | end 155 | 156 | it "raises a ValidationSpecificationError when a non-Hash validation_type argument is passed to validate" do 157 | lambda { ValidatableTask::validate(:field_name, :not_a_hash) }.should.raise 158 | end 159 | 160 | it "raises a ValidationSpecificationError when no validation_type argument is passed to validate" do 161 | lambda { ValidatableTask::validate(:field_name) }.should.raise 162 | end 163 | end 164 | end 165 | 166 | class VTask 167 | include MotionModel::Model 168 | include MotionModel::ArrayModelAdapter 169 | include MotionModel::Validatable 170 | 171 | columns :name => :string 172 | validate :name, :presence => true 173 | end 174 | 175 | describe "saving with validations" do 176 | 177 | it "fails loudly" do 178 | task = VTask.new 179 | lambda { task.save!}.should.raise 180 | end 181 | 182 | it "can skip the validations" do 183 | task = VTask.new 184 | lambda { task.save({:validate => false})}.should.change { VTask.count } 185 | end 186 | 187 | it "should not save when validation fails" do 188 | task = VTask.new 189 | lambda { task.save }.should.not.change{ VTask.count } 190 | task.save.should == false 191 | end 192 | 193 | it "saves it when everything is ok" do 194 | task = VTask.new 195 | task.name = "Save it" 196 | lambda { task.save }.should.change { VTask.count } 197 | end 198 | 199 | end 200 | --------------------------------------------------------------------------------