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.
@@ -41,26 +41,31 @@ module MotionModel
41
41
  class PersistFileError < Exception; end
42
42
  class RelationIsNilError < Exception; end
43
43
  class AdapterNotFoundError < Exception; end
44
+ class RecordNotSaved < Exception; end
44
45
 
45
46
  module Model
46
47
  def self.included(base)
47
48
  base.extend(PrivateClassMethods)
48
49
  base.extend(PublicClassMethods)
49
- base.instance_variable_set("@_columns", []) # Columns in model
50
- base.instance_variable_set("@_column_hashes", {}) # Hashes to for quick column lookup
51
- base.instance_variable_set("@_relations", {}) # relations
52
- base.instance_variable_set("@_next_id", 1) # Next assignable id
53
- base.instance_variable_set("@_issue_notifications", true) # Next assignable id
54
50
  end
55
51
 
56
52
  module PublicClassMethods
53
+
54
+ def new(options = {})
55
+ object_class = options[:inheritance_type] ? Kernel.const_get(options[:inheritance_type]) : self
56
+ object_class.alloc.instance_eval do
57
+ initialize(options)
58
+ self
59
+ end
60
+ end
61
+
57
62
  # Use to do bulk insertion, updating, or deleting without
58
63
  # making repeated calls to a delegate. E.g., when syncing
59
64
  # with an external data source.
60
65
  def bulk_update(&block)
61
- @_issue_notifications = false
66
+ self._issue_notifications = false
62
67
  class_eval &block
63
- @_issue_notifications = true
68
+ self._issue_notifications = true
64
69
  end
65
70
 
66
71
  # Macro to define names and types of columns. It can be used in one of
@@ -79,7 +84,7 @@ module MotionModel
79
84
  # columns :name, :age, :hobby
80
85
 
81
86
  def columns(*fields)
82
- return @_columns.map{|c| c.name} if fields.empty?
87
+ return _columns.map{|c| c.name} if fields.empty?
83
88
 
84
89
  case fields.first
85
90
  when Hash
@@ -90,7 +95,7 @@ module MotionModel
90
95
  raise ArgumentError.new("arguments to `columns' must be a symbol, a hash, or a hash of hashes -- was #{fields.first}.")
91
96
  end
92
97
 
93
- unless self.respond_to?(:id)
98
+ unless columns.include?(:id)
94
99
  add_field(:id, :integer)
95
100
  end
96
101
  end
@@ -120,6 +125,11 @@ module MotionModel
120
125
  add_field relation, :has_many, options # Relation must be plural
121
126
  end
122
127
 
128
+ def has_one(relation, options = {})
129
+ raise ArgumentError.new("arguments to has_one must be a symbol or string.") unless [Symbol, String].include? relation.class
130
+ add_field relation, :has_one, options # Relation must be plural
131
+ end
132
+
123
133
  def generate_belongs_to_id(relation)
124
134
  (relation.to_s.singularize.underscore + '_id').to_sym
125
135
  end
@@ -135,26 +145,43 @@ module MotionModel
135
145
  # Allows code like this:
136
146
  #
137
147
  # Assignee.find(:assignee_name).like('smith').first.task
138
- def belongs_to(relation)
139
- add_field relation, :belongs_to
140
- add_field generate_belongs_to_id(relation), :belongs_to_id # a relation is singular.
148
+ def belongs_to(relation, options = {})
149
+ add_field relation, :belongs_to, options
141
150
  end
142
151
 
143
152
  # Returns true if a column exists on this model, otherwise false.
144
153
  def column?(column)
145
- respond_to?(column)
154
+ !column_named(column).nil?
146
155
  end
147
156
 
148
157
  # Returns type of this column.
149
- def type(column)
158
+ def column_type(column)
150
159
  column_named(column).type || nil
151
160
  end
152
161
 
162
+ def has_many_columns
163
+ _column_hashes.select { |name, col| col.type == :has_many}
164
+ end
165
+
166
+ def has_one_columns
167
+ _column_hashes.select { |name, col| col.type == :has_one}
168
+ end
169
+
170
+ def belongs_to_columns
171
+ _column_hashes.select { |name, col| col.type == :belongs_to}
172
+ end
173
+
174
+ def association_columns
175
+ _column_hashes.select { |name, col| [:belongs_to, :has_many, :has_one].include?(col.type)}
176
+ end
177
+
153
178
  # returns default value for this column or nil.
