• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

gregschmit / rails-rest-framework / 324

pending completion
324

push

travis-ci-com

gregschmit
Add option to allow all nested attributes by default.

11 of 11 new or added lines in 1 file covered. (100.0%)

805 of 887 relevant lines covered (90.76%)

74.33 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

86.56
/lib/rest_framework/controller_mixins/models.rb
1
require_relative "base"
1✔
2
require_relative "../filters"
1✔
3

4
# This module provides the core functionality for controllers based on models.
5
module RESTFramework::BaseModelControllerMixin
1✔
6
  include RESTFramework::BaseControllerMixin
1✔
7

8
  RRF_BASE_MODEL_CONTROLLER_CONFIG = {
9
    # Core attributes related to models.
10
    model: nil,
1✔
11
    recordset: nil,
12

13
    # Attributes for configuring record fields.
14
    fields: nil,
15
    field_config: nil,
16
    action_fields: nil,
17

18
    # Options for what should be included/excluded from default fields.
19
    exclude_associations: false,
20
    include_active_storage: false,
21
    include_action_text: false,
22

23
    # Attributes for finding records.
24
    find_by_fields: nil,
25
    find_by_query_param: "find_by",
26

27
    # Attributes for create/update parameters.
28
    allowed_parameters: nil,
29
    allowed_action_parameters: nil,
30

31
    # Attributes for the default native serializer.
32
    native_serializer_config: nil,
33
    native_serializer_singular_config: nil,
34
    native_serializer_plural_config: nil,
35
    native_serializer_only_query_param: "only",
36
    native_serializer_except_query_param: "except",
37
    native_serializer_associations_limit: nil,
38
    native_serializer_associations_limit_query_param: "associations_limit",
39
    native_serializer_include_associations_count: false,
40

41
    # Attributes for default model filtering, ordering, and searching.
42
    filterset_fields: nil,
43
    ordering_fields: nil,
44
    ordering_query_param: "ordering",
45
    ordering_no_reorder: false,
46
    search_fields: nil,
47
    search_query_param: "search",
48
    search_ilike: false,
49

50
    # Options for association assignment.
51
    permit_id_assignment: true,
52
    permit_nested_attributes_assignment: true,
53
    allow_all_nested_attributes: false,
54

55
    # Option for `recordset.create` vs `Model.create` behavior.
56
    create_from_recordset: true,
57

58
    # Control if filtering is done before find.
59
    filter_recordset_before_find: true,
60

61
    # Control if bulk operations are done in a transaction and rolled back on error, or if all bulk
62
    # operations are attempted and errors simply returned in the response.
63
    bulk_transactional: false,
64

65
    # Control if bulk operations should be done in "batch" mode, using efficient queries, but also
66
    # skipping model validations/callbacks.
67
    bulk_batch_mode: false,
68
  }
69

70
  module ClassMethods
1✔
71
    IGNORE_VALIDATORS_WITH_KEYS = [:if, :unless].freeze
1✔
72

73
    # Get the model for this controller.
74
    def get_model(from_get_recordset: false)
1✔
75
      return @model if @model
1,044✔
76
      return (@model = self.model) if self.model
14✔
77

78
      # Try to determine model from controller name.
79
      begin
80
        return @model = self.name.demodulize.chomp("Controller").singularize.constantize
8✔
81
      rescue NameError
82
      end
83

84
      # Delegate to the recordset's model, if it's defined. This option prevents infinite recursion.
85
      unless from_get_recordset
×
86
        # Instantiate a new controller to get the recordset.
87
        controller = self.new
×
88
        controller.request = ActionController::TestRequest.new
×
89
        controller.params = {}
×
90

91
        if (recordset = controller.get_recordset)
×
92
          return @model = recordset.klass
×
93
        end
94
      end
95

96
      return nil
97
    end
98

99
    # Override `get_label` to include ActiveRecord i18n-translated column names.
100
    def get_label(s)
1✔
101
      return self.get_model.human_attribute_name(s, default: super)
64✔
102
    end
103

104
    # Get the available fields. Returning `nil` indicates that anything should be accepted. If
