rest_framework 0.7.5 → 0.7.6

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: af670b7b95c56b3afafeb8947b732be7e5ff3adc9f26b7bdd8f3dc6705c48094
4
- data.tar.gz: ba4de3b422fcae2451bbb5ea5e979acaaaedfba53773ddf3a77f16c97fd4db85
3
+ metadata.gz: edeb7963dbca0185ff384e628a185c83341e3154d6414c9d8f70a05115abd23a
4
+ data.tar.gz: 8d6bc94fea07e57c20625c8aa192ced88789e078bf3a4b29b71e6c80099f2436
5
5
  SHA512:
6
- metadata.gz: 1f98df76846047b3435c6caaf071ebd4ac60a9ddd9da517b621d21d9e84498a28d5387e2338128c4b7815d36f0750e6c06d84147409a9ce3982a329d626a9c60
7
- data.tar.gz: 3aa62deb130160763d08b1487496973bdc587968c278cf26be0390d9e3464d4c408ea9f30d01d8fb38702a93e311df26a81b54f3b5adf9a49b344667af5d7508
6
+ metadata.gz: 68ecf8a8a045c3e217c597f86b46573dc80bb931e20be45af01b939079f8cf2dd27d68e8bebd8d458726b04b8b55fb2dd65a60bcf4c847c30feb1257f7cfb9ea
7
+ data.tar.gz: 388478f7c8f2c51a96740f25043aa7d188b5e59731478f4bca2ce6171fadc5ba1e201b12ee051859386ce151c1114b9b6f207dbe840e732dec652d9639323825
data/VERSION CHANGED
@@ -1 +1 @@
1
- 0.7.5
1
+ 0.7.6
@@ -12,7 +12,6 @@ module RESTFramework::BaseControllerMixin
12
12
  :created_at, :created_by, :created_by_id, :updated_at, :updated_by, :updated_by_id
13
13
  ].freeze,
14
14
  accept_generic_params_as_body_params: false,
15
- show_backtrace: false,
16
15
  extra_actions: nil,
17
16
  extra_member_actions: nil,
18
17
  filter_backends: nil,
@@ -21,7 +20,7 @@ module RESTFramework::BaseControllerMixin
21
20
  # Options related to metadata and display.
22
21
  title: nil,
23
22
  description: nil,
24
- inflect_acronyms: ["ID", "REST", "API"].freeze,
23
+ inflect_acronyms: ["ID", "IDs", "REST", "API", "APIs"].freeze,
25
24
 
26
25
  # Options related to serialization.
27
26
  rescue_unknown_format_with: :json,
@@ -170,11 +169,17 @@ module RESTFramework::BaseControllerMixin
170
169
  end
171
170
 
172
171
  # Handle some common exceptions.
173
- base.rescue_from(ActiveRecord::RecordNotFound, with: :record_not_found)
174
- base.rescue_from(ActiveRecord::RecordInvalid, with: :record_invalid)
175
- base.rescue_from(ActiveRecord::RecordNotSaved, with: :record_not_saved)
176
- base.rescue_from(ActiveRecord::RecordNotDestroyed, with: :record_not_destroyed)
177
- base.rescue_from(ActiveModel::UnknownAttributeError, with: :unknown_attribute_error)
172
+ unless RESTFramework.config.disable_rescue_from
173
+ base.rescue_from(
174
+ ActiveRecord::RecordNotFound,
175
+ ActiveRecord::RecordInvalid,
176
+ ActiveRecord::RecordNotSaved,
177
+ ActiveRecord::RecordNotDestroyed,
178
+ ActiveRecord::RecordNotUnique,
179
+ ActiveModel::UnknownAttributeError,
180
+ with: :rrf_error_handler,
181
+ )
182
+ end
178
183
 
179
184
  # Use `TracePoint` hook to automatically call `rrf_finalize`.
180
185
  unless RESTFramework.config.disable_auto_finalize
@@ -222,6 +227,7 @@ module RESTFramework::BaseControllerMixin
222
227
 
223
228
  # Filter an arbitrary data set over all configured filter backends.
224
229
  def get_filtered_data(data)
230
+ # Apply each filter sequentially.
225
231
  self.get_filter_backends.each do |filter_class|
226
232
  filter = filter_class.new(controller: self)
227
233
  data = filter.get_filtered_data(data)
@@ -234,48 +240,21 @@ module RESTFramework::BaseControllerMixin
234
240
  return self.class.get_options_metadata
235
241
  end
236
242
 
