motion_model 0.2.8 → 0.3.0

Sign up to get free protection for your applications and to get access to all the features.
data/CHANGELOG CHANGED
@@ -1,3 +1,22 @@
1
+ 2012-12-07: Added MIT license file.
2
+ InputHelpers: Whitespace cleanup. Fixed keyboard show/hide to scroll to correct position.
3
+ MotionModel::Column: Whitespace cleanup, added code to support cascading delete (:dependent => :destroy)
4
+ MotionModel::Model: Whitespace cleanup, added code to support cascading destroy, destroy_all, and cascading if specified.
5
+ relation_spec.rb: removed delete tests into cascading_delete_spec.rb
6
+
7
+ 2012-12-06: Work on has_many to add cascading delete
8
+
9
+ MotionModel: POTENTIAL CODE-BREAKING CHANGE. has_many now takes two arguments
10
+ only. Previously, it would allow a list of symbols or strings,
11
+ now it conforms more to the Rails way of one call per relation.
12
+ E.g.:
13
+
14
+ has_many :pets
15
+
16
+ -or-
17
+
18
+ has_many :pets, :delete => :destroy # cascade delete.
19
+
1
20
  2012-10-14: Primary New Feature: Notifications
2
21
 
3
22
  MotionModel: Added bulk update, which suppresses notifications and added it to delete_all.
@@ -17,15 +36,15 @@ to the column metadata.
17
36
 
18
37
  * Default values have been added to fill in values
19
38
  if not specified in new or create.
20
-
39
+
21
40
  2012-09-06: Added block-style finders. Added delete method.
22
41
 
23
42
  2012-09-07: IMPORTANT! PLEASE READ! Two new methods were added
24
43
  to MotionModel to support persistence:
25
-
44
+
26
45
  Task#serialize_to_file(file_name)
27
46
  Task.deserialize_from_file(file_name)
28
-
47
+
29
48
  Note that serialize operates on an instance and
30
49
  deserialize is a class method that creates an
