motion_model 0.2.8 → 0.3.0

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/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