Skip to content

Commit e8955e5

Browse files
committed
Merge pull request #6 from tubergen/dirty
Add ActiveRecord::Dirty functionality to resources
2 parents ea9b473 + b665eba commit e8955e5

File tree

7 files changed

+128
-22
lines changed

7 files changed

+128
-22
lines changed

lib/protip/resource.rb

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@
1313
require 'active_model/translation'
1414
require 'active_model/errors'
1515

16+
require 'active_model/attribute_methods' # ActiveModel::Dirty depends on this
17+
require 'active_model/dirty'
18+
1619
require 'forwardable'
1720

1821
require 'protip/error'
@@ -129,14 +132,15 @@ def self.collection(resource_class, action, method, message, response_type)
129132
include ActiveModel::Validations
130133
include ActiveModel::Conversion
131134

135+
include ActiveModel::Dirty
136+
132137
included do
133138
extend ActiveModel::Naming
134139
extend ActiveModel::Translation
135140
extend Forwardable
136141

137142
def_delegator :@wrapper, :message
138143
def_delegator :@wrapper, :as_json
139-
def_delegator :@wrapper, :assign_attributes
140144
end
141145
module ClassMethods
142146

@@ -166,12 +170,23 @@ def resource(actions:, message:, query: nil)
166170
@message = message
167171
@message.descriptor.each do |field|
168172
def_delegator :@wrapper, :"#{field.name}"
169-
def_delegator :@wrapper, :"#{field.name}="
170173
if ::Protip::Wrapper.matchable?(field)
171174
def_delegator :@wrapper, :"#{field.name}?"
172175
end
176+
177+
define_method "#{field.name}=" do |new_value|
178+
old_wrapped_value = @wrapper.send(field.name)
179+
@wrapper.send("#{field.name}=", new_value)
180+
new_wrapped_value = @wrapper.send(field.name)
181+
182+
# needed for ActiveModel::Dirty
183+
send("#{field.name}_will_change!") if new_wrapped_value != old_wrapped_value
184+
end
173185
end
174186

187+
# needed for ActiveModel::Dirty
188+
define_attribute_methods @message.descriptor.map(&:name)
189+
175190
# Validate arguments
176191
actions.map!{|action| action.to_sym}
177192
(actions - %i(show index create update destroy)).each do |action|
@@ -258,6 +273,12 @@ def initialize(message_or_attributes = {})
258273
super()
259274
end
260275

276+
def assign_attributes(attributes)
277+
# the resource needs to call its own setters so that fields get marked as dirty
278+
attributes.each { |field_name, value| send("#{field_name}=", value) }
279+
nil # return nil to match ActiveRecord behavior
280+
end
281+
261282
def message=(message)
262283
@wrapper = Protip::Wrapper.new(message, self.class.converter)
263284
end
@@ -271,6 +292,7 @@ def save
271292
else
272293
create!
273294
end
295+
changes_applied
274296
rescue Protip::UnprocessableEntityError => error
275297
success = false
276298
error.errors.messages.each do |message|
@@ -298,5 +320,13 @@ def attributes
298320
def errors
299321
@errors ||= ActiveModel::Errors.new(self)
300322
end
323+
324+
private
325+
326+
# needed for ActiveModel::Dirty
327+
def changes_applied
328+
@previously_changed = changes
329+
@changed_attributes.clear
330+
end
301331
end
302332
end

lib/protip/wrapper.rb

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,9 @@ def respond_to?(name)
2828
def method_missing(name, *args)
2929
if (name =~ /=$/ && field = message.class.descriptor.detect{|field| :"#{field.name}=" == name})
3030
raise ArgumentError unless args.length == 1
31-
set field, args[0]
31+
attributes = {}.tap { |hash| hash[field.name] = args[0] }
32+
assign_attributes attributes
33+
args[0] # return the input value (to match ActiveRecord behavior)
3234
elsif (name =~ /\?$/ && field = message.class.descriptor.detect{|field| self.class.matchable?(field) && :"#{field.name}?" == name})
3335
raise ArgumentError unless args.length == 1
3436
matches? field, args[0]
@@ -98,9 +100,11 @@ def assign_attributes(attributes)
98100
if field.type == :message && !converter.convertible?(field.subtype.msgclass)
99101
if value.is_a?(field.subtype.msgclass) # If a message, set it directly
100102
set(field, value)
101-
else # If a hash, pass it through to the nested message
103+
elsif value.is_a?(Hash) # If a hash, pass it through to the nested message
102104
wrapper = get(field) || build(field.name) # Create the field if it doesn't already exist
103105
wrapper.assign_attributes value
106+
else # If value is a simple type (e.g. nil), set the value directly
107+
set(field, value)
104108
end
105109
# Otherwise, if the field is a convertible message or a simple type, we set the value directly
106110
else

