motion_model 0.4.1 → 0.4.2

Sign up to get free protection for your applications and to get access to all the features.
data/CHANGELOG CHANGED
@@ -1,10 +1,21 @@
1
- 2102-03-15: Fixed bug where created_at and updated_at were being incorrectly
1
+ 2013-04-13: WARNING: Possible breaking change. Hook methods changed to send
2
+ affected object. So, if you have:
3
+
4
+ def after_create
5
+
6
+ You will get an error about expecting 1 argument, got 0
7
+
8
+ The correct signature is:
9
+
10
+ def after_create(sender)
11
+
12
+ 2013-03-15: Fixed bug where created_at and updated_at were being incorrectly
2
13
  when restored from persistence (thanks Justin McPherson for finding
3
14
  that).
4
15
 
5
16
  Moved all NSCoder stuff out of Model to ArrayModelAdapter.
6
17
 
7
- 2012-02-19: Included Doug Puchalski's great refactoring of Model that provides an
18
+ 2013-02-19: Included Doug Puchalski's great refactoring of Model that provides an
8
19
  adapter for ArrayModelAdapter. WARNING!!! This is a breaking change
9
20
  since version 0.3.8. You will have to include:
10
21
 
@@ -14,9 +25,9 @@
14
25
  Failure to include an adapter (note: spelling counts :) will result
15
26
  in an exception so this will not quietly fail.
16
27
 
17
- 2012-01-24: Added block-structured transactions.
28
+ 2013-01-24: Added block-structured transactions.
18
29
 
19
- 2012-01-14: Fixed problem where data returned from forms was of type NSString, which
30
+ 2013-01-14: Fixed problem where data returned from forms was of type NSString, which
20
31
  confused some monkey-patching code.
21
32
  Changed before_ hooks such that handlers returning false would terminate
22
33
  the process. So, if before_save returns anything other than false, the
@@ -24,7 +35,7 @@
24
35
  interrupted.
25
36
  Fixed immutable string issue in validations.
26
37
 
27
- 2012-01-09: Added automatic date/timestamp support for created_at and updated_at columns
38
+ 2013-01-09: Added automatic date/timestamp support for created_at and updated_at columns
28
39
  Added Hash extension except, Array introspection methods has_hash_key? and
29
40
  has_hash_value?
30
41
  Commit of Formotion module including optional inclusion/suppression of the
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source "https://rubygems.org"
2
+
3
+ gem "rake"
4
+ gem "motion-stump", '~>0.2'
data/README.md CHANGED
@@ -97,6 +97,7 @@ You can define your models and their schemas in Ruby. For example:
97
97
  ```ruby
98
98
  class Task
99
99
  include MotionModel::Model
100
+ include MotionModel::ArrayModelAdapter
100
101
 
101
102
  columns :name => :string,
102
103
  :description => :string,
@@ -117,6 +118,7 @@ Models support default values, so if you specify your model like this, you get d
117
118
  ```ruby
118
119
  class Task
119
120
  include MotionModel::Model
121
+ include MotionModel::ArrayModelAdapter
120
122
 
121
123
  columns :name => :string,
122
124
  :due_date => {:type => :date, :default => '2012-09-15'}
@@ -128,6 +130,7 @@ You can also include the `Validatable` module to get field validation. For examp
128
130
  ```ruby
129
131
  class Task
130
132
  include MotionModel::Model
133
+ include MotionModel::ArrayModelAdapter
131
134
  include MotionModel::Validatable
132
135
 
133
136
  columns :name => :string,
@@ -188,6 +191,7 @@ To use validations in your model, declare your model as follows:
188
191
  ```ruby
189
192
  class MyValidatableModel
190
193
  include MotionModel::Model
194
+ include MotionModel::ArrayModelAdapter
191
195
  include MotionModel::Validatable
192
196
 
193
197
  # All other model-y stuff here
