rest_framework 0.7.5 → 0.7.6

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