protip.gemspec

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# encoding: utf-8
22
Gem::Specification.new do |spec|
33
spec.name = 'protip'
4-
spec.version = '0.12.4'
4+
spec.version = '0.13.0'
55
spec.summary = 'ActiveModel resources backed by protocol buffers'
66
spec.licenses = ['MIT']
77
spec.homepage = 'https://github.com/AngelList/protip'

test/functional/protip/resource_test.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
require 'test_helper'
22

33
require 'protip'
4+
require 'google/protobuf/wrappers'
45

56
describe 'Protip::Resource (functional)' do
67

test/test_helper.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22
require 'mocha/mini_test'
33
require 'webmock/minitest'
44

5-
require 'minitest/pride'
5+
require 'minitest/pride'

test/unit/protip/resource_test.rb

Lines changed: 67 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -112,17 +112,17 @@ class << self
112112
end
113113

114114
it 'checks with the converter when setting message types' do
115-
converter.expects(:convertible?).once.with(nested_message_class).returns(false)
115+
converter.expects(:convertible?).at_least_once.with(nested_message_class).returns(false)
116116
resource = resource_class.new
117117
assert_raises(ArgumentError) do
118118
resource.nested_message = 5
119119
end
120120
end
121121

122122
it 'converts message types to and from their Ruby values when the converter allows' do
123-
converter.expects(:convertible?).times(2).with(nested_message_class).returns(true)
123+
converter.expects(:convertible?).at_least_once.with(nested_message_class).returns(true)
124124
converter.expects(:to_message).once.with(6, nested_message_class).returns(nested_message_class.new number: 100)
125-
converter.expects(:to_object).once.with(nested_message_class.new number: 100).returns 'intern'
125+
converter.expects(:to_object).at_least_once.with(nested_message_class.new number: 100).returns 'intern'
126126

127127
resource = resource_class.new
128128
resource.nested_message = 6
@@ -434,13 +434,41 @@ def self.it_converts_query_parameters
434434
attrs = {id: 2}
435435
assert_equal resource_message_class.new(attrs), resource_class.new(attrs).message
436436
end
437+
end
438+
end
437439

438-
it 'delegates to #assign_attributes on its wrapper object when a hash is given' do
439-
attrs = {id: 3}
440-
Protip::Wrapper.any_instance.expects(:assign_attributes).once.with({id: 3})
441-
resource_class.new(attrs)
440+
describe 'attribute writer' do
441+
before do
442+
resource_class.class_exec(resource_message_class) do |resource_message_class|
443+
resource actions: [], message: resource_message_class
442444
end
443445
end
446+
447+
it 'delegates writes to the wrapper object' do
448+
resource = resource_class.new
449+
test_string = 'new'
450+
Protip::Wrapper.any_instance.expects(:string=).with(test_string)
451+
resource.string = test_string
452+
end
453+
454+
it 'marks the resource and attribute as changed if the value is changed' do
455+
resource = resource_class.new string: 'original'
456+
resource.string = 'new'
457+
assert resource.changed?, 'resource should be marked as changed'
458+
assert resource.string_changed?, 'string field should be marked as changed'
459+
end
460+
461+
it 'does not mark the resource and attribute as changed if the value is not changed' do
462+
resource = resource_class.new string: 'original'
463+
resource.send :changes_applied # clear the changes
464+
# establish that the changes were cleared
465+
assert !resource.changed?, 'resource should be not marked as changed'
466+
assert !resource.string_changed?, 'string field should not be marked as changed'
467+
468+
resource.string = 'original'
469+
assert !resource.changed?, 'resource should be not marked as changed'
470+
assert !resource.string_changed?, 'string field should not be marked as changed'
471+
end
444472
end
445473

446474
describe '#assign_attributes' do
@@ -449,11 +477,17 @@ def self.it_converts_query_parameters
449477
resource actions: [], message: resource_message_class
450478
end
451479
end
452-
it 'delegates to #assign_attributes on its wrapper object' do
480+
481+
it 'calls the attribute writer for each attribute' do
453482
resource = resource_class.new
483+
test_string = 'whodunnit'
484+
resource.expects(:string=).with(test_string)
485+
resource.assign_attributes(string: test_string)
486+
end
454487

455-
Protip::Wrapper.any_instance.expects(:assign_attributes).once.with(string: 'whodunnit').returns('boo')
456-
assert_equal 'boo', resource.assign_attributes(string: 'whodunnit')
488+
it 'returns nil' do
489+
resource = resource_class.new
490+
assert_nil resource.assign_attributes(string: 'asdf')
457491
end
458492
end
459493

