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.
@@ -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