105
    # `fallback` is true, then we should fallback to this controller's model columns, or an empty
106
    # array.
107
    def get_fields(input_fields: nil, fallback: true)
1✔
108
      input_fields ||= self.fields if fallback
453✔
109

110
      # If fields is a hash, then parse it.
111
      if input_fields.is_a?(Hash)
453✔
112
        return RESTFramework::Utils.parse_fields_hash(
13✔
113
          input_fields, self.get_model, exclude_associations: self.exclude_associations
114
        )
115
      elsif !input_fields && fallback
440✔
116
        # Otherwise, if fields is nil and fallback is true, then fallback to columns.
117
        model = self.get_model
272✔
118
        return model ? RESTFramework::Utils.fields_for(
272✔
119
          model, exclude_associations: self.exclude_associations
120
        ) : []
121
      end
122

123
      return input_fields
168✔
124
    end
125

126
    # Get a field's config, including defaults.
127
    def get_field_config(f)
1✔
128
      config = self.field_config&.dig(f.to_sym) || {}
298✔
129

130
      # Default sub-fields if field is an association.
131
      if ref = self.get_model.reflections[f.to_s]
298✔
132
        if ref.polymorphic?
254✔
133
          columns = {}
×
134
        else
135
          model = ref.klass
254✔
136
          columns = model.columns_hash
254✔
137
        end
138
        config[:sub_fields] ||= RESTFramework::Utils.sub_fields_for(ref)
254✔
139

140
        # Serialize very basic metadata about sub-fields.
141
        config[:sub_fields_metadata] = config[:sub_fields].map { |sf|
254✔
142
          v = {}
496✔
143

144
          if columns[sf]
496✔
145
            v[:kind] = "column"
472✔
146
          end
147

148
          next [sf, v]
496✔
149
        }.to_h.compact.presence
150
      end
151

152
      return config.compact
298✔
153
    end
154

155
    # Get metadata about the resource's fields.
156
    def get_fields_metadata
1✔
157
      # Get metadata sources.
158
      model = self.get_model
7✔
159
      fields = self.get_fields.map(&:to_s)
7✔
160
      columns = model.columns_hash
7✔
161
      column_defaults = model.column_defaults
7✔
162
      reflections = model.reflections
7✔
163
      attributes = model._default_attributes
7✔
164

