motion_model 0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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
|