safrano 0.3.2 → 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.
- checksums.yaml +4 -4
- data/lib/odata/attribute.rb +1 -1
- data/lib/odata/batch.rb +24 -10
- data/lib/odata/collection.rb +242 -96
- data/lib/odata/collection_filter.rb +40 -9
- data/lib/odata/collection_media.rb +279 -0
- data/lib/odata/collection_order.rb +46 -36
- data/lib/odata/common_logger.rb +59 -0
- data/lib/odata/entity.rb +268 -54
- data/lib/odata/error.rb +58 -17
- data/lib/odata/expand.rb +123 -0
- data/lib/odata/filter/error.rb +6 -0
- data/lib/odata/filter/parse.rb +4 -12
- data/lib/odata/filter/sequel.rb +11 -13
- data/lib/odata/filter/tree.rb +11 -15
- data/lib/odata/navigation_attribute.rb +150 -0
- data/lib/odata/relations.rb +2 -2
- data/lib/odata/select.rb +42 -0
- data/lib/odata/url_parameters.rb +51 -36
- data/lib/odata/walker.rb +12 -4
- data/lib/safrano.rb +23 -12
- data/lib/{safrano_core.rb → safrano/core.rb} +14 -3
- data/lib/{multipart.rb → safrano/multipart.rb} +51 -29
- data/lib/{odata_rack_builder.rb → safrano/odata_rack_builder.rb} +1 -1
- data/lib/{rack_app.rb → safrano/rack_app.rb} +15 -10
- data/lib/{request.rb → safrano/request.rb} +21 -8
- data/lib/{response.rb → safrano/response.rb} +1 -2
- data/lib/{sequel_join_by_paths.rb → safrano/sequel_join_by_paths.rb} +1 -1
- data/lib/{service.rb → safrano/service.rb} +93 -97
- data/lib/safrano/version.rb +3 -0
- data/lib/sequel/plugins/join_by_paths.rb +11 -10
- metadata +34 -15
@@ -0,0 +1,59 @@
|
|
1
|
+
module Rack
|
2
|
+
class ODataCommonLogger < CommonLogger
|
3
|
+
def call(env)
|
4
|
+
env['safrano.logger_mw'] = self
|
5
|
+
super
|
6
|
+
end
|
7
|
+
|
8
|
+
# Handle https://github.com/rack/rack/pull/1526
|
9
|
+
# new in Rack 2.2.2 : Format has now 11 placeholders instead of 10
|
10
|
+
|
11
|
+
MSG_FUNC = if FORMAT.count('%') == 10
|
12
|
+
lambda { |env, length, status, began_at|
|
13
|
+
FORMAT % [
|
14
|
+
env['HTTP_X_FORWARDED_FOR'] || env['REMOTE_ADDR'] || '-',
|
15
|
+
env['REMOTE_USER'] || '-',
|
16
|
+
Time.now.strftime('%d/%b/%Y:%H:%M:%S %z'),
|
17
|
+
env[REQUEST_METHOD],
|
18
|
+
env[SCRIPT_NAME] + env[PATH_INFO],
|
19
|
+
env[QUERY_STRING].empty? ? '' : "?#{env[QUERY_STRING]}",
|
20
|
+
env[SERVER_PROTOCOL],
|
21
|
+
status.to_s[0..3],
|
22
|
+
length,
|
23
|
+
Utils.clock_time - began_at
|
24
|
+
]
|
25
|
+
}
|
26
|
+
elsif FORMAT.count('%') == 11
|
27
|
+
lambda { |env, length, status, began_at|
|
28
|
+
FORMAT % [
|
29
|
+
env['HTTP_X_FORWARDED_FOR'] || env['REMOTE_ADDR'] || '-',
|
30
|
+
env['REMOTE_USER'] || '-',
|
31
|
+
Time.now.strftime('%d/%b/%Y:%H:%M:%S %z'),
|
32
|
+
env[REQUEST_METHOD],
|
33
|
+
env[SCRIPT_NAME],
|
34
|
+
env[PATH_INFO],
|
35
|
+
env[QUERY_STRING].empty? ? '' : "?#{env[QUERY_STRING]}",
|
36
|
+
env[SERVER_PROTOCOL],
|
37
|
+
status.to_s[0..3],
|
38
|
+
length,
|
39
|
+
Utils.clock_time - began_at
|
40
|
+
]
|
41
|
+
}
|
42
|
+
end
|
43
|
+
|
44
|
+
def batch_log(env, status, header, began_at)
|
45
|
+
length = extract_content_length(header)
|
46
|
+
|
47
|
+
msg = MSG_FUNC.call(env, length, status, began_at)
|
48
|
+
|
49
|
+
logger = @logger || env[RACK_ERRORS]
|
50
|
+
# Standard library logger doesn't support write but it supports << which actually
|
51
|
+
# calls to write on the log device without formatting
|
52
|
+
if logger.respond_to?(:write)
|
53
|
+
logger.write(msg)
|
54
|
+
else
|
55
|
+
logger << msg
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
data/lib/odata/entity.rb
CHANGED
@@ -2,13 +2,16 @@ require 'json'
|
|
2
2
|
require 'rexml/document'
|
3
3
|
require 'safrano.rb'
|
4
4
|
require 'odata/collection.rb' # required for self.class.entity_type_name ??
|
5
|
+
require_relative 'navigation_attribute'
|
5
6
|
|
6
7
|
module OData
|
7
8
|
# this will be mixed in the Model classes (subclasses of Sequel Model)
|
8
|
-
module
|
9
|
+
module EntityBase
|
9
10
|
attr_reader :params
|
10
11
|
attr_reader :uribase
|
11
12
|
|
13
|
+
include EntityBase::NavigationInfo
|
14
|
+
|
12
15
|
# methods related to transitions to next state (cf. walker)
|
13
16
|
module Transitions
|
14
17
|
def allowed_transitions
|
@@ -16,6 +19,7 @@ module OData
|
|
16
19
|
Safrano::TransitionEnd,
|
17
20
|
Safrano::TransitionCount,
|
18
21
|
Safrano::TransitionLinks,
|
22
|
+
Safrano::TransitionValue,
|
19
23
|
Safrano::Transition.new(self.class.transition_attribute_regexp,
|
20
24
|
trans: 'transition_attribute')
|
21
25
|
]
|
@@ -41,6 +45,11 @@ module OData
|
|
41
45
|
[self, :end]
|
42
46
|
end
|
43
47
|
|
48
|
+
def transition_value(_match_result)
|
49
|
+
# $value is only allowd for media entities (or attributes)
|
50
|
+
[self, :end_with_media_value]
|
51
|
+
end
|
52
|
+
|
44
53
|
def transition_links(_match_result)
|
45
54
|
[self, :run_with_links]
|
46
55
|
end
|
@@ -54,8 +63,6 @@ module OData
|
|
54
63
|
def transition_nav_collection(match_result)
|
55
64
|
attrib = match_result[1]
|
56
65
|
[get_related(attrib), :run]
|
57
|
-
# attr_klass = self.class.nav_collection_attribs[attrib].to_s
|
58
|
-
# [get_related(attr_klass), :run]
|
59
66
|
end
|
60
67
|
|
61
68
|
def transition_nav_entity(match_result)
|
@@ -72,7 +79,6 @@ module OData
|
|
72
79
|
self.class.nav_entity_attribs&.each_key do |na_str|
|
73
80
|
@nav_values[na_str.to_sym] = send(na_str)
|
74
81
|
end
|
75
|
-
|
76
82
|
@nav_values
|
77
83
|
end
|
78
84
|
|
@@ -84,30 +90,51 @@ module OData
|
|
84
90
|
@nav_coll
|
85
91
|
end
|
86
92
|
|
87
|
-
def uri_path
|
88
|
-
kla = self.class
|
89
|
-
"#{kla.entity_set_name}(#{pk_uri})"
|
90
|
-
end
|
91
|
-
|
92
93
|
def uri(uriba)
|
93
|
-
"#{uriba}/#{
|
94
|
+
"#{uriba}/#{self.class.entity_set_name}(#{pk_uri})"
|
94
95
|
end
|
96
|
+
D = 'd'.freeze
|
97
|
+
DJ_OPEN = '{"d":'.freeze
|
98
|
+
DJ_CLOSE = '}'.freeze
|
95
99
|
|
96
100
|
# Json formatter for a single entity (probably OData V1/V2 like)
|
97
101
|
def to_odata_json(service:)
|
98
|
-
|
99
|
-
|
100
|
-
|
101
|
-
uribase: @uribase)
|
102
|
+
template = self.class.output_template(@uparms)
|
103
|
+
innerj = service.get_entity_odata_h(entity: self,
|
104
|
+
template: template,
|
105
|
+
uribase: @uribase).to_json
|
106
|
+
"#{DJ_OPEN}#{innerj}#{DJ_CLOSE}"
|
107
|
+
end
|
108
|
+
|
109
|
+
# Json formatter for a single entity reached by navigation $links
|
110
|
+
def to_odata_onelink_json(service:)
|
111
|
+
innerj = service.get_entity_odata_link_h(entity: self,
|
112
|
+
uribase: @uribase).to_json
|
113
|
+
"#{DJ_OPEN}#{innerj}#{DJ_CLOSE}"
|
114
|
+
end
|
115
|
+
|
116
|
+
def selected_values_for_odata(cols)
|
117
|
+
allvals = values_for_odata
|
118
|
+
selvals = {}
|
119
|
+
cols.map(&:to_sym).each { |k| selvals[k] = allvals[k] if allvals.key?(k) }
|
120
|
+
selvals
|
102
121
|
end
|
103
122
|
|
104
123
|
# needed for proper datetime output
|
105
|
-
# TODO design/performance
|
106
|
-
def casted_values
|
124
|
+
# TODO: design/performance
|
125
|
+
def casted_values(cols = nil)
|
126
|
+
vals = case cols
|
127
|
+
when nil
|
128
|
+
values_for_odata
|
129
|
+
else
|
130
|
+
selected_values_for_odata(cols)
|
131
|
+
end
|
132
|
+
|
107
133
|
# WARNING; this code is duplicated in attribute.rb
|
108
134
|
# (and the inverted transformation is in test/client.rb)
|
109
135
|
# will require a more systematic solution some day
|
110
|
-
|
136
|
+
|
137
|
+
vals.transform_values! do |v|
|
111
138
|
case v
|
112
139
|
when Time
|
113
140
|
# try to get back the database time zone and value
|
@@ -115,56 +142,92 @@ module OData
|
|
115
142
|
else
|
116
143
|
v
|
117
144
|
end
|
118
|
-
|
145
|
+
end
|
119
146
|
end
|
120
147
|
|
121
148
|
# post paylod expects the new entity in an array
|
122
149
|
def to_odata_post_json(service:)
|
123
|
-
|
124
|
-
|
150
|
+
innerj = service.get_coll_odata_h(array: [self],
|
151
|
+
template: self.class.default_template,
|
152
|
+
uribase: @uribase).to_json
|
153
|
+
"#{DJ_OPEN}#{innerj}#{DJ_CLOSE}"
|
125
154
|
end
|
126
155
|
|
127
156
|
def type_name
|
128
157
|
self.class.type_name
|
129
158
|
end
|
130
159
|
|
131
|
-
# metadata for json h
|
132
|
-
def metadata_h(uribase:)
|
133
|
-
{ uri: uri(uribase),
|
134
|
-
type: type_name }
|
135
|
-
end
|
136
|
-
|
137
160
|
def copy_request_infos(req)
|
138
161
|
@params = req.params
|
139
162
|
@uribase = req.uribase
|
140
163
|
@do_links = req.walker.do_links
|
164
|
+
@uparms = UrlParameters4Single.new(@params)
|
141
165
|
end
|
142
166
|
|
143
167
|
# Finally Process REST verbs...
|
144
168
|
def odata_get(req)
|
145
169
|
copy_request_infos(req)
|
146
|
-
|
147
|
-
|
148
|
-
|
170
|
+
if req.walker.media_value
|
171
|
+
odata_media_value_get(req)
|
172
|
+
elsif req.accept?(APPJSON)
|
173
|
+
if req.walker.do_links
|
174
|
+
[200, CT_JSON, [to_odata_onelink_json(service: req.service)]]
|
175
|
+
else
|
176
|
+
[200, CT_JSON, [to_odata_json(service: req.service)]]
|
177
|
+
end
|
149
178
|
else # TODO: other formats
|
150
179
|
415
|
151
180
|
end
|
152
181
|
end
|
153
182
|
|
154
|
-
|
155
|
-
|
156
|
-
|
157
|
-
|
158
|
-
|
159
|
-
|
183
|
+
DELETE_REL_AND_ENTY = lambda do |entity, assoc, parent|
|
184
|
+
OData.remove_nav_relation(assoc, parent)
|
185
|
+
entity.destroy(transaction: false)
|
186
|
+
end
|
187
|
+
|
188
|
+
def odata_delete_relation_and_entity(req, assoc, parent)
|
189
|
+
if parent
|
190
|
+
if req.in_changeset
|
191
|
+
# in-changeset requests get their own transaction
|
192
|
+
DELETE_REL_AND_ENTY.call(self, assoc, parent)
|
193
|
+
else
|
194
|
+
db.transaction do
|
195
|
+
DELETE_REL_AND_ENTY.call(self, assoc, parent)
|
196
|
+
end
|
197
|
+
end
|
198
|
+
else
|
199
|
+
destroy(transaction: false)
|
160
200
|
end
|
201
|
+
rescue StandardError => e
|
202
|
+
raise SequelAdapterError.new(e)
|
161
203
|
end
|
162
204
|
|
205
|
+
# TODO: differentiate between POST/PUT/PATCH/MERGE
|
163
206
|
def odata_post(req)
|
164
|
-
|
165
|
-
|
207
|
+
if req.walker.media_value
|
208
|
+
odata_media_value_put(req)
|
209
|
+
elsif req.accept?(APPJSON)
|
210
|
+
data.delete('__metadata')
|
166
211
|
|
167
|
-
|
212
|
+
if req.in_changeset
|
213
|
+
set_fields(data, self.class.data_fields, missing: :skip)
|
214
|
+
save(transaction: false)
|
215
|
+
else
|
216
|
+
update_fields(data, self.class.data_fields, missing: :skip)
|
217
|
+
end
|
218
|
+
|
219
|
+
[202, EMPTY_HASH, to_odata_post_json(service: req.service)]
|
220
|
+
else # TODO: other formats
|
221
|
+
415
|
222
|
+
end
|
223
|
+
end
|
224
|
+
|
225
|
+
def odata_put(req)
|
226
|
+
if req.walker.media_value
|
227
|
+
odata_media_value_put(req)
|
228
|
+
elsif req.accept?(APPJSON)
|
229
|
+
data = JSON.parse(req.body.read)
|
230
|
+
@uribase = req.uribase
|
168
231
|
data.delete('__metadata')
|
169
232
|
|
170
233
|
if req.in_changeset
|
@@ -174,21 +237,20 @@ module OData
|
|
174
237
|
update_fields(data, self.class.data_fields, missing: :skip)
|
175
238
|
end
|
176
239
|
|
177
|
-
|
240
|
+
ARY_204_EMPTY_HASH_ARY
|
178
241
|
else # TODO: other formats
|
179
242
|
415
|
180
243
|
end
|
181
244
|
end
|
182
245
|
|
183
246
|
def odata_patch(req)
|
184
|
-
|
185
|
-
req.with_parsed_data(on_error: on_error) do |data|
|
247
|
+
req.with_parsed_data do |data|
|
186
248
|
data.delete('__metadata')
|
187
249
|
|
188
250
|
# validate payload column names
|
189
251
|
if (invalid = self.class.invalid_hash_data?(data))
|
190
|
-
|
191
|
-
return [422,
|
252
|
+
::OData::Request::ON_CGST_ERROR.call(req)
|
253
|
+
return [422, EMPTY_HASH, ['Invalid attribute name: ', invalid.to_s]]
|
192
254
|
end
|
193
255
|
# TODO: check values/types
|
194
256
|
|
@@ -203,7 +265,7 @@ module OData
|
|
203
265
|
update_fields(data, my_data_fields, missing: :skip)
|
204
266
|
end
|
205
267
|
# patch should return 204 + no content
|
206
|
-
|
268
|
+
ARY_204_EMPTY_HASH_ARY
|
207
269
|
end
|
208
270
|
end
|
209
271
|
|
@@ -218,11 +280,6 @@ module OData
|
|
218
280
|
@child_method.call.count
|
219
281
|
end
|
220
282
|
|
221
|
-
# TODO: fix design
|
222
|
-
def navigated_coll
|
223
|
-
true
|
224
|
-
end
|
225
|
-
|
226
283
|
def dataset
|
227
284
|
@child_dataset_method.call
|
228
285
|
end
|
@@ -236,6 +293,23 @@ module OData
|
|
236
293
|
y.each { |enty| yield enty }
|
237
294
|
end
|
238
295
|
|
296
|
+
# TODO: design... this is not DRY
|
297
|
+
def slug_field
|
298
|
+
superclass.slug_field
|
299
|
+
end
|
300
|
+
|
301
|
+
def type_name
|
302
|
+
superclass.type_name
|
303
|
+
end
|
304
|
+
|
305
|
+
def media_handler
|
306
|
+
superclass.media_handler
|
307
|
+
end
|
308
|
+
|
309
|
+
def default_template
|
310
|
+
superclass.default_template
|
311
|
+
end
|
312
|
+
|
239
313
|
def to_a
|
240
314
|
y = @child_method.call
|
241
315
|
y.to_a
|
@@ -254,6 +328,8 @@ module OData
|
|
254
328
|
# childattrib(collection)
|
255
329
|
@child_method = parent.method(childattrib.to_sym)
|
256
330
|
@child_dataset_method = parent.method("#{childattrib}_dataset".to_sym)
|
331
|
+
@nav_parent = parent
|
332
|
+
@navattr_reflection = parent.class.association_reflections[childattrib.to_sym]
|
257
333
|
prepare_pk
|
258
334
|
prepare_fields
|
259
335
|
# Now in this anonymous Class we can refine the "all, count and []
|
@@ -269,11 +345,134 @@ module OData
|
|
269
345
|
# 'Race[12].RaceType'
|
270
346
|
# where Race[12] would be our self and 'RaceType' is the single
|
271
347
|
# childattrib entity
|
272
|
-
|
273
|
-
|
348
|
+
|
349
|
+
# when the child attribute is nil (because the FK is NULL on DB)
|
350
|
+
# then we return a Nil... wrapper object. This object then
|
351
|
+
# allows to receive a POST operation that would actually create the nav attribute entity
|
352
|
+
|
353
|
+
ret = method(childattrib.to_sym).call || OData::NilNavigationAttribute.new
|
354
|
+
|
355
|
+
ret.set_relation_info(self, childattrib)
|
356
|
+
|
357
|
+
ret
|
274
358
|
end
|
275
359
|
end
|
276
360
|
# end of module ODataEntity
|
361
|
+
module Entity
|
362
|
+
include EntityBase
|
363
|
+
end
|
364
|
+
|
365
|
+
module NonMediaEntity
|
366
|
+
# non media entity metadata for json h
|
367
|
+
def metadata_h(uribase:)
|
368
|
+
{ uri: uri(uribase),
|
369
|
+
type: type_name }
|
370
|
+
end
|
371
|
+
|
372
|
+
def values_for_odata
|
373
|
+
values.dup
|
374
|
+
end
|
375
|
+
|
376
|
+
def odata_delete(req)
|
377
|
+
if req.accept?(APPJSON)
|
378
|
+
# delete
|
379
|
+
begin
|
380
|
+
odata_delete_relation_and_entity(req, @navattr_reflection, @nav_parent)
|
381
|
+
[200, CT_JSON, [{ 'd' => req.service.get_emptycoll_odata_h }.to_json]]
|
382
|
+
rescue SequelAdapterError => e
|
383
|
+
BadRequestSequelAdapterError.new(e).odata_get(req)
|
384
|
+
end
|
385
|
+
else # TODO: other formats
|
386
|
+
415
|
387
|
+
end
|
388
|
+
end
|
389
|
+
|
390
|
+
# in case of a non media entity, we have to return an error on $value request
|
391
|
+
def odata_media_value_get(_req)
|
392
|
+
BadRequestNonMediaValue.odata_get
|
393
|
+
end
|
394
|
+
|
395
|
+
# in case of a non media entity, we have to return an error on $value PUT
|
396
|
+
def odata_media_value_put(_req)
|
397
|
+
BadRequestNonMediaValue.odata_get
|
398
|
+
end
|
399
|
+
end
|
400
|
+
|
401
|
+
module MediaEntity
|
402
|
+
# media entity metadata for json h
|
403
|
+
def metadata_h(uribase:)
|
404
|
+
{ uri: uri(uribase),
|
405
|
+
type: type_name,
|
406
|
+
media_src: media_src(uribase),
|
407
|
+
edit_media: edit_media(uribase),
|
408
|
+
content_type: @values[:content_type] }
|
409
|
+
end
|
410
|
+
|
411
|
+
def media_src(uribase)
|
412
|
+
version = self.class.media_handler.ressource_version(self)
|
413
|
+
"#{uri(uribase)}/$value?version=#{version}"
|
414
|
+
end
|
415
|
+
|
416
|
+
def edit_media(uribase)
|
417
|
+
"#{uri(uribase)}/$value"
|
418
|
+
end
|
419
|
+
|
420
|
+
# directory where to put/find the media files for this entity-type
|
421
|
+
def klass_dir
|
422
|
+
type_name
|
423
|
+
end
|
424
|
+
|
425
|
+
# # this is just ModelKlass/pk as a single string
|
426
|
+
# def qualified_media_path_id
|
427
|
+
# "#{self.class}/#{media_path_id}"
|
428
|
+
# end
|
429
|
+
|
430
|
+
def values_for_odata
|
431
|
+
ret = values.dup
|
432
|
+
ret.delete(:content_type)
|
433
|
+
ret
|
434
|
+
end
|
435
|
+
|
436
|
+
def odata_delete(req)
|
437
|
+
if req.accept?(APPJSON)
|
438
|
+
# delete the MR
|
439
|
+
# delegate to the media handler on collection(ie class) level
|
440
|
+
# TODO error handling
|
441
|
+
|
442
|
+
self.class.media_handler.odata_delete(entity: self)
|
443
|
+
# delete the relation(s) to parent(s) (if any) and then entity
|
444
|
+
odata_delete_relation_and_entity(req, @navattr_reflection, @nav_parent)
|
445
|
+
# result
|
446
|
+
[200, CT_JSON, [{ 'd' => req.service.get_emptycoll_odata_h }.to_json]]
|
447
|
+
else # TODO: other formats
|
448
|
+
415
|
449
|
+
end
|
450
|
+
end
|
451
|
+
|
452
|
+
# real implementation for returning $value for a media entity
|
453
|
+
def odata_media_value_get(req)
|
454
|
+
# delegate to the media handler on collection(ie class) level
|
455
|
+
self.class.media_handler.odata_get(request: req, entity: self)
|
456
|
+
end
|
457
|
+
|
458
|
+
# real implementation for replacing $value for a media entity
|
459
|
+
def odata_media_value_put(req)
|
460
|
+
model = self.class
|
461
|
+
req.with_media_data do |data, mimetype, filename|
|
462
|
+
emdata = { content_type: mimetype }
|
463
|
+
if req.in_changeset
|
464
|
+
set_fields(emdata, model.data_fields, missing: :skip)
|
465
|
+
save(transaction: false)
|
466
|
+
else
|
467
|
+
update_fields(emdata, model.data_fields, missing: :skip)
|
468
|
+
end
|
469
|
+
model.media_handler.replace_file(data: data,
|
470
|
+
entity: self,
|
471
|
+
filename: filename)
|
472
|
+
ARY_204_EMPTY_HASH_ARY
|
473
|
+
end
|
474
|
+
end
|
475
|
+
end
|
277
476
|
|
278
477
|
# for a single public key
|
279
478
|
module EntitySinglePK
|
@@ -281,15 +480,30 @@ module OData
|
|
281
480
|
def pk_uri
|
282
481
|
pk
|
283
482
|
end
|
483
|
+
|
484
|
+
def media_path_id
|
485
|
+
pk.to_s
|
486
|
+
end
|
487
|
+
|
488
|
+
def media_path_ids
|
489
|
+
[pk]
|
490
|
+
end
|
284
491
|
end
|
285
492
|
|
286
493
|
# for multiple key
|
287
494
|
module EntityMultiPK
|
288
495
|
include Entity
|
289
496
|
def pk_uri
|
290
|
-
#
|
291
|
-
|
292
|
-
|
497
|
+
# pk_hash is provided by Sequel
|
498
|
+
pk_hash.map { |k, v| "#{k}='#{v}'" }.join(COMMA)
|
499
|
+
end
|
500
|
+
|
501
|
+
def media_path_id
|
502
|
+
pk_hash.values.join(SPACE)
|
503
|
+
end
|
504
|
+
|
505
|
+
def media_path_ids
|
506
|
+
pk_hash.values
|
293
507
|
end
|
294
508
|
end
|
295
509
|
end
|