remotely 0.0.4
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/.autotest +1 -0
- data/.gitignore +18 -0
- data/.rspec +0 -0
- data/Gemfile +2 -0
- data/README.md +70 -0
- data/Rakefile +6 -0
- data/lib/remotely.rb +91 -0
- data/lib/remotely/associations.rb +241 -0
- data/lib/remotely/collection.rb +75 -0
- data/lib/remotely/ext/url.rb +29 -0
- data/lib/remotely/http_methods.rb +205 -0
- data/lib/remotely/model.rb +317 -0
- data/lib/remotely/version.rb +3 -0
- data/remotely.gemspec +30 -0
- data/spec/remotely/associations_spec.rb +146 -0
- data/spec/remotely/collection_spec.rb +57 -0
- data/spec/remotely/ext/url_spec.rb +27 -0
- data/spec/remotely/http_methods_spec.rb +25 -0
- data/spec/remotely/model_spec.rb +368 -0
- data/spec/remotely_spec.rb +23 -0
- data/spec/spec_helper.rb +16 -0
- data/spec/support/test_classes.rb +97 -0
- data/spec/support/webmock.rb +40 -0
- metadata +199 -0
@@ -0,0 +1,29 @@
|
|
1
|
+
class URL
|
2
|
+
include Comparable
|
3
|
+
|
4
|
+
def initialize(*args)
|
5
|
+
@url = "/" + args.flatten.compact.join("/")
|
6
|
+
@url.gsub! %r[/{2,}], "/"
|
7
|
+
@url.gsub! %r[/$], ""
|
8
|
+
end
|
9
|
+
|
10
|
+
def +(other)
|
11
|
+
URL.new(to_s, other.to_s)
|
12
|
+
end
|
13
|
+
|
14
|
+
def -(other)
|
15
|
+
URL.new(to_s.gsub(other.to_s, ""))
|
16
|
+
end
|
17
|
+
|
18
|
+
def <=>(other)
|
19
|
+
@url <=> other.to_s
|
20
|
+
end
|
21
|
+
|
22
|
+
def to_s
|
23
|
+
@url
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
def URL(*args)
|
28
|
+
URL.new(*args)
|
29
|
+
end
|
@@ -0,0 +1,205 @@
|
|
1
|
+
module Remotely
|
2
|
+
module HTTPMethods
|
3
|
+
# HTTP status codes that are represent successful requests
|
4
|
+
SUCCESS_STATUSES = (200..299)
|
5
|
+
|
6
|
+
# @return [Symbol] the name of the app the model is fetched from
|
7
|
+
attr_accessor :app
|
8
|
+
|
9
|
+
# @return [String] the relative uri to the model's type of resource
|
10
|
+
attr_accessor :uri
|
11
|
+
|
12
|
+
# Set or get the app for this model belongs to. If name is passed,
|
13
|
+
# it's a setter, otherwise, a getter.
|
14
|
+
#
|
15
|
+
# @overload app()
|
16
|
+
# Gets the current `app` value.
|
17
|
+
#
|
18
|
+
# @overload app(name)
|
19
|
+
# Sets the value of `app`.
|
20
|
+
# @param [Symbol] name Name corresponding to an app defined via Remotely.app.
|
21
|
+
#
|
22
|
+
# @return [Symbol] New app symbol or current value.
|
23
|
+
#
|
24
|
+
def app(name=nil)
|
25
|
+
if @app.nil? && name.nil? && Remotely.apps.size == 1
|
26
|
+
name = Remotely.apps.first.first
|
27
|
+
end
|
28
|
+
|
29
|
+
(name and @app = name) or @app
|
30
|
+
end
|
31
|
+
|
32
|
+
# Set or get the base uri for this model. If name is passed,
|
33
|
+
# it's a setter, otherwise, a getter.
|
34
|
+
#
|
35
|
+
# @overload uri()
|
36
|
+
# Gets the current `uri` value.
|
37
|
+
#
|
38
|
+
# @overload uri(path)
|
39
|
+
# Sets the value of `uri`.
|
40
|
+
# @param [Symbol] path Relative path to this type of resource.
|
41
|
+
#
|
42
|
+
# @return [String] New uri or current value.
|
43
|
+
#
|
44
|
+
def uri(path=nil)
|
45
|
+
(path and @uri = path) or @uri
|
46
|
+
end
|
47
|
+
|
48
|
+
# The connection to the remote API.
|
49
|
+
#
|
50
|
+
# @return [Faraday::Connection] Connection to the remote API.
|
51
|
+
#
|
52
|
+
def remotely_connection
|
53
|
+
address = Remotely.apps[app][:base]
|
54
|
+
address = "http://#{address}" unless address =~ /^http/
|
55
|
+
|
56
|
+
@connection ||= Faraday::Connection.new(address) do |b|
|
57
|
+
b.request :url_encoded
|
58
|
+
b.adapter :net_http
|
59
|
+
end
|
60
|
+
|
61
|
+
@connection.basic_auth(*Remotely.basic_auth) if Remotely.basic_auth
|
62
|
+
@connection
|
63
|
+
end
|
64
|
+
|
65
|
+
# GET request.
|
66
|
+
#
|
67
|
+
# @param [String] uri Relative path of request.
|
68
|
+
# @param [Hash] params Query string, in key-value Hash form.
|
69
|
+
#
|
70
|
+
# @return [Remotely::Collection, Remotely::Model, Hash] If the result
|
71
|
+
# is an array, Collection, if it's a hash, Model, otherwise it's the
|
72
|
+
# parsed response body.
|
73
|
+
#
|
74
|
+
def get(uri, options={})
|
75
|
+
uri = expand(uri)
|
76
|
+
klass = options.delete(:class)
|
77
|
+
parent = options.delete(:parent)
|
78
|
+
|
79
|
+
before_request(uri, :get, options)
|
80
|
+
|
81
|
+
response = remotely_connection.get { |req| req.url(uri, options) }
|
82
|
+
parse_response(raise_if_html(response), klass, parent)
|
83
|
+
end
|
84
|
+
|
85
|
+
# POST request.
|
86
|
+
#
|
87
|
+
# Used mainly to create new resources. Remotely assumes that the
|
88
|
+
# remote API will return the newly created object, in JSON form,
|
89
|
+
# with the `id` assigned to it.
|
90
|
+
#
|
91
|
+
# @param [String] uri Relative path of request.
|
92
|
+
# @param [Hash] params Request payload. Gets JSON-encoded.
|
93
|
+
#
|
94
|
+
# @return [Remotely::Collection, Remotely::Model, Hash] If the result
|
95
|
+
# is an array, Collection, if it's a hash, Model, otherwise it's the
|
96
|
+
# parsed response body.
|
97
|
+
#
|
98
|
+
def post(uri, options={})
|
99
|
+
uri = expand(uri)
|
100
|
+
klass = options.delete(:class)
|
101
|
+
parent = options.delete(:parent)
|
102
|
+
body = options.delete(:body) || Yajl::Encoder.encode(options)
|
103
|
+
|
104
|
+
before_request(uri, :post, body)
|
105
|
+
raise_if_html(remotely_connection.post(uri, body))
|
106
|
+
end
|
107
|
+
|
108
|
+
# PUT request.
|
109
|
+
#
|
110
|
+
# @param [String] uri Relative path of request.
|
111
|
+
# @param [Hash] params Request payload. Gets JSON-encoded.
|
112
|
+
#
|
113
|
+
# @return [Boolean] Was the request successful? (Resulted in a
|
114
|
+
# 200-299 response code)
|
115
|
+
#
|
116
|
+
def put(uri, options={})
|
117
|
+
uri = expand(uri)
|
118
|
+
body = options.delete(:body) || Yajl::Encoder.encode(options)
|
119
|
+
|
120
|
+
before_request(uri, :put, body)
|
121
|
+
raise_if_html(remotely_connection.put(uri, body))
|
122
|
+
end
|
123
|
+
|
124
|
+
# DELETE request.
|
125
|
+
#
|
126
|
+
# @param [String] uri Relative path of request.
|
127
|
+
#
|
128
|
+
# @return [Boolean] Was the resource deleted? (Resulted in a
|
129
|
+
# 200-299 response code)
|
130
|
+
#
|
131
|
+
def http_delete(uri)
|
132
|
+
uri = expand(uri)
|
133
|
+
before_request(uri, :delete)
|
134
|
+
response = raise_if_html(remotely_connection.delete(uri))
|
135
|
+
SUCCESS_STATUSES.include?(response.status)
|
136
|
+
end
|
137
|
+
|
138
|
+
# Expand a URI to include any path specified in the the main app
|
139
|
+
# configuration. When creating a Faraday object with a path that
|
140
|
+
# includes a uri, eg: "localhost:1234/api", Faraday drops the path,
|
141
|
+
# making it "localhost:1234". We need to add the "/api" back in
|
142
|
+
# before our relative uri.
|
143
|
+
#
|
144
|
+
# @example
|
145
|
+
# Remotely.configure { app :thingapp, "http://example.com/api" }
|
146
|
+
# Model.expand("/members") # => "/api/members"
|
147
|
+
#
|
148
|
+
def expand(uri)
|
149
|
+
baseuri = Remotely.apps[app][:uri]
|
150
|
+
uri =~ /^#{baseuri}/ ? uri : URL(baseuri, uri)
|
151
|
+
end
|
152
|
+
|
153
|
+
# Gets called before a request. Override to add logging, etc.
|
154
|
+
def before_request(uri, http_verb = :get, options = {})
|
155
|
+
if ENV['REMOTELY_DEBUG']
|
156
|
+
puts "-> #{http_verb.to_s.upcase} #{uri}"
|
157
|
+
puts " #{options.inspect}"
|
158
|
+
end
|
159
|
+
end
|
160
|
+
|
161
|
+
def raise_if_html(response)
|
162
|
+
if response.body =~ %r(<html>)
|
163
|
+
raise Remotely::NonJsonResponseError.new(response.body)
|
164
|
+
end
|
165
|
+
response
|
166
|
+
end
|
167
|
+
|
168
|
+
# Parses the response depending on what was returned. The following
|
169
|
+
# table described what gets return in what situations.
|
170
|
+
#
|
171
|
+
# ------------+------------------+--------------
|
172
|
+
# Status Code | Return Body Type | Return Value
|
173
|
+
# ------------+------------------+--------------
|
174
|
+
# >= 400 | N/A | false
|
175
|
+
# ------------+------------------+--------------
|
176
|
+
# 200-299 | Array | Collection
|
177
|
+
# ------------+------------------+--------------
|
178
|
+
# 200-299 | Hash | Model
|
179
|
+
# ------------+------------------+--------------
|
180
|
+
# 200-299 | Other | Parsed JSON
|
181
|
+
# ------------+------------------+--------------
|
182
|
+
#
|
183
|
+
# @param [Faraday::Response] response Response object
|
184
|
+
#
|
185
|
+
# @return [Remotely::Collection, Remotely::Model, Other] If the result
|
186
|
+
# is an array, Collection, if it's a hash, Model, otherwise it's the
|
187
|
+
# parsed response body.
|
188
|
+
#
|
189
|
+
def parse_response(response, klass=nil, parent=nil)
|
190
|
+
return false if response.status >= 400
|
191
|
+
|
192
|
+
body = Yajl::Parser.parse(response.body) rescue nil
|
193
|
+
klass = (klass || self)
|
194
|
+
|
195
|
+
case body
|
196
|
+
when Array
|
197
|
+
Collection.new(parent, klass, body.map { |o| klass.new(o) })
|
198
|
+
when Hash
|
199
|
+
klass.new(body)
|
200
|
+
else
|
201
|
+
body
|
202
|
+
end
|
203
|
+
end
|
204
|
+
end
|
205
|
+
end
|
@@ -0,0 +1,317 @@
|
|
1
|
+
module Remotely
|
2
|
+
class Model
|
3
|
+
extend Forwardable
|
4
|
+
extend ActiveModel::Naming
|
5
|
+
include ActiveModel::Conversion
|
6
|
+
include Associations
|
7
|
+
|
8
|
+
class << self
|
9
|
+
include Remotely::HTTPMethods
|
10
|
+
|
11
|
+
# Array of attributes to be sent when saving
|
12
|
+
attr_reader :savable_attributes
|
13
|
+
|
14
|
+
# Mark an attribute as safe to save. The `save` method
|
15
|
+
# will only send these attributes when called.
|
16
|
+
#
|
17
|
+
# @param [Symbols] *attrs List of attributes to make savable.
|
18
|
+
#
|
19
|
+
# @example Mark `name` and `age` as savable
|
20
|
+
# attr_savable :name, :age
|
21
|
+
#
|
22
|
+
def attr_savable(*attrs)
|
23
|
+
@savable_attributes ||= []
|
24
|
+
@savable_attributes += attrs
|
25
|
+
@savable_attributes.uniq!
|
26
|
+
end
|
27
|
+
|
28
|
+
# Fetch all entries.
|
29
|
+
#
|
30
|
+
# @return [Remotely::Collection] collection of entries
|
31
|
+
#
|
32
|
+
def all
|
33
|
+
get uri
|
34
|
+
end
|
35
|
+
|
36
|
+
# Retreive a single object. Combines `uri` and `id` to determine
|
37
|
+
# the URI to use.
|
38
|
+
#
|
39
|
+
# @param [Fixnum] id The `id` of the resource.
|
40
|
+
#
|
41
|
+
# @example Find the User with id=1
|
42
|
+
# User.find(1)
|
43
|
+
#
|
44
|
+
# @return [Remotely::Model] Single model object.
|
45
|
+
#
|
46
|
+
def find(id)
|
47
|
+
get URL(uri, id)
|
48
|
+
end
|
49
|
+
|
50
|
+
# Fetch the first record matching +attrs+ or initialize a new instance
|
51
|
+
# with those attributes.
|
52
|
+
#
|
53
|
+
# @param [Hash] attrs Attributes to search by, and subsequently instantiate
|
54
|
+
# with, if not found.
|
55
|
+
#
|
56
|
+
# @return [Remotely::Model] Fetched or initialized model object
|
57
|
+
#
|
58
|
+
def find_or_initialize(attrs={})
|
59
|
+
where(attrs).first or new(attrs)
|
60
|
+
end
|
61
|
+
|
62
|
+
# Fetch the first record matching +attrs+ or initialize and save a new
|
63
|
+
# instance with those attributes.
|
64
|
+
#
|
65
|
+
# @param [Hash] attrs Attributes to search by, and subsequently instantiate
|
66
|
+
# and save with, if not found.
|
67
|
+
#
|
68
|
+
# @return [Remotely::Model] Fetched or initialized model object
|
69
|
+
#
|
70
|
+
def find_or_create(attrs={})
|
71
|
+
where(attrs).first or create(attrs)
|
72
|
+
end
|
73
|
+
|
74
|
+
# Search the remote API for a resource matching conditions specified
|
75
|
+
# in `params`. Sends `params` as a url-encoded query string. It
|
76
|
+
# assumes the search endpoint is at "/resource_plural/search".
|
77
|
+
#
|
78
|
+
# @param [Hash] params Key-value pairs of attributes and values to search by.
|
79
|
+
#
|
80
|
+
# @example Search for a person by name and title
|
81
|
+
# User.where(:name => "Finn", :title => "The Human")
|
82
|
+
#
|
83
|
+
# @return [Remotely::Collection] Array-like collection of model objects.
|
84
|
+
#
|
85
|
+
def where(params={})
|
86
|
+
get URL(uri, "search"), params
|
87
|
+
end
|
88
|
+
|
89
|
+
# Creates a new resource.
|
90
|
+
#
|
91
|
+
# @param [Hash] params Attributes to create the new resource with.
|
92
|
+
#
|
93
|
+
# @return [Remotely::Model, Boolean] If the creation succeeds, a new
|
94
|
+
# model object is returned, otherwise false.
|
95
|
+
#
|
96
|
+
def create(params={})
|
97
|
+
new(params).save
|
98
|
+
end
|
99
|
+
|
100
|
+
alias :create! :create
|
101
|
+
|
102
|
+
# Update every entry with values from +params+.
|
103
|
+
#
|
104
|
+
# @param [Hash] params Key-Value pairs of attributes to update
|
105
|
+
# @return [Boolean] If the update succeeded
|
106
|
+
#
|
107
|
+
def update_all(params={})
|
108
|
+
put uri, params
|
109
|
+
end
|
110
|
+
|
111
|
+
alias :update_all! :update_all
|
112
|
+
|
113
|
+
# Destroy an individual resource.
|
114
|
+
#
|
115
|
+
# @param [Fixnum] id id of the resource to destroy.
|
116
|
+
#
|
117
|
+
# @return [Boolean] If the destruction succeeded.
|
118
|
+
#
|
119
|
+
def destroy(id)
|
120
|
+
http_delete URL(uri, id)
|
121
|
+
end
|
122
|
+
|
123
|
+
alias :destroy! :destroy
|
124
|
+
|
125
|
+
# Remotely models don't support single table inheritence
|
126
|
+
# so the base class is always itself.
|
127
|
+
#
|
128
|
+
def base_class
|
129
|
+
self
|
130
|
+
end
|
131
|
+
|
132
|
+
private
|
133
|
+
|
134
|
+
# Search by one or more attribute and their values.
|
135
|
+
#
|
136
|
+
# @param [String, Symbol] name The attribute name
|
137
|
+
# @param [String, Symbol] value Value to search by
|
138
|
+
#
|
139
|
+
# @see .where
|
140
|
+
#
|
141
|
+
def find_by(name, *args)
|
142
|
+
where(Hash[name.split("_and_").zip(args)]).first
|
143
|
+
end
|
144
|
+
|
145
|
+
def method_missing(name, *args, &block)
|
146
|
+
return find_by($1, *args) if name.to_s =~ /^find_by_(.*)!?$/
|
147
|
+
super
|
148
|
+
end
|
149
|
+
end
|
150
|
+
|
151
|
+
def_delegators :"self.class", :uri, :get, :post, :put, :parse_response
|
152
|
+
|
153
|
+
# @return [Hash] Key-value of attributes and values.
|
154
|
+
attr_accessor :attributes
|
155
|
+
|
156
|
+
def initialize(attributes={})
|
157
|
+
set_errors(attributes.delete('errors')) if attributes['errors']
|
158
|
+
self.attributes = attributes.symbolize_keys
|
159
|
+
associate!
|
160
|
+
end
|
161
|
+
|
162
|
+
# Update a single attribute.
|
163
|
+
#
|
164
|
+
# @param [Symbol, String] name Attribute name
|
165
|
+
# @param [Mixed] value New value for the attribute
|
166
|
+
# @param [Boolean] should_save Should it save after updating
|
167
|
+
# the attributes. Default: true
|
168
|
+
# @return [Boolean, Mixed] Boolean if the it tried to save, the
|
169
|
+
# new value otherwise.
|
170
|
+
#
|
171
|
+
def update_attribute(name, value)
|
172
|
+
self.attributes[name.to_sym] = value
|
173
|
+
save
|
174
|
+
end
|
175
|
+
|
176
|
+
# Update multiple attributes.
|
177
|
+
#
|
178
|
+
# @param [Hash] attrs Hash of attributes/values to update with.
|
179
|
+
# @return [Boolean] Did the save succeed.
|
180
|
+
#
|
181
|
+
def update_attributes(attrs={})
|
182
|
+
@attribute_cache = self.attributes.dup
|
183
|
+
self.attributes.merge!(attrs.symbolize_keys)
|
184
|
+
|
185
|
+
if save && self.errors.empty?
|
186
|
+
true
|
187
|
+
else
|
188
|
+
self.attributes = @attribute_cache
|
189
|
+
false
|
190
|
+
end
|
191
|
+
end
|
192
|
+
|
193
|
+
# Persist this object to the remote API.
|
194
|
+
#
|
195
|
+
# If the request returns a status code of 201 or 200
|
196
|
+
# (for creating new records and updating existing ones,
|
197
|
+
# respectively) it is considered a successful save and returns
|
198
|
+
# the object. Any other status will result in a return value
|
199
|
+
# of false. In addition, the `obj.errors` collection will be
|
200
|
+
# populated with any errors returns from the remote API.
|
201
|
+
#
|
202
|
+
# For `save` to handle errors correctly, the remote API should
|
203
|
+
# return a response body which matches a JSONified ActiveRecord
|
204
|
+
# errors object. ie:
|
205
|
+
#
|
206
|
+
# {"errors":{"attribute":["message", "message"]}}
|
207
|
+
#
|
208
|
+
# @return [Boolean, Model]
|
209
|
+
# Remote API returns 200/201 status: the new/updated model object
|
210
|
+
# Remote API returns any other status: false
|
211
|
+
#
|
212
|
+
def save
|
213
|
+
method = new_record? ? :post : :put
|
214
|
+
status = new_record? ? 201 : 200
|
215
|
+
attrs = new_record? ? attributes : attributes.slice(*savable_attributes)
|
216
|
+
url = new_record? ? uri : URL(uri, id)
|
217
|
+
|
218
|
+
resp = public_send(method, url, attrs)
|
219
|
+
body = Yajl::Parser.parse(resp.body)
|
220
|
+
|
221
|
+
if resp.status == status && !body.nil?
|
222
|
+
self.attributes.merge!(body.symbolize_keys)
|
223
|
+
else
|
224
|
+
set_errors(body.delete("errors")) unless body.nil?
|
225
|
+
end
|
226
|
+
|
227
|
+
self
|
228
|
+
end
|
229
|
+
|
230
|
+
def savable_attributes
|
231
|
+
(self.class.savable_attributes || attributes.keys) << :id
|
232
|
+
end
|
233
|
+
|
234
|
+
# Sets multiple errors with a hash
|
235
|
+
def set_errors(hash)
|
236
|
+
(hash || {}).each do |attribute, messages|
|
237
|
+
Array(messages).each {|m| errors.add(attribute, m) }
|
238
|
+
end
|
239
|
+
end
|
240
|
+
|
241
|
+
# Track errors with ActiveModel::Errors
|
242
|
+
def errors
|
243
|
+
@errors ||= ActiveModel::Errors.new(self)
|
244
|
+
end
|
245
|
+
|
246
|
+
# Destroy this object with the might of 60 jotun!
|
247
|
+
#
|
248
|
+
def destroy
|
249
|
+
self.class.destroy(id)
|
250
|
+
end
|
251
|
+
|
252
|
+
# Re-fetch the resource from the remote API.
|
253
|
+
#
|
254
|
+
def reload
|
255
|
+
self.attributes = get(URL(uri, id)).attributes
|
256
|
+
self
|
257
|
+
end
|
258
|
+
|
259
|
+
def id
|
260
|
+
self.attributes[:id]
|
261
|
+
end
|
262
|
+
|
263
|
+
# Assumes that if the object doesn't have an `id`, it's new.
|
264
|
+
#
|
265
|
+
def new_record?
|
266
|
+
self.attributes[:id].nil?
|
267
|
+
end
|
268
|
+
|
269
|
+
def persisted?
|
270
|
+
!new_record?
|
271
|
+
end
|
272
|
+
|
273
|
+
def respond_to?(name)
|
274
|
+
self.attributes and self.attributes.include?(name) or super
|
275
|
+
end
|
276
|
+
|
277
|
+
def to_json
|
278
|
+
Yajl::Encoder.encode(self.attributes)
|
279
|
+
end
|
280
|
+
|
281
|
+
private
|
282
|
+
|
283
|
+
def metaclass
|
284
|
+
(class << self; self; end)
|
285
|
+
end
|
286
|
+
|
287
|
+
# Finds all attributes that match `*_id`, and creates a method for it,
|
288
|
+
# that will fetch that record. It uses the `*` part of the attribute
|
289
|
+
# to determine the model class and calls `find` on it with the value
|
290
|
+
# if the attribute.
|
291
|
+
#
|
292
|
+
def associate!
|
293
|
+
self.attributes.select { |k,v| k =~ /_id$/ }.each do |key, id|
|
294
|
+
name = key.to_s.gsub("_id", "")
|
295
|
+
metaclass.send(:define_method, name) { |reload=false| fetch(name, id, reload) }
|
296
|
+
end
|
297
|
+
end
|
298
|
+
|
299
|
+
def fetch(name, id, reload)
|
300
|
+
klass = name.to_s.classify.constantize
|
301
|
+
set_association(name, klass.find(id)) if reload || association_undefined?(name)
|
302
|
+
get_association(name)
|
303
|
+
end
|
304
|
+
|
305
|
+
def method_missing(name, *args, &block)
|
306
|
+
if self.attributes.include?(name)
|
307
|
+
self.attributes[name]
|
308
|
+
elsif name =~ /(.*)=$/ && self.attributes.include?($1.to_sym)
|
309
|
+
self.attributes[$1.to_sym] = args.first
|
310
|
+
elsif name =~ /(.*)\?$/ && self.attributes.include?($1.to_sym)
|
311
|
+
!!self.attributes[$1.to_sym]
|
312
|
+
else
|
313
|
+
super
|
314
|
+
end
|
315
|
+
end
|
316
|
+
end
|
317
|
+
end
|