154
179
  def default(column)
155
- column_named(column).default || nil
180
+ col = column_named(column)
181
+ col.nil? ? nil : col.default
156
182
  end
157
183
 
184
+ # Build an instance that represents a saved object from the persistence layer.
158
185
  def read(attrs)
159
186
  new(attrs).instance_eval do
160
187
  @new_record = false
@@ -163,6 +190,12 @@ module MotionModel
163
190
  end
164
191
  end
165
192
 
193
+ def create!(options)
194
+ result = create(options)
195
+ raise RecordNotSaved unless result
196
+ result
197
+ end
198
+
166
199
  # Creates an object and saves it. E.g.:
167
200
  #
168
201
  # @bob = Person.create(:name => 'Bob', :hobby => 'Bird Watching')
@@ -176,12 +209,12 @@ module MotionModel
176
209
 
177
210
  # Destroys all rows in the model -- before_delete and after_delete
178
211
  # hooks are called and deletes are not cascading if declared with
179
- # :delete => destroy in the has_many macro.
212
+ # :dependent => :destroy in the has_many macro.
180
213
  def destroy_all
181
214
  ids = self.all.map{|item| item.id}
182
215
  bulk_update do
183
216
  ids.each do |item|
184
- find(item).destroy
217
+ find_by_id(item).destroy
185
218
  end
186
219
  end
187
220
  # Note collection is not emptied, and next_id is not reset.
@@ -208,6 +241,27 @@ module MotionModel
208
241
  end
209
242
 
210
243
  module PrivateClassMethods
244
+
245
+ private
246
+
247
+ # Hashes to for quick column lookup
248
+ def _column_hashes
249
+ @_column_hashes ||= {}
250
+ end
251
+
252
+ @_issue_notifications = true
253
+ def _issue_notifications
254
+ @_issue_notifications
255
+ end
256
+
257
+ def _issue_notifications=(value)
258
+ @_issue_notifications = value
259
+ end
260
+
261
+ def _columns
262
+ _column_hashes.values
263
+ end
264
+
211
265
  # This populates a column from something like:
212
266
  #
213
267
  # columns :name => :string, :age => :integer
@@ -242,65 +296,168 @@ module MotionModel
242
296
  end
243
297
 
244
298
  def issue_notification(object, info) #nodoc
245
- if @_issue_notifications == true && !object.nil?
299
+ if _issue_notifications == true && !object.nil?
246
300
  NSNotificationCenter.defaultCenter.postNotificationName('MotionModelDataDidChangeNotification', object: object, userInfo: info)
247
301
  end
248
302
  end
249
303
 
250
- def define_accessor_methods(name) #nodoc
251
- define_method(name.to_sym) {
252
- @data[name]
253
- }
304
+ def define_accessor_methods(name, type, options = {}) #nodoc
305
+ unless alloc.respond_to?(name.to_sym)
306
+ define_method(name.to_sym) {
307
+ return nil if @data[name].nil?
308
+ if options[:symbolize]
309
+ @data[name].to_sym
310
+ else
311
+ @data[name]
312
+ end
313
+ }
314
+ end
254
315
  define_method("#{name}=".to_sym) { |value|
255
- @data[name] = cast_to_type(name, value)
256
- @dirty = true
316
+ old_value = @data[name]
317
+ new_value = cast_to_type(name, value)
318
+ if new_value != old_value
319
+ @data[name] = new_value
320
+ @dirty = true
321
+ end
257
322
  }
258
323
  end
259
324
 
260
325
  def define_belongs_to_methods(name) #nodoc
