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

gregschmit / rails-rest-framework / 24742830888

21 Apr 2026 07:44PM UTC coverage: 87.73% (+0.1%) from 87.619%
24742830888

Pull #39

github

gregschmit
Fix copy
Pull Request #39: Initial work to allow both per-record and single-query bulk ops.

111 of 128 new or added lines in 4 files covered. (86.72%)

1 existing line in 1 file now uncovered.

1194 of 1361 relevant lines covered (87.73%)

216.64 hits per line

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

83.85
/lib/rest_framework/controller/bulk.rb
1
module RESTFramework::Controller
2✔
2
  # Serialize the records, including any errors that might exist.
3
  def _bulk_serialize(records)
2✔
4
    # This is kinda slow, so perhaps we should eventually integrate `errors` serialization into
5
    # the serializer directly. This would fail for active model serializers, but maybe we don't
6
    # care?
7
    s = RESTFramework::Utils.wrap_ams(self.get_serializer_class)
12✔
8
    records.map do |record|
12✔
9
      s.new(record, controller: self).serialize.merge!({ errors: record.errors.presence }.compact)
24✔
10
    end
11
  end
12

13
  # Resolve the effective bulk mode (:default or :raw) for this request.
14
  def _bulk_mode
2✔
15
    return @_bulk_mode if defined?(@_bulk_mode)
42✔
16

17
    # If mode override is allowed, check the query param.
18
    if self.class.bulk_allow_mode_override && (qp = self.class.bulk_mode_query_param)
34✔
NEW
19
      if (requested = request.query_parameters[qp].presence)
×
NEW
20
        requested = requested.to_sym
×
NEW
21
        unless requested.in?([ :default, :raw ])
×
NEW
22
          raise RESTFramework::InvalidBulkParametersError.new(
×
23
            "Invalid bulk mode: #{requested}. Must be `default` or `raw`.",
24
          )
25
        end
NEW
26
        return @_bulk_mode = requested
×
27
      end
28
    end
29

30
    # Normalize: `true` and `:default` both mean per-record processing.
31
    @_bulk_mode = self.class.bulk == :raw ? :raw : :default
34✔
32
  end
33

34
  # Resolve whether partial fulfillment is enabled for this request.
35
  def _bulk_partial
2✔
36
    return @_bulk_partial if defined?(@_bulk_partial)
26✔
37

38
    # Check the query param first if configured.
39
    if (qp = self.class.bulk_partial_query_param)
20✔
40
      if (requested = request.query_parameters[qp].presence)
20✔
41
        return @_bulk_partial = ActiveModel::Type::Boolean.new.cast(requested)
6✔
42
      end
43
    end
44

45
    @_bulk_partial = self.class.bulk_partial
14✔
46
  end
47

48
  # Validate and extract bulk object data from request parameters.
49
  def _bulk_object_data(bulk_action, bulk_mode)
2✔
50
    data = self.get_body_params(bulk_action: bulk_action)[:_json]
26✔
51

52
    unless data&.is_a?(Array) && data.all? { |r| r.is_a?(ActionController::Parameters) }
74✔
53
      raise RESTFramework::InvalidBulkParametersError.new("Expected an array of objects.")
×
54
    end
55

56
    # Enforce size limits.
57
    max = bulk_mode == :raw ? self.class.bulk_max_raw_size : self.class.bulk_max_size
26✔
58
    if max && data.length > max
26✔
NEW
59
      raise RESTFramework::InvalidBulkParametersError.new(
×
60
        "Too many records (#{data.length}) for #{bulk_mode} mode; maximum is #{max}.",
61
      )
62
    end
63

64
    data
26✔
65
  end
66

67
  # Validate and extract bulk primary key data from request parameters.
68
  def _bulk_pk_data
2✔
69
    data = self.get_destroy_params(bulk_action: :destroy)[:_json]
8✔
70

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

75
    # Enforce size limits.
76
    max = self._bulk_mode == :raw ? self.class.bulk_max_raw_size : self.class.bulk_max_size
8✔
77
    if max && data.length > max
8✔
NEW
78
      raise RESTFramework::InvalidBulkParametersError.new(
×
79
        "Too many records (#{data.length}) for #{self._bulk_mode} mode; maximum is #{max}.",
80
      )
81
    end
82

83
    data
8✔
84
  end
85

86
  def create_all
2✔
87
    if self._bulk_mode == :raw
18✔
88
      result = self.create_all_raw!
10✔
89
      return render(api: { message: "Bulk create successful.", result: result })
6✔
90
    end
91

92
    records = self.create_all_default!
8✔
93
    render(
4✔
94
      api: { message: "Bulk create successful.", records: self._bulk_serialize(records) },
95
      status: :created,
96
    )
97
  end
98

99
  def create_all_raw!
2✔
100
    pk = self.class.model.primary_key
10✔
101
    data = self._bulk_object_data(:create, :raw)
10✔
102

103
    unless first_keys = data.first&.keys&.sort
10✔
104
      raise RESTFramework::InvalidBulkParametersError.new("Expected objects with attrs.")
×
105
    end
106
    unless data.all? { |r| r.keys.sort == first_keys }
26✔
107
      raise RESTFramework::InvalidBulkParametersError.new("All objects must have the same attrs.")
2✔
108
    end
109

110
    self.create_from.insert_all(data, unique_by: pk)
8✔
111
  end
112

113
  def create_all_default!
2✔
114
    data = self._bulk_object_data(:create, :default)
8✔
115
    collection = self.create_from
8✔
116

117
    if self._bulk_partial
8✔
118
      # Partial: save each record individually, return all (some may have errors).
119
      data.map { |attrs| collection.create(attrs) }
6✔
120
    else
121
      # Transactional: validate all first, then save in a transaction or raise.
122
      records = data.map { |attrs| collection.new(attrs) }
18✔
123
      failed = records.reject(&:valid?)
6✔
124

125
      if failed.any?
6✔
126
        raise RESTFramework::BulkRecordErrorsError.new(records)
2✔
127
      end
128

129
      self.class.model.transaction do
4✔
130
        records.each(&:save!)
4✔
131
      end
132

133
      records
2✔
134
    end
135
  end
136

137
  def update_all
2✔
138
    if self._bulk_mode == :raw
8✔
139
      result = self.update_all_raw!
2✔
140
      return render(api: { message: "Bulk update successful.", result: result })
2✔
141
    end
142

143
    records = self.update_all_default!
6✔
144
    render(api: { message: "Bulk update successful.", records: self._bulk_serialize(records) })
4✔
145
  end
146

147
  def update_all_raw!
2✔
148
    pk = self.class.model.primary_key
2✔
149
    data = self._bulk_object_data(:update, :raw)
2✔
150

151
    data_ids = data.map { |r| r[pk] }.uniq
6✔
152
    if data_ids.include?(nil)
2✔
NEW
153
      raise RESTFramework::InvalidBulkParametersError.new(
×
154
        "Bulk update requires the primary key (#{pk}) for all records.",
155
      )
156
    end
157
    found_ids = self.get_recordset.where(pk => data_ids).pluck(pk)
2✔
158
    if found_ids.length != data_ids.length
2✔
NEW
159
      missing = data_ids - found_ids
×
NEW
160
      raise RESTFramework::InvalidBulkParametersError.new(
×
161
        "Records not found with #{pk}: #{missing.join(', ')}.",
162
      )
163
    end
164

165
    unless first_keys = data.first&.keys&.sort
2✔
166
      raise RESTFramework::InvalidBulkParametersError.new("Expected objects with attrs.")
×
167
    end
168
    unless data.all? { |r| r.keys.sort == first_keys }
6✔
UNCOV
169
      raise RESTFramework::InvalidBulkParametersError.new("All objects must have the same attrs.")
×
170
    end
171

172
    self.get_recordset.upsert_all(data, unique_by: pk)
2✔
173
  end
174

175
  def update_all_default!
2✔
176
    pk = self.class.model.primary_key
6✔
177
    data = self._bulk_object_data(:update, :default)
6✔
178

179
    data_ids = data.map { |r| r[pk] }.uniq
18✔
180
    if data_ids.include?(nil)
6✔
NEW
181
      raise RESTFramework::InvalidBulkParametersError.new(
×
182
        "Bulk update requires the primary key (#{pk}) for all records.",
183
      )
184
    end
185
    existing = self.get_recordset.where(pk => data_ids).index_by { |r| r.send(pk) }
18✔
186
    if existing.length != data_ids.length
6✔
NEW
187
      missing = data_ids - existing.keys
×
NEW
188
      raise RESTFramework::InvalidBulkParametersError.new(
×
189
        "Records not found with #{pk}: #{missing.join(', ')}.",
190
      )
191
    end
192

193
    # Assign attributes to each record.
194
    records = data.map { |attrs|
6✔
195
      record = existing[attrs[pk]]
12✔
196
      record.assign_attributes(attrs.except(pk))
12✔
197
      record
12✔
198
    }
199

200
    if self._bulk_partial
6✔
201
      # Partial: save each record individually.
202
      records.each(&:save)
2✔
203
      records
2✔
204
    else
205
      # Transactional: validate all first, then save in a transaction or raise.
206
      failed = records.reject(&:valid?)
4✔
207

208
      if failed.any?
4✔
209
        raise RESTFramework::BulkRecordErrorsError.new(records)
2✔
210
      end
211

212
      self.class.model.transaction do
2✔
213
        records.each(&:save!)
2✔
214
      end
215

216
      records
2✔
217
    end
218
  end
219

220
  def destroy_all
2✔
221
    if self._bulk_mode == :raw
8✔
222
      deleted = self.destroy_all_raw!
2✔
223
      return render(api: { message: "Bulk destroy successful.", result: deleted })
2✔
224
    end
225

226
    records = self.destroy_all_default!
6✔
227
    render(api: { message: "Bulk destroy successful.", records: self._bulk_serialize(records) })
4✔
228
  end
229

230
  def destroy_all_raw!
2✔
231
    data = self._bulk_pk_data
2✔
232
    pk = self.class.model.primary_key
2✔
233
    self.get_recordset.where(pk => data).delete_all
2✔
234
  end
235

236
  def destroy_all_default!
2✔
237
    data = self._bulk_pk_data
6✔
238
    pk = self.class.model.primary_key
6✔
239
    records = self.get_recordset.where(pk => data).to_a
6✔
240

241
    # In transactional mode, verify all requested records exist.
242
    if !self._bulk_partial && records.length != data.uniq.length
6✔
NEW
243
      found_ids = records.map { |r| r.send(pk) }
×
NEW
244
      missing = data.uniq - found_ids
×
NEW
245
      raise RESTFramework::InvalidBulkParametersError.new(
×
246
        "Bulk destroy requires all records to exist. Missing #{pk}: #{missing.join(', ')}.",
247
      )
248
    end
249

250
    if self._bulk_partial
6✔
251
      # Partial: destroy each record individually.
252
      records.each(&:destroy)
2✔
253
      records
2✔
254
    else
255
      # Transactional: destroy all in a transaction, roll back on failure.
256
      self.class.model.transaction do
4✔
257
        records.each(&:destroy!)
4✔
258
      end
259

260
      records
2✔
261
    end
262
  end
263
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