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

gregschmit / rails-rest-framework / 24732674750

21 Apr 2026 03:59PM UTC coverage: 87.657% (+0.04%) from 87.619%
24732674750

Pull #39

github

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

108 of 120 new or added lines in 4 files covered. (90.0%)

5 existing lines in 2 files now uncovered.

1186 of 1353 relevant lines covered (87.66%)

214.75 hits per line

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

86.07
/lib/rest_framework/controller/bulk.rb
1
module RESTFramework::Controller
2✔
2
  # Serialize the records, but also include 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)
38✔
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)
30✔
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
30✔
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)
20✔
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]
22✔
51

52
    unless data&.is_a?(Array) && data.all? { |r| r.is_a?(ActionController::Parameters) }
64✔
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
22✔
58
    if max && data.length > max
22✔
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
22✔
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
14✔
88
      result = self.create_all_raw!
6✔
89
      return render(api: { message: "Records created successfully.", result: result })
4✔
90
    end
91

92
    records = self.create_all_default!
8✔
93
    render(
4✔
94
      api: { message: "Records created successfully.", 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
6✔
101
    data = self._bulk_object_data(:create, :raw)
6✔
102

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

110
    self.create_from.insert_all(data, unique_by: pk)
6✔
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: "Records updated successfully.", result: result })
2✔
141
    end
142

143
    records = self.update_all_default!
6✔
144
    render(
4✔
145
      api: { message: "Records updated successfully.", records: self._bulk_serialize(records) },
146
    )
147
  end
148

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

153
    data_ids = data.map { |r| r[pk] }.uniq
6✔
154
    found_ids = self.get_recordset.where(pk => data_ids).pluck(pk)
2✔
155
    if found_ids.length != data_ids.length
2✔
NEW
156
      missing = data_ids - found_ids
×
NEW
157
      raise RESTFramework::InvalidBulkParametersError.new(
×
158
        "Records not found with #{pk}: #{missing.join(', ')}.",
159
      )
160
    end
161

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

169
    self.get_recordset.upsert_all(data, unique_by: pk)
2✔
170
  end
171

172
  def update_all_default!
2✔
173
    pk = self.class.model.primary_key
6✔
174
    data = self._bulk_object_data(:update, :default)
6✔
175

176
    data_ids = data.map { |r| r[pk] }.uniq
18✔
177
    existing = self.get_recordset.where(pk => data_ids).index_by { |r| r.send(pk) }
18✔
178
    if existing.length != data_ids.length
6✔
NEW
179
      missing = data_ids - existing.keys
×
NEW
180
      raise RESTFramework::InvalidBulkParametersError.new(
×
181
        "Records not found with #{pk}: #{missing.join(', ')}.",
182
      )
183
    end
184

185
    # Assign attributes to each record.
186
    records = data.map { |attrs|
6✔
187
      record = existing[attrs[pk]]
12✔
188
      record.assign_attributes(attrs.except(pk))
12✔
189
      record
12✔
190
    }
191

192
    if self._bulk_partial
6✔
193
      # Partial: save each record individually.
194
      records.each(&:save)
2✔
195
      records
2✔
196
    else
197
      # Transactional: validate all first, then save in a transaction or raise.
198
      failed = records.reject(&:valid?)
4✔
199

200
      if failed.any?
4✔
201
        raise RESTFramework::BulkRecordErrorsError.new(records)
2✔
202
      end
203

204
      self.class.model.transaction do
2✔
205
        records.each(&:save!)
2✔
206
      end
207

208
      records
2✔
209
    end
210
  end
211

212
  def destroy_all
2✔
213
    if self._bulk_mode == :raw
8✔
214
      deleted = self.destroy_all_raw!
2✔
215
      return render(api: { message: "Records destroyed successfully.", result: deleted })
2✔
216
    end
217

218
    records = self.destroy_all_default!
6✔
219
    render(
4✔
220
      api: { message: "Records destroyed successfully.", records: self._bulk_serialize(records) },
221
    )
222
  end
223

224
  def destroy_all_raw!
2✔
225
    data = self._bulk_pk_data
2✔
226
    pk = self.class.model.primary_key
2✔
227
    self.get_recordset.where(pk => data).delete_all
2✔
228
  end
229

230
  def destroy_all_default!
2✔
231
    data = self._bulk_pk_data
6✔
232
    pk = self.class.model.primary_key
6✔
233
    records = self.get_recordset.where(pk => data).to_a
6✔
234

235
    if self._bulk_partial
6✔
236
      # Partial: destroy each record individually.
237
      records.each(&:destroy)
2✔
238
      records
2✔
239
    else
240
      # Transactional: destroy all in a transaction, roll back on failure.
241
      self.class.model.transaction do
4✔
242
        records.each(&:destroy!)
4✔
243
      end
244

245
      records
2✔
246
    end
247
  end
248
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