@@ -317,12 +321,14 @@ Using MotionModel
317
321
  ```ruby
318
322
  class Task
319
323
  include MotionModel::Model
324
+ include MotionModel::ArrayModelAdapter
320
325
  columns :name => :string
321
326
  has_many :assignees
322
327
  end
323
328
 
324
329
  class Assignee
325
330
  include MotionModel::Model
331
+ include MotionModel::ArrayModelAdapter
326
332
  columns :assignee_name => :string
327
333
  belongs_to :task
328
334
  end
@@ -361,6 +367,7 @@ The key to how the `destroy` variants work in how the relation is declared. You
361
367
  ```ruby
362
368
  class Task
363
369
  include MotionModel::Model
370
+ include MotionModel::ArrayModelAdapter
364
371
  columns :name => :string
365
372
  has_many :assignees
366
373
  end
@@ -388,6 +395,43 @@ related to the assignees remains intact.
388
395
 
389
396
  Note: This syntax is modeled on the Rails `:dependent => :destroy` options in `ActiveRecord`.
390
397
 
398
+ ## Hook Methods
399
+
400
+ During a save or delete operation, hook methods are called to allow you a chance to modify the
401
+ object at that point. These hook methods are:
402
+
403
+ ```ruby
404
+ before_save(sender)
405
+ after_save(sender)
406
+ before_delete(sender)
407
+ after_delete(sender)
408
+ ```
409
+
410
+ MotionModel makes no distinction between destroy and delete when calling hook methods, as it only
411
+ calls them when the actual object is deleted. In a destroy operation, during the cascading delete,
412
+ the delete hooks are called (again) at the point of object deletion.
413
+
414
+ Note that the method signatures may be different from previous implementations. No longer can you
415
+ declare a hook method without the `sender` argument.
416
+
417
+ Finally, contrasting hook methods with notifications, the hook methods `before_save` and `after_save`
418
+ are called before the save operation begins and after it completes. However, the notification (covered
419
+ below) is only issued after the save operation. However... the notification understands whether the
420
+ operation was a save or update. Rule of thumb: If you want to catch an operation before it begins,
421
+ use the hook. If you just want to know about it when it happens, use the notification.
422
+
423
+ The delete hooks happen around the delete operation and, again, allow you the option to mess with the
424
+ object before you allow the process to go forward (pretty much, the `before_delete` hook does this).
425
+
426
+ *IMPORTANT*: Returning false in a before hook stops the rest of the operation. So, for example, you
427
+ could prevent the deletion of the last admin by writing something like this:
428
+
429
+ ```ruby
430
+ def before_delete(sender)
431
+ return false if sender.find(:privilege_level).eq('admin').count < 2
432
+ end
433
+ ```
434
+
391
435
  ## Transactions and Undo/Cancel
392
436
 
393
437
  MotionModel is not ActiveRecord. MotionModel is not a database-backed mapper. The bottom line is that when you change a field in a model, even if you don't save it, you are partying on the central object store. In part, this is because Ruby copies objects by reference, so when you do a find, you get a reference to the object *in the central object store*.
@@ -460,12 +504,14 @@ end
460
504
  ```ruby
461
505
  class Task
462
506
  include MotionModel::Model
507
+ include MotionModel::ArrayModelAdapter
463
508
  has_many :assignees
464
509
  # etc
465
510
  end
466
511
 
467
512
  class Assignee
468
513
  include MotionModel::Model
514
+ include MotionModel::ArrayModelAdapter
469
515
  belongs_to :task
470
516
  # etc
471
517
  end
data/Rakefile CHANGED
@@ -2,10 +2,13 @@
2
2
  require "bundler/gem_tasks"
3
3
  $:.unshift("/Library/RubyMotion/lib")
4
4
  require 'motion/project'
5
+ require 'bundler'
6
+ Bundler.require
5
7
 
6
8
  Motion::Project::App.setup do |app|
7
9
  # Use `rake config' to see complete project settings.