326
+ col = column_named(name)
327
+
261
328
  define_method(name) {
262
- col = column_named(name)
263
- parent_id = @data[self.class.generate_belongs_to_id(col.name)]
264
- col.classify.find(parent_id)
329
+ return @data[name] if @data[name]
330
+ if col.options[:polymorphic]
331
+ if (owner_class_name = send("#{name}_type"))
332
+ owner_class = Kernel::deep_const_get(owner_class_name.classify)
333
+ parent_id = send("#{name}_id")
334
+ end
335
+ else
336
+ owner_class = col.classify
337
+ parent_id = send(self.class.generate_belongs_to_id(col.name))
338
+ end
339
+ parent_id.nil? ? nil : owner_class.find_by_id(parent_id)
340
+ }
341
+
342
+ define_method("#{name}_relation") {
343
+ relation_for(name)
265
344
  }
266
- define_method("#{name}=") { |value|
267
- col = column_named(name)
268
- parent_id = self.class.generate_belongs_to_id(col.name)
269
- @data[parent_id.to_sym] = value.to_i
345
+
346
+ # Associate the parent and delegate the inverse assignment
347
+ define_method("#{name}=") { |parent|
348
+ rebuild_relation_for(name, parent)
349
+ send("set_#{name}", parent)
350
+ if col.options[:polymorphic]
351
+ foreign_column_name = parent.column_as_name(col.options[:as] || col.name)
352
+ else
353
+ foreign_column_name = self.class.name.underscore.to_sym
354
+ end
355
+ parent.rebuild_relation_for(foreign_column_name, self) if parent
356
+ }
357
+
358
+ # Associate the parent but without delegating the inverse assignment
359
+ define_method("set_#{name}") { |parent|
360
+ @data[name] = parent
361
+ if col.options[:polymorphic]
362
+ send("#{name}_type=", parent.class.name)
363
+ send("#{name}_id=", parent.id)
364
+ else
365
+ parent_id_name = self.class.generate_belongs_to_id(col.name)
366
+ send("#{parent_id_name}=", parent ? parent.id : nil)
367
+ end
270
368
  }
369
+
370
+ # TODO also define #{name}+id= methods....
371
+
372
+ if col.options[:polymorphic]
373
+ add_field "#{name}_type", :belongs_to_type
374
+ add_field "#{name}_id", :belongs_to_id
375
+ else
376
+ add_field generate_belongs_to_id(name), :belongs_to_id # a relation is singular.
377
+ end
271
378
  end
272
379
 
273
380
  def define_has_many_methods(name) #nodoc
381
+ col = column_named(name)
382
+
383
+ define_method("#{name}_relation") {
384
+ relation_for(name)
385
+ }
386
+
274
387
  define_method(name) {
388
+ send("#{name}_relation").to_a
389
+ }
390
+
391
+ define_method("#{name}=") do |collection|
392
+ rebuild_relation_for(name, collection)
393
+ collection.each do |instance|
394
+ if col.options[:polymorphic]
395
+ foreign_column_name = col.options[:as] || col.name
396
+ else
397
+ foreign_column_name = self.class.name.underscore.to_sym
398
+ end
399
+ instance.send("set_#{foreign_column_name}", self)
400
+ instance.rebuild_relation_for(foreign_column_name, self)
401
+ end
402
+ end
403
+
404
+ end
405
+
406
+ def define_has_one_methods(name) #nodoc
407
+ col = column_named(name)
408
+
409
+ define_method("#{name}_relation") {
275
410
  relation_for(name)
276
411
  }
412
+
413
+ define_method(name) {
414
+ send("#{name}_relation").instance
415
+ }
416
+
417
+ define_method("#{name}=") do |instance|
418
+ relation_for(name).instance = instance
419
+ if instance
420
+ if col.options[:polymorphic]
421
+ foreign_column_name = col.options[:as] || col.name
422
+ else
423
+ foreign_column_name = self.class.name.underscore.to_sym
424
+ end
425
+ instance.rebuild_relation_for(foreign_column_name, self)
426
+ end
427
+ end
277
428
  end
278
429
 
279
430
  def add_field(name, type, options = {:default => nil}) #nodoc
280
431
  col = Column.new(name, type, options)
281
432
 
282
- @_columns.push col
283
- @_column_hashes[col.name.to_sym] = col
433
+ _column_hashes[col.name.to_sym] = col
284
434
 
285
435
  case type
286
- when :has_many then define_has_many_methods(name)
287
- when :belongs_to then define_belongs_to_methods(name)
288
- else
289
- define_accessor_methods(name)
436
+ when :has_many then define_has_many_methods(name)
437
+ when :has_one then define_has_one_methods(name)
438
+ when :belongs_to then define_belongs_to_methods(name)
439
+ else define_accessor_methods(name, type, options)
290
440
  end
291
441
  end
292
442
 
293
443
  # Returns a column denoted by +name+
294
444
  def column_named(name) #nodoc
295
- @_column_hashes[name.to_sym]
445
+ _column_hashes[name.to_sym]
296
446
  end
297
447
 
448
+ # Returns the column that has the name as its :as option
449
+ def column_as(name) #nodoc
450
+ _column_hashes.values.find{ |c| c.options[:as] == name }
451
+ end
452
+
453
+ # All relation columns, including type and id columns for polymorphic associations
298
454
  def relation_column?(column) #nodoc
299
- [:belongs_to, :belongs_to_id, :has_many].include? column_named(column).type
455
+ [:belongs_to, :belongs_to_id, :belongs_to_type, :has_many, :has_one].include? column_named(column).type
300
456
  end
301
457
 
302
- def virtual_relation_column?(column) #nodoc
303
- [:belongs_to, :has_many].include? column_named(column).type
458
+ # Polymorphic association columns that are not stored in DB
459
+ def virtual_polymorphic_relation_column?(column) #nodoc
460
+ [:belongs_to, :has_many, :has_one].include? column_named(column).type
304
461
  end
305
462
 
306
463
  def has_relation?(col) #nodoc
@@ -312,7 +469,7 @@ module MotionModel
312
469
  else
313
470
  column_named(col)
314
471
  end
315
- col.type == :has_many || col.type == :belongs_to
472
+ [:has_many, :has_one, :belongs_to].include?(col.type)
316
473
  end
317
474
 
318
475
  end
@@ -321,24 +478,73 @@ module MotionModel
321
478
  raise AdapterNotFoundError.new("You must specify a persistence adapter.") unless self.respond_to? :adapter
322
479
 
323
480
  @data ||= {}
324
- before_initialize(options)
481
+ before_initialize(options) if respond_to?(:before_initialize)
325
482
 
483
+ # Gather defaults
326
484
  columns.each do |col|
327
- unless self.class.relation_column?(col) # all data columns
328
- initialize_data_columns col, options
329
- else
330
- @data[col] = options[col] if column_named(col).type == :belongs_to_id
331
- end
485
+ next if options.has_key?(col)
486
+ next if relation_column?(col)
487
+ default = self.class.default(col)
488
+ options[col] = default unless default.nil?
489
+ end
490
+
491
+ options.each do |col, value|
492
+ initialize_data_columns col, value
332
493
  end
333
494
 
334
495
  @dirty = true
335
496
  @new_record = true
336
497
  end
337
498
 
499
+ # String uniquely identifying a saved model instance in memory
500
+ def object_identifier
501
+ ["#{self.class.name}", (id.nil? ? nil : "##{id}"), ":0x#{self.object_id.to_s(16)}"].join
502
+ end
503
+
504
+ # String uniquely identifying a saved model instance
505
+ def model_identifier
506
+ raise 'Invalid' unless id
507
+ "#{self.class.name}##{id}"
508
+ end
509
+
338
510
  def new_record?
339
511
  @new_record
340
512
  end
341
513
 
514
+ # Returns true if +comparison_object+ is the same exact object, or +comparison_object+
515
+ # is of the same type and +self+ has an ID and it is equal to +comparison_object.id+.
516
+ #
517
+ # Note that new records are different from any other record by definition, unless the
518
+ # other record is the receiver itself. Besides, if you fetch existing records with
519
+ # +select+ and leave the ID out, you're on your own, this predicate will return false.
520
+ #
521
+ # Note also that destroying a record preserves its ID in the model instance, so deleted
522
+ # models are still comparable.
523
+ def ==(comparison_object)
524
+ super ||
525
+ comparison_object.instance_of?(self.class) &&
526
+ id.present? &&
527
+ comparison_object.id == id
528
+ end
529
+ alias :eql? :==
530
+
531
+ def attributes
532
+ @data
533
+ end
534
+
535
+ def attributes=(attrs)
536
+ attrs.each { |k, v| send("#{k}=", v) }
537
+ end
538
+
539
+ def update_attributes(attrs)
540
+ self.attributes = attrs
541
+ save
542
+ end
543
+
544
+ def read_attribute(name)
545
+ @data[name]
546
+ end
547
+
342
548
  # Default to_i implementation returns value of id column, much as
343
549
  # in Rails.
344
550
 
@@ -352,37 +558,52 @@ module MotionModel
352
558
  columns.each{|c| "#{c}: #{self.send(c)}\n"}
353
559
  end
354
560
 
561
+ def save!(options = {})
562
+ result = save(options)
563
+ raise RecordNotSaved unless result
564
+ result
565
+ end
566
+
355
567
  # Save current object. Speaking from the context of relational
356
568
  # databases, this inserts a row if it's a new one, or updates
357
569
  # in place if not.
358
- def save(*)
570
+ def save(options = {})
571
+ save_without_transaction(options)
572
+ end
573
+
574
+ # Performs the save.
575
+ # This is separated to allow #save to do any transaction handling that might be necessary.
576
+ def save_without_transaction(options = {})
577
+ return false if @deleted
359
578
  call_hooks 'save' do
360
579
  # Existing object implies update in place
361
580
  action = 'add'
362
- set_auto_date_field 'created_at'
363
- if @new_record
364
- do_insert
581
+ set_auto_date_field 'updated_at'
582
+ if new_record?
583
+ set_auto_date_field 'created_at'
584
+ result = do_insert(options)
365
585
  else
366
- do_update
367
- set_auto_date_field 'updated_at'
586
+ result = do_update(options)
368
587
  action = 'update'
369
588
  end
370
589
  @new_record = false
371
590
  @dirty = false
372
- self.class.issue_notification(self, :action => action)
591
+ issue_notification(:action => action)
592
+ result
373
593
  end
374
594
  end
375
595
 
376
596
  # Set created_at and updated_at fields
377
597
  def set_auto_date_field(field_name)
378
- self.send("#{field_name}=", Time.now) if self.respond_to? field_name
598
+ method = "#{field_name}="
599
+ self.send(method, Time.now) if self.respond_to?(method)
379
600
  end
380
601
 
381
602
  # Stub methods for hook protocols
382
- def before_save(*); end
383
- def after_save(*); end
384
- def before_delete(*); end
385
- def after_delete(*); end
603
+ def before_save(sender); end
604
+ def after_save(sender); end
605
+ def before_delete(sender); end
606
+ def after_delete(sender); end
386
607
 
387
608
  def call_hook(hook_name, postfix)
388
609
  hook = "#{hook_name}_#{postfix}"
@@ -392,33 +613,50 @@ module MotionModel
392
613
  def call_hooks(hook_name, &block)
393
614
  result = call_hook('before', hook_name)
394
615
  # returning false from a before_ hook stops the process
395
- block.call if result != false && block_given?
396
- call_hook('after', hook_name)
616
+ result = block.call if result != false && block_given?
617
+ call_hook('after', hook_name) if result
618
+ result
397
619
  end
398
620
 
399
- def delete
400
- call_hooks('delete') { do_delete }
621
+ def delete(options = {})
622
+ return if @deleted
623
+ call_hooks('delete') do
624
+ options = options.dup
625
+ options[:omit_model_identifiers] ||= {}
626
+ options[:omit_model_identifiers][model_identifier] = self
627
+ do_delete
628
+ @deleted = true
629
+ end
401
630
  end
402
631
 
403
632
  # Destroys the current object. The difference between delete
404
633
  # and destroy is that destroy calls <tt>before_delete</tt>
405
634
  # and <tt>after_delete</tt> hooks. As well, it will cascade
406
635
  # into related objects, deleting them if they are related
407
- # using <tt>:delete => :destroy</tt> in the <tt>has_many</tt>
408
- # declaration
636
+ # using <tt>:dependent => :destroy</tt> in the <tt>has_many</tt>
637
+ # and <tt>has_one></tt> declarations
409
638
  #
410
639
  # Note: lifecycle hooks are only called when individual objects
411
640
  # are deleted.
412
- def destroy
413
- has_many_columns.each do |col|
414
- delete_candidates = self.send(col.name)
415
-
416
- delete_candidates.each do |candidate|
417
- candidate.delete if col.destroy == :delete
418
- candidate.destroy if col.destroy == :destroy
641
+ def destroy(options = {})
642
+ call_hooks 'destroy' do
643
+ options = options.dup
644
+ options[:omit_model_identifiers] ||= {}
645
+ options[:omit_model_identifiers][model_identifier] = self
646
+ self.class.association_columns.each do |name, col|
647
+ delete_candidates = self.send(name)
648
+ Array(delete_candidates).each do |candidate|
649
+ next if options[:omit_model_identifiers][candidate.model_identifier]
650
+ if col.dependent == :destroy
651
+ candidate.destroy(options)
652
+ elsif col.dependent == :delete
653
+ candidate.delete(options)
654
+ end
655
+ end
419
656
  end
657
+ delete
420
658
  end
421
- delete
659
+ self
422
660
  end
423
661
 
424
662
  # True if the column exists, otherwise false
@@ -432,8 +670,8 @@ module MotionModel
432
670
  end
433
671
 
434
672
  # Type of a given column
435
- def type(column_name)
436
- self.class.type(column_name)
673
+ def column_type(column_name)
674
+ self.class.column_type(column_name)
437
675
  end
438
676
 
439
677
  # Options hash for column, excluding the core
@@ -452,35 +690,65 @@ module MotionModel
452
690
  @dirty
453
691
  end
454
692
 
693
+ def set_dirty
694
+ @dirty = true
695
+ end
696
+
697
+ def column_as_name(name) #nodoc
698
+ self.class.send(:column_as, name.to_sym).try(:name)
699
+ end
700
+
455
701
  private
456
702
 
457
- def initialize_data_columns(column, options) #nodoc
458
- self.send("#{column}=".to_sym, options[column] || self.class.default(column))
703
+ def _column_hashes
704
+ self.class.send(:_column_hashes)
705
+ end
706
+
707
+ def relation_column?(col)
708
+ self.class.send(:relation_column?, col)
709
+ end
710
+
711
+ def virtual_polymorphic_relation_column?(col)
712
+ self.class.send(:virtual_polymorphic_relation_column?, col)
713
+ end
714
+
715
+ def has_relation?(col) #nodoc
716
+ self.class.send(:has_relation?, col)
717
+ end
718
+
719
+ def initialize_data_columns(column, value) #nodoc
720
+ self.attributes = {column => value || self.class.default(column)}
459
721
  end
460
722
 
461
723
  def column_named(name) #nodoc
462
- self.class.column_named(name.to_sym)
724
+ self.class.send(:column_named, name.to_sym)
463
725
  end
464
726
 
465
- def has_many_columns
466
- columns.map{|col| column_named(col)}.select{|col| col.type == :has_many}
727
+ def column_as(name) #nodoc
728
+ self.class.send(:column_as, name.to_sym)
467
729
  end
468
730
 
469
- def generate_belongs_to_id(class_or_column) # nodoc
731
+ def generate_belongs_to_id(class_or_column) # nodoc
470
732
  self.class.generate_belongs_to_id(self.class)
471
733
  end
472
734
 
473
- def relation_for(col) # nodoc
474
- col = column_named(col)
475
- related_klass = col.classify
735
+ def issue_notification(info) #nodoc
736
+ self.class.send(:issue_notification, self, info)
737
+ end
476
738
 
477
- case col.type
478
- when :belongs_to
479
- related_klass.find(@data[:id])
480
- when :has_many
481
- related_klass.find(generate_belongs_to_id(self.class)).belongs_to(self, related_klass).eq(@data[:id])
482
- else
483
- nil
739
+ def method_missing(sym, *args, &block)
740
+ if sym.to_s[-1] == '='
741
+ @data["#{sym.to_s.chop}".to_sym] = args.first
742
+ return args.first
743
+ else
744
+ return @data[sym] if @data && @data.has_key?(sym)
745
+ end
746
+ begin
747
+ r = super
748
+ rescue NoMethodError => exc
749
+ unless exc.to_s =~ /undefined method `(?:before|after)_/
750
+ raise
751
+ end
484
752
  end
485
753
  end
486
754