237
- def record_invalid(e)
238
- return api_response(
239
- {
240
- message: "Record invalid.", errors: e.record&.errors
241
- }.merge(self.class.show_backtrace ? {exception: e.full_message} : {}),
242
- status: 400,
243
- )
244
- end
245
-
246
- def record_not_found(e)
247
- return api_response(
248
- {
249
- message: "Record not found.",
250
- }.merge(self.class.show_backtrace ? {exception: e.full_message} : {}),
251
- status: 404,
252
- )
253
- end
254
-
255
- def record_not_saved(e)
256
- return api_response(
257
- {
258
- message: "Record not saved.", errors: e.record&.errors
259
- }.merge(self.class.show_backtrace ? {exception: e.full_message} : {}),
260
- status: 400,
261
- )
262
- end
263
-
264
- def record_not_destroyed(e)
265
- return api_response(
266
- {
267
- message: "Record not destroyed.", errors: e.record&.errors
268
- }.merge(self.class.show_backtrace ? {exception: e.full_message} : {}),
269
- status: 400,
270
- )
271
- end
243
+ def rrf_error_handler(e)
244
+ status = case e
245
+ when ActiveRecord::RecordNotFound
246
+ 404
247
+ else
248
+ 400
249
+ end
272
250
 
273
- def unknown_attribute_error(e)
274
251
  return api_response(
275
252
  {
276
- message: e.message.capitalize,
277
- }.merge(self.class.show_backtrace ? {exception: e.full_message} : {}),
278
- status: 400,
253
+ message: e.message,
254
+ errors: e.try(:record).try(:errors),
255
+ exception: RESTFramework.config.show_backtrace ? e.full_message : nil,
256
+ }.compact,
257
+ status: status,
279
258
  )
280
259
  end
281
260
 
@@ -2,14 +2,44 @@ require_relative "models"
2
2
 
3
3
  # Mixin for creating records in bulk. This is unique compared to update/destroy because we overload
4
4
  # the existing `create` action to support bulk creation.
5
+ # :nocov:
5
6
  module RESTFramework::BulkCreateModelMixin
6
7
  def create
7
- raise NotImplementedError, "TODO"
8
+ status, payload = self.create_all!
9
+ return api_response(payload, status: status)
8
10
  end
9
11
 
10
- # Perform the `create!` call and return the created record.
12
+ # Perform the `create` or `insert_all` call and return the created records with any errors. The
13
+ # result should be of the form: `(status, payload)`, and `payload` should be of the form:
14
+ # `[{success:, record: | errors:}]`, unless batch mode is enabled, in which case `payload` is
15
+ # blank with a status of `202`.
11
16
  def create_all!
12
- raise NotImplementedError, "TODO"
17
+ if self.class.bulk_batch_mode
18
+ insert_from = if self.get_recordset.respond_to?(:insert_all) && self.create_from_recordset
19
+ # Create with any properties inherited from the recordset. We exclude any `select` clauses
20
+ # in case model callbacks need to call `count` on this collection, which typically raises a
21
+ # SQL `SyntaxError`.
22
+ self.get_recordset.except(:select)
23
+ else
24
+ # Otherwise, perform a "bare" insert_all.
25
+ self.class.get_model
26
+ end
27
+
28
+ insert_from
29
+ end
30
+
31
+ # Perform bulk creation, possibly in a transaction.
32
+ self.class._rrf_bulk_transaction do
33
+ if self.get_recordset.respond_to?(:insert_all) && self.create_from_recordset
34
+ # Create with any properties inherited from the recordset. We exclude any `select` clauses
35
+ # in case model callbacks need to call `count` on this collection, which typically raises a
36
+ # SQL `SyntaxError`.
37
+ return self.get_recordset.except(:select).create!(self.get_create_params)
38
+ else
39
+ # Otherwise, perform a "bare" insert_all.
40
+ return self.class.get_model.insert_all(self.get_create_params)
41
+ end
42
+ end
13
43
  end
14
44
  end
15
45
 
@@ -49,3 +79,4 @@ module RESTFramework::BulkModelControllerMixin
49
79
  RESTFramework::ModelControllerMixin.included(base)
50
80
  end
51
81
  end
82
+ # :nocov:
@@ -12,6 +12,7 @@ module RESTFramework::BaseModelControllerMixin
12
12
 
13
13
  # Attributes for configuring record fields.
14
14
  fields: nil,
15
+ field_config: nil,
15
16
  action_fields: nil,
16
17
 
17
18
  # Attributes for finding records.
