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

gregschmit / rails-rest-framework / 4002900794

pending completion
4002900794

push

github

GitHub
Bump commonmarker from 0.23.6 to 0.23.7 in /docs

807 of 890 relevant lines covered (90.67%)

74.1 hits per line

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

86.38
/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,043✔
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
          # Resolve procs (and lambdas), and symbols for certain arguments.
270
          if options[:in].is_a?(Proc)
10✔
271
            options = options.merge(in: options[:in].call)
×
272
          elsif options[:in].is_a?(Symbol)
10✔
273
            options = options.merge(in: model.send(options[:in]))
2✔
274
          end
275

276
          metadata[:validators] ||= {}
10✔
277
          metadata[:validators][kind] ||= []
10✔
278
          metadata[:validators][kind] << options
10✔
279
        end
280

281
        # Serialize any field config.
282
        metadata[:config] = self.get_field_config(f).presence
64✔
283

284
        next [f, metadata.compact]
64✔
285
      }.to_h
286
    end
287

288
    # Get a hash of metadata to be rendered in the `OPTIONS` response. Cache the result.
289
    def get_options_metadata
1✔
290
      return super().merge(
7✔
291
        {
292
          fields: self.get_fields_metadata,
293
        },
294
      )
295
    end
296

297
    def setup_delegation
1✔
298
      # Delegate extra actions.
299
      self.extra_actions&.each do |action, config|
×
300
        next unless config.is_a?(Hash) && config.dig(:metadata, :delegate)
×
301
        next unless self.get_model.respond_to?(action)
×
302

303
        self.define_method(action) do
×
304
          model = self.class.get_model
×
305

306
          if model.method(action).parameters.last&.first == :keyrest
×
307
            return api_response(model.send(action, **params))
×
308
          else
309
            return api_response(model.send(action))
×
310
          end
311
        end
312
      end
313

314
      # Delegate extra member actions.
315
      self.extra_member_actions&.each do |action, config|
×
316
        next unless config.is_a?(Hash) && config.dig(:metadata, :delegate)
×
317
        next unless self.get_model.method_defined?(action)
×
318

319
        self.define_method(action) do
×
320
          record = self.get_record
1✔
321

322
          if record.method(action).parameters.last&.first == :keyrest
1✔
323
            return api_response(record.send(action, **params))
×
324
          else
325
            return api_response(record.send(action))
1✔
326
          end
327
        end
328
      end
329
    end
330

331
    # Define any behavior to execute at the end of controller definition.
332
    # :nocov:
333
    def rrf_finalize
334
      super
335
      self.setup_delegation
336
      # self.setup_channel
337

338
      if RESTFramework.config.freeze_config
339
        self::RRF_BASE_MODEL_CONTROLLER_CONFIG.keys.each { |k|
340
          v = self.send(k)
341
          v.freeze if v.is_a?(Hash) || v.is_a?(Array)
342
        }
343
      end
344
    end
345
    # :nocov:
346
  end
347

348
  def self.included(base)
1✔
349
    RESTFramework::BaseControllerMixin.included(base)
23✔
350

351
    return unless base.is_a?(Class)
23✔
352

353
    base.extend(ClassMethods)
20✔
354

355
    # Add class attributes (with defaults) unless they already exist.
356
    RRF_BASE_MODEL_CONTROLLER_CONFIG.each do |a, default|
20✔
357
      next if base.respond_to?(a)
680✔
358

359
      base.class_attribute(a)
670✔
360

361
      # Set default manually so we can still support Rails 4. Maybe later we can use the default
362
      # parameter on `class_attribute`.
363
      base.send(:"#{a}=", default)
670✔
364
    end
365
  end
366

367
  def _get_specific_action_config(action_config_key, generic_config_key)
1✔
368
    action_config = self.class.send(action_config_key)&.with_indifferent_access || {}
474✔
369
    action = self.action_name&.to_sym
474✔
370

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

374
    return (action_config[action] if action) || self.class.send(generic_config_key)
474✔
375
  end
376

377
  # Get a list of fields, taking into account the current action.
378
  def get_fields(fallback: false)
1✔
379
    fields = self._get_specific_action_config(:action_fields, :fields)
446✔
380
    return self.class.get_fields(input_fields: fields, fallback: fallback)
446✔
381
  end
382

383
  # Pass fields to get dynamic metadata based on which fields are available.
384
  def get_options_metadata
1✔
385
    return self.class.get_options_metadata
7✔
386
  end
387

388
  # Get a list of find_by fields for the current action. Do not fallback to columns in case the user
389
  # wants to find by virtual columns.
390
  def get_find_by_fields
1✔
391
    return self.class.find_by_fields || self.get_fields
1✔
392
  end
393

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

399
    @_get_allowed_parameters = self._get_specific_action_config(
28✔
400
      :allowed_action_parameters,
401
      :allowed_parameters,
402
    )
403
    return @_get_allowed_parameters if @_get_allowed_parameters
28✔
404
    return @_get_allowed_parameters = nil unless fields = self.get_fields
28✔
405

406
    # For fields, automatically add `_id`/`_ids` and `_attributes` variations for associations.
407
    id_variations = []
2✔
408
    variations = {}
2✔
409
    @_get_allowed_parameters = fields.map { |f|
2✔
410
      f = f.to_s
5✔
411
      next f unless ref = self.class.get_model.reflections[f]
5✔
412

413
      if self.class.permit_id_assignment
×
414
        if ref.collection?
×
415
          variations["#{f.singularize}_ids"] = []
×
416
        elsif ref.belongs_to?
×
417
          id_variations << "#{f}_id"
×
418
        end
419
      end
420

421
      if self.class.permit_nested_attributes_assignment
×
422
        if self.class.allow_all_nested_attributes
×
423
          variations["#{f}_attributes"] = {}
×
424
        else
425
          variations["#{f}_attributes"] = self.class.get_field_config(f)[:sub_fields]
×
426
        end
427
      end
428

429
      next f
×
430
    }.flatten
431
    @_get_allowed_parameters += id_variations
2✔
432
    @_get_allowed_parameters << variations
2✔
433
    return @_get_allowed_parameters
2✔
434
  end
435

436
  # Get the configured serializer class, or `NativeSerializer` as a default.
437
  def get_serializer_class
1✔
438
    return super || RESTFramework::NativeSerializer
105✔
439
  end
440

441
  # Get filtering backends, defaulting to using `ModelFilter` and `ModelOrderingFilter`.
442
  def get_filter_backends
1✔
443
    return self.class.filter_backends || [
111✔
444
      RESTFramework::ModelFilter, RESTFramework::ModelOrderingFilter
445
    ]
446
  end
447

448
  # Use strong parameters to filter the request body using the configured allowed parameters.
449
  def get_body_params(data: nil)
1✔
450
    data ||= request.request_parameters
28✔
451

452
    # Filter the request body and map to strings. Return all params if we cannot resolve a list of
453
    # allowed parameters or fields.
454
    body_params = if allowed_parameters = self.get_allowed_parameters
28✔
455
      data = ActionController::Parameters.new(data)
2✔
456
      data.permit(*allowed_parameters)
2✔
457
    else
458
      data
26✔
459
    end
460

461
    # Filter primary key if configured.
462
    if self.class.filter_pk_from_request_body
28✔
463
      body_params.delete(self.class.get_model&.primary_key)
28✔
464
    end
465

466
    # Filter fields in `exclude_body_fields`.
467
    (self.class.exclude_body_fields || []).each { |f| body_params.delete(f) }
196✔
468

469
    return body_params
28✔
470
  end
471
  alias_method :get_create_params, :get_body_params
1✔
472
  alias_method :get_update_params, :get_body_params
1✔
473

474
  # Get the set of records this controller has access to. The return value is cached and exposed to
475
  # the view as the `@recordset` instance variable.
476
  def get_recordset
1✔
477
    return @recordset if instance_variable_defined?(:@recordset)
211✔
478
    return (@recordset = self.class.recordset) if self.class.recordset
133✔
479

480
    # If there is a model, return that model's default scope (all records by default).
481
    if (model = self.class.get_model(from_get_recordset: true))
133✔
482
      return @recordset = model.all
133✔
483
    end
484

485
    return @recordset = nil
×
486
  end
487

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

493
    if associations.any?
111✔
494
      return self.get_recordset.includes(associations)
94✔
495
    end
496

497
    return self.get_recordset
17✔
498
  end
499

500
  # Get the records this controller has access to *after* any filtering is applied.