10
+ app.name = 'MotionModel'
8
11
  app.delegate_class = 'FakeDelegate'
9
- app.files = Dir.glob('./lib/motion_model/**/*.rb')
12
+ app.files = Dir.glob('./lib/motion_model/**/*.rb') + app.files
10
13
  app.files = (app.files + Dir.glob('./app/**/*.rb')).uniq
11
14
  end
@@ -7,18 +7,20 @@ module MotionModel
7
7
  def self.included(base)
8
8
  base.extend(PrivateClassMethods)
9
9
  base.extend(PublicClassMethods)
10
- base.instance_variable_set("@collection", []) # Actual data
10
+ base.instance_eval do
11
+ _reset_next_id
12
+ end
11
13
  end
12
14
 
13
15
  module PublicClassMethods
14
-
15
- def collection
16
+ def collection
16
17
  @collection ||= []
17
18
  end
18
19
 
19
20
  def insert(object)
20
21
  collection << object
21
22
  end
23
+ alias :<< :insert
22
24
 
23
25
  def length
24
26
  collection.length
@@ -32,11 +34,8 @@ module MotionModel
32
34
  # Do each delete so any on_delete and
33
35
  # cascades are called, then empty the
34
36
  # collection and compact the array.
35
- bulk_update do
36
- collection.each{|item| item.delete}
37
- end
38
- @collection = []
39
- @_next_id = 1
37
+ bulk_update { collection.pop.delete until collection.empty? }
38
+ _reset_next_id
40
39
  end
41
40
 
42
41
  # Finds row(s) within the data store. E.g.,
@@ -59,36 +58,40 @@ module MotionModel
59
58
  return collection.select{|element| element.id == target_id}.first
60
59
  end
61
60
 
62
- ArrayFinderQuery.new(args[0].to_sym, @collection)
61
+ ArrayFinderQuery.new(args[0].to_sym, collection)
63
62
  end
64
63
  alias_method :where, :find
65
64
 
65
+ def find_by_id(id)
66
+ find(:id).eq(id).first
67
+ end
68
+
66
69
  # Returns query result as an array
67
70
  def all
68
71
  collection
69
72
  end
70
73
 
71
74
  def order(field_name = nil, &block)
72
- ArrayFinderQuery.new(@collection).order(field_name, &block)
75
+ ArrayFinderQuery.new(collection).order(field_name, &block)
73
76
  end
74
77
 
75
78
  end
76
79
 
77
80
  module PrivateClassMethods
81
+ private
78
82
 
79
83
  # Returns next available id
80
- def next_id #nodoc
84
+ def _next_id #nodoc
81
85
  @_next_id
82
86
  end
83
87
 
84
- # Sets next available id
85
- def next_id=(value) #nodoc
86
- @_next_id = value
88
+ def _reset_next_id
89
+ @_next_id = 1
87
90
  end
88
91
 
89
92
  # Increments next available id
90
- def increment_id #nodoc
91
- @_next_id += 1
93
+ def increment_next_id(other_id) #nodoc
94
+ @_next_id = [@_next_id, other_id.to_i].max + 1
92
95
  end
93
96
 
94
97
  end
@@ -97,6 +100,10 @@ module MotionModel
97
100
  assign_id(options)
98
101
  end
99
102
 
103
+ def increment_next_id(other_id)
104
+ self.class.send(:increment_next_id, other_id)
105
+ end
106
+
100
107
  # Undelete does pretty much as its name implies. However,
101
108
  # the natural sort order is not preserved. IMPORTANT: If
102
109
  # you are trying to undo a cascading delete, this will not
@@ -104,7 +111,11 @@ module MotionModel
104
111
 
105
112
  def undelete
106
113
  collection << self
107
- self.class.issue_notification(self, :action => 'add')
114
+ issue_notification(:action => 'add')
115
+ end
116
+
117
+ def collection #nodoc
118
+ self.class.collection
108
119
  end