165
      return fields.map { |f|
7✔
166
        # Initialize metadata to make the order consistent.
167
        metadata = {
168
          type: nil,
64✔
169
          kind: nil,
170
          label: self.get_label(f),
171
          primary_key: nil,
172
          required: nil,
173
          read_only: nil,
174
        }
175

176
        # Determine `primary_key` based on model.
177
        if model.primary_key == f
64✔
178
          metadata[:primary_key] = true
7✔
179
        end
180

181
        # Determine `type`, `required`, `label`, and `kind` based on schema.
182
        if column = columns[f]
64✔
183
          metadata[:kind] = "column"
44✔
184
          metadata[:type] = column.type
44✔
185
          metadata[:required] = true unless column.null
44✔
186
        end
187

188
        # Determine `default` based on schema; we use `column_defaults` rather than `columns_hash`
189
        # because these are casted to the proper type.
190
        column_default = column_defaults[f]
64✔
191
        unless column_default.nil?
64✔
192
          metadata[:default] = column_default
17✔
193
        end
194

195
        # Extract details from the model's attributes hash.
196
        if attributes.key?(f) && attribute = attributes[f]
64✔
197
          unless metadata.key?(:default)
44✔
198
            default = attribute.value_before_type_cast
27✔
199
            metadata[:default] = default unless default.nil?
27✔
200
          end
201
          metadata[:kind] ||= "attribute"
44✔
202

203
          # Get any type information from the attribute.
204
          if type = attribute.type
44✔
205
            metadata[:type] ||= type.type
44✔
206

207
            # Get enum variants.
208
            if type.is_a?(ActiveRecord::Enum::EnumType)
44✔
209
              metadata[:enum_variants] = type.send(:mapping)
2✔
210
            end
211
          end
212
        end
213

214
        # Get association metadata.
215
        if ref = reflections[f]
64✔
216
          metadata[:kind] = "association"
20✔
217

218
          # Determine if we render id/ids fields.
219
          if self.permit_id_assignment
20✔
220
            if ref.collection?
20✔
221
              metadata[:id_field] = "#{f.singularize}_ids"
12✔
222
            elsif ref.belongs_to?
8✔
223
              metadata[:id_field] = "#{f}_id"
6✔
224
            end
225
          end
226

227
          # Determine if we render nested attributes options.
228
          if self.permit_nested_attributes_assignment
20✔
229
            if nested_opts = model.nested_attributes_options[f.to_sym].presence
20✔
230
              nested_opts[:field] = "#{f}_attributes"
6✔
231
              metadata[:nested_attributes_options] = nested_opts
6✔
232
            end
233
          end
234

235
          begin
236
            pk = ref.active_record_primary_key
20✔
237
          rescue ActiveRecord::UnknownPrimaryKey
238
          end
239
          metadata[:association] = {
20✔
240
            macro: ref.macro,
241
            collection: ref.collection?,
242
            class_name: ref.class_name,
243
            foreign_key: ref.foreign_key,
244
            primary_key: pk,
245
            polymorphic: ref.polymorphic?,
246
            table_name: ref.table_name,
247
            options: ref.options.as_json.presence,
248
          }.compact
249
        end
250

251
        # Determine if this is just a method.
252
        if model.method_defined?(f)
64✔
253
          metadata[:kind] ||= "method"
64✔
254
        end
255

256
        # Collect validator options into a hash on their type, while also updating `required` based
257
        # on any presence validators.
258
        model.validators_on(f).each do |validator|
64✔
259
          kind = validator.kind
10✔
260
          options = validator.options
10✔
261

262
          # Reject validator if it includes keys like `:if` and `:unless` because those are
263
          # conditionally applied in a way that is not feasible to communicate via the API.
264
          next if IGNORE_VALIDATORS_WITH_KEYS.any? { |k| options.key?(k) }
30✔
265

266
          # Update `required` if we find a presence validator.
267
          metadata[:required] = true if kind == :presence
10✔
268

269
          metadata[:validators] ||= {}
10✔
270
          metadata[:validators][kind] ||= []
10✔
271
          metadata[:validators][kind] << options
10✔
272
        end
273

274
        # Serialize any field config.
275
        metadata[:config] = self.get_field_config(f).presence
64✔
276

277
        next [f, metadata.compact]
64✔
278
      }.to_h
279
    end
280

281
    # Get a hash of metadata to be rendered in the `OPTIONS` response. Cache the result.
282
    def get_options_metadata
1✔
283
      return super().merge(
7✔
284
        {
285
          fields: self.get_fields_metadata,
286
        },
287
      )
288
    end
289

290
    def setup_delegation
1✔
291
      # Delegate extra actions.
292
      self.extra_actions&.each do |action, config|
×
293
        next unless config.is_a?(Hash) && config[:delegate]
×
294
        next unless self.get_model.respond_to?(action)
×
295

296
        self.define_method(action) do
×
297
          model = self.class.get_model
×
298

299
          if model.method(action).parameters.last&.first == :keyrest
×
300
            return api_response(model.send(action, **params))
×
301
          else
302
            return api_response(model.send(action))
×
303
          end
304
        end
305
      end
306

307
      # Delegate extra member actions.
308
      self.extra_member_actions&.each do |action, config|
×
309
        next unless config.is_a?(Hash) && config[:delegate]
×
310
        next unless self.get_model.method_defined?(action)
×
311

312
        self.define_method(action) do
×
313
          record = self.get_record
1✔
314

315
          if record.method(action).parameters.last&.first == :keyrest
1✔
316
            return api_response(record.send(action, **params))
×
317
          else
318
            return api_response(record.send(action))
1✔
319
          end
320
        end
321
      end
322
    end
323

324
    # Define any behavior to execute at the end of controller definition.
325
    # :nocov:
326
    def rrf_finalize
327
      super
328
      self.setup_delegation
329
      # self.setup_channel
330

331
      if RESTFramework.config.freeze_config