@@ -38,9 +39,22 @@ module RESTFramework::BaseModelControllerMixin
38
39
  search_query_param: "search",
39
40
  search_ilike: false,
40
41
 
41
- # Other misc attributes.
42
- create_from_recordset: true, # Option for `recordset.create` vs `Model.create` behavior.
43
- filter_recordset_before_find: true, # Control if filtering is done before find.
42
+ # Option for `recordset.create` vs `Model.create` behavior.
43
+ create_from_recordset: true,
44
+
45
+ # Control if filtering is done before find.
46
+ filter_recordset_before_find: true,
47
+
48
+ # Option to exclude associations from default fields.
49
+ exclude_associations: false,
50
+
51
+ # Control if bulk operations are done in a transaction and rolled back on error, or if all bulk
52
+ # operations are attempted and errors simply returned in the response.
53
+ bulk_transactional: false,
54
+
55
+ # Control if bulk operations should be done in "batch" mode, using efficient queries, but also
56
+ # skipping model validations/callbacks.
57
+ bulk_batch_mode: false,
44
58
  }
45
59
 
46
60
  module ClassMethods
@@ -77,14 +91,54 @@ module RESTFramework::BaseModelControllerMixin
77
91
  return self.get_model.human_attribute_name(s, default: super)
78
92
  end
79
93
 
94
+ # Get fields without any action context. Always fallback to columns at the class level.
95
+ def get_fields
96
+ if self.fields.is_a?(Hash)
97
+ return RESTFramework::Utils.parse_fields_hash(
98
+ self.fields, self.get_model, exclude_associations: self.exclude_associations
99
+ )
100
+ end
101
+
102
+ return self.fields || (
103
+ self.get_model ? RESTFramework::Utils.fields_for(
104
+ self.get_model, exclude_associations: self.exclude_associations
105
+ ) : []
106
+ )
107
+ end
108
+
109
+ # Get a field's config, including defaults.
110
+ def get_field_config(f)
111
+ config = self.field_config&.dig(f.to_sym) || {}
112
+
113
+ # Default sub-fields if field is an association.
114
+ if ref = self.get_model.reflections[f]
115
+ model = ref.klass
116
+ columns = model.columns_hash
117
+ config[:sub_fields] ||= RESTFramework::Utils.sub_fields_for(ref)
118
+
119
+ # Serialize very basic metadata about sub-fields.
120
+ config[:sub_fields_metadata] = config[:sub_fields].map { |sf|
121
+ v = {}
122
+
123
+ if columns[sf]
124
+ v[:kind] = "column"
125
+ end
126
+
127
+ next [sf, v]
128
+ }.to_h.compact.presence
129
+ end
130
+
131
+ return config.compact
132
+ end
133
+
80
134
  # Get metadata about the resource's fields.
81
- def get_fields_metadata(fields: nil)
135
+ def get_fields_metadata
82
136
  # Get metadata sources.
83
137
  model = self.get_model
84
- fields ||= self.get_fields
85
- fields = fields.map(&:to_s)
138
+ fields = self.get_fields.map(&:to_s)
86
139
  columns = model.columns_hash
87
140
  column_defaults = model.column_defaults
141
+ reflections = model.reflections
88
142
  attributes = model._default_attributes
89
143
 