109
120
 
110
121
  # This adds to the ArrayStore without the magic date
@@ -122,32 +133,45 @@ module MotionModel
122
133
  end
123
134
  alias_method :count, :length
124
135
 
136
+ def rebuild_relation_for(name, instance_or_collection) # nodoc
137
+ end
138
+
125
139
  private
126
140
 
141
+ def _next_id
142
+ self.class.send(:_next_id)
143
+ end
144
+
127
145
  def assign_id(options) #nodoc
128
- unless options[:id]
129
- options[:id] = self.class.next_id
130
- else
131
- self.class.next_id = [options[:id].to_i, self.class.next_id].max
132
- end
133
- self.class.increment_id
146
+ options[:id] ||= _next_id
147
+ increment_next_id(options[:id])
134
148
  end
135
149
 
136
- def collection #nodoc
137
- self.class.instance_variable_get('@collection')
150
+ def relation_for(col) # nodoc
151
+ col = column_named(col)
152
+ related_klass = col.classify
153
+
154
+ case col.type
155
+ when :belongs_to
156
+ related_klass.find(@data[:id])
157
+ when :has_many
158
+ related_klass.find(generate_belongs_to_id(self.class)).belongs_to(self, related_klass).eq(@data[:id])
159
+ else
160
+ nil
161
+ end
138
162
  end
139
163
 
140
- def do_insert
164
+ def do_insert(options = {})
141
165
  collection << self
142
166
  end
143
167
 
144
- def do_update
168
+ def do_update(options = {})
145
169
  end
146
170
 
147
171
  def do_delete
148
172
  target_index = collection.index{|item| item.id == self.id}
149
173
  collection.delete_at(target_index) unless target_index.nil?
150
- self.class.issue_notification(self, :action => 'delete')
174
+ issue_notification(:action => 'delete')
151
175
  end
152
176
 
153
177
  end