@@ -495,6 +529,14 @@ def self.it_converts_query_parameters
495529
resource.save
496530
assert_equal response, resource.message
497531
end
532+
533+
it 'marks changes as applied' do
534+
client.stubs(:request).returns(response)
535+
resource = resource_class.new(string: 'time')
536+
assert resource.string_changed?, 'string should initially be changed'
537+
assert resource.save
538+
assert !resource.string_changed?, 'string should no longer be changed after save'
539+
end
498540
end
499541

500542
describe 'for an existing record' do
@@ -528,6 +570,14 @@ def self.it_converts_query_parameters
528570
resource.save
529571
assert_equal response, resource.message
530572
end
573+
574+
it 'marks changes as applied' do
575+
client.stubs(:request).returns(response)
576+
resource = resource_class.new id: 5, string: 'new_string'
577+
assert resource.string_changed?, 'string should initially be changed'
578+
assert resource.save
579+
assert !resource.string_changed?, 'string should no longer be changed after save'
580+
end
531581
end
532582

533583
describe 'when validation errors are thrown' do
@@ -567,6 +617,13 @@ def self.it_converts_query_parameters
567617
it 'returns false' do
568618
refute @resource.save, 'save returned true'
569619
end
620+
621+
it 'does not mark changes as applied' do
622+
@resource.string = 'new_string'
623+
assert @resource.string_changed?, 'string should initially be changed'
624+
refute @resource.save
625+
assert @resource.string_changed?, 'string should still be changed after unsuccessful save'
626+
end
570627
end
571628
end
572629

test/unit/protip/wrapper_test.rb

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,13 @@ module Protip::WrapperTest # namespace for internal constants
216216
wrapper.assign_attributes inner: {value: 50, note: 'noted'}
217217
end
218218

219+
it "sets message fields to nil when they're assigned nil" do
220+
wrapped_message.inner = inner_message_class.new(value: 60)
221+
assert wrapped_message.inner
222+
wrapper.assign_attributes inner: nil
223+
assert_nil wrapped_message.inner
224+
end
225+
219226
it 'allows messages to be assigned directly' do
220227
message = inner_message_class.new
221228
wrapper.assign_attributes inner: message
@@ -341,7 +348,7 @@ module Protip::WrapperTest # namespace for internal constants
341348
end
342349
end
343350

344-
describe '#set' do
351+
describe 'attribute writer' do # generated via method_missing?
345352
it 'does not convert simple fields' do
346353
converter.expects(:convertible?).never
347354
converter.expects(:to_message).never
@@ -351,34 +358,36 @@ module Protip::WrapperTest # namespace for internal constants
351358
end
352359

353360
it 'converts convertible messages' do
354-
converter.expects(:convertible?).with(inner_message_class).once.returns(true)
361+
converter.expects(:convertible?).at_least_once.with(inner_message_class).returns(true)
355362
converter.expects(:to_message).with(40, inner_message_class).returns(inner_message_class.new(value: 30))
356363

357364
wrapper.inner = 40
358365
assert_equal inner_message_class.new(value: 30), wrapper.message.inner
359366
end
360367

361368
it 'removes message fields when assigning nil' do
362-
converter.expects(:convertible?).never
369+
converter.expects(:convertible?).at_least_once.with(inner_message_class).returns(false)
363370
converter.expects(:to_message).never
364371

365372
wrapper.inner = nil
366373
assert_equal nil, wrapper.message.inner
367374
end
368375

369376
it 'raises an error when setting inconvertible messages' do
370-
converter.expects(:convertible?).with(inner_message_class).once.returns(false)
377+
converter.expects(:convertible?).at_least_once.with(inner_message_class).returns(false)
371378
converter.expects(:to_message).never
372379
assert_raises ArgumentError do
373380
wrapper.inner = 'cannot convert me'
374381
end
375382
end
376383

377384
it 'passes through messages without checking whether they are convertible' do
385+
converter.expects(:convertible?).once.returns(true)
386+
message = inner_message_class.new(value: 50)
387+
378388
converter.expects(:convertible?).never
379389
converter.expects(:to_message).never
380-
381-
wrapper.inner = inner_message_class.new(value: 50)
390+
wrapper.inner = message
382391
assert_equal inner_message_class.new(value: 50), wrapper.message.inner
383392
end
384393

@@ -410,6 +419,11 @@ module Protip::WrapperTest # namespace for internal constants
410419
wrapper.number = m
411420
assert_equal :ONE, wrapper.number
412421
end
422+
423+
it 'returns the input value' do
424+
input_value = 'str'
425+
assert_equal input_value, wrapper.send(:string=, input_value)
426+
end
413427
end
414428

415429
describe '#matches?' do

0 commit comments

Comments
 (0)