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

gregschmit / rails-rest-framework / 24747641499

21 Apr 2026 09:31PM UTC coverage: 87.783% (+0.2%) from 87.619%
24747641499

push

github

gregschmit
Improve bulk operations.

Expand bulk operations to support regular activerecord operations and
single-query operations.
Various adjustments and improvements to code organization.
Remove disable_rescue_from as there are easy alternatives.

122 of 143 new or added lines in 4 files covered. (85.31%)

1 existing line in 1 file now uncovered.

1200 of 1367 relevant lines covered (87.78%)

215.72 hits per line

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

84.56
/lib/rest_framework/controller/bulk.rb
1
module RESTFramework::Controller
2✔
2
  RRF_DEFAULT_BULK_MAX_SIZE = 1000
2✔
3
  RRF_DEFAULT_BULK_MAX_RAW_SIZE = 10000
2✔
4

5
  def _bulk_max_size
2✔
6
    @_bulk_max_size ||= self.class.bulk_max_size || RRF_DEFAULT_BULK_MAX_SIZE
20✔
7
  end
8

9
  def _bulk_max_raw_size
2✔
10
    @_bulk_max_raw_size ||= self.class.bulk_max_raw_size || RRF_DEFAULT_BULK_MAX_RAW_SIZE
14✔
11
  end
12

13
  def _bulk_serialize(records)
2✔
14
    # This is kinda slow, so perhaps we should eventually integrate `errors` serialization into
15
    # the serializer directly. This would fail for active model serializers, but maybe we don't
16
    # care?
17
    s = RESTFramework::Utils.wrap_ams(self.get_serializer_class)
12✔
18
    records.map do |record|
12✔
19
      s.new(record, controller: self).serialize.merge!({ errors: record.errors.presence }.compact)
24✔
20
    end
21
  end
22

23
  def _bulk_mode
2✔
24
    return @_bulk_mode if defined?(@_bulk_mode)
42✔
25

26
    # If mode override is allowed, check the query param.
27
    if self.class.bulk_allow_mode_override && (qp = self.class.bulk_mode_query_param)
34✔
NEW
28
      if (requested = request.query_parameters[qp].presence)
×
NEW
29
        requested = requested.to_sym
×
NEW
30
        unless requested.in?([ :default, :raw ])
×
NEW
31
          raise RESTFramework::InvalidBulkParametersError.new(
×
32
            "Invalid bulk mode: #{requested}. Must be `default` or `raw`.",
33
          )
34
        end
NEW
35
        return @_bulk_mode = requested
×
36
      end
37
    end
38

39
    # Normalize: `true` and `:default` both mean per-record processing.
40
    @_bulk_mode = self.class.bulk == :raw ? :raw : :default
34✔
41
  end
42

43
  # Resolve whether partial fulfillment is enabled for this request.
44
  def _bulk_partial
2✔
45
    return @_bulk_partial if defined?(@_bulk_partial)
26✔
46

47
    # Check the query param first if configured.
48
    if (qp = self.class.bulk_partial_query_param)
20✔
49
      if (requested = request.query_parameters[qp].presence)
20✔
50
        return @_bulk_partial = ActiveModel::Type::Boolean.new.cast(requested)
6✔
51
      end
52
    end
53

54
    @_bulk_partial = self.class.bulk_partial
14✔
55
  end
56

57
  # Validate and extract bulk object data from request parameters.
58
  def _bulk_object_data(bulk_action, bulk_mode)
2✔
59
    data = self.get_body_params(bulk_action: bulk_action)[:_json]
26✔
60

61
    unless data&.is_a?(Array) && data.all? { |r| r.is_a?(ActionController::Parameters) }
74✔
62
      raise RESTFramework::InvalidBulkParametersError.new("Expected an array of objects.")
×
63
    end
64

65
    # Enforce size limits.
66
    max = bulk_mode == :raw ? self._bulk_max_raw_size : self._bulk_max_size
26✔
67
    if max && data.length > max
26✔
NEW
68
      raise RESTFramework::InvalidBulkParametersError.new(
×
69
        "Too many records (#{data.length}) for #{bulk_mode} mode; maximum is #{max}.",
70
      )