@@ -0,0 +1,141 @@
1
+ module MotionModel
2
+ module ArrayModelAdapter
3
+ class PersistFileError < Exception; end
4
+ class VersionNumberError < ArgumentError; end
5
+
6
+ module PublicClassMethods
7
+
8
+ def validate_schema_version(version_number)
9
+ raise MotionModel::ArrayModelAdapter::VersionNumberError.new('version number must be a string') unless version_number.is_a?(String)
10
+ if version_number !~ /^[\d.]+$/
11
+ raise MotionModel::ArrayModelAdapter::VersionNumberError.new('version number string must contain only numbers and periods')
12
+ end
13
+ end
14
+
15
+ # Declare a version number for this schema. For example:
16
+ #
17
+ # class Task
18
+ # include MotionModel::Model
19
+ # include MotionModel::ArrayModelAdapter
20
+ #
21
+ # version_number 1.0.1
22
+ # end
23
+ #
24
+ # When a version number mismatch occurs as an individual row is loaded
25
+ # from persistent storage, the migrate method is invoked, allowing
26
+ # you to programmatically migrate on a per-row basis.
27
+
28
+ def schema_version(*version_number)
29
+ if version_number.empty?
30
+ return @schema_version
31
+ else
32
+ validate_schema_version(version_number[0])
33
+ @schema_version = version_number[0]
34
+ end
35
+ end
36
+
37
+ def migrate
38
+ end
39
+
40
+
41
+ # Returns the unarchived object if successful, otherwise false
42
+ #
43
+ # Note that subsequent calls to serialize/deserialize methods
44
+ # will remember the file name, so they may omit that argument.
45
+ #
46
+ # Raises a +MotionModel::PersistFileFailureError+ on failure.
47
+ def deserialize_from_file(file_name = nil)
48
+ if schema_version != '1.0.0'
49
+ migrate
50
+ end
51
+
52
+ @file_name = file_name if file_name
53
+
54
+ if File.exist? documents_file(@file_name)
55
+ error_ptr = Pointer.new(:object)
56
+
57
+ data = NSData.dataWithContentsOfFile(documents_file(@file_name), options:NSDataReadingMappedIfSafe, error:error_ptr)
58
+
59
+ if data.nil?
60
+ error = error_ptr[0]
61
+ raise MotionModel::PersistFileError.new "Error when reading the data: #{error}"
62
+ else
63
+ bulk_update do
64
+ NSKeyedUnarchiver.unarchiveObjectWithData(data)
65
+ end
66
+ return self
67
+ end
68
+ else
69
+ return false
70
+ end
71
+ end
72
+ # Serializes data to a persistent store (file, in this
73
+ # terminology). Serialization is synchronous, so this
74
+ # will pause your run loop until complete.
75
+ #
76
+ # +file_name+ is the name of the persistent store you
77
+ # want to use. If you omit this, it will use the last
78
+ # remembered file name.
79
+ #
80
+ # Raises a +MotionModel::PersistFileError+ on failure.
81
+ def serialize_to_file(file_name = nil)
82
+ @file_name = file_name if file_name
83
+ error_ptr = Pointer.new(:object)
84
+
85
+ data = NSKeyedArchiver.archivedDataWithRootObject collection
86
+ unless data.writeToFile(documents_file(@file_name), options: NSDataWritingAtomic, error: error_ptr)
87
+ # De-reference the pointer.
88
+ error = error_ptr[0]
89
+
90
+ # Now we can use the `error' object.
91
+ raise MotionModel::PersistFileError.new "Error when writing data: #{error}"
92
+ end
93
+ end
94
+
95
+
96
+ def documents_file(file_name)
97
+ file_path = File.join NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, true), file_name
98
+ file_path
99
+ end
100
+ end
101
+
102
+ def initWithCoder(coder)
103
+ self.init
104
+
105
+ new_tag_id = 1
106
+ columns.each do |attr|
107
+ next if has_relation?(attr)
108
+ # If a model revision has taken place, don't try to decode
109
+ # something that's not there.
110
+ if coder.containsValueForKey(attr.to_s)
111
+ value = coder.decodeObjectForKey(attr.to_s)
112
+ self.send("#{attr}=", value)
113
+ else
114
+ self.send("#{attr}=", nil)
115
+ end
116
+
117
+ # re-issue tags to make sure they are unique
118
+ @tag = new_tag_id
119
+ new_tag_id += 1
120
+ end
121
+ add_to_store
122
+
123
+ self
124
+ end
125
+
126
+ # Follow Apple's recommendation not to encode missing
127
+ # values.
128
+ def encodeWithCoder(coder)
129
+ columns.each do |attr|
130
+ # Serialize attributes except the proxy has_many and belongs_to ones.
131
+ unless [:belongs_to, :has_many].include? column_named(attr).type
132
+ value = self.send(attr)
133
+ unless value.nil?
134
+ coder.encodeObject(value, forKey: attr.to_s)
135
+ end
136
+ end
137
+ end
138
+ end
139
+
140
+ end
141
+ end
@@ -351,3 +351,20 @@ def UIInterfaceOrientationIsPortrait(orientation)
351
351
  orientation == UIInterfaceOrientationPortrait ||
352
352
  orientation == UIInterfaceOrientationPortraitUpsideDown
353
353
  end
354
+
355
+ class Module
356
+ # Retrieve a constant within its scope
357
+ def deep_const_get(const)
358
+ if Symbol === const
359
+ const = const.to_s
360
+ else
361
+ const = const.to_str.dup
362
+ end
363
+ if const.sub!(/^::/, '')
364
+ base = Object
365
+ else
366
+ base = self
367
+ end
368
+ const.split(/::/).inject(base) { |mod, name| mod.const_get(name) }
369
+ end
370
+ end
@@ -161,9 +161,14 @@ module MotionModel
161
161
 
