motion_model 0.2
Sign up to get free protection for your applications and to get access to all the features.
- data/.gitignore +9 -0
- data/README.md +170 -0
- data/Rakefile +10 -0
- data/app/app_delegate.rb +2 -0
- data/lib/motion_model/ext.rb +37 -0
- data/lib/motion_model/input_helpers.rb +220 -0
- data/lib/motion_model/model.rb +346 -0
- data/lib/motion_model/validatable.rb +64 -0
- data/lib/motion_model/version.rb +3 -0
- data/lib/motion_model.rb +5 -0
- data/motion_model.gemspec +17 -0
- data/spec/model_spec.rb +200 -0
- metadata +58 -0
data/.gitignore
ADDED
data/README.md
ADDED
@@ -0,0 +1,170 @@
|
|
1
|
+
MotionModel -- Simple Model, Validation, and Input Mixins for RubyMotion
|
2
|
+
================
|
3
|
+
|
4
|
+
MotionModel is for cases where Core Data is too heavy to lift but you are
|
5
|
+
still intending to work with your data.
|
6
|
+
|
7
|
+
MotionModel is a bunch of "I don't ever want to have to write that code
|
8
|
+
again if I can help it" things extracted into modules. The four modules
|
9
|
+
are:
|
10
|
+
|
11
|
+
- ext: Core Extensions that provide a few Rails-like niceties. Nothing
|
12
|
+
new here, moving on...
|
13
|
+
|
14
|
+
- model.rb: You can read about it in "What Model Can Do" but it's a
|
15
|
+
mixin that provides you accessible attributes, row indexing,
|
16
|
+
serialization for persistence, and some other niceties like row
|
17
|
+
counting.
|
18
|
+
|
19
|
+
- validatable.rb: Provides a basic validation framework for any
|
20
|
+
arbitrary class. Right now, it can only validate for presence,
|
21
|
+
but expect that to change soon.
|
22
|
+
|
23
|
+
- input_helpers: Hooking an array up to a data form, populating
|
24
|
+
it, and retrieving the data afterwards can be a bunch of code.
|
25
|
+
Not something I'd like to write more often that I have to. These
|
26
|
+
helpers are certainly not the focus of this strawman release, but
|
27
|
+
I am using these in an app to create Apple-like input forms in
|
28
|
+
static tables. I expect some churn in this module.
|
29
|
+
|
30
|
+
What Model Can Do
|
31
|
+
================
|
32
|
+
|
33
|
+
You can define your models and their schemas in Ruby. For example:
|
34
|
+
|
35
|
+
```ruby
|
36
|
+
class Task
|
37
|
+
include MotionModel::Model
|
38
|
+
|
39
|
+
columns :name => :string,
|
40
|
+
:description => :string,
|
41
|
+
:due_date => :date
|
42
|
+
end
|
43
|
+
|
44
|
+
class MyCoolController
|
45
|
+
def some_method
|
46
|
+
@task = Task.create :name => 'walk the dog',
|
47
|
+
:description => 'get plenty of exercise. pick up the poop',
|
48
|
+
:due_date => '2012-09-15'
|
49
|
+
end
|
50
|
+
end
|
51
|
+
```
|
52
|
+
|
53
|
+
You can also include the `Validations` module to get field validation. For example:
|
54
|
+
|
55
|
+
```ruby
|
56
|
+
class Task
|
57
|
+
include MotionModel::Model
|
58
|
+
include MotionModel::Validations
|
59
|
+
|
60
|
+
columns :name => :string,
|
61
|
+
:description => :string,
|
62
|
+
:due_date => :date
|
63
|
+
validates :name => :presence => true
|
64
|
+
end
|
65
|
+
|
66
|
+
class MyCoolController
|
67
|
+
def some_method
|
68
|
+
@task = Task.new :name => 'walk the dog',
|
69
|
+
:description => 'get plenty of exercise. pick up the poop',
|
70
|
+
:due_date => '2012-09-15'
|
71
|
+
|
72
|
+
show_scary_warning unless @task.valid?
|
73
|
+
end
|
74
|
+
end
|
75
|
+
```
|
76
|
+
|
77
|
+
Model Instances and Unique IDs
|
78
|
+
-----------------
|
79
|
+
|
80
|
+
It is assumed that models can be created from an external source (JSON from a Web
|
81
|
+
application or NSCoder from the device) or simply be a stand-alone data store.
|
82
|
+
To identify rows properly, the model tracks a special field called `:id`. If it's
|
83
|
+
already present, it's left alone. If it's missing, then it is created for you.
|
84
|
+
Each row id is guaranteed to be unique, so you can use this when communicating
|
85
|
+
with a server or syncing your rowset to a UITableView.
|
86
|
+
|
87
|
+
Things That Work
|
88
|
+
-----------------
|
89
|
+
|
90
|
+
* Models, in general, work. They aren't ultra full-featured, but more is in the
|
91
|
+
works. In particular, finders are just coming online. All column data may be
|
92
|
+
accessed by member name, e.g., `@task.name`.
|
93
|
+
|
94
|
+
* Finders are implemented using chaining. Here is an examples:
|
95
|
+
|
96
|
+
```ruby
|
97
|
+
@tasks = Task.where(:assigned_to).eq('bob').and(:location).contains('seattle')
|
98
|
+
@tasks.all.each { |task| do_something_with(task) }
|
99
|
+
```
|
100
|
+
|
101
|
+
You can perform ordering using either a field name or block syntax. Here's an example:
|
102
|
+
|
103
|
+
```ruby
|
104
|
+
@tasks = Task.order(:name).all # Get tasks ordered ascending by :name
|
105
|
+
@tasks = Task.order{|one, two| two.details <=> one.details}.all # Get tasks ordered descending by :details
|
106
|
+
```
|
107
|
+
|
108
|
+
* Serialization using `NSCoder` works. Basically, you might do something like this
|
109
|
+
in your `AppDelegate`:
|
110
|
+
|
111
|
+
```ruby
|
112
|
+
def load_data
|
113
|
+
if File.exist? documents_file("my_fine.dat")
|
114
|
+
error_ptr = Pointer.new(:object)
|
115
|
+
|
116
|
+
data = NSData.dataWithContentsOfFile(documents_file('my_fine.dat'), options:NSDataReadingMappedIfSafe, error:error_ptr)
|
117
|
+
|
118
|
+
if data.nil?
|
119
|
+
error = error_ptr[0]
|
120
|
+
show_user_scary_warning error
|
121
|
+
else
|
122
|
+
@my_data_tree = NSKeyedUnarchiver.unarchiveObjectWithData(data)
|
123
|
+
end
|
124
|
+
else
|
125
|
+
show_user_first_time_welcome
|
126
|
+
end
|
127
|
+
end
|
128
|
+
```
|
129
|
+
|
130
|
+
and of course on the "save" side:
|
131
|
+
|
132
|
+
```ruby
|
133
|
+
error_ptr = Pointer.new(:object)
|
134
|
+
|
135
|
+
data = NSKeyedArchiver.archivedDataWithRootObject App.delegate.events
|
136
|
+
unless data.writeToFile(documents_file('my_fine.dat'), options: NSDataWritingAtomic, error: error_ptr)
|
137
|
+
error = error_ptr[0]
|
138
|
+
show_scary_message error
|
139
|
+
end
|
140
|
+
```
|
141
|
+
|
142
|
+
Note that the archiving of any arbitrarily complex set of relations is
|
143
|
+
automatically handled by `NSCoder` provided you conform to the coding
|
144
|
+
protocol. When you declare your columns, `MotionModel` understands how
|
145
|
+
to serialize your data so you need take no further action.
|
146
|
+
|
147
|
+
* Relations, in principle work. This is a part I'm still noodling over
|
148
|
+
so it's not really safe to use them. In any case, how I expect it will
|
149
|
+
shake out is that one-to-one or one-to-many will be supported out of
|
150
|
+
the box, but you will have to take some extra steps to implement
|
151
|
+
many-to-many, just as you would in Rails' `has_many :through`.
|
152
|
+
|
153
|
+
* Core extensions work. The following are supplied:
|
154
|
+
|
155
|
+
- String#humanize
|
156
|
+
- String#titleize
|
157
|
+
- String#empty?
|
158
|
+
- NilClass#empty?
|
159
|
+
- Array#empty?
|
160
|
+
- Hash#empty?
|
161
|
+
- Symbol#titleize
|
162
|
+
|
163
|
+
Things In The Pipeline
|
164
|
+
----------------------
|
165
|
+
|
166
|
+
- More tests!
|
167
|
+
- More robust id assignment
|
168
|
+
- Testing relations
|
169
|
+
- Adding validations and custom validations
|
170
|
+
- Did I say more tests?
|
data/Rakefile
ADDED
@@ -0,0 +1,10 @@
|
|
1
|
+
# -*- coding: utf-8 -*-
|
2
|
+
$:.unshift("/Library/RubyMotion/lib")
|
3
|
+
require 'motion/project'
|
4
|
+
|
5
|
+
Motion::Project::App.setup do |app|
|
6
|
+
# Use `rake config' to see complete project settings.
|
7
|
+
app.delegate_class = 'FakeDelegate'
|
8
|
+
app.files = Dir.glob('./lib/motion_model/**/*.rb')
|
9
|
+
app.files = (Dir.glob('./app/**/*.rb') + app.files).uniq
|
10
|
+
end
|
data/app/app_delegate.rb
ADDED
@@ -0,0 +1,37 @@
|
|
1
|
+
class String
|
2
|
+
def humanize
|
3
|
+
self.split(/_|-| /).join(' ')
|
4
|
+
end
|
5
|
+
|
6
|
+
def titleize
|
7
|
+
self.split(/_|-| /).each{|word| word[0...1] = word[0...1].upcase}.join(' ')
|
8
|
+
end
|
9
|
+
|
10
|
+
def empty?
|
11
|
+
self.length < 1
|
12
|
+
end
|
13
|
+
end
|
14
|
+
|
15
|
+
class NilClass
|
16
|
+
def empty?
|
17
|
+
true
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
class Array
|
22
|
+
def empty?
|
23
|
+
self.length < 1
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
class Hash
|
28
|
+
def empty?
|
29
|
+
self.length < 1
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
class Symbol
|
34
|
+
def titleize
|
35
|
+
self.to_s.titleize
|
36
|
+
end
|
37
|
+
end
|
@@ -0,0 +1,220 @@
|
|
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('@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
|
+
puts "adding field #{field}"
|
41
|
+
label = options[:label] || field.humanize
|
42
|
+
@data << FieldBindingMap.new(:label => label, :name => field)
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
# +model+ is a mandatory method in which you
|
47
|
+
# specify the instance of the model to which
|
48
|
+
# your fields are bound.
|
49
|
+
|
50
|
+
def model(model_instance)
|
51
|
+
@model = model_instance
|
52
|
+
end
|
53
|
+
|
54
|
+
# +field_count+ specifies how many fields have
|
55
|
+
# been bound.
|
56
|
+
#
|
57
|
+
# Usage:
|
58
|
+
#
|
59
|
+
# def tableView(table, numberOfRowsInSection: section)
|
60
|
+
# field_count
|
61
|
+
# end
|
62
|
+
|
63
|
+
def field_count
|
64
|
+
self.class.instance_variable_get('@data'.to_sym).length
|
65
|
+
end
|
66
|
+
|
67
|
+
# +field_at+ retrieves the field at a given index.
|
68
|
+
#
|
69
|
+
# Usage:
|
70
|
+
#
|
71
|
+
# field = field_at(indexPath.row)
|
72
|
+
# label_view = subview(UILabel, :label_frame, text: field.label)
|
73
|
+
|
74
|
+
def field_at(index)
|
75
|
+
data = self.class.instance_variable_get('@data'.to_sym)
|
76
|
+
data[index].tag = index + 1
|
77
|
+
data[index]
|
78
|
+
end
|
79
|
+
|
80
|
+
# +value_at+ retrieves the value from the form that corresponds
|
81
|
+
# to the name of the field.
|
82
|
+
#
|
83
|
+
# Usage:
|
84
|
+
#
|
85
|
+
# value_edit_view = subview(UITextField, :input_value_frame, text: value_at(field))
|
86
|
+
|
87
|
+
def value_at(field)
|
88
|
+
@model.send(field.name)
|
89
|
+
end
|
90
|
+
|
91
|
+
# +fields+ is the iterator for all fields
|
92
|
+
# mapped for this class.
|
93
|
+
#
|
94
|
+
# Usage:
|
95
|
+
#
|
96
|
+
# fields do |field|
|
97
|
+
# do_something_with field.label, field.value
|
98
|
+
# end
|
99
|
+
|
100
|
+
def fields
|
101
|
+
self.class.instance_variable_get('@data'.to_sym).each{|datum| yield datum}
|
102
|
+
end
|
103
|
+
|
104
|
+
# +bind+ fetches all mapped fields from
|
105
|
+
# any subview of the current +UIView+
|
106
|
+
# and transfers the contents to the
|
107
|
+
# corresponding fields of the model
|
108
|
+
# specified by the +model+ method.
|
109
|
+
def bind
|
110
|
+
raise ModelNotSetError.new("You must set the model before binding it.") unless @model
|
111
|
+
|
112
|
+
fields do |field|
|
113
|
+
puts "*** retrieving data for #{field.name} and tag #{field.tag} ***"
|
114
|
+
view_obj = view.viewWithTag(field.tag)
|
115
|
+
puts "view object with tag is #{view_obj.inspect}"
|
116
|
+
@model.send("#{field.name}=".to_sym, view.viewWithTag(field.tag).text)
|
117
|
+
end
|
118
|
+
end
|
119
|
+
|
120
|
+
# Handle hiding the keyboard if the user
|
121
|
+
# taps "return". If you don't want this behavior,
|
122
|
+
# define the function as empty in your class.
|
123
|
+
def textFieldShouldReturn(textField)
|
124
|
+
textField.resignFirstResponder
|
125
|
+
end
|
126
|
+
|
127
|
+
# Keyboard show/hide handlers do this:
|
128
|
+
#
|
129
|
+
# * Reset the table insets so that the
|
130
|
+
# UITableView knows how large its real
|
131
|
+
# visible area.
|
132
|
+
# * Scroll the UITableView to reveal the
|
133
|
+
# cell that has the +firstResponder+
|
134
|
+
# if it is not already showing.
|
135
|
+
#
|
136
|
+
# Of course, the process is exactly reversed
|
137
|
+
# when the keyboard hides.
|
138
|
+
#
|
139
|
+
# An instance variable +@table+ is assumed to
|
140
|
+
# be the table to affect; if this is missing,
|
141
|
+
# this code will simply no-op.
|
142
|
+
#
|
143
|
+
# Rejigger everything under the sun when the
|
144
|
+
# keyboard slides up.
|
145
|
+
#
|
146
|
+
# You *must* handle the +UIKeyboardWillShowNotification+ and
|
147
|
+
# when you receive it, call this method to handle the keyboard
|
148
|
+
# showing.
|
149
|
+
def handle_keyboard_will_show(notification)
|
150
|
+
return unless @table
|
151
|
+
|
152
|
+
animationCurve = notification.userInfo.valueForKey(UIKeyboardAnimationCurveUserInfoKey)
|
153
|
+
animationDuration = notification.userInfo.valueForKey(UIKeyboardAnimationDurationUserInfoKey)
|
154
|
+
keyboardEndRect = notification.userInfo.valueForKey(UIKeyboardFrameEndUserInfoKey)
|
155
|
+
|
156
|
+
keyboardEndRect = view.convertRect(keyboardEndRect.CGRectValue, fromView:App.delegate.window)
|
157
|
+
|
158
|
+
UIView.beginAnimations "changeTableViewContentInset", context:nil
|
159
|
+
UIView.setAnimationDuration animationDuration
|
160
|
+
UIView.setAnimationCurve animationCurve
|
161
|
+
|
162
|
+
intersectionOfKeyboardRectAndWindowRect = CGRectIntersection(App.delegate.window.frame, keyboardEndRect)
|
163
|
+
bottomInset = intersectionOfKeyboardRectAndWindowRect.size.height;
|
164
|
+
|
165
|
+
@table.contentInset = UIEdgeInsetsMake(0, 0, bottomInset, 0)
|
166
|
+
|
167
|
+
# Find active cell
|
168
|
+
indexPathOfOwnerCell = nil
|
169
|
+
numberOfCells = @table.dataSource.tableView(@table, numberOfRowsInSection:0)
|
170
|
+
0.upto(numberOfCells) do |index|
|
171
|
+
indexPath = NSIndexPath.indexPathForRow(index, inSection:0)
|
172
|
+
cell = @table.cellForRowAtIndexPath(indexPath)
|
173
|
+
if cell_has_first_responder?(cell)
|
174
|
+
indexPathOfOwnerCell = indexPath
|
175
|
+
break
|
176
|
+
end
|
177
|
+
end
|
178
|
+
|
179
|
+
UIView.commitAnimations
|
180
|
+
|
181
|
+
if indexPathOfOwnerCell
|
182
|
+
@table.scrollToRowAtIndexPath(indexPathOfOwnerCell,
|
183
|
+
atScrollPosition:UITableViewScrollPositionMiddle,
|
184
|
+
animated: true)
|
185
|
+
end
|
186
|
+
end
|
187
|
+
|
188
|
+
# Undo all the rejiggering when the keyboard slides
|
189
|
+
# down.
|
190
|
+
#
|
191
|
+
# You *must* handle the +UIKeyboardWillHideNotification+ and
|
192
|
+
# when you receive it, call this method to handle the keyboard
|
193
|
+
# hiding.
|
194
|
+
def handle_keyboard_will_hide(notification)
|
195
|
+
return unless @table
|
196
|
+
|
197
|
+
if UIEdgeInsetsEqualToEdgeInsets(@table.contentInset, UIEdgeInsetsZero)
|
198
|
+
return
|
199
|
+
end
|
200
|
+
|
201
|
+
animationCurve = notification.userInfo.valueForKey(UIKeyboardAnimationCurveUserInfoKey)
|
202
|
+
animationDuration = notification.userInfo.valueForKey(UIKeyboardAnimationDurationUserInfoKey)
|
203
|
+
|
204
|
+
UIView.beginAnimations("changeTableViewContentInset", context:nil)
|
205
|
+
UIView.setAnimationDuration(animationDuration)
|
206
|
+
UIView.setAnimationCurve(animationCurve)
|
207
|
+
|
208
|
+
@table.contentInset = UIEdgeInsetsZero;
|
209
|
+
|
210
|
+
UIView.commitAnimations
|
211
|
+
end
|
212
|
+
|
213
|
+
def cell_has_first_responder?(cell)
|
214
|
+
cell.subviews.each do |subview|
|
215
|
+
return true if subview.isFirstResponder
|
216
|
+
end
|
217
|
+
false
|
218
|
+
end
|
219
|
+
end
|
220
|
+
end
|
@@ -0,0 +1,346 @@
|
|
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
|
+
# Task.create :task_name => 'Walk the dog',
|
20
|
+
# :details => 'Pick up after yourself',
|
21
|
+
# :due_date => '2012-09-17'
|
22
|
+
#
|
23
|
+
# Recognized types are:
|
24
|
+
#
|
25
|
+
# * :string
|
26
|
+
# * :date (must be in a form that Date.parse can recognize)
|
27
|
+
# * :time (must be in a form that Time.parse can recognize)
|
28
|
+
# * :integer
|
29
|
+
# * :float
|
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
|
+
module Model
|
42
|
+
def self.included(base)
|
43
|
+
base.extend(ClassMethods)
|
44
|
+
base.instance_variable_set("@column_attrs", [])
|
45
|
+
base.instance_variable_set("@typed_attrs", [])
|
46
|
+
base.instance_variable_set("@collection", [])
|
47
|
+
base.instance_variable_set("@_next_id", 1)
|
48
|
+
end
|
49
|
+
|
50
|
+
module ClassMethods
|
51
|
+
# Macro to define names and types of columns. It can be used in one of
|
52
|
+
# two forms:
|
53
|
+
#
|
54
|
+
# Pass a hash, and you define columns with types. E.g.,
|
55
|
+
#
|
56
|
+
# columns :name => :string, :age => :integer
|
57
|
+
#
|
58
|
+
# Pass an array, and you create column names, all of which have type +:string+.
|
59
|
+
#
|
60
|
+
# columns :name, :age, :hobby
|
61
|
+
def columns(*fields)
|
62
|
+
return @column_attrs if fields.empty?
|
63
|
+
|
64
|
+
case fields.first
|
65
|
+
when Hash
|
66
|
+
fields.first.each_pair do |attr, type|
|
67
|
+
add_attribute(attr, type)
|
68
|
+
end
|
69
|
+
else
|
70
|
+
fields.each do |attr|
|
71
|
+
add_attribute(attr, :string)
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
75
|
+
unless self.respond_to?(:id)
|
76
|
+
add_attribute(:id, :integer)
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
def add_attribute(attr, type) #nodoc
|
81
|
+
attr_accessor attr
|
82
|
+
@column_attrs << attr
|
83
|
+
@typed_attrs << type
|
84
|
+
end
|
85
|
+
|
86
|
+
def next_id #nodoc
|
87
|
+
@_next_id
|
88
|
+
end
|
89
|
+
|
90
|
+
def increment_id #nodoc
|
91
|
+
@_next_id += 1
|
92
|
+
end
|
93
|
+
|
94
|
+
# Returns true if a column exists on this model, otherwise false.
|
95
|
+
def column?(column)
|
96
|
+
@column_attrs.each{|key|
|
97
|
+
return true if key == column
|
98
|
+
}
|
99
|
+
false
|
100
|
+
end
|
101
|
+
|
102
|
+
# Returns type of this column.
|
103
|
+
def type(column)
|
104
|
+
index = @column_attrs.index(column)
|
105
|
+
index ? @typed_attrs[index] : nil
|
106
|
+
end
|
107
|
+
|
108
|
+
# Creates an object and saves it. E.g.:
|
109
|
+
#
|
110
|
+
# @bob = Person.create(:name => 'Bob', :hobby => 'Bird Watching')
|
111
|
+
#
|
112
|
+
# returns the object created or false.
|
113
|
+
def create(options = {})
|
114
|
+
row = self.new(options)
|
115
|
+
# TODO: Check for Validatable and if it's
|
116
|
+
# present, check valid? before saving.
|
117
|
+
@collection.push(row)
|
118
|
+
row
|
119
|
+
end
|
120
|
+
|
121
|
+
def length
|
122
|
+
@collection.length
|
123
|
+
end
|
124
|
+
alias_method :count, :length
|
125
|
+
|
126
|
+
# Empties the entire store.
|
127
|
+
def delete_all
|
128
|
+
@collection = [] # TODO: Handle cascading or let GC take care of it.
|
129
|
+
end
|
130
|
+
|
131
|
+
# Finds row(s) within the data store. E.g.,
|
132
|
+
#
|
133
|
+
# @post = Post.find(1) # find a specific row by ID
|
134
|
+
#
|
135
|
+
# or...
|
136
|
+
#
|
137
|
+
# @posts = Post.find(:author).eq('bob').all
|
138
|
+
def find(*args)
|
139
|
+
unless args[0].is_a?(Symbol) || args[0].is_a?(String)
|
140
|
+
return @collection[args[0].to_i] || nil
|
141
|
+
end
|
142
|
+
|
143
|
+
FinderQuery.new(args[0].to_sym, @collection)
|
144
|
+
end
|
145
|
+
alias_method :where, :find
|
146
|
+
|
147
|
+
# Retrieves first row of query
|
148
|
+
def first
|
149
|
+
@collection.first
|
150
|
+
end
|
151
|
+
|
152
|
+
# Retrieves last row of query
|
153
|
+
def last
|
154
|
+
@collection.last
|
155
|
+
end
|
156
|
+
|
157
|
+
# Returns query result as an array
|
158
|
+
def all
|
159
|
+
@collection
|
160
|
+
end
|
161
|
+
|
162
|
+
def order(field_name = nil, &block)
|
163
|
+
FinderQuery.new(@collection).order(field_name, &block)
|
164
|
+
end
|
165
|
+
|
166
|
+
def each(&block)
|
167
|
+
raise ArgumentError.new("each requires a block") unless block_given?
|
168
|
+
@collection.each{|item| yield item}
|
169
|
+
end
|
170
|
+
|
171
|
+
end
|
172
|
+
|
173
|
+
####### Instance Methods #######
|
174
|
+
def initialize(options = {})
|
175
|
+
columns.each{|col| instance_variable_set("@#{col.to_s}", nil) unless options.has_key?(col)}
|
176
|
+
|
177
|
+
options.each do |key, value|
|
178
|
+
instance_variable_set("@#{key.to_s}", value || '') if self.class.column?(key.to_sym)
|
179
|
+
end
|
180
|
+
unless self.id
|
181
|
+
self.id = self.class.next_id
|
182
|
+
self.class.increment_id
|
183
|
+
end
|
184
|
+
end
|
185
|
+
|
186
|
+
def length
|
187
|
+
@collection.length
|
188
|
+
end
|
189
|
+
|
190
|
+
alias_method :count, :length
|
191
|
+
|
192
|
+
def column?(target_key)
|
193
|
+
self.class.column?(target_key)
|
194
|
+
end
|
195
|
+
|
196
|
+
def columns
|
197
|
+
self.class.columns
|
198
|
+
end
|
199
|
+
|
200
|
+
def type(field_name)
|
201
|
+
self.class.type(field_name)
|
202
|
+
end
|
203
|
+
|
204
|
+
def initWithCoder(coder)
|
205
|
+
self.init
|
206
|
+
self.class.instance_variable_get("@column_attrs").each do |attr|
|
207
|
+
# If a model revision has taken place, don't try to decode
|
208
|
+
# something that's not there.
|
209
|
+
new_tag_id = 1
|
210
|
+
if coder.containsValueForKey(attr.to_s)
|
211
|
+
value = coder.decodeObjectForKey(attr.to_s)
|
212
|
+
self.instance_variable_set('@' + attr.to_s, value || '')
|
213
|
+
else
|
214
|
+
self.instance_variable_set('@' + attr.to_s, '') # set to empty string if new attribute
|
215
|
+
end
|
216
|
+
|
217
|
+
# re-issue tags to make sure they are unique
|
218
|
+
@tag = new_tag_id
|
219
|
+
new_tag_id += 1
|
220
|
+
end
|
221
|
+
self
|
222
|
+
end
|
223
|
+
|
224
|
+
def encodeWithCoder(coder)
|
225
|
+
self.class.instance_variable_get("@column_attrs").each do |attr|
|
226
|
+
coder.encodeObject(self.send(attr), forKey: attr.to_s)
|
227
|
+
end
|
228
|
+
end
|
229
|
+
|
230
|
+
end
|
231
|
+
|
232
|
+
class FinderQuery
|
233
|
+
attr_accessor :field_name
|
234
|
+
|
235
|
+
def initialize(*args)
|
236
|
+
@field_name = args[0] if args.length > 1
|
237
|
+
@collection = args.last
|
238
|
+
end
|
239
|
+
|
240
|
+
def and(field_name)
|
241
|
+
@field_name = field_name
|
242
|
+
self
|
243
|
+
end
|
244
|
+
|
245
|
+
def order(field = nil, &block)
|
246
|
+
if block_given?
|
247
|
+
@collection = @collection.sort{|o1, o2| yield(o1, o2)}
|
248
|
+
else
|
249
|
+
raise ArgumentError.new('you must supply a field name to sort unless you supply a block.') if field.nil?
|
250
|
+
@collection = @collection.sort{|o1, o2| o1.send(field) <=> o2.send(field)}
|
251
|
+
end
|
252
|
+
self
|
253
|
+
end
|
254
|
+
|
255
|
+
######## relational methods ########
|
256
|
+
def do_comparison(query_string)
|
257
|
+
# TODO: Flag case-insensitive searching
|
258
|
+
query_string = query_string.downcase if query_string.respond_to?(:downcase)
|
259
|
+
@collection = @collection.select do |item|
|
260
|
+
comparator = item.send(@field_name.to_sym)
|
261
|
+
yield query_string, comparator
|
262
|
+
end
|
263
|
+
self
|
264
|
+
end
|
265
|
+
|
266
|
+
def contain(query_string)
|
267
|
+
do_comparison(query_string) do |comparator, item|
|
268
|
+
item =~ Regexp.new(comparator)
|
269
|
+
end
|
270
|
+
end
|
271
|
+
alias_method :contains, :contain
|
272
|
+
alias_method :like, :contain
|
273
|
+
|
274
|
+
def eq(query_string)
|
275
|
+
do_comparison(query_string) do |comparator, item|
|
276
|
+
comparator == item
|
277
|
+
end
|
278
|
+
end
|
279
|
+
alias_method :==, :eq
|
280
|
+
alias_method :equal, :eq
|
281
|
+
|
282
|
+
def gt(query_string)
|
283
|
+
do_comparison(query_string) do |comparator, item|
|
284
|
+
comparator > item
|
285
|
+
end
|
286
|
+
end
|
287
|
+
alias_method :>, :gt
|
288
|
+
alias_method :greater_than, :gt
|
289
|
+
|
290
|
+
def lt(query_string)
|
291
|
+
do_comparison(query_string) do |comparator, item|
|
292
|
+
comparator < item
|
293
|
+
end
|
294
|
+
end
|
295
|
+
alias_method :<, :lt
|
296
|
+
alias_method :less_than, :lt
|
297
|
+
|
298
|
+
def gte(query_string)
|
299
|
+
do_comparison(query_string) do |comparator, item|
|
300
|
+
comparator >= item
|
301
|
+
end
|
302
|
+
end
|
303
|
+
alias_method :>=, :gte
|
304
|
+
alias_method :greater_than_or_equal, :gte
|
305
|
+
|
306
|
+
|
307
|
+
def lte(query_string)
|
308
|
+
do_comparison(query_string) do |comparator, item|
|
309
|
+
comparator <= item
|
310
|
+
end
|
311
|
+
end
|
312
|
+
alias_method :<=, :lte
|
313
|
+
alias_method :less_than_or_equal, :lte
|
314
|
+
|
315
|
+
def ne(query_string)
|
316
|
+
do_comparison(query_string) do |comparator, item|
|
317
|
+
comparator != item
|
318
|
+
end
|
319
|
+
end
|
320
|
+
alias_method :!=, :ne
|
321
|
+
alias_method :not_equal, :ne
|
322
|
+
|
323
|
+
########### accessor methods #########
|
324
|
+
def first
|
325
|
+
@collection.first
|
326
|
+
end
|
327
|
+
|
328
|
+
def last
|
329
|
+
@collection.last
|
330
|
+
end
|
331
|
+
|
332
|
+
def all
|
333
|
+
@collection
|
334
|
+
end
|
335
|
+
|
336
|
+
# each is a shortcut method to turn a query into an iterator. It allows
|
337
|
+
# you to write code like:
|
338
|
+
#
|
339
|
+
# Task.where(:assignee).eq('bob').each{ |assignee| do_something_with(assignee) }
|
340
|
+
def each(&block)
|
341
|
+
raise ArgumentError.new("each requires a block") unless block_given?
|
342
|
+
@collection.each{|item| yield item}
|
343
|
+
end
|
344
|
+
end
|
345
|
+
end
|
346
|
+
|
@@ -0,0 +1,64 @@
|
|
1
|
+
module MotionModel
|
2
|
+
module Validatable
|
3
|
+
def self.included(base)
|
4
|
+
base.extend(ClassMethods)
|
5
|
+
base.instance_variable_set('@validations', [])
|
6
|
+
end
|
7
|
+
|
8
|
+
module ClassMethods
|
9
|
+
def validate(field = nil, validation_type = {})
|
10
|
+
if field.nil? || field.to_s == ''
|
11
|
+
ex = ValidationSpecificationError.new('field not present in validation call')
|
12
|
+
raise ex
|
13
|
+
end
|
14
|
+
|
15
|
+
if validation_type == {} # || !(validation_type is_a?(Hash))
|
16
|
+
ex = ValidationSpecificationError.new('validation type not present or not a hash')
|
17
|
+
raise ex
|
18
|
+
end
|
19
|
+
|
20
|
+
@validations << {field => validation_type}
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
def valid?
|
25
|
+
@messages = []
|
26
|
+
@valid = true
|
27
|
+
self.class.instance_variable_get(@validations).each do |validations|
|
28
|
+
validate_each(validations)
|
29
|
+
end
|
30
|
+
@valid
|
31
|
+
end
|
32
|
+
|
33
|
+
def validate_each(validations)
|
34
|
+
validations.each_pair do |field, validation|
|
35
|
+
validate_one field, validation
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
def validate_one(field, validation)
|
40
|
+
validation.each_pair do |validation_type, setting|
|
41
|
+
case validation_type
|
42
|
+
when :presence
|
43
|
+
@valid &&= validate_presence(field)
|
44
|
+
if setting
|
45
|
+
additional_message = "non-empty"
|
46
|
+
else
|
47
|
+
additional_message = "empty"
|
48
|
+
end
|
49
|
+
@valid = !@valid if setting == false
|
50
|
+
@messages << {field => "incorrect value supplied for #{field.to_s} -- should be #{additional_message}"}
|
51
|
+
else
|
52
|
+
@valid = false
|
53
|
+
ex = ValidationSpecificationError.new("unknown validation type :#{validation_type.to_s}")
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
def validate_presence(field)
|
59
|
+
value = self.send(field.to_s)
|
60
|
+
return false if value.nil?
|
61
|
+
return value.strip.length > 0
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
data/lib/motion_model.rb
ADDED
@@ -0,0 +1,17 @@
|
|
1
|
+
# -*- encoding: utf-8 -*-
|
2
|
+
require File.expand_path('../lib/motion_model/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
|
+
puts "gem files are #{`git ls-files`.split($\)}"
|
13
|
+
gem.test_files = gem.files.grep(%r{^(test|spec|features)/})
|
14
|
+
gem.name = "motion_model"
|
15
|
+
gem.require_paths = ["lib"]
|
16
|
+
gem.version = MotionModel::VERSION
|
17
|
+
end
|
data/spec/model_spec.rb
ADDED
@@ -0,0 +1,200 @@
|
|
1
|
+
class Task
|
2
|
+
include MotionModel::Model
|
3
|
+
columns :name => :string,
|
4
|
+
:details => :string,
|
5
|
+
:some_day => :date
|
6
|
+
end
|
7
|
+
|
8
|
+
class ATask
|
9
|
+
include MotionModel::Model
|
10
|
+
columns :name, :details, :some_day
|
11
|
+
end
|
12
|
+
|
13
|
+
describe "Creating a model" do
|
14
|
+
before do
|
15
|
+
Task.delete_all
|
16
|
+
end
|
17
|
+
|
18
|
+
describe 'column macro behavior' do
|
19
|
+
|
20
|
+
it 'succeeds when creating a valid model from attributes' do
|
21
|
+
a_task = Task.new(:name => 'name', :details => 'details')
|
22
|
+
a_task.name.should.equal('name')
|
23
|
+
end
|
24
|
+
|
25
|
+
it 'creates a model with all attributes even if some omitted' do
|
26
|
+
atask = Task.create(:name => 'bob')
|
27
|
+
atask.should.respond_to(:details)
|
28
|
+
end
|
29
|
+
|
30
|
+
it 'simply bypasses spurious attributes erroneously set' do
|
31
|
+
a_task = Task.new(:name => 'details', :zoo => 'very bad')
|
32
|
+
a_task.should.not.respond_to(:zoo)
|
33
|
+
a_task.name.should.equal('details')
|
34
|
+
end
|
35
|
+
|
36
|
+
it "can check for a column's existence on a model" do
|
37
|
+
Task.column?(:name).should.be.true
|
38
|
+
end
|
39
|
+
|
40
|
+
it "can check for a column's existence on an instance" do
|
41
|
+
a_task = Task.new(:name => 'name', :details => 'details')
|
42
|
+
a_task.column?(:name).should.be.true
|
43
|
+
end
|
44
|
+
|
45
|
+
it "gets a list of columns on a model" do
|
46
|
+
cols = Task.columns
|
47
|
+
cols.should.include(:name)
|
48
|
+
cols.should.include(:details)
|
49
|
+
end
|
50
|
+
|
51
|
+
it "gets a list of columns on an instance" do
|
52
|
+
a_task = Task.new
|
53
|
+
cols = a_task.columns
|
54
|
+
cols.should.include(:name)
|
55
|
+
cols.should.include(:details)
|
56
|
+
end
|
57
|
+
|
58
|
+
it "columns can be specified as a Hash" do
|
59
|
+
lambda{Task.new}.should.not.raise
|
60
|
+
Task.new.column?(:name).should.be.true
|
61
|
+
end
|
62
|
+
|
63
|
+
it "columns can be specified as an Array" do
|
64
|
+
lambda{ATask.new}.should.not.raise
|
65
|
+
Task.new.column?(:name).should.be.true
|
66
|
+
end
|
67
|
+
|
68
|
+
it "the type of a column can be retrieved" do
|
69
|
+
Task.new.type(:some_day).should.equal(:date)
|
70
|
+
end
|
71
|
+
|
72
|
+
end
|
73
|
+
|
74
|
+
describe "ID handling" do
|
75
|
+
|
76
|
+
it 'creates an id if none present' do
|
77
|
+
task = Task.create
|
78
|
+
task.should.respond_to(:id)
|
79
|
+
end
|
80
|
+
|
81
|
+
it 'does not overwrite an existing ID' do
|
82
|
+
task = Task.create(:id => 999)
|
83
|
+
task.id.should.equal(999)
|
84
|
+
end
|
85
|
+
|
86
|
+
it 'creates multiple objects with unique ids' do
|
87
|
+
Task.create.id.should.not.equal(Task.create.id)
|
88
|
+
end
|
89
|
+
|
90
|
+
end
|
91
|
+
|
92
|
+
describe 'count and length methods' do
|
93
|
+
|
94
|
+
it 'has a length method' do
|
95
|
+
Task.should.respond_to(:length)
|
96
|
+
end
|
97
|
+
|
98
|
+
it 'has a count method' do
|
99
|
+
Task.should.respond_to(:count)
|
100
|
+
end
|
101
|
+
|
102
|
+
it 'when there is one element, length returns 1' do
|
103
|
+
task = Task.create
|
104
|
+
Task.length.should.equal(1)
|
105
|
+
end
|
106
|
+
|
107
|
+
it 'when there is one element, count returns 1' do
|
108
|
+
task = Task.create
|
109
|
+
Task.count.should.equal(1)
|
110
|
+
end
|
111
|
+
|
112
|
+
it 'when there is more than one element, length returned is correct' do
|
113
|
+
10.times { Task.create }
|
114
|
+
Task.length.should.equal(10)
|
115
|
+
end
|
116
|
+
|
117
|
+
end
|
118
|
+
|
119
|
+
describe 'finders' do
|
120
|
+
before do
|
121
|
+
10.times {|i| Task.create(:name => "task #{i}")}
|
122
|
+
end
|
123
|
+
|
124
|
+
describe 'find' do
|
125
|
+
it 'finds elements within the collection' do
|
126
|
+
Task.find(3).name.should.equal('task 3')
|
127
|
+
end
|
128
|
+
|
129
|
+
it 'returns nil if find by id is not found' do
|
130
|
+
Task.find(999).should.be.nil
|
131
|
+
end
|
132
|
+
|
133
|
+
it 'looks into fields if field name supplied' do
|
134
|
+
Task.create(:name => 'find me')
|
135
|
+
Task.find(:name).eq('find me').all.length.should.equal(1)
|
136
|
+
end
|
137
|
+
|
138
|
+
it 'allows for multiple (chained) query parameters' do
|
139
|
+
Task.create(:name => 'find me', :details => "details 1")
|
140
|
+
Task.create(:name => 'find me', :details => "details 2")
|
141
|
+
tasks = Task.find(:name).eq('find me').and(:details).like('2')
|
142
|
+
tasks.first.details.should.equal('details 2')
|
143
|
+
tasks.all.length.should.equal(1)
|
144
|
+
end
|
145
|
+
|
146
|
+
it 'where should respond to finder methods' do
|
147
|
+
Task.where(:details).should.respond_to(:contain)
|
148
|
+
end
|
149
|
+
|
150
|
+
it 'returns a FinderQuery object' do
|
151
|
+
Task.where(:details).should.is_a(MotionModel::FinderQuery)
|
152
|
+
end
|
153
|
+
|
154
|
+
it 'using where instead of find' do
|
155
|
+
atask = Task.create(:name => 'find me', :details => "details 1")
|
156
|
+
found_task = Task.where(:details).contain("details 1").first.details.should.equal("details 1")
|
157
|
+
end
|
158
|
+
|
159
|
+
it 'all returns all members of the collection as an array' do
|
160
|
+
Task.all.length.should.equal(10)
|
161
|
+
end
|
162
|
+
|
163
|
+
it 'each yields each row in sequence' do
|
164
|
+
i = 0
|
165
|
+
Task.each do |task|
|
166
|
+
task.name.should.equal("task #{i}")
|
167
|
+
i += 1
|
168
|
+
end
|
169
|
+
end
|
170
|
+
|
171
|
+
end
|
172
|
+
|
173
|
+
describe 'sorting' do
|
174
|
+
before do
|
175
|
+
Task.delete_all
|
176
|
+
Task.create(:name => 'Task 3', :details => 'detail 3')
|
177
|
+
Task.create(:name => 'Task 1', :details => 'detail 1')
|
178
|
+
Task.create(:name => 'Task 2', :details => 'detail 6')
|
179
|
+
Task.create(:name => 'Random Task', :details => 'another random task')
|
180
|
+
end
|
181
|
+
|
182
|
+
it 'sorts by field' do
|
183
|
+
tasks = Task.order(:name).all
|
184
|
+
tasks[0].name.should.equal('Random Task')
|
185
|
+
tasks[1].name.should.equal('Task 1')
|
186
|
+
tasks[2].name.should.equal('Task 2')
|
187
|
+
tasks[3].name.should.equal('Task 3')
|
188
|
+
end
|
189
|
+
|
190
|
+
it 'sorts observing block syntax' do
|
191
|
+
tasks = Task.order{|one, two| two.details <=> one.details}.all
|
192
|
+
tasks[0].details.should.equal('detail 6')
|
193
|
+
tasks[1].details.should.equal('detail 3')
|
194
|
+
tasks[2].details.should.equal('detail 1')
|
195
|
+
tasks[3].details.should.equal('another random task')
|
196
|
+
end
|
197
|
+
end
|
198
|
+
|
199
|
+
end
|
200
|
+
end
|
metadata
ADDED
@@ -0,0 +1,58 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: motion_model
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: '0.2'
|
5
|
+
prerelease:
|
6
|
+
platform: ruby
|
7
|
+
authors:
|
8
|
+
- Steve Ross
|
9
|
+
autorequire:
|
10
|
+
bindir: bin
|
11
|
+
cert_chain: []
|
12
|
+
date: 2012-08-20 00:00:00.000000000 Z
|
13
|
+
dependencies: []
|
14
|
+
description: Simple model and validation mixins for RubyMotion
|
15
|
+
email:
|
16
|
+
- sxross@gmail.com
|
17
|
+
executables: []
|
18
|
+
extensions: []
|
19
|
+
extra_rdoc_files: []
|
20
|
+
files:
|
21
|
+
- .gitignore
|
22
|
+
- README.md
|
23
|
+
- Rakefile
|
24
|
+
- app/app_delegate.rb
|
25
|
+
- lib/motion_model.rb
|
26
|
+
- lib/motion_model/ext.rb
|
27
|
+
- lib/motion_model/input_helpers.rb
|
28
|
+
- lib/motion_model/model.rb
|
29
|
+
- lib/motion_model/validatable.rb
|
30
|
+
- lib/motion_model/version.rb
|
31
|
+
- motion_model.gemspec
|
32
|
+
- spec/model_spec.rb
|
33
|
+
homepage: https://github.com/sxross/MotionModel
|
34
|
+
licenses: []
|
35
|
+
post_install_message:
|
36
|
+
rdoc_options: []
|
37
|
+
require_paths:
|
38
|
+
- lib
|
39
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
40
|
+
none: false
|
41
|
+
requirements:
|
42
|
+
- - ! '>='
|
43
|
+
- !ruby/object:Gem::Version
|
44
|
+
version: '0'
|
45
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
46
|
+
none: false
|
47
|
+
requirements:
|
48
|
+
- - ! '>='
|
49
|
+
- !ruby/object:Gem::Version
|
50
|
+
version: '0'
|
51
|
+
requirements: []
|
52
|
+
rubyforge_project:
|
53
|
+
rubygems_version: 1.8.24
|
54
|
+
signing_key:
|
55
|
+
specification_version: 3
|
56
|
+
summary: Simple model and validation mixins for RubyMotion
|
57
|
+
test_files:
|
58
|
+
- spec/model_spec.rb
|