31
- instance.
50
+ instance.
data/LICENSE ADDED
@@ -0,0 +1,20 @@
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.
data/README.md CHANGED
@@ -1,3 +1,5 @@
1
+ [![Code Climate](https://codeclimate.com/badge.png)](https://codeclimate.com/github/sxross/MotionModel)
2
+
1
3
  MotionModel -- Simple Model, Validation, and Input Mixins for RubyMotion
2
4
  ================
3
5
 
@@ -26,6 +28,9 @@ are:
26
28
  helpers are certainly not the focus of this release, but
27
29
  I am using these in an app to create Apple-like input forms in
28
30
  static tables.
31
+
32
+ MotionModel is MIT licensed, which means you can pretty much do whatever
33
+ you like with it. See the LICENSE file in this project.
29
34
 
30
35
  Getting Going
31
36
  ================
@@ -237,11 +242,45 @@ Using MotionModel
237
242
  Assignee.first.task.name # => "Walk the Dog"
238
243
  ```
239
244
 
240
- At this point, there are a few methods that need to be added
241
- for relations, and they will.
242
-
243
- * destroy
245
+ There are four ways to delete objects from your data store:
246
+
247
+ * `object.delete #` just deletes the object and ignores all relations
248
+ * `object.destroy #` deletes the object and honors any cascading declarations
249
+ * `Class.delete_all #` just deletes all objects of this class and ignores all relations
250
+ * `Class.destroy_all #` deletes all objects of this class and honors any cascading declarations
244
251
 
252
+ The key to how the `destroy` variants work in how the relation is declared. You can declare:
253
+
254
+ ```ruby
255
+ class Task
256
+ include MotionModel::Model
257
+ columns :name => :string
258
+ has_many :assignees
259
+ end
260
+ ```
261
+
262
+ and `assignees` will *not be considered* when deleting `Task`s. However, by modifying the `has_many`,
263
+
264
+ ```ruby
265
+ has_many :assignees, :dependent => :destroy
266
+ ```
267
+
268
+ When you `destroy` an object, all of the objects related to it, and only those related
269
+ to that object, are also destroyed. So, if you call `task.destroy` and there are 5
270
+ `assignees` related to that task, they will also be destroyed. Any other `assignees`
271
+ are left untouched.
272
+
273
+ You can also specify:
274
+
275
+ ```ruby
276
+ has_many :assignees, :dependent => :delete
277
+ ```
278
+
279
+ The difference here is that the cascade stops as the `assignees` are deleted so anything
280
+ related to the assignees remains intact.
281
+
282
+ Note: This syntax is modeled on the Rails `:dependent => :destroy` options in `ActiveRecord`.
283
+
245
284
  Notifications
246
285
  -------------
247
286
 
@@ -405,4 +444,4 @@ specs.
405
444
 
406
445
  Really, for a bug report, even a failing spec or some proposed code is fine. I really want to make
407
446
  this a decent tool for RubyMotion developers who need a straightforward data
408
- modeling and persistence framework.
447
+ modeling and persistence framework.
@@ -52,11 +52,11 @@ end
52
52
  # words. It is very much based on the Rails
53
53
  # ActiveSupport implementation or Inflector
54
54
  class Inflector
55
- def self.instance
55
+ def self.instance #nodoc
56
56
  @__instance__ ||= new
57
57
  end
58
58
 
59
- def initialize
59
+ def initialize #nodoc
60
60
  reset
61
61
  end
62
62
 
@@ -123,32 +123,28 @@ class Inflector
123
123
  false
124
124
  end
125
125
 
126
- def singularize(word)
126
+ def inflect(word, direction) #nodoc
127
127
  return word if uncountable?(word)
128
- plural = word.dup
128
+
129
+ subject = word.dup
129
130
 
130
131
  @irregulars.each do |rule|
131
- return plural if plural.gsub!(rule.first, rule.last)
132
+ return subject if subject.gsub!(rule.first, rule.last)
132
133
  end
133
134
 
134
- @singulars.each do |rule|
135
- return plural if plural.gsub!(rule.first, rule.last)
135
+ sense_group = direction == :singularize ? @singulars : @plurals
136
+ sense_group.each do |rule|
137
+ return subject if subject.gsub!(rule.first, rule.last)
136
138
  end
137
- plural
139
+ subject
138
140
  end
139
141
 
140
- def pluralize(word)
141
- return word if uncountable?(word)
142
- singular = word.dup
143
-
144
- @irregulars.each do |rule|
145
- return singular if singular.gsub!(rule.first, rule.last)
146
- end
142
+ def singularize(word)
143
+ inflect word, :singularize
144
+ end
147
145
 
148
- @plurals.each do |rule|
149
- return singular if singular.gsub!(rule.first, rule.last)
150
- end
151
- singular
146
+ def pluralize(word)
147
+ inflect word, :pluralize
152
148
  end
153
149
  end
154
150
 
@@ -1,24 +1,24 @@
1
1
  module MotionModel
2
2
  module InputHelpers
3
3
  class ModelNotSetError < RuntimeError; end
4
-
4
+
5
5
  # FieldBindingMap contains a simple label to model
6
6
  # field binding, and is decorated by a tag to be
7
7
  # used on the UI control.
8
8
  class FieldBindingMap
9
9
  attr_accessor :label, :name, :tag
10
-
10
+
11
11
  def initialize(options = {})
12
12
  @name = options[:name]
13
13
  @label = options[:label]
14
14
  end
15
15
  end
16
-
16
+
17
17
  def self.included(base)
18
18
  base.extend(ClassMethods)
19
19
  base.instance_variable_set('@binding_data', [])
20
20
  end
21
-
21
+
22
22
  module ClassMethods
23
23
  # +field+ is a declarative macro that specifies
24
24
  # the field name (i.e., the model field name)
@@ -41,15 +41,15 @@ module MotionModel
41
41
  @binding_data << FieldBindingMap.new(:label => label, :name => field)
42
42
  end
43
43
  end
44
-
44
+
45
45
  # +model+ is a mandatory method in which you
46
46
  # specify the instance of the model to which
47
47
  # your fields are bound.
48
-
48
+
49
49
  def model(model_instance)
50
50
  @model = model_instance
51
51
  end
52
-
52
+
53
53
  # +field_count+ specifies how many fields have
54
54
  # been bound.
55
55
  #
@@ -66,10 +66,10 @@ module MotionModel
66
66
  # +field_at+ retrieves the field at a given index.
67
67
  #
68
68
  # Usage:
69
- #
69
+ #
70
70
  # field = field_at(indexPath.row)
71
71
  # label_view = subview(UILabel, :label_frame, text: field.label)
72
-
72
+
73
73
  def field_at(index)
74
74
  data = self.class.instance_variable_get('@binding_data'.to_sym)
75
75
  data[index].tag = index + 1
@@ -86,7 +86,7 @@ module MotionModel
86
86
  def value_at(field)
87
87
  @model.send(field.name)
88
88
  end
89
-
89
+
90
90
  # +fields+ is the iterator for all fields
91
91
  # mapped for this class.
92
92
  #
@@ -95,11 +95,11 @@ module MotionModel
95
95
  # fields do |field|
96
96
  # do_something_with field.label, field.value
97
97
  # end
98
-
98
+
99
99
  def fields
100
100
  self.class.instance_variable_get('@binding_data'.to_sym).each{|datum| yield datum}
101
101
  end
102
-
102
+
103
103
  # +bind+ fetches all mapped fields from
104
104
  # any subview of the current +UIView+
105
105
  # and transfers the contents to the
@@ -107,7 +107,7 @@ module MotionModel
107
107
  # specified by the +model+ method.
108
108
  def bind
109
109
  raise ModelNotSetError.new("You must set the model before binding it.") unless @model
110
-
110
+
111
111
  fields do |field|
112
112
  view_obj = self.view.viewWithTag(field.tag)
113
113
  @model.send("#{field.name}=".to_sym, view_obj.text) if view_obj.respond_to?(:text)
@@ -149,7 +149,6 @@ module MotionModel
149
149
  animationCurve = notification.userInfo.valueForKey(UIKeyboardAnimationCurveUserInfoKey)
150
150
  animationDuration = notification.userInfo.valueForKey(UIKeyboardAnimationDurationUserInfoKey)
151
151
  keyboardEndRect = notification.userInfo.valueForKey(UIKeyboardFrameEndUserInfoKey)
152
-
153
152
  keyboardEndRect = view.convertRect(keyboardEndRect.CGRectValue, fromView:App.delegate.window)
154
153
 
155
154
  UIView.beginAnimations "changeTableViewContentInset", context:nil
@@ -158,28 +157,29 @@ module MotionModel
158
157
 
159
158
  intersectionOfKeyboardRectAndWindowRect = CGRectIntersection(App.delegate.window.frame, keyboardEndRect)
160
159
  bottomInset = intersectionOfKeyboardRectAndWindowRect.size.height;
161
-
160
+
162
161
  @table.contentInset = UIEdgeInsetsMake(0, 0, bottomInset, 0)
163
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
164
172
  # Find active cell
165
173
  indexPathOfOwnerCell = nil
166
174
  numberOfCells = @table.dataSource.tableView(@table, numberOfRowsInSection:0)
167
175
  0.upto(numberOfCells) do |index|
168
176
  indexPath = NSIndexPath.indexPathForRow(index, inSection:0)
169
177
  cell = @table.cellForRowAtIndexPath(indexPath)
170
- if cell_has_first_responder?(cell)
171
- indexPathOfOwnerCell = indexPath
172
- break
173
- end
178
+ return indexPath if find_first_responder(cell)
174
179
  end
175
180
 
176
- UIView.commitAnimations
177
-
178
- if indexPathOfOwnerCell
179
- @table.scrollToRowAtIndexPath(indexPathOfOwnerCell,
180
- atScrollPosition:UITableViewScrollPositionMiddle,
181
- animated: true)
182
- end
181
+ # By default use the first section, first row.
182
+ NSIndexPath.indexPathForRow 0, inSection: 0
183
183
  end
184
184
 
185
185
  # Undo all the rejiggering when the keyboard slides
@@ -194,25 +194,28 @@ module MotionModel
194
194
  if UIEdgeInsetsEqualToEdgeInsets(@table.contentInset, UIEdgeInsetsZero)
195
195
  return
196
196
  end
197
-
197
+
198
198
  animationCurve = notification.userInfo.valueForKey(UIKeyboardAnimationCurveUserInfoKey)
199
199
  animationDuration = notification.userInfo.valueForKey(UIKeyboardAnimationDurationUserInfoKey)
200
-
200
+
201
201
  UIView.beginAnimations("changeTableViewContentInset", context:nil)
202
202
  UIView.setAnimationDuration(animationDuration)
203
203
  UIView.setAnimationCurve(animationCurve)
204
-
204
+
205
205
  @table.contentInset = UIEdgeInsetsZero;
206
-
207
- UIView.commitAnimations
206
+
207
+ UIView.commitAnimations
208
208
  end
209
-
210
- def cell_has_first_responder?(cell)
211
- return false unless cell.respond_to?(:subviews)
212
- cell.subviews.each do |subview|
213
- return true if subview.isFirstResponder
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
214
216
  end
215
- false
217
+
218
+ return false
216
219
  end
217
220
  end
218
221
  end
@@ -4,20 +4,25 @@ module MotionModel
4
4
  attr_accessor :name
5
5
  attr_accessor :type
6
6
  attr_accessor :default
7
+ attr_accessor :destroy
7
8
 
8
- def initialize(name = nil, type = nil, default = nil)
9
+ def initialize(name = nil, type = nil, options = {})
9
10
  @name = name
10
11
  @type = type
11
- @default = default || nil
12
+ @default = options[:default]
13
+ @destroy = options[:dependent]
12
14
  end
13
-
14
- def add_attr(name, type, default = nil)
15
+
16
+ # REVIEW: Dead code?
17
+ def add_attr(name, type, options)
15
18
  @name = name
16
19
  @type = type
17
- @default = default || nil
20
+ @default = options[:default]
21
+ @destroy = options[:dependent]
18
22
  end
23
+
19
24
  alias_method :add_attribute, :add_attr
20
-
25
+
21
26
  def classify
22
27
  case @type
23
28
  when :belongs_to
@@ -30,4 +35,4 @@ module MotionModel
30
35
  end
31
36
  end
32
37
  end
33
- end
38
+ end
@@ -28,7 +28,7 @@
28
28
  # * :float
29
29
  #
30
30
  # Assuming you have a bunch of tasks in your data store, you can do this:
31
- #
31
+ #
32
32
  # tasks_this_week = Task.where(:due_date).ge(beginning_of_week).and(:due_date).le(end_of_week).order(:due_date)
33
33
  #
34
34
  # Partial queries are supported so you can do:
@@ -36,13 +36,15 @@
36
36
  # tasks_this_week = Task.where(:due_date).ge(beginning_of_week).and(:due_date).le(end_of_week)
37
37
  # ordered_tasks_this_week = tasks_this_week.order(:due_date)
38
38
  #
39
-
39
+
40
40
  module MotionModel
41
41
  class PersistFileError < Exception; end
42
-
42
+ class RelationIsNilError < Exception; end
43
+
43
44
  module Model
44
45
  def self.included(base)
45
- base.extend(ClassMethods)
46
+ base.extend(PrivateClassMethods)
47
+ base.extend(PublicClassMethods)
46
48
  base.instance_variable_set("@_columns", []) # Columns in model
47
49
  base.instance_variable_set("@_column_hashes", {}) # Hashes to for quick column lookup
48
50
  base.instance_variable_set("@_relations", {}) # relations
@@ -50,8 +52,8 @@ module MotionModel
50
52
  base.instance_variable_set("@_next_id", 1) # Next assignable id
51
53
  base.instance_variable_set("@_issue_notifications", true) # Next assignable id
52
54
  end
53
-
54
- module ClassMethods
55
+
56
+ module PublicClassMethods
55
57
  # Use to do bulk insertion, updating, or deleting without
56
58
  # making repeated calls to a delegate. E.g., when syncing
57
59
  # with an external data source.
@@ -60,55 +62,6 @@ module MotionModel
60
62
  class_eval &block
61
63
  @_issue_notifications = true
62
64
  end
63
-
64
- def issue_notification(object, info) #nodoc
65
- if @_issue_notifications == true && !object.nil?
66
- NSNotificationCenter.defaultCenter.postNotificationName('MotionModelDataDidChangeNotification', object: object, userInfo: info)
67
- end
68
- end
69
-
70
- def define_accessor_methods(name)
71
- define_method(name.to_sym) {
72
- @data[name]
73
- }
74
- define_method("#{name}=".to_sym) { |value|
75
- @data[name] = cast_to_type(name, value)
76
- }
77
- end
78
-
79
- def define_belongs_to_methods(name)
80
- define_method(name) {
81
- col = column_named(name)
82
- parent_id = @data[self.class.belongs_to_id(col.name)]
83
- col.classify.find(parent_id)
84
- }
85
- define_method("#{name}=") { |value|
86
- col = column_named(name)
87
- parent_id = self.class.belongs_to_id(col.name)
88
- @data[parent_id.to_sym] = value.to_i
89
- }
90
- end
91
-
92
- def define_has_many_methods(name)
93
- define_method(name) {
94
- relation_for(name)
95
- }
96
- end
97
-
98
- def add_field(name, type, default = nil) #nodoc
99
- col = Column.new(name, type, default)
100
- @_columns.push col
101
- @_column_hashes[col.name.to_sym] = col
102
-
103
- case type
104
- when :has_many
105
- define_has_many_methods(name)
106
- when :belongs_to
107
- define_belongs_to_methods(name)
108
- else
109
- define_accessor_methods(name)
110
- end
111
- end
112
65
 
113
66
  # Macro to define names and types of columns. It can be used in one of
114
67
  # two forms:
@@ -120,41 +73,30 @@ module MotionModel
120
73
  # Pass a hash of hashes and you can specify defaults such as:
121
74
  #
122
75
  # columns :name => {:type => :string, :default => 'Joe Bob'}, :age => :integer
123
- #
76
+ #
124
77
  # Pass an array, and you create column names, all of which have type +:string+.
125
- #
78
+ #
126
79
  # columns :name, :age, :hobby
127
-
80
+
128
81
  def columns(*fields)
129
82
  return @_columns.map{|c| c.name} if fields.empty?
130
83
 
131
- col = Column.new
132
-
84
+ # col = Column.new # REVIEW: Dead code?
85
+
133
86
  case fields.first
134
87
  when Hash
135
- fields.first.each_pair do |name, options|
136
- raise ArgumentError.new("you cannot use `description' as a column name because of a conflict with Cocoa.") if name.to_s == 'description'
137
-
138
- case options
139
- when Symbol, String
140
- add_field(name, options)
141
- when Hash
142
- add_field(name, options[:type], options[:default])
143
- else
144
- raise ArgumentError.new("arguments to fields must be a symbol, a hash, or a hash of hashes.")
145
- end
146
- end
88
+ column_from_hash fields
89
+ when String, Symbol
90
+ column_from_string_or_sym fields
147
91
  else
148
- fields.each do |name|
149
- add_field(name, :string)
150
- end
92
+ raise ArgumentError.new("arguments to `columns' must be a symbol, a hash, or a hash of hashes -- was #{fields.first}.")
151
93
  end
152
94
 
153
95
  unless self.respond_to?(:id)
154
96
  add_field(:id, :integer)
155
97
  end
156
98
  end
157
-
99
+
158
100
  # Use at class level, as follows:
159
101
  #
160
102
  # class Task
@@ -174,15 +116,14 @@ module MotionModel
174
116
  #
175
117
  # This must be used with a belongs_to macro in the related model class
176
118
  # if you want to be able to access the inverse relation.
177
- def has_many(*relations)
178
- relations.each do |relation|
179
- raise ArgumentError.new("arguments to has_many must be a symbol, a string or an array of same.") unless relation.is_a?(Symbol) || relation.is_a?(String)
180
- add_field relation, :has_many # Relation must be plural
181
- end
119
+
120
+ def has_many(relation, options = {})
121
+ raise ArgumentError.new("arguments to has_many must be a symbol or string.") unless [Symbol, String].include? relation.class
122
+ add_field relation, :has_many, options # Relation must be plural
182
123
  end
183
-
184
- def belongs_to_id(relation)
185
- (relation.to_s.underscore + '_id').to_sym
124
+
125
+ def generate_belongs_to_id(relation)
126
+ (relation.to_s.singularize.underscore + '_id').to_sym
186
127
  end
187
128
 
188
129
  # Use at class level, as follows
@@ -198,54 +139,24 @@ module MotionModel
198
139
  # Assignee.find(:assignee_name).like('smith').first.task
199
140
  def belongs_to(relation)
200
141
  add_field relation, :belongs_to
201
- add_field belongs_to_id(relation), :belongs_to_id # a relation is singular.
202
- end
203
-
204
- # Returns a column denoted by +name+
205
- def column_named(name)
206
- @_column_hashes[name.to_sym]
207
- end
208
-
209
- # Returns next available id
210
- def next_id #nodoc
211
- @_next_id
212
- end
213
-
214
- # Sets next available id
215
- def next_id=(value)
216
- @_next_id = value
217
- end
218
-
219
- # Increments next available id
220
- def increment_id #nodoc
221
- @_next_id += 1
142
+ add_field generate_belongs_to_id(relation), :belongs_to_id # a relation is singular.
222
143
  end
223
144
 
224
145
  # Returns true if a column exists on this model, otherwise false.
225
146
  def column?(column)
226
147
  respond_to?(column)
227
148
  end
228
-
149
+
229
150
  # Returns type of this column.
230
151
  def type(column)
231
152
  column_named(column).type || nil
232
153
  end
233
-
154
+
234
155
  # returns default value for this column or nil.
235
156
  def default(column)
236
157
  column_named(column).default || nil
237
158
  end
238
-
239
- def has_relation?(col)
240
- col = case col
241
- when MotionModel::Model::Column
242
- column_named(col.name)
243
- else
244
- column_named(col)
245
- end
246
- col.type == :has_many || col.type == :belongs_to
247
- end
248
-
159
+
249
160
  # Creates an object and saves it. E.g.:
250
161
  #
251
162
  # @bob = Person.create(:name => 'Bob', :hobby => 'Bird Watching')
@@ -255,20 +166,22 @@ module MotionModel
255
166
  row = self.new(options)
256
167
  row.before_create if row.respond_to?(:before_create)
257
168
  row.before_save if row.respond_to?(:before_save)
258
-
169
+
259
170
  # TODO: Check for Validatable and if it's
260
171
  # present, check valid? before saving.
261
172
 
262
173
  row.save
263
174
  row
264
175
  end
265
-
176
+
266
177
  def length
267
178
  @collection.length
268
179
  end
269
180
  alias_method :count, :length
270
181
 
271
- # Empties the entire store.
182
+ # Deletes all rows in the model -- no hooks are called and
183
+ # deletes are not cascading so this does not affected related
184
+ # data.
272
185
  def delete_all
273
186
  # Do each delete so any on_delete and
274
187
  # cascades are called, then empty the
@@ -278,7 +191,19 @@ module MotionModel
278
191
  end
279
192
  @collection = []
280
193
  @_next_id = 1
281
- # @collection.compact!
194
+ end
195
+
196
+ # Destroys all rows in the model -- before_delete and after_delete
197
+ # hooks are called and deletes are not cascading if declared with
198
+ # :delete => destroy in the has_many macro.
199
+ def destroy_all
200
+ ids = self.all.map{|item| item.id}
201
+ bulk_update do
202
+ ids.each do |item|
203
+ find(item).destroy
204
+ end
205
+ end
206
+ # Note collection is not emptied, and next_id is not reset.
282
207
  end
283
208
 
284
209
  # Finds row(s) within the data store. E.g.,
@@ -295,226 +220,366 @@ module MotionModel
295
220
  end.compact
296
221
  return FinderQuery.new(matches)
297
222
  end
298
-
223
+
299
224
  unless args[0].is_a?(Symbol) || args[0].is_a?(String)
300
- return @collection.select{|c| c.id == args[0].to_i}.first || nil
225
+ target_id = args[0].to_i
226
+ return @collection.select{|element| element.id == target_id}.first
301
227
  end
302
-
228
+
303
229
  FinderQuery.new(args[0].to_sym, @collection)
304
230
  end
305
231
  alias_method :where, :find
306
-
232
+
307
233
  # Retrieves first row of query
308
234
  def first
309
235
  @collection.first
310
236
  end
311
-
237
+
312
238
  # Retrieves last row of query
313
239
  def last
314
240
  @collection.last
315
241
  end
316
-
242
+
317
243
  # Returns query result as an array
318
244
  def all
319
245
  @collection
320
246
  end
321
-
247
+
322
248
  def order(field_name = nil, &block)
323
249
  FinderQuery.new(@collection).order(field_name, &block)
324
250
  end
325
-
251
+
326
252
  def each(&block)
327
253
  raise ArgumentError.new("each requires a block") unless block_given?
328
254
  @collection.each{|item| yield item}
329
- end
330
-
255
+ end
256
+
331
257
  def empty?
332
258
  @collection.empty?
333
259
  end
334
260
  end
335
-
336
- ####### Instance Methods #######
337
- def initialize(options = {})
338
- @data ||= {} # REVIEW: Why make this conditional?
339
-
340
- # Time zone, for future use.
341
- @tz_offset ||= NSDate.date.to_s.gsub(/^.*?( -\d{4})/, '\1')
342
-
343
- @cached_date_formatter = NSDateFormatter.alloc.init # Create once, as they are expensive to create
344
- @cached_date_formatter.dateFormat = "MM-dd-yyyy HH:mm"
345
-
346
- unless options[:id]
347
- options[:id] = self.class.next_id
348
- else
349
- self.class.next_id = [options[:id].to_i, self.class.next_id].max
261
+
262
+ module PrivateClassMethods
263
+ # This populates a column from something like:
264
+ #
265
+ # columns :name => :string, :age => :integer
266
+ #
267
+ # or
268
+ #
269
+ # columns :name => {:type => :string, :default => 'Joe Bob'}, :age => :integer
270
+
271
+ def column_from_hash(hash) #nodoc
272
+ hash.first.each_pair do |name, options|
273
+ raise ArgumentError.new("you cannot use `description' as a column name because of a conflict with Cocoa.") if name.to_s == 'description'
274
+
275
+ case options
276
+ when Symbol, String
277
+ add_field(name, options)
278
+ when Hash
279
+ add_field(name, options[:type], :default => options[:default])
280
+ else
281
+ raise ArgumentError.new("arguments to `columns' must be a symbol, a hash, or a hash of hashes.")
282
+ end
283
+ end
284
+ end
285
+
286
+ # This populates a column from something like:
287
+ #
288
+ # columns :name, :age, :hobby
289
+
290
+ def column_from_string_or_sym(string) #nodoc
291
+ string.each do |name|
292
+ add_field(name.to_sym, :string)
293
+ end
294
+ end
295
+
296
+ def issue_notification(object, info) #nodoc
297
+ if @_issue_notifications == true && !object.nil?
298
+ NSNotificationCenter.defaultCenter.postNotificationName('MotionModelDataDidChangeNotification', object: object, userInfo: info)
299
+ end
300
+ end
301
+
302
+ def define_accessor_methods(name) #nodoc
303
+ define_method(name.to_sym) {
304
+ @data[name]
305
+ }
306
+ define_method("#{name}=".to_sym) { |value|
307
+ @data[name] = cast_to_type(name, value)
308
+ @dirty = true
309
+ }
350
310
  end
351
- self.class.increment_id
311
+
312
+ def define_belongs_to_methods(name) #nodoc
313
+ define_method(name) {
314
+ col = column_named(name)
315
+ parent_id = @data[self.class.generate_belongs_to_id(col.name)]
316
+ col.classify.find(parent_id)
317
+ }
318
+ define_method("#{name}=") { |value|
319
+ col = column_named(name)
320
+ parent_id = self.class.generate_belongs_to_id(col.name)
321
+ @data[parent_id.to_sym] = value.to_i
322
+ }
323
+ end
324
+
325
+ def define_has_many_methods(name) #nodoc
326
+ define_method(name) {
327
+ relation_for(name)
328
+ }
329
+ end
330
+
331
+ def add_field(name, type, options = {:default => nil}) #nodoc
332
+ col = Column.new(name, type, options)
333
+
334
+ @_columns.push col
335
+ @_column_hashes[col.name.to_sym] = col
336
+
337
+ case type
338
+ when :has_many then define_has_many_methods(name)
339
+ when :belongs_to then define_belongs_to_methods(name)
340
+ else
341
+ define_accessor_methods(name)
342
+ end
343
+ end
344
+
345
+ # Returns a column denoted by +name+
346
+ def column_named(name) #nodoc
347
+ @_column_hashes[name.to_sym]
348
+ end
349
+
350
+ # Returns next available id
351
+ def next_id #nodoc
352
+ @_next_id
353
+ end
354
+
355
+ # Sets next available id
356
+ def next_id=(value) #nodoc
357
+ @_next_id = value
358
+ end
359
+
360
+ # Increments next available id
361
+ def increment_id #nodoc
362
+ @_next_id += 1
363
+ end
364
+
365
+ def has_relation?(col) #nodoc
366
+ return false if col.nil?
367
+
368
+ col = case col
369
+ when MotionModel::Model::Column
370
+ column_named(col.name)
371
+ else
372
+ column_named(col)
373
+ end
374
+ col.type == :has_many || col.type == :belongs_to
375
+ end
376
+
377
+ end
378
+
379
+ def initialize(options = {})
380
+ @data ||= {}
381
+
382
+ assign_id options
352
383
 
353
384
  columns.each do |col|
354
- unless [:belongs_to, :belongs_to_id, :has_many].include? column_named(col).type
355
- options[col] ||= self.class.default(col)
356
- cast_value = cast_to_type(col, options[col])
357
- @data[col] = cast_value
385
+ unless relation_column?(col) # all data columns
386
+ initialize_data_columns col, options
358
387
  else
359
- if column_named(col).type == :belongs_to_id
360
- @data[col] = options[col]
361
- end
388
+ @data[col] = options[col] if column_named(col).type == :belongs_to_id
362
389
  end
363
390
  end
364
-
365
- dirty = true
391
+
392
+ @dirty = true
366
393
  end
367
394
 
395
+ # Default to_i implementation returns value of id column, much as
396
+ # in Rails.
397
+
368
398
  def to_i
369
399
  @data[:id].to_i
370
400
  end
371
401
 
372
- def cast_to_type(column_name, arg)
373
- return nil if arg.nil?
374
-
375
- return_value = arg
376
-
377
- case type(column_name)
378
- when :string
379
- return_value = arg.to_s
380
- when :int, :integer, :belongs_to_id
381
- return_value = arg.is_a?(Integer) ? arg : arg.to_i
382
- when :float, :double
383
- return_value = arg.is_a?(Float) ? arg : arg.to_f
384
- when :date
385
- return arg if arg.is_a?(NSDate)
386
- return_value = NSDate.dateWithNaturalLanguageString(arg, locale:NSUserDefaults.standardUserDefaults.dictionaryRepresentation)
387
- else
388
- raise ArgumentError.new("type #{column_name} : #{type(column_name)} is not possible to cast.")
389
- end
390
- return_value
391
- end
392
-
402
+ # Default to_s implementation returns a list of columns and values
403
+ # separated by newlines.
393
404
  def to_s
394
405
  columns.each{|c| "#{c}: #{self.send(c)}\n"}
395
406
  end
396
-
407
+
408
+ # Save current object. Speaking from the context of relational
409
+ # databases, this inserts a row if it's a new one, or updates
410
+ # in place if not.
397
411
  def save
398
- collection = self.class.instance_variable_get('@collection')
399
412
  @dirty = false
400
-
413
+
401
414
  # Existing object implies update in place
402
415
  # TODO: Optimize location of existing id
403
416
  action = 'add'
404
417
  if obj = collection.find{|o| o.id == @data[:id]}
405
- collection = self
418
+ obj = self
406
419
  action = 'update'
407
420
  else
408
421
  collection << self
409
422
  end
410
423
  self.class.issue_notification(self, :action => action)
411
424
  end
412
-
425
+
426
+ # Deletes the current object. The object can still be used.
427
+
413
428
  def delete
414
- collection = self.class.instance_variable_get('@collection')
415
-
416
429
  target_index = collection.index{|item| item.id == self.id}
417
430
  collection.delete_at(target_index)
418
431
  self.class.issue_notification(self, :action => 'delete')
419
432
  end
420
433
 
434
+ # Destroys the current object. The difference between delete
435
+ # and destroy is that destroy calls <tt>before_delete</tt>
436
+ # and <tt>after_delete</tt> hooks. As well, it will cascade
437
+ # into related objects, deleting them if they are related
438
+ # using <tt>:delete => :destroy</tt> in the <tt>has_many</tt>
439
+ # declaration
440
+ def destroy
441
+ before_delete if respond_to? :before_delete
442
+ has_many_columns.each do |col|
443
+ delete_candidates = self.send(col.name)
444
+
445
+ delete_candidates.each do |candidate|
446
+ candidate.delete if col.destroy == :delete
447
+ candidate.destroy if col.destroy == :destroy
448
+ end
449
+ end
450
+ delete
451
+ after_delete if respond_to? :after_delete
452
+ end
453
+
454
+ # Undelete does pretty much as its name implies. However,
455
+ # the natural sort order is not preserved. IMPORTANT: If
456
+ # you are trying to undo a cascading delete, this will not
457
+ # work. It only undeletes the object you still own.
458
+
459
+ def undelete
460
+ collection << self
461
+ self.class.issue_notification(self, :action => 'add')
462
+ end
463
+
464
+ # Count of objects in the current collection
421
465
  def length
422
- @collection.length
466
+ collection.length
423
467
  end
424
-
425
468
  alias_method :count, :length
426
-
427
- def column?(target_key)
428
- self.class.column?(target_key.to_sym)
469
+
470
+ # True if the column exists, otherwise false
471
+ def column?(column_name)
472
+ self.class.column?(column_name.to_sym)
429
473
  end
430
474
 
475
+ # Returns list of column names as an array
431
476
  def columns
432
477
  self.class.columns
433
478
  end
434
479
 
435
- def column_named(name)
436
- self.class.column_named(name.to_sym)
480
+ # Type of a given column
481
+ def type(column_name)
482
+ self.class.type(column_name)
437
483
  end
438
484
 
439
- def type(field_name)
440
- self.class.type(field_name)
441
- end
442
-
443
- # Modify respond_to? to add model's attributes.
485
+ # True if this object responds to the method or
486
+ # property, otherwise false.
444
487
  alias_method :old_respond_to?, :respond_to?
445
488
  def respond_to?(method)
446
489
  column_named(method) || old_respond_to?(method)
447
490
  end
448
-
491
+
449
492
  def dirty?
450
- @dirty
493
+ @dirty
451
494
  end
452
-
453
- def relation_for(col)
454
- # relation is a belongs_to or a has_many
495
+
496
+
497
+ private
498
+
499
+ def assign_id(options) #nodoc
500
+ unless options[:id]
501
+ options[:id] = self.class.next_id
502
+ else
503
+ self.class.next_id = [options[:id].to_i, self.class.next_id].max
504
+ end
505
+ self.class.increment_id
506
+ end
507
+
508
+ def relation_column?(column) #nodoc
509
+ [:belongs_to, :belongs_to_id, :has_many].include? column_named(column).type
510
+ end
511
+
512
+ def initialize_data_columns(column, options) #nodoc
513
+ options[column] ||= self.class.default(column)
514
+ cast_value = cast_to_type(column, options[column])
515
+ @data[column] = cast_value
516
+ end
517
+
518
+ def cast_to_type(column_name, arg) #nodoc
519
+ return nil if arg.nil?
520
+
521
+ return case type(column_name)
522
+ when :string then arg.to_s
523
+ when :int, :integer, :belongs_to_id
524
+ arg.is_a?(Integer) ? arg : arg.to_i
525
+ when :float, :double
526
+ arg.is_a?(Float) ? arg : arg.to_f
527
+ when :date
528
+ arg.is_a?(NSDate) ? arg : NSDate.dateWithNaturalLanguageString(arg, locale:NSUserDefaults.standardUserDefaults.dictionaryRepresentation)
529
+ else
530
+ raise ArgumentError.new("type #{column_name} : #{type(column_name)} is not possible to cast.")
531
+ end
532
+ end
533
+
534
+ def collection #nodoc
535
+ self.class.instance_variable_get('@collection')
536
+ end
537
+
538
+ def column_named(name) #nodoc
539
+ self.class.column_named(name.to_sym)
540
+ end
541
+
542
+ def has_many_columns
543
+ columns.map{|col| column_named(col)}.select{|col| col.type == :has_many}
544
+ end
545
+
546
+ def generate_belongs_to_id(class_or_column) # nodoc
547
+ self.class.generate_belongs_to_id(self.class)
548
+ end
549
+
550
+ def relation_for(col) # nodoc
455
551
  col = column_named(col)
552
+ related_klass = col.classify
553
+
456
554
  case col.type
457
555
  when :belongs_to
458
- result = col.classify.find( # for clarity, we get the class
459
- @data.send( # and look inside it to find the
460
- :[], :id # parent element that the current
461
- ) # object belongs to.
462
- )
463
- result
556
+ related_klass.find(@data[:id])
464
557
  when :has_many
465
- belongs_to_id = self.class.send(:belongs_to_id, self.class.to_s)
466
-
467
- # For has_many to work, the finder query needs the
468
- # actual object, and the class of the relation
469
- result = col.classify.find(belongs_to_id).belongs_to(self, col.classify).eq( # find all elements of belongs_to
470
- @data.send(:[], :id) # class that have the ID of this element Task.find(:assignee_id).eq(3)
471
- )
472
- result
558
+ related_klass.find(generate_belongs_to_id(self.class)).belongs_to(self, related_klass).eq(@data[:id])
473
559
  else
474
560
  nil
475
561
  end
476
562
  end
477
-
563
+
564
+
478
565
  # Handle attribute retrieval
479
- #
566
+ #
480
567
  # Gets and sets work as expected, and type casting occurs
481
568
  # For example:
482
- #
569
+ #
483
570
  # Task.date = '2012-09-15'
484
- #
571
+ #
485
572
  # This creates a real Date object in the data store.
486
- #
573
+ #
487
574
  # date = Task.date
488
- #
575
+ #
489
576
  # Date is a real date object.
490
- def method_missing(method, *args, &block)
491
- base_method = method.to_s.gsub('=', '').to_sym
492
- col = column_named(base_method)
493
- raise NoMethodError.new("nil column #{method} accessed from #{caller[1]}.") if col.nil?
494
-
495
- unless col.type == :belongs_to_id
496
- Debug.error "method missing for #{base_method}"
497
- has_relation = relation_for(col) if self.class.has_relation?(col)
498
- return has_relation if has_relation
499
- end
500
-
501
- unless col.nil?
502
- if method.to_s.include?('=')
503
- @dirty = true
504
- return @data[base_method] = col.type == :belongs_to_id ? args[0] : self.cast_to_type(base_method, args[0])
505
- else
506
- return @data[base_method]
507
- end
577
+ def method_missing(method, *args, &block) #nodoc
578
+ if self.respond_to? method
579
+ return method(args, &block)
508
580
  else
509
- exception = NoMethodError.new(<<ERRORINFO
510
- method: #{method}
511
- args: #{args.inspect}
512
- in: #{self.class.name}
513
- ERRORINFO
514
- )
515
- raise exception
581
+ raise NoMethodError.new("nil column #{self.class}##{method} accessed from #{caller[1]}.")
516
582
  end
517
583
  end
518
-
519
584
  end
520
585
  end