motion_model 0.4.1 → 0.4.2

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