90
144
  return fields.map { |f|
@@ -105,9 +159,9 @@ module RESTFramework::BaseModelControllerMixin
105
159
 
106
160
  # Determine `type`, `required`, `label`, and `kind` based on schema.
107
161
  if column = columns[f]
162
+ metadata[:kind] = "column"
108
163
  metadata[:type] = column.type
109
164
  metadata[:required] = true unless column.null
110
- metadata[:kind] = "column"
111
165
  end
112
166
 
113
167
  # Determine `default` based on schema; we use `column_defaults` rather than `columns_hash`
@@ -117,25 +171,46 @@ module RESTFramework::BaseModelControllerMixin
117
171
  metadata[:default] = column_default
118
172
  end
119
173
 
120
- # Determine `default` and `kind` based on attribute only if not determined by the DB.
174
+ # Extract details from the model's attributes hash.
121
175
  if attributes.key?(f) && attribute = attributes[f]
122
176
  unless metadata.key?(:default)
123
177
  default = attribute.value_before_type_cast
124
178
  metadata[:default] = default unless default.nil?
125
179
  end
180
+ metadata[:kind] ||= "attribute"
126
181
 
127
- unless metadata[:kind]
128
- metadata[:kind] = "attribute"
182
+ # Get any type information from the attribute.
183
+ if type = attribute.type
184
+ metadata[:type] ||= type.type
185
+
186
+ # Get enum variants.
187
+ if type.is_a?(ActiveRecord::Enum::EnumType)
188
+ metadata[:enum_variants] = type.send(:mapping)
189
+ end
129
190
  end
130
191
  end
131
192
 
132
- # Determine if `kind` is a association or method if not determined already.
133
- unless metadata[:kind]
134
- if association = model.reflections[f]
135
- metadata[:kind] = "association.#{association.macro}"
136
- elsif model.method_defined?(f)
137
- metadata[:kind] = "method"
193
+ # Get association metadata.
194
+ if ref = reflections[f]
195
+ metadata[:kind] = "association"
196
+ begin
197
+ pk = ref.active_record_primary_key
198
+ rescue ActiveRecord::UnknownPrimaryKey
138
199
  end
200
+ metadata[:association] = {
201
+ macro: ref.macro,
202
+ class_name: ref.class_name,
203
+ foreign_key: ref.foreign_key,
204
+ primary_key: pk,
205
+ polymorphic: ref.polymorphic?,
206
+ table_name: ref.table_name,
207
+ options: ref.options.presence,
208
+ }.compact
209
+ end
210
+
211
+ # Determine if this is just a method.
212
+ if model.method_defined?(f)
213
+ metadata[:kind] ||= "method"
139
214
  end
140
215
 
141
216
  # Collect validator options into a hash on their type, while also updating `required` based
@@ -156,37 +231,18 @@ module RESTFramework::BaseModelControllerMixin
156
231
  metadata[:validators][kind] << options
157
232
  end
158
233
 
159
- next [f, metadata.compact]
160
- }.to_h
161
- end
162
-
163
- # Get metadata about the resource's associations (reflections).
164
- def get_associations_metadata
165
- return self.get_model.reflections.map { |k, v|
166
- begin
167
- pk = v.active_record_primary_key
168
- rescue ActiveRecord::UnknownPrimaryKey
169
- end
234
+ # Serialize any field config.
235
+ metadata[:config] = self.get_field_config(f).presence
170
236
 
171
- next [k, {
172
- macro: v.macro,
173
- label: self.get_label(k),
174
- class_name: v.class_name,
175
- foreign_key: v.foreign_key,
176
- primary_key: pk,
177
- polymorphic: v.polymorphic?,
178
- table_name: v.table_name,
179
- options: v.options.presence,
180
- }.compact]
237
+ next [f, metadata.compact]
181
238
  }.to_h
182
239
  end
183
240
 
184
241
  # Get a hash of metadata to be rendered in the `OPTIONS` response. Cache the result.
185
- def get_options_metadata(fields: nil)
242
+ def get_options_metadata
186
243
  return super().merge(
187
244
  {
188
- fields: self.get_fields_metadata(fields: fields),
189
- associations: self.get_associations_metadata,
245
+ fields: self.get_fields_metadata,
190
246
  },
191
247
  )
192
248
  end
@@ -271,27 +327,23 @@ module RESTFramework::BaseModelControllerMixin
271
327
  return (action_config[action] if action) || self.class.send(generic_config_key)
272
328
  end
273
329
 
274
- # Get fields without any action context. Always fallback to columns at the class level.
275
- def self.get_fields
276
- if self.fields.is_a?(Hash)
277
- return RESTFramework::Utils.parse_fields_hash(self.fields, self.get_model)
278
- end
279
-
280
- return self.fields || self.get_model&.column_names || []
281
- end
282
-
283
330
  # Get a list of fields for the current action. Returning `nil` indicates that anything should be
284
331
  # accepted unless `fallback` is true, in which case we should fallback to this controller's model
285
332
  # columns, or en empty array.
286
333
  def get_fields(fallback: false)
287
334
  fields = _get_specific_action_config(:action_fields, :fields)
288
335
 
289
- # If fields is a hash, then parse using columns as a base, respecting `only` and `except`.
336
+ # If fields is a hash, then parse it.
290
337
  if fields.is_a?(Hash)
291
- return RESTFramework::Utils.parse_fields_hash(fields, self.class.get_model)
338
+ return RESTFramework::Utils.parse_fields_hash(
339
+ fields, self.class.get_model, exclude_associations: self.class.exclude_associations
340
+ )
292
341
  elsif !fields && fallback
293
342
  # Otherwise, if fields is nil and fallback is true, then fallback to columns.