501
  def get_records
1✔
502
    return @records if instance_variable_defined?(:@records)
111✔
503

504
    return @records = self.get_filtered_data(self.get_recordset_with_includes)
111✔
505
  end
506

507
  # Get a single record by primary key or another column, if allowed. The return value is cached and
508
  # exposed to the view as the `@record` instance variable.
509
  def get_record
1✔
510
    # Cache the result.
511
    return @record if instance_variable_defined?(:@record)
58✔
512

513
    recordset = self.get_recordset
58✔
514
    find_by_key = self.class.get_model.primary_key
58✔
515

516
    # Find by another column if it's permitted.
517
    if find_by_param = self.class.find_by_query_param.presence
58✔
518
      if find_by = params[find_by_param].presence
58✔
519
        find_by_fields = self.get_find_by_fields&.map(&:to_s)
1✔
520

521
        if !find_by_fields || find_by.in?(find_by_fields)
1✔
522
          find_by_key = find_by
1✔
523
        end
524
      end
525
    end
526

527
    # Filter recordset, if configured.
528
    if self.filter_recordset_before_find
58✔
529
      recordset = self.get_records
58✔
530
    end
531

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

536
  # Create a transaction around the passed block, if configured. This is used primarily for bulk
537
  # actions, but we include it here so it's always available.
538
  def self._rrf_bulk_transaction(&block)
1✔
539
    if self.bulk_transactional
×
540
      ActiveRecord::Base.transaction(&block)
×
541
    else
542
      yield
×
543
    end
544
  end
545
end
546

547
# Mixin for listing records.
548
module RESTFramework::ListModelMixin
1✔
549
  def index
1✔
550
    return api_response(self.get_index_records)
53✔
551
  end
552

553
  # Get records with both filtering and pagination applied.
554
  def get_index_records
1✔
555
    records = self.get_records
53✔
556

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

571
    return records
28✔
572
  end
573
end
574

575
# Mixin for showing records.
576
module RESTFramework::ShowModelMixin
1✔
577
  def show
1✔
578
    return api_response(self.get_record)
45✔
579
  end
580
end
581

582
# Mixin for creating records.
583
module RESTFramework::CreateModelMixin
1✔
584
  def create
1✔
585
    return api_response(self.create!, status: :created)
21✔
586
  end
587

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

600
    return create_from.create!(self.get_create_params)
21✔
601
  end
602
end
603

604
# Mixin for updating records.
605
module RESTFramework::UpdateModelMixin
1✔
606
  def update
1✔
607
    return api_response(self.update!)
7✔
608
  end
609

610
  # Perform the `update!` call and return the updated record.
611
  def update!
1✔
612
    record = self.get_record
7✔
613
    record.update!(self.get_update_params)
7✔
614
    return record
6✔
615
  end
616
end
617

618
# Mixin for destroying records.
619
module RESTFramework::DestroyModelMixin
1✔
620
  def destroy
1✔
621
    self.destroy!
7✔
622
    return api_response("")
6✔
623
  end
624

625
  # Perform the `destroy!` call and return the destroyed (and frozen) record.
626
  def destroy!
1✔
627
    return self.get_record.destroy!
7✔
628
  end
629
end
630

631
# Mixin that includes show/list mixins.
632
module RESTFramework::ReadOnlyModelControllerMixin
1✔
633
  include RESTFramework::BaseModelControllerMixin
1✔
634

635
  include RESTFramework::ListModelMixin
1✔
636
  include RESTFramework::ShowModelMixin
1✔
637

638
  def self.included(base)
1✔
639
    RESTFramework::BaseModelControllerMixin.included(base)
2✔
640
  end
641
end
642

643
# Mixin that includes all the CRUD mixins.
644
module RESTFramework::ModelControllerMixin
1✔
645
  include RESTFramework::BaseModelControllerMixin
1✔
646

647
  include RESTFramework::ListModelMixin
1✔
648
  include RESTFramework::ShowModelMixin
1✔
649
  include RESTFramework::CreateModelMixin
1✔
650
  include RESTFramework::UpdateModelMixin
1✔
651
  include RESTFramework::DestroyModelMixin
1✔
652

653
  def self.included(base)
1✔
654
    RESTFramework::BaseModelControllerMixin.included(base)
18✔
655
  end
656
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