162
162
  # returns all elements that match as an array.
163
163
  def all
164
- @collection
164
+ to_a
165
165
  end
166
166
 
167
+ # returns all elements that match as an array.
168
+ def to_a
169
+ @collection
170
+ end
171
+
167
172
  # each is a shortcut method to turn a query into an iterator. It allows
168
173
  # you to write code like:
169
174
  #
@@ -4,14 +4,14 @@ module MotionModel
4
4
  attr_accessor :name
5
5
  attr_accessor :type
6
6
  attr_accessor :default
7
- attr_accessor :destroy
7
+ attr_accessor :dependent
8
8
 
9
9
  def initialize(name = nil, type = nil, options = {})
10
10
  @name = name
11
11
  @type = type
12
12
  raise RuntimeError.new "columns need a type declared." if type.nil?
13
13
  @default = options.delete :default
14
- @destroy = options.delete :dependent
14
+ @dependent = options.delete :dependent
15
15
  @options = options
16
16
  end
17
17
 
@@ -19,16 +19,32 @@ module MotionModel
19
19
  @options
20
20
  end
21
21
 
22
+ def class_name
23
+ @options[:joined_class_name] || @name
24
+ end
25
+
22
26
  def classify
23
- case @type
24
- when :belongs_to
25
- @klass ||= Object.const_get(@name.to_s.camelize)
26
- when :has_many
27
- @klass ||= Object.const_get(@name.to_s.singularize.camelize)
27
+ if @options[:class]
28
+ @options[:class]
28
29
  else
29
- raise "#{@name} is not a relation. This isn't supposed to happen."
30
+ case @type
31
+ when :belongs_to
32
+ @klass ||= Object.const_get(class_name.to_s.camelize)
33
+ when :has_many, :has_one
34
+ @klass ||= Object.const_get(class_name.to_s.singularize.camelize)
35
+ else
36
+ raise "#{@name} is not a relation. This isn't supposed to happen."
37
+ end
30
38
  end
31
39
  end
40
+
41
+ def class_const_get
42
+ Kernel::const_get(classify)
43
+ end
44
+
45
+ def through_class
46
+ Kernel::const_get(@options[:through].to_s.classify)
47
+ end
32
48
  end
33
49
  end
34
50
  end
@@ -16,7 +16,7 @@ module MotionModel
16
16
  def should_return(column) #nodoc
17
17
  skippable = [:id]
18
18
  skippable += [:created_at, :updated_at] unless @expose_auto_date_fields
19
- !skippable.include?(column) && !self.class.relation_column?(column)
19
+ !skippable.include?(column) && !relation_column?(column)
20
20
  end
21
21
 
22
22
  def returnable_columns #nodoc
@@ -26,14 +26,14 @@ module MotionModel
26
26
  def default_hash_for(column, value)
27
27
  {:key => column.to_sym,
28
28
  :title => column.to_s.humanize,
29
- :type => FORMOTION_MAP[type(column)],
29
+ :type => FORMOTION_MAP[column_type(column)],
30
30
  :placeholder => column.to_s.humanize,
31
31
  :value => value
32
32
  }
33
33
  end
34
34
 
35
35
  def is_date_time?(column)
36
- column_type = type(column)
36
+ column_type = column_type(column)
37
37
  [:date, :time].include?(column_type)
38
38
  end
39
39
 
@@ -78,7 +78,7 @@ module MotionModel
78
78
  # you say so, offering you the opportunity to validate your form data.
79
79
  def from_formotion!(data)
80
80
  self.returnable_columns.each{|column|
81
- if data[column] && type(column) == :date || type(column) == :time
81
+ if data[column] && column_type(column) == :date || column_type(column) == :time
82
82
  data[column] = Time.at(data[column]) unless data[column].nil?
83
83
  end
84
84
  value = self.send("#{column}=", data[column])