294
- return self.class.get_model&.column_names || []
343
+ model = self.class.get_model
344
+ return model ? RESTFramework::Utils.fields_for(
345
+ model, exclude_associations: self.class.exclude_associations
346
+ ) : []
295
347
  end
296
348
 
297
349
  return fields
@@ -299,7 +351,7 @@ module RESTFramework::BaseModelControllerMixin
299
351
 
300
352
  # Pass fields to get dynamic metadata based on which fields are available.
301
353
  def get_options_metadata
302
- return self.class.get_options_metadata(fields: self.get_fields(fallback: true))
354
+ return self.class.get_options_metadata
303
355
  end
304
356
 
305
357
  # Get a list of find_by fields for the current action. Do not fallback to columns in case the user
@@ -330,16 +382,16 @@ module RESTFramework::BaseModelControllerMixin
330
382
  end
331
383
 
332
384
  # Filter the request body for keys in current action's allowed_parameters/fields config.
333
- def get_body_params(request_parameters: nil)
334
- request_parameters ||= request.request_parameters
385
+ def get_body_params(data: nil)
386
+ data ||= request.request_parameters
335
387
 
336
388
  # Filter the request body and map to strings. Return all params if we cannot resolve a list of
337
389
  # allowed parameters or fields.
338
390
  allowed_params = self.get_allowed_parameters&.map(&:to_s)
339
391
  body_params = if allowed_params
340
- request_parameters.select { |p| allowed_params.include?(p) }
392
+ data.select { |p| allowed_params.include?(p) }
341
393
  else
342
- request_parameters
394
+ data
343
395
  end
344
396
 
345
397
  # Add query params in place of missing body params, if configured.
@@ -411,7 +463,17 @@ module RESTFramework::BaseModelControllerMixin
411
463
  end
412
464
 
413
465
  # Return the record. Route key is always `:id` by Rails convention.
414
- return @record = recordset.find_by!(find_by_key => params[:id])
466
+ return @record = recordset.find_by!(find_by_key => request.path_parameters[:id])
467
+ end
468
+
469
+ # Create a transaction around the passed block, if configured. This is used primarily for bulk
470
+ # actions, but we include it here so it's always available.
471
+ def self._rrf_bulk_transaction(&block)
472
+ if self.bulk_transactional
473
+ ActiveRecord::Base.transaction(&block)
474
+ else
475
+ yield
476
+ end
415
477
  end
416
478
  end
417
479
 
@@ -458,15 +520,17 @@ module RESTFramework::CreateModelMixin
458
520
 
459
521
  # Perform the `create!` call and return the created record.
460
522
  def create!
461
- if self.get_recordset.respond_to?(:create!) && self.create_from_recordset
523
+ create_from = if self.get_recordset.respond_to?(:create!) && self.create_from_recordset
462
524
  # Create with any properties inherited from the recordset. We exclude any `select` clauses in
463
525
  # case model callbacks need to call `count` on this collection, which typically raises a SQL
464
526
  # `SyntaxError`.
465
- return self.get_recordset.except(:select).create!(self.get_create_params)
527
+ self.get_recordset.except(:select)
466
528
  else
467
529
  # Otherwise, perform a "bare" create.
468
- return self.class.get_model.create!(self.get_create_params)
530
+ self.class.get_model
469
531
  end
532
+
533
+ return create_from.create!(self.get_create_params)
470
534
  end
471
535
  end
472
536
 
@@ -14,23 +14,59 @@ class RESTFramework::ModelFilter < RESTFramework::BaseFilter
14
14
  # Get a list of filterset fields for the current action. Fallback to columns because we don't want
15
15
  # to try filtering by any query parameter because that could clash with other query parameters.
16
16
  def _get_fields
17
- return @controller.class.filterset_fields || @controller.get_fields(fallback: true)
17
+ return @_get_fields ||= (
18
+ @controller.class.filterset_fields || @controller.get_fields(fallback: true)
19
+ ).map(&:to_s)
18
20
  end
19
21
 
20
22
  # Filter params for keys allowed by the current action's filterset_fields/fields config.
21
23
  def _get_filter_params
22
24
  # Map filterset fields to strings because query parameter keys are strings.