332
        self::RRF_BASE_MODEL_CONTROLLER_CONFIG.keys.each { |k|
333
          v = self.send(k)
334
          v.freeze if v.is_a?(Hash) || v.is_a?(Array)
335
        }
336
      end
337
    end
338
    # :nocov:
339
  end
340

341
  def self.included(base)
1✔
342
    RESTFramework::BaseControllerMixin.included(base)
23✔
343

344
    return unless base.is_a?(Class)
23✔
345

346
    base.extend(ClassMethods)
20✔
347

348
    # Add class attributes (with defaults) unless they already exist.
349
    RRF_BASE_MODEL_CONTROLLER_CONFIG.each do |a, default|
20✔
350
      next if base.respond_to?(a)
680✔
351

352
      base.class_attribute(a)
670✔
353

354
      # Set default manually so we can still support Rails 4. Maybe later we can use the default
355
      # parameter on `class_attribute`.
356
      base.send(:"#{a}=", default)
670✔
357
    end
358
  end
359

360
  def _get_specific_action_config(action_config_key, generic_config_key)
1✔
361
    action_config = self.class.send(action_config_key)&.with_indifferent_access || {}
474✔
362
    action = self.action_name&.to_sym
474✔
363

364
    # Index action should use :list serializer if :index is not provided.
365
    action = :list if action == :index && !action_config.key?(:index)
474✔
366

367
    return (action_config[action] if action) || self.class.send(generic_config_key)
474✔
368
  end
369

370
  # Get a list of fields, taking into account the current action.
371
  def get_fields(fallback: false)
1✔
372
    fields = self._get_specific_action_config(:action_fields, :fields)
446✔
373
    return self.class.get_fields(input_fields: fields, fallback: fallback)
446✔
374
  end
375

376
  # Pass fields to get dynamic metadata based on which fields are available.
377
  def get_options_metadata
1✔
378
    return self.class.get_options_metadata
7✔
379
  end
380

381
  # Get a list of find_by fields for the current action. Do not fallback to columns in case the user
382
  # wants to find by virtual columns.
383
  def get_find_by_fields
1✔
384
    return self.class.find_by_fields || self.get_fields
1✔
385
  end
386

387
  # Get a list of parameters allowed for the current action. By default we do not fallback to
388
  # columns so arbitrary fields can be submitted if no fields are defined.
389
  def get_allowed_parameters
1✔
390
    return @_get_allowed_parameters if defined?(@_get_allowed_parameters)
28✔
391

392
    @_get_allowed_parameters = self._get_specific_action_config(
28✔
393
      :allowed_action_parameters,
394
      :allowed_parameters,
395
    )
396
    return @_get_allowed_parameters if @_get_allowed_parameters
28✔
397
    return @_get_allowed_parameters = nil unless fields = self.get_fields
28✔
398

399
    # For fields, automatically add `_id`/`_ids` and `_attributes` variations for associations.
400
    id_variations = []
2✔
401
    variations = {}
2✔
402
    @_get_allowed_parameters = fields.map { |f|
2✔
403
      f = f.to_s
5✔
404
      next f unless ref = self.class.get_model.reflections[f]
5✔
405

406
      if self.class.permit_id_assignment
×
407
        if ref.collection?
×
408
          variations["#{f.singularize}_ids"] = []
×
409
        elsif ref.belongs_to?
×
410
          id_variations << "#{f}_id"
×
411
        end
412
      end
413

414
      if self.class.permit_nested_attributes_assignment
×
415
        if self.class.allow_all_nested_attributes
×
416
          variations["#{f}_attributes"] = {}
×
417
        else
418
          variations["#{f}_attributes"] = self.class.get_field_config(f)[:sub_fields]
×
419
        end
420
      end
421

422
      next f
×
423
    }.flatten
424
    @_get_allowed_parameters += id_variations
2✔
425
    @_get_allowed_parameters << variations
2✔
426
    return @_get_allowed_parameters
2✔
427
  end
428

429
  # Get the configured serializer class, or `NativeSerializer` as a default.
430
  def get_serializer_class