71
    end
72

73
    data
26✔
74
  end
75

76
  # Validate and extract bulk primary key data from request parameters.
77
  def _bulk_pk_data
2✔
78
    data = self.get_destroy_params(bulk_action: :destroy)[:_json]
8✔
79

80
    unless data&.is_a?(Array) && data.all? { |r| r.is_a?(String) || r.is_a?(Numeric) }
24✔
NEW
81
      raise RESTFramework::InvalidBulkParametersError.new("Expected an array of primary keys.")
×
82
    end
83

84
    # Enforce size limits.
85
    max = self._bulk_mode == :raw ? self._bulk_max_raw_size : self._bulk_max_size
8✔
86
    if max && data.length > max
8✔
NEW
87
      raise RESTFramework::InvalidBulkParametersError.new(
×
88
        "Too many records (#{data.length}) for #{self._bulk_mode} mode; maximum is #{max}.",
89
      )
90
    end
91

92
    data
8✔
93
  end
94

95
  def create_all
2✔
96
    if self._bulk_mode == :raw
18✔
97
      result = self.create_all_raw!
10✔
98
      return render(api: { message: "Bulk create successful.", result: result })
6✔
99
    end
100

101
    records = self.create_all_default!
8✔
102
    render(
4✔
103
      api: { message: "Bulk create successful.", records: self._bulk_serialize(records) },
104
      status: :created,
105
    )
106
  end
107

108
  def create_all_raw!
2✔
109
    pk = self.class.model.primary_key
10✔
110
    data = self._bulk_object_data(:create, :raw)
10✔
111

112
    unless first_keys = data.first&.keys&.sort
10✔
113
      raise RESTFramework::InvalidBulkParametersError.new("Expected objects with attrs.")
×
114
    end
115
    unless data.all? { |r| r.keys.sort == first_keys }
26✔
116
      raise RESTFramework::InvalidBulkParametersError.new("All objects must have the same attrs.")
2✔
117
    end
118

119
    self.create_from.insert_all(data, unique_by: pk)
8✔
120
  end
121

122
  def create_all_default!
2✔
123
    data = self._bulk_object_data(:create, :default)
8✔
124
    collection = self.create_from
8✔
125

126
    if self._bulk_partial
8✔
127
      # Partial: save each record individually, return all (some may have errors).
128
      data.map { |attrs| collection.create(attrs) }
6✔
129
    else
130
      # Transactional: validate all first, then save in a transaction or raise.
131
      records = data.map { |attrs| collection.new(attrs) }
18✔
132
      failed = records.reject(&:valid?)
6✔
133

134
      if failed.any?
6✔
135
        raise RESTFramework::BulkRecordErrorsError.new(records)
2✔
136
      end
137

138
      self.class.model.transaction do
4✔
139
        records.each(&:save!)
4✔
140
      end
141

142
      records
2✔
143
    end
144
  end
145

146
  def update_all
2✔
147
    if self._bulk_mode == :raw
8✔
148
      result = self.update_all_raw!
2✔
149
      return render(api: { message: "Bulk update successful.", result: result })
2✔
150
    end
151

152
    records = self.update_all_default!
6✔
153
    render(api: { message: "Bulk update successful.", records: self._bulk_serialize(records) })
4✔
154
  end
155

156
  def update_all_raw!
2✔
157
    pk = self.class.model.primary_key
2✔
158
    data = self._bulk_object_data(:update, :raw)
2✔
159

160
    data_ids = data.map { |r| r[pk] }.uniq
6✔
161
    if data_ids.include?(nil)
2✔
NEW
162
      raise RESTFramework::InvalidBulkParametersError.new(
×
163
        "Bulk update requires the primary key (#{pk}) for all records.",
164
      )
165
    end
166
    found_ids = self.get_recordset.where(pk => data_ids).pluck(pk)
2✔
167
    if found_ids.length != data_ids.length
2✔
NEW
168
      missing = data_ids - found_ids
×
NEW
169
      raise RESTFramework::InvalidBulkParametersError.new(
×
170
        "Records not found with #{pk}: #{missing.join(', ')}.",
171
      )
172
    end
173

174
    unless first_keys = data.first&.keys&.sort
2✔
175
      raise RESTFramework::InvalidBulkParametersError.new("Expected objects with attrs.")
×
176
    end
177
    unless data.all? { |r| r.keys.sort == first_keys }
6✔
UNCOV
178
      raise RESTFramework::InvalidBulkParametersError.new("All objects must have the same attrs.")
×
179
    end
180

181
    self.get_recordset.upsert_all(data, unique_by: pk)
2✔
182
  end
183

184
  def update_all_default!
2✔
185
    pk = self.class.model.primary_key
6✔
186
    data = self._bulk_object_data(:update, :default)
6✔
187

188
    data_ids = data.map { |r| r[pk] }.uniq
18✔
189
    if data_ids.include?(nil)
6✔
NEW
190
      raise RESTFramework::InvalidBulkParametersError.new(
×
191
        "Bulk update requires the primary key (#{pk}) for all records.",
192
      )
193
    end
194
    existing = self.get_recordset.where(pk => data_ids).index_by { |r| r.send(pk) }
18✔
195
    if existing.length != data_ids.length
6✔
NEW
196
      missing = data_ids - existing.keys
×
NEW
197
      raise RESTFramework::InvalidBulkParametersError.new(
×
198
        "Records not found with #{pk}: #{missing.join(', ')}.",
199
      )
200
    end
201

202
    # Assign attributes to each record.
203
    records = data.map { |attrs|
6✔
204
      record = existing[attrs[pk]]
12✔
205
      record.assign_attributes(attrs.except(pk))
12✔
206
      record
12✔
207
    }
208

209
    if self._bulk_partial
6✔
210
      # Partial: save each record individually.
211
      records.each(&:save)
2✔
212
      records
2✔
213
    else
214
      # Transactional: validate all first, then save in a transaction or raise.
215
      failed = records.reject(&:valid?)
4✔
216

217
      if failed.any?
4✔
218
        raise RESTFramework::BulkRecordErrorsError.new(records)
2✔
219
      end
220

221
      self.class.model.transaction do
2✔
222
        records.each(&:save!)
2✔
223
      end
224

225
      records
2✔
226
    end
227
  end
228

229
  def destroy_all
2✔
230
    if self._bulk_mode == :raw
8✔
231
      deleted = self.destroy_all_raw!
2✔
232
      return render(api: { message: "Bulk destroy successful.", result: deleted })
2✔
233
    end
234

235
    records = self.destroy_all_default!
6✔
236
    render(api: { message: "Bulk destroy successful.", records: self._bulk_serialize(records) })
4✔
237
  end
238

239
  def destroy_all_raw!
2✔
240
    data = self._bulk_pk_data
2✔
241
    pk = self.class.model.primary_key
2✔
242
    self.get_recordset.where(pk => data).delete_all
2✔
243
  end
244

245
  def destroy_all_default!
2✔
246
    data = self._bulk_pk_data
6✔
247
    pk = self.class.model.primary_key
6✔
248
    records = self.get_recordset.where(pk => data).to_a
6✔
249

250
    # In transactional mode, verify all requested records exist.
251
    if !self._bulk_partial && records.length != data.uniq.length
6✔
NEW
252
      found_ids = records.map { |r| r.send(pk) }
×
NEW
253
      missing = data.uniq - found_ids
×
NEW
254
      raise RESTFramework::InvalidBulkParametersError.new(
×
255
        "Bulk destroy requires all records to exist. Missing #{pk}: #{missing.join(', ')}.",
256
      )
257
    end
258

259
    if self._bulk_partial
6✔
260
      # Partial: destroy each record individually.
261
      records.each(&:destroy)
2✔
262
      records
2✔
263
    else
264
      # Transactional: destroy all in a transaction, roll back on failure.
265
      self.class.model.transaction do
4✔
266
        records.each(&:destroy!)
4✔
267
      end
268

269
      records
2✔
270
    end
271
  end
272
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

© 2026 Coveralls, Inc