23
- if fields = self._get_fields.map(&:to_s)
24
- return @controller.request.query_parameters.select { |p, _| fields.include?(p) }
25
- end
25
+ fields = self._get_fields
26
+ @associations = []
27
+
28
+ return @controller.request.query_parameters.select { |p, _|
29
+ # Remove any trailing `__in` from the field name.
30
+ field = p.chomp("__in")
31
+
32
+ # Remove any associations whose sub-fields are not filterable. Also populate `@associations`
33
+ # so the caller can include them.
34
+ if match = /(.*)\.(.*)/.match(field)
35
+ field, sub_field = match[1..2]
36
+ next false unless field.in?(fields)
37
+
38
+ sub_fields = @controller.class.get_field_config(field)[:sub_fields]
39
+ if sub_field.in?(sub_fields)
40
+ @associations << field.to_sym
41
+ next true
42
+ end
43
+
44
+ next false
45
+ end
46
+
47
+ next field.in?(fields)
48
+ }.map { |p, v|
49
+ # Convert fields ending in `__in` to array values.
50
+ if p.end_with?("__in")
51
+ p = p.chomp("__in")
52
+ v = v.split(",")
53
+ end
54
+
55
+ # Convert "nil" and "null" to nil.
56
+ if v == "nil" || v == "null"
57
+ v = nil
58
+ end
26
59
 
27
- return @controller.request.query_parameters.to_h
60
+ [p, v]
61
+ }.to_h.symbolize_keys
28
62
  end
29
63
 
30
64
  # Filter data according to the request query parameters.
31
65
  def get_filtered_data(data)
32
- filter_params = self._get_filter_params.symbolize_keys
33
- unless filter_params.blank?
66
+ if filter_params = self._get_filter_params.presence
67
+ # Include any associations.
68
+ data = data.includes(*@associations) unless @associations.empty?
69
+
34
70
  return data.where(**filter_params)
35
71
  end
36
72
 
@@ -43,18 +79,22 @@ class RESTFramework::ModelOrderingFilter < RESTFramework::BaseFilter
43
79
  # Get a list of ordering fields for the current action. Do not fallback to columns in case the
44
80
  # user wants to order by a virtual column.
45
81
  def _get_fields
46
- return @controller.class.ordering_fields || @controller.get_fields
82
+ return @_get_fields ||= (
83
+ @controller.class.ordering_fields || @controller.get_fields
84
+ )&.map(&:to_s)
47
85
  end
48
86
 
49
87
  # Convert ordering string to an ordering configuration.
50
88
  def _get_ordering
51
89
  return nil if @controller.class.ordering_query_param.blank?
52
90
 
91
+ @associations = []
92
+
53
93
  # Ensure ordering_fields are strings since the split param will be strings.
54
- fields = self._get_fields&.map(&:to_s)
94
+ fields = self._get_fields
55
95
  order_string = @controller.params[@controller.class.ordering_query_param]
56
96
 
57
- if order_string.present? && fields
97
+ if order_string.present?
58
98
  ordering = {}.with_indifferent_access
59
99
  order_string.split(",").each do |field|
60
100
  if field[0] == "-"
@@ -64,9 +104,19 @@ class RESTFramework::ModelOrderingFilter < RESTFramework::BaseFilter
64
104
  column = field
65
105
  direction = :asc
66
106
  end
67
- if !fields || column.in?(fields)
68
- ordering[column] = direction
107
+ next unless !fields || column.in?(fields)
108
+
109
+ # Populate any `@associations` so the caller can include them.
110
+ if match = /(.*)\.(.*)/.match(column)
111
+ association, sub_field = match[1..2]
112
+ @associations << association.to_sym
113
+
114
+ # Also, due to Rails weirdness, we need to convert the association name to the table name.
115
+ table_name = @controller.class.get_model.reflections[association].table_name
116
+ column = "#{table_name}.#{sub_field}"
69
117
  end
118
+
119
+ ordering[column] = direction
70
120
  end
71
121
  return ordering
72
122
  end
@@ -80,7 +130,10 @@ class RESTFramework::ModelOrderingFilter < RESTFramework::BaseFilter
80
130
  reorder = !@controller.class.ordering_no_reorder
81
131
 
82
132
  if ordering && !ordering.empty?
83
- return data.send(reorder ? :reorder : :order, _get_ordering)
133
+ # Include any associations.
134
+ data = data.includes(*@associations) unless @associations.empty?
135
+
136
+ return data.send(reorder ? :reorder : :order, ordering)
84
137
  end
85
138
 
86
139
  return data
@@ -148,16 +148,14 @@ class RESTFramework::NativeSerializer < RESTFramework::BaseSerializer
148
148
  return subcfg
149
149
  end
150
150
 
151
- # Filter out configuration properties based on the :except query parameter.
152
- def filter_except(cfg)
151
+ # Filter out configuration properties based on the :except/:only query parameters.
152
+ def filter_from_request(cfg)
153
153
  return cfg unless @controller
154
154
 
155
155
  except_param = @controller.class.try(:native_serializer_except_query_param)
156
156
  only_param = @controller.class.try(:native_serializer_only_query_param)
157
157
  if except_param && except = @controller.request.query_parameters[except_param].presence
158
- except = except.split(",").map(&:strip).map(&:to_sym)
159
-
160
- unless except.empty?
158
+ if except = except.split(",").map(&:strip).map(&:to_sym).presence
161
159
  # Filter `only`, `except` (additive), `include`, `methods`, and `serializer_methods`.
162
160
  if cfg[:only]
163
161
  cfg[:only] = self.class.filter_subcfg(cfg[:only], fields: except)
@@ -166,6 +164,7 @@ class RESTFramework::NativeSerializer < RESTFramework::BaseSerializer
166
164
  else
167
165
  cfg[:except] = except
168
166
  end
167
+
169
168
  cfg[:include] = self.class.filter_subcfg(cfg[:include], fields: except)
170
169
  cfg[:methods] = self.class.filter_subcfg(cfg[:methods], fields: except)
171
170
  cfg[:serializer_methods] = self.class.filter_subcfg(
@@ -173,20 +172,15 @@ class RESTFramework::NativeSerializer < RESTFramework::BaseSerializer
173
172
  )
174
173
  end
175
174
  elsif only_param && only = @controller.request.query_parameters[only_param].presence
176
- only = only.split(",").map(&:strip).map(&:to_sym)
177
-
178
- unless only.empty?
179
- # Filter `only`, `except` (additive), `include`, and `methods`.
175
+ if only = only.split(",").map(&:strip).map(&:to_sym).presence
176
+ # Filter `only`, `include`, and `methods`. Adding anything to `except` is not needed,
177
+ # because any configuration there takes precedence over `only`.
180
178
  if cfg[:only]
181
179
  cfg[:only] = self.class.filter_subcfg(cfg[:only], fields: only, only: true)
182
- elsif cfg[:except]
183
- # For the `except` part of the serializer, we need to append any columns not in `only`.
184
- model = @controller.class.get_model
185
- except_cols = model&.column_names&.map(&:to_sym)&.reject { |c| c.in?(only) }
186
- cfg[:except] = self.class.filter_subcfg(cfg[:except], fields: except_cols, add: true)
187
180
  else
188
181
  cfg[:only] = only
189
182
  end
183
+
190
184
  cfg[:include] = self.class.filter_subcfg(cfg[:include], fields: only, only: true)
191
185
  cfg[:methods] = self.class.filter_subcfg(cfg[:methods], fields: only, only: true)
192
186
  cfg[:serializer_methods] = self.class.filter_subcfg(
@@ -212,18 +206,28 @@ class RESTFramework::NativeSerializer < RESTFramework::BaseSerializer
212
206
  end
213
207
 
214
208
  # If the config wasn't determined, build a serializer config from controller fields.
215
- if fields = @controller&.get_fields
209
+ if fields = @controller&.get_fields(fallback: true)
216
210
  fields = fields.deep_dup
217
211
 
218
212
  columns = []
219
- includes = []
213
+ includes = {}
220
214
  methods = []
221
215
  if @model
222
216
  fields.each do |f|
223
217
  if f.in?(@model.column_names)
224
218
  columns << f
225
219
  elsif @model.reflections.key?(f)
226
- includes << f
220
+ sub_columns = []
221
+ sub_methods = []
222
+ @controller.class.get_field_config(f)[:sub_fields].each do |sf|
223
+ sub_model = @model.reflections[f].klass
224
+ if sf.in?(sub_model.column_names)
225
+ sub_columns << sf
226
+ elsif sub_model.method_defined?(sf)
227
+ sub_methods << sf
228
+ end
229
+ end
230
+ includes[f] = {only: sub_columns, methods: sub_methods}
227
231
  elsif @model.method_defined?(f)
228
232
  methods << f
229
233
  end
@@ -241,7 +245,7 @@ class RESTFramework::NativeSerializer < RESTFramework::BaseSerializer
241
245
 
242
246
  # Get a configuration passable to `serializable_hash` for the object, filtered if required.
243
247
  def get_serializer_config
244
- return filter_except(self._get_raw_serializer_config)
248
+ return filter_from_request(self._get_raw_serializer_config)
245
249
  end
246
250
 
247
251
  # Serialize a single record and merge results of `serializer_methods`.
@@ -1,5 +1,6 @@
1
1
  module RESTFramework::Utils
2
2
  HTTP_METHOD_ORDERING = %w(GET POST PUT PATCH DELETE OPTIONS HEAD)
3
+ LABEL_FIELDS = %w(name label login title email username)
3
4
 
4
5
  # Convert `extra_actions` hash to a consistent format: `{path:, methods:, kwargs:}`, and
5
6
  # additional metadata fields.
@@ -139,15 +140,63 @@ module RESTFramework::Utils
139
140
  end
140
141
 
141
142
  # Parse fields hashes.
142
- def self.parse_fields_hash(fields_hash, model)
143
- parsed_fields = fields_hash[:only] || model&.column_names || []
144
- parsed_fields -= fields_hash[:except] if fields_hash[:except]
143
+ def self.parse_fields_hash(fields_hash, model, exclude_associations: nil)
144
+ parsed_fields = fields_hash[:only] || (
145
+ model ? self.fields_for(model, exclude_associations: exclude_associations) : []
146
+ )
147
+ parsed_fields += fields_hash[:include] if fields_hash[:include]
148
+ parsed_fields -= fields_hash[:exclude] if fields_hash[:exclude]
145
149
 
146
150
  # Warn for any unknown keys.
147
- (fields_hash.keys - [:only, :except]).each do |k|
151
+ (fields_hash.keys - [:only, :include, :exclude]).each do |k|
148
152
  Rails.logger.warn("RRF: Unknown key in fields hash: #{k}")
149
153
  end
150
154
 
151
155
  return parsed_fields
152
156
  end
157
+
158
+ # Get the fields for a given model, including not just columns (which includes
159
+ # foreign keys), but also associations.
160
+ def self.fields_for(model, exclude_associations: nil)
161
+ foreign_keys = model.reflect_on_all_associations(:belongs_to).map(&:foreign_key)
162
+
163
+ if exclude_associations
164
+ return model.column_names.reject { |c| c.in?(foreign_keys) }
165
+ end
166
+
167
+ # Add associations in addition to normal columns.
168
+ return model.column_names.reject { |c|
169
+ c.in?(foreign_keys)
170
+ } + model.reflections.map { |association, ref|
171
+ if ref.macro.in?([:has_many, :has_and_belongs_to_many]) &&
172
+ RESTFramework.config.large_reverse_association_tables&.include?(ref.table_name)
173
+ next nil
174
+ end
175
+
176
+ next association
177
+ }.compact
178
+ end
179
+
180
+ # Get the sub-fields that may be serialized and filtered/ordered for a reflection.
181
+ def self.sub_fields_for(ref)
182
+ model = ref.klass
183
+
184
+ if model
185
+ sub_fields = [model.primary_key].flatten.compact
186
+
187
+ # Preferrably find a database column to use as label.
188
+ if match = LABEL_FIELDS.find { |f| f.in?(model.column_names) }
189
+ return sub_fields + [match]
190
+ end
191
+
192
+ # Otherwise, find a method.
193
+ if match = LABEL_FIELDS.find { |f| model.method_defined?(f) }
194
+ return sub_fields + [match]
195
+ end
196
+
197
+ return sub_fields
198
+ end
199
+
200
+ return ["id", "name"]
201
+ end
153
202
  end
@@ -28,11 +28,25 @@ module RESTFramework
28
28
  # in:
29
29
  # - Model delegation, for the helper methods to be defined dynamically.
30
30
  # - Websockets, for `::Channel` class to be defined dynamically.
31
- # - Controller configuration finalization.
31
+ # - Controller configuration freezing.
32
32
  attr_accessor :disable_auto_finalize
33
33
 
34
34
  # Freeze configuration attributes during finalization to prevent accidental mutation.
35
35
  attr_accessor :freeze_config
36
+
37
+ # Specify reverse association tables that are typically very large, andd therefore should not be
38
+ # added to fields by default.
39
+ attr_accessor :large_reverse_association_tables
40
+
41
+ # Whether the backtrace should be shown in rescued errors.
42
+ attr_accessor :show_backtrace
43
+
44
+ # Option to disable `rescue_from` on the controller mixins.
45
+ attr_accessor :disable_rescue_from
46
+
47
+ def initialize
48
+ self.show_backtrace = Rails.env.development?
49
+ end
36
50
  end
37
51
 
38
52
  def self.config
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rest_framework
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.5
4
+ version: 0.7.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Gregory N. Schmit
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2023-01-11 00:00:00.000000000 Z
11
+ date: 2023-01-15 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails