├── .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 | [](https://codeclimate.com/github/sxross/MotionModel)[](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 |
--------------------------------------------------------------------------------