1✔
431
    return super || RESTFramework::NativeSerializer
105✔
432
  end
433

434
  # Get filtering backends, defaulting to using `ModelFilter` and `ModelOrderingFilter`.
435
  def get_filter_backends
1✔
436
    return self.class.filter_backends || [
111✔
437
      RESTFramework::ModelFilter, RESTFramework::ModelOrderingFilter
438
    ]
439
  end
440

441
  # Use strong parameters to filter the request body using the configured allowed parameters.
442
  def get_body_params(data: nil)
1✔
443
    data ||= request.request_parameters
28✔
444

445
    # Filter the request body and map to strings. Return all params if we cannot resolve a list of
446
    # allowed parameters or fields.
447
    body_params = if allowed_parameters = self.get_allowed_parameters
28✔
448
      data = ActionController::Parameters.new(data)
2✔
449
      data.permit(*allowed_parameters)
2✔
450
    else
451
      data
26✔
452
    end
453

454
    # Filter primary key if configured.
455
    if self.class.filter_pk_from_request_body
28✔
456
      body_params.delete(self.class.get_model&.primary_key)
28✔
457
    end
458

459
    # Filter fields in `exclude_body_fields`.
460
    (self.class.exclude_body_fields || []).each { |f| body_params.delete(f) }
196✔
461

462
    return body_params
28✔
463
  end
464
  alias_method :get_create_params, :get_body_params
1✔
465
  alias_method :get_update_params, :get_body_params
1✔
466

467
  # Get the set of records this controller has access to. The return value is cached and exposed to
468
  # the view as the `@recordset` instance variable.
469
  def get_recordset
1✔
470
    return @recordset if instance_variable_defined?(:@recordset)
212✔
471
    return (@recordset = self.class.recordset) if self.class.recordset
134✔
472

473
    # If there is a model, return that model's default scope (all records by default).
474
    if (model = self.class.get_model(from_get_recordset: true))
134✔
475
      return @recordset = model.all
134✔
476
    end
477

478
    return @recordset = nil
×
479
  end
480

481
  # Get the recordset but with any associations included to avoid N+1 queries.
482
  def get_recordset_with_includes
1✔
483
    reflections = self.class.get_model.reflections.keys
111✔
484
    associations = self.get_fields(fallback: true).select { |f| f.in?(reflections) }
1,068✔
485

486
    if associations.any?
111✔
487
      return self.get_recordset.includes(associations)
94✔
488
    end
489

490
    return self.get_recordset
17✔
491
  end
492

493
  # Get the records this controller has access to *after* any filtering is applied.
494
  def get_records
1✔
495
    return @records if instance_variable_defined?(:@records)
111✔
496

497
    return @records = self.get_filtered_data(self.get_recordset_with_includes)
111✔
498
  end
499

500
  # Get a single record by primary key or another column, if allowed. The return value is cached and
501
  # exposed to the view as the `@record` instance variable.
502
  def get_record
1✔
503
    # Cache the result.
504
    return @record if instance_variable_defined?(:@record)
58✔
505

506
    recordset = self.get_recordset
58✔
507
    find_by_key = self.class.get_model.primary_key
58✔
508

509
    # Find by another column if it's permitted.
510
    if find_by_param = self.class.find_by_query_param.presence
58✔
511
      if find_by = params[find_by_param].presence
58✔
512
        find_by_fields = self.get_find_by_fields&.map(&:to_s)
1✔
513

514
        if !find_by_fields || find_by.in?(find_by_fields)
1✔
515
          find_by_key = find_by
1✔
516
        end
517
      end
518
    end
519

520
    # Filter recordset, if configured.
521
    if self.filter_recordset_before_find
58✔
522
      recordset = self.get_records
58✔
523
    end
524

525
    # Return the record. Route key is always `:id` by Rails convention.
526
    return @record = recordset.find_by!(find_by_key => request.path_parameters[:id])
58✔
527
  end
528

529
  # Create a transaction around the passed block, if configured. This is used primarily for bulk
530
  # actions, but we include it here so it's always available.
531
  def self._rrf_bulk_transaction(&block)
1✔
532
    if self.bulk_transactional
×
533
      ActiveRecord::Base.transaction(&block)
×
534
    else
535
      yield
×
536
    end
537
  end
538
end
539

540
# Mixin for listing records.
541
module RESTFramework::ListModelMixin
1✔
542
  def index
1✔
543
    return api_response(self.get_index_records)
53✔
544
  end
545

546
  # Get records with both filtering and pagination applied.
547
  def get_index_records
1✔
548
    records = self.get_records
53✔
549

550
    # Handle pagination, if enabled.
551
    if self.class.paginator_class
53✔
552
      # If there is no `max_page_size`, `page_size_query_param` is not `nil`, and the page size is
553
      # set to "0", then skip pagination.
554
      unless !self.class.max_page_size &&
25✔
555
          self.class.page_size_query_param &&
556
          params[self.class.page_size_query_param] == "0"
557
        paginator = self.class.paginator_class.new(data: records, controller: self)
25✔
558
        page = paginator.get_page
25✔
559
        serialized_page = self.serialize(page)
25✔
560
        return paginator.get_paginated_response(serialized_page)
25✔
561
      end
562
    end
563

564
    return records
28✔
565
  end
566
end
567

568
# Mixin for showing records.
569
module RESTFramework::ShowModelMixin
1✔
570
  def show
1✔
571
    return api_response(self.get_record)
45✔
572
  end
573
end
574

575
# Mixin for creating records.
576
module RESTFramework::CreateModelMixin
1✔
577
  def create
1✔
578
    return api_response(self.create!, status: :created)
21✔
579
  end
580

581
  # Perform the `create!` call and return the created record.
582
  def create!
1✔
583
    create_from = if self.get_recordset.respond_to?(:create!) && self.create_from_recordset
21✔
584
      # Create with any properties inherited from the recordset. We exclude any `select` clauses in
585
      # case model callbacks need to call `count` on this collection, which typically raises a SQL
586
      # `SyntaxError`.
587
      self.get_recordset.except(:select)
20✔
588
    else
589
      # Otherwise, perform a "bare" create.
590
      self.class.get_model
1✔
591
    end
592

593
    return create_from.create!(self.get_create_params)
21✔
594
  end
595
end
596

597
# Mixin for updating records.
598
module RESTFramework::UpdateModelMixin
1✔
599
  def update
1✔
600
    return api_response(self.update!)
7✔
601
  end
602

603
  # Perform the `update!` call and return the updated record.
604
  def update!
1✔
605
    record = self.get_record
7✔
606
    record.update!(self.get_update_params)
7✔
607
    return record
6✔
608
  end
609
end
610

611
# Mixin for destroying records.
612
module RESTFramework::DestroyModelMixin
1✔
613
  def destroy
1✔
614
    self.destroy!
7✔
615
    return api_response("")
6✔
616
  end
617

618
  # Perform the `destroy!` call and return the destroyed (and frozen) record.
619
  def destroy!
1✔
620
    return self.get_record.destroy!
7✔
621
  end
622
end
623

624
# Mixin that includes show/list mixins.
625
module RESTFramework::ReadOnlyModelControllerMixin
1✔
626
  include RESTFramework::BaseModelControllerMixin
1✔
627

628
  include RESTFramework::ListModelMixin
1✔
629
  include RESTFramework::ShowModelMixin
1✔
630

631
  def self.included(base)
1✔
632
    RESTFramework::BaseModelControllerMixin.included(base)
2✔
633
  end
634
end
635

636
# Mixin that includes all the CRUD mixins.
637
module RESTFramework::ModelControllerMixin
1✔
638
  include RESTFramework::BaseModelControllerMixin
1✔
639

640
  include RESTFramework::ListModelMixin
1✔
641
  include RESTFramework::ShowModelMixin
1✔
642
  include RESTFramework::CreateModelMixin
1✔
643
  include RESTFramework::UpdateModelMixin
1✔
644
  include RESTFramework::DestroyModelMixin
1✔
645

646
  def self.included(base)
1✔
647
    RESTFramework::BaseModelControllerMixin.included(base)
18✔
648
  end
649
end
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc