rest_framework 0.8.17 → 0.9.1

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9b8d9a14de396b6832c13e0f3e1f2627decb432af03c6a2cbb59ce27328cdd7c
4
- data.tar.gz: 6d1e675245643d96e2b35aaaadd9118eb441025a1bb7acc303db63d6761dc79c
3
+ metadata.gz: '036384f3209aba594f8af47997294c73c4ca95294601d1abf46ee31086610296'
4
+ data.tar.gz: 52424a145b6c959eed3818c23a3e21cd952cb8cb9dc0520506803c08ba5b63b3
5
5
  SHA512:
6
- metadata.gz: a0ef4d98bec92656d0541f34aebd652d30b90af0bdde8ea91e0b1809b82ee381bdbfef0ea04235b2c24005ec43646ad59b7fce01d3eda1312e48e1a976bd8092
7
- data.tar.gz: d64fdd4c884a6173c9df9f87074748290b1093917a053acfa70b5b6579bbe07f7479adf15b226178fb784181edfe28ac3cbce461c13b64372e988d5abbab5966
6
+ metadata.gz: 28d9e2ec67ae6ac76f9709a78f7818f96336b87956e447095e48af59386c4cf5375d2fbc9ff243bc5bb915d982a868849a32bda8f3b5406c6fd853e6d2245a58
7
+ data.tar.gz: f8618a040b3ea3cdf44017a2b7dbedd465546f6b073750925635124b97a11e15486883d0902a8259ac8f4d94f4c0a582f6687f1f8148f7332c76342a67c9ff11
data/VERSION CHANGED
@@ -1 +1 @@
1
- 0.8.17
1
+ 0.9.1
@@ -64,7 +64,7 @@
64
64
  <div>
65
65
  <h1 class="m-0"><%= (@header_text if defined? @header_text) || @title %></h1>
66
66
  <div style="float: right">
67
- <% if @route_groups.values[0].any? { |r| r[:matches_path] && r[:verb] == "DELETE" } %>
67
+ <% if @route_groups.values[0].any? { |r| r[:matches_path] && r[:verb] == "DELETE" && r[:action] == "destroy" } %>
68
68
  <button type="button" class="btn btn-danger" onclick="rrfDelete(this)">DELETE</button>
69
69
  <% end %>
70
70
  <% if @route_groups.values[0].any? { |r| r[:matches_path] && r[:verb] == "OPTIONS" } %>
@@ -150,22 +150,28 @@
150
150
  Routes
151
151
  </a>
152
152
  </li>
153
- <% @_rrf_form_routes = @route_groups.values[0].select { |r|
153
+ <% @_rrf_form_routes_raw = @route_groups.values[0].select { |r|
154
+ r[:matches_params] && (
155
+ r[:verb].in?(["POST", "PUT", "PATCH"]) ||
156
+ (r[:verb] == "DELETE" && r[:action] == "destroy_all")
157
+ )
158
+ } %>
159
+ <% @_rrf_form_routes_html = @route_groups.values[0].select { |r|
154
160
  r[:matches_params] && r[:verb].in?(["POST", "PUT", "PATCH"])
155
161
  } %>
156
- <% unless @_rrf_form_routes.empty? %>
162
+ <% if @_rrf_form_routes_raw.present? %>
157
163
  <li class="nav-item">
158
- <a class="nav-link" href="#tab-raw-form" data-bs-toggle="tab" role="tab">
164
+ <a class="nav-link" href="#tabRawForm" data-bs-toggle="tab" role="tab">
159
165
  Raw Form
160
166
  </a>
161
167
  </li>
162
- <% if @is_model_controller %>
163
- <li class="nav-item">
164
- <a class="nav-link" href="#tab-html-form" data-bs-toggle="tab" role="tab">
165
- HTML Form
166
- </a>
167
- </li>
168
- <% end %>
168
+ <% end %>
169
+ <% if @_rrf_form_routes_html.present? && @is_model_controller %>
170
+ <li class="nav-item">
171
+ <a class="nav-link" href="#tabHtmlForm" data-bs-toggle="tab" role="tab">
172
+ HTML Form
173
+ </a>
174
+ </li>
169
175
  <% end %>
170
176
  </ul>
171
177
  </div>
@@ -173,15 +179,15 @@
173
179
  <div class="tab-pane fade show active" id="tab-routes" role="tabpanel">
174
180
  <%= render partial: "rest_framework/routes" %>
175
181
  </div>
176
- <% unless @_rrf_form_routes.empty? %>
177
- <div class="tab-pane fade" id="tab-raw-form" role="tabpanel">
182
+ <% if @_rrf_form_routes_raw.present? %>
183
+ <div class="tab-pane fade" id="tabRawForm" role="tabpanel">
178
184
  <%= render partial: "rest_framework/raw_form" %>
179
185
  </div>
180
- <% if @is_model_controller %>
181
- <div class="tab-pane fade" id="tab-html-form" role="tabpanel">
182
- <%= render partial: "rest_framework/html_form" %>
183
- </div>
184
- <% end %>
186
+ <% end %>
187
+ <% if @_rrf_form_routes_html.present? && @is_model_controller %>
188
+ <div class="tab-pane fade" id="tabHtmlForm" role="tabpanel">
189
+ <%= render partial: "rest_framework/html_form" %>
190
+ </div>
185
191
  <% end %>
186
192
  </div>
187
193
  </div>
@@ -135,11 +135,15 @@ html[data-bs-theme="dark"] .rrf-routes .rrf-route-group-header:hover {
135
135
  }
136
136
  .rrf-copy .rrf-copy-link {
137
137
  position: absolute;
138
- top: .5em;
139
- right: .5em;
138
+ top: .25em;
139
+ right: .4em;
140
140
  transition: 0.3s ease;
141
+ font-size: 1.5em;
141
142
  }
142
- .rrf-copy .rrf-copy-link.rrf-clicked{
143
+ .rrf-copy .rrf-copy-link.rrf-clicked {
144
+ color: green;
145
+ }
146
+ .rrf-copy .rrf-copy-link.rrf-clicked:hover {
143
147
  color: green;
144
148
  }
145
149
  </style>
@@ -167,8 +171,6 @@ html[data-bs-theme="dark"] .rrf-routes .rrf-route-group-header:hover {
167
171
 
168
172
  // Set the mode, given a "selected" mode.
169
173
  const rrfSetSelectedMode = (selectedMode) => {
170
- const modeComponent = document.getElementById("rrfModeComponent")
171
-
172
174
  // Anything except "light" or "dark" is casted to "system".
173
175
  if (selectedMode !== "light" && selectedMode !== "dark") {
174
176
  selectedMode = "system"
@@ -178,16 +180,19 @@ html[data-bs-theme="dark"] .rrf-routes .rrf-route-group-header:hover {
178
180
  localStorage.setItem("rrfMode", selectedMode)
179
181
 
180
182
  // Set the mode selector to the selected mode.
181
- let labelHTML
182
- modeComponent.querySelectorAll("button[data-rrf-mode-value]").forEach((el) => {
183
- if (el.getAttribute("data-rrf-mode-value") === selectedMode) {
184
- el.classList.add("active")
185
- labelHTML = el.querySelector("i").outerHTML.replace("ms-2", "me-1")
186
- } else {
187
- el.classList.remove("active")
188
- }
189
- })
190
- modeComponent.querySelector("button[data-bs-toggle]").innerHTML = labelHTML
183
+ const modeComponent = document.getElementById("rrfModeComponent")
184
+ if (modeComponent) {
185
+ let labelHTML
186
+ modeComponent.querySelectorAll("button[data-rrf-mode-value]").forEach((el) => {
187
+ if (el.getAttribute("data-rrf-mode-value") === selectedMode) {
188
+ el.classList.add("active")
189
+ labelHTML = el.querySelector("i").outerHTML.replace("ms-2", "me-1")
190
+ } else {
191
+ el.classList.remove("active")
192
+ }
193
+ })
194
+ modeComponent.querySelector("button[data-bs-toggle]").innerHTML = labelHTML
195
+ }
191
196
 
192
197
  // Get the real mode to use.
193
198
  realMode = rrfGetRealMode(selectedMode)
@@ -218,10 +223,14 @@ html[data-bs-theme="dark"] .rrf-routes .rrf-route-group-header:hover {
218
223
  }
219
224
  }
220
225
 
221
- // Initialize dark/light mode.
226
+ // Initialize dark/light mode before page fully loads to prevent flash.
227
+ rrfSetSelectedMode(localStorage.getItem("rrfMode"))
228
+
229
+ // Initialize dark/light mode after page load (mostly so mode component is updated).
222
230
  document.addEventListener("DOMContentLoaded", (event) => {
223
- const selectedMode = localStorage.getItem("rrfMode")
224
- rrfSetSelectedMode(selectedMode)
231
+ rrfSetSelectedMode(localStorage.getItem("rrfMode"))
232
+
233
+ // Also set up mode selector.
225
234
  document.querySelectorAll("#rrfModeComponent button[data-rrf-mode-value]").forEach((el) => {
226
235
  el.addEventListener("click", (event) => {
227
236
  rrfSetSelectedMode(event.target.getAttribute("data-rrf-mode-value"))
@@ -246,7 +255,7 @@ html[data-bs-theme="dark"] .rrf-routes .rrf-route-group-header:hover {
246
255
 
247
256
  document.addEventListener("DOMContentLoaded", (event) => {
248
257
  // Pretty-print JSON.
249
- document.querySelectorAll(".language-json").forEach((el, index) => {
258
+ document.querySelectorAll(".language-json").forEach((el) => {
250
259
  el.innerHTML = neatJSON(JSON.parse(el.innerText), {
251
260
  wrap: 80,
252
261
  afterComma: 1,
@@ -263,17 +272,55 @@ document.addEventListener("DOMContentLoaded", (event) => {
263
272
  hljs.highlightAll()
264
273
 
265
274
  // Replace text node links with anchor tag links.
266
- document.querySelectorAll(".rrf-copy code").forEach((el, index) => {
275
+ document.querySelectorAll(".rrf-copy code").forEach((el) => {
267
276
  el.innerHTML = rrfLinkify(el.innerHTML)
268
277
  })
269
278
 
270
- // Insert copy link and callback to copy contents of `<code>` element.
271
- document.querySelectorAll("rrf-copy").forEach((el, index) => {
279
+ // Insert copy links.
280
+ document.querySelectorAll(".rrf-copy").forEach((el) => {
272
281
  el.insertAdjacentHTML(
273
282
  "afterbegin",
274
- "<a class=\"rrf-copy-link\" onclick=\"return rrfCopyToClipboard(this)\" href=\"#\">Copy to Clipboard</a>",
283
+ '<a class="rrf-copy-link" title="Copy to Clipboard" href="#"><i class="bi bi-clipboard-fill"></i></a>',
275
284
  )
276
285
  })
286
+
287
+ // Copy link implementation.
288
+ document.querySelectorAll(".rrf-copy-link").forEach((el) => {
289
+ el.addEventListener("click", (event) => {
290
+ const range = document.createRange()
291
+ range.selectNode(el.nextSibling)
292
+ window.getSelection().removeAllRanges()
293
+ window.getSelection().addRange(range)
294
+ if (document.execCommand("copy")) {
295
+ // Trigger clicked animation.
296
+ el.classList.add("rrf-clicked")
297
+ el.innerHTML = '<i class="bi bi-clipboard-check-fill">'
298
+ setTimeout(() => {
299
+ el.classList.remove("rrf-clicked")
300
+ el.innerHTML = '<i class="bi bi-clipboard-fill">'
301
+ }, 1000)
302
+ }
303
+ event.preventDefault()
304
+ })
305
+ })
306
+
307
+ // Check if `rawFilesFormWrapper` should be displayed when media type is changed.
308
+ const rawFormRouteSelect = document.getElementById("rawFormRoute")
309
+ const rawFormMediaTypeSelect = document.getElementById("rawFormMediaType")
310
+ const rawFilesFormWrapper = document.getElementById("rawFilesFormWrapper")
311
+ if (rawFilesFormWrapper) {
312
+ const rawFormFilesHandler = () => {
313
+ const selectedRouteOption = rawFormRouteSelect.options[rawFormRouteSelect.selectedIndex]
314
+ if (rawFormMediaTypeSelect.value === "multipart/form-data" && selectedRouteOption.dataset.supportsFiles) {
315
+ rawFilesFormWrapper.style.display = "block"
316
+ } else {
317
+ rawFilesFormWrapper.style.display = "none"
318
+ }
319
+ }
320
+
321
+ rawFormRouteSelect.addEventListener("change", rawFormFilesHandler)
322
+ rawFormMediaTypeSelect.addEventListener("change", rawFormFilesHandler)
323
+ }
277
324
  })
278
325
 
279
326
  // Convert plain-text links to anchor tag links.
@@ -292,26 +339,6 @@ function rrfReplaceDocument(content) {
292
339
  document.dispatchEvent(new Event("DOMContentLoaded", {bubbles: true, cancelable: true}))
293
340
  }
294
341
 
295
- // Copy the element's next `<code>` sibling's content to the clipboard.
296
- function rrfCopyToClipboard(element) {
297
- let range = document.createRange()
298
- range.selectNode(element.nextSibling)
299
- window.getSelection().removeAllRanges()
300
- window.getSelection().addRange(range)
301
- if (document.execCommand("copy")) {
302
- // Trigger clicked animation.
303
- element.classList.add("rrf-clicked")
304
- element.innerText = "Copied!"
305
- setTimeout(() => {
306
- element.classList.remove("rrf-clicked")
307
- element.innerText = "Copy to Clipboard"
308
- }, 700)
309
- }
310
-
311
- // Return false to prevent normal link behavior.
312
- return false
313
- }
314
-
315
342
  // Refresh the window as a `GET` request.
316
343
  function rrfGet(button) {
317
344
  button.disabled = true
@@ -352,7 +379,7 @@ function rrfSubmitRawForm(button) {
352
379
  // Add file(s) to `formData`.
353
380
  const rawFilesForm = document.getElementById("rawFilesForm")
354
381
  if (rawFilesForm) {
355
- rawFilesForm.querySelectorAll("input[type=file]").forEach((el, index) => {
382
+ rawFilesForm.querySelectorAll("input[type=file]").forEach((el) => {
356
383
  const files = el.files
357
384
  for (let i = 0; i < files.length; i++) {
358
385
  formData.append(el.name, files[i])
@@ -382,17 +409,4 @@ function rrfAPICall(path, method, kwargs={}) {
382
409
  .then((response) => response.text())
383
410
  .then((body) => { rrfReplaceDocument(body) })
384
411
  }
385
-
386
- // Check if `rawFilesFormWrapper` should be displayed when media type is changed.
387
- function rrfCheckRawFilesFormDisplay(el) {
388
- const rawFilesFormWrapper = document.getElementById("rawFilesFormWrapper")
389
-
390
- if (rawFilesFormWrapper) {
391
- if (el.value === "multipart/form-data") {
392
- rawFilesFormWrapper.style.display = "block"
393
- } else {
394
- rawFilesFormWrapper.style.display = "none"
395
- }
396
- }
397
- }
398
412
  </script>
@@ -2,7 +2,7 @@
2
2
  <div class="mb-2">
3
3
  <label class="form-label w-100">Route
4
4
  <select class="form-control form-control-sm" id="htmlFormRoute">
5
- <% @_rrf_form_routes.each do |route| %>
5
+ <% @_rrf_form_routes_html.each do |route| %>
6
6
  <% path = @route_props[:with_path_args].call(route[:route]) %>
7
7
  <option value="<%= route[:verb] %>:<%= path %>"><%= route[:verb] %> <%= route[:relative_path] %></option>
8
8
  <% end %>
@@ -2,9 +2,12 @@
2
2
  <div class="mb-2">
3
3
  <label class="form-label w-100">Route
4
4
  <select class="form-control form-control-sm" id="rawFormRoute">
5
- <% @_rrf_form_routes.each do |route| %>
5
+ <% @_rrf_form_routes_raw.each do |route| %>
6
6
  <% path = @route_props[:with_path_args].call(route[:route]) %>
7
- <option value="<%= route[:verb] %>:<%= path %>"><%= route[:verb] %> <%= route[:relative_path] %></option>
7
+ <option
8
+ value="<%= route[:verb] %>:<%= path %>"
9
+ data-supports-files="<%= !route[:action].in?(["update_all", "destroy", "destroy_all"]) ? "true" : "" %>"
10
+ ><%= route[:verb] %> <%= route[:relative_path] %></option>
8
11
  <% end %>
9
12
  </select>
10
13
  </label>
@@ -12,7 +15,7 @@
12
15
 
13
16
  <div class="mb-2">
14
17
  <label class="form-label w-100">Media Type
15
- <select class="form-control form-control-sm" id="rawFormMediaType" onchange="rrfCheckRawFilesFormDisplay(this)">
18
+ <select class="form-control form-control-sm" id="rawFormMediaType">
16
19
  <% ["application/json", "application/x-www-form-urlencoded", "multipart/form-data"].each do |t| %>
17
20
  <option value="<%= t %>"><%= t %></option>
18
21
  <% end %>
@@ -19,8 +19,6 @@
19
19
 
20
20
  // Set the mode, given a "selected" mode.
21
21
  const rrfSetSelectedMode = (selectedMode) => {
22
- const modeComponent = document.getElementById("rrfModeComponent")
23
-
24
22
  // Anything except "light" or "dark" is casted to "system".
25
23
  if (selectedMode !== "light" && selectedMode !== "dark") {
26
24
  selectedMode = "system"
@@ -30,16 +28,19 @@
30
28
  localStorage.setItem("rrfMode", selectedMode)
31
29
 
32
30
  // Set the mode selector to the selected mode.
33
- let labelHTML
34
- modeComponent.querySelectorAll("button[data-rrf-mode-value]").forEach((el) => {
35
- if (el.getAttribute("data-rrf-mode-value") === selectedMode) {
36
- el.classList.add("active")
37
- labelHTML = el.querySelector("i").outerHTML.replace("ms-2", "me-1")
38
- } else {
39
- el.classList.remove("active")
40
- }
41
- })
42
- modeComponent.querySelector("button[data-bs-toggle]").innerHTML = labelHTML
31
+ const modeComponent = document.getElementById("rrfModeComponent")
32
+ if (modeComponent) {
33
+ let labelHTML
34
+ modeComponent.querySelectorAll("button[data-rrf-mode-value]").forEach((el) => {
35
+ if (el.getAttribute("data-rrf-mode-value") === selectedMode) {
36
+ el.classList.add("active")
37
+ labelHTML = el.querySelector("i").outerHTML.replace("ms-2", "me-1")
38
+ } else {
39
+ el.classList.remove("active")
40
+ }
41
+ })
42
+ modeComponent.querySelector("button[data-bs-toggle]").innerHTML = labelHTML
43
+ }
43
44
 
44
45
  // Get the real mode to use.
45
46
  realMode = rrfGetRealMode(selectedMode)
@@ -70,10 +71,14 @@
70
71
  }
71
72
  }
72
73
 
73
- // Initialize dark/light mode.
74
+ // Initialize dark/light mode before page fully loads to prevent flash.
75
+ rrfSetSelectedMode(localStorage.getItem("rrfMode"))
76
+
77
+ // Initialize dark/light mode after page load (mostly so mode component is updated).
74
78
  document.addEventListener("DOMContentLoaded", (event) => {
75
- const selectedMode = localStorage.getItem("rrfMode")
76
- rrfSetSelectedMode(selectedMode)
79
+ rrfSetSelectedMode(localStorage.getItem("rrfMode"))
80
+
81
+ // Also set up mode selector.
77
82
  document.querySelectorAll("#rrfModeComponent button[data-rrf-mode-value]").forEach((el) => {
78
83
  el.addEventListener("click", (event) => {
79
84
  rrfSetSelectedMode(event.target.getAttribute("data-rrf-mode-value"))
@@ -2,43 +2,31 @@ require_relative "models"
2
2
 
3
3
  # Mixin for creating records in bulk. This is unique compared to update/destroy because we overload
4
4
  # the existing `create` action to support bulk creation.
5
- # :nocov:
6
5
  module RESTFramework::BulkCreateModelMixin
6
+ # While bulk update/destroy are obvious because they create new router endpoints, bulk create
7
+ # overloads the existing collection `POST` endpoint, so we add a special key to the options
8
+ # metadata to indicate bulk create is supported.
9
+ def get_options_metadata
10
+ return super.merge({bulk_create: true})
11
+ end
12
+
7
13
  def create
8
- status, payload = self.create_all!
9
- return api_response(payload, status: status)
14
+ if params[:_json].is_a?(Array)
15
+ records = self.create_all!
16
+ serialized_records = self.bulk_serialize(records)
17
+ return api_response(serialized_records)
18
+ end
19
+
20
+ return super
10
21
  end
11
22
 
12
- # Perform the `create` or `insert_all` call and return the created records with any errors. The
13
- # result should be of the form: `(status, payload)`, and `payload` should be of the form:
14
- # `[{success:, record: | errors:}]`, unless batch mode is enabled, in which case `payload` is
15
- # blank with a status of `202`.
23
+ # Perform the `create` call, and return the collection of (possibly) created records.
16
24
  def create_all!
17
- if self.class.bulk_batch_mode
18
- insert_from = if self.get_recordset.respond_to?(:insert_all) && self.create_from_recordset
19
- # Create with any properties inherited from the recordset. We exclude any `select` clauses
20
- # in case model callbacks need to call `count` on this collection, which typically raises a
21
- # SQL `SyntaxError`.
22
- self.get_recordset.except(:select)
23
- else
24
- # Otherwise, perform a "bare" insert_all.
25
- self.class.get_model
26
- end
25
+ create_data = self.get_create_params(bulk_mode: true)[:_json]
27
26
 
28
- insert_from
29
- end
30
-
31
- # Perform bulk creation, possibly in a transaction.
32
- self.class._rrf_bulk_transaction do
33
- if self.get_recordset.respond_to?(:insert_all) && self.create_from_recordset
34
- # Create with any properties inherited from the recordset. We exclude any `select` clauses
35
- # in case model callbacks need to call `count` on this collection, which typically raises a
36
- # SQL `SyntaxError`.
37
- return self.get_recordset.except(:select).create!(self.get_create_params)
38
- else
39
- # Otherwise, perform a "bare" insert_all.
40
- return self.class.get_model.insert_all(self.get_create_params)
41
- end
27
+ # Perform bulk create in a transaction.
28
+ return ActiveRecord::Base.transaction do
29
+ next self.get_create_from.create(create_data)
42
30
  end
43
31
  end
44
32
  end
@@ -46,24 +34,52 @@ end
46
34
  # Mixin for updating records in bulk.
47
35
  module RESTFramework::BulkUpdateModelMixin
48
36
  def update_all
49
- raise NotImplementedError, "TODO"
37
+ records = self.update_all!
38
+ serialized_records = self.bulk_serialize(records)
39
+ return api_response(serialized_records)
50
40
  end
51
41
 
52
- # Perform the `update!` call and return the updated record.
42
+ # Perform the `update` call and return the collection of (possibly) updated records.
53
43
  def update_all!
54
- raise NotImplementedError, "TODO"
44
+ pk = self.class.get_model.primary_key
45
+ update_data = if params[:_json].is_a?(Array)
46
+ self.get_create_params(bulk_mode: :update)[:_json].index_by { |r| r[pk] }
47
+ else
48
+ create_params = self.get_create_params
49
+ {create_params[pk] => create_params}
50
+ end
51
+
52
+ # Perform bulk update in a transaction.
53
+ return ActiveRecord::Base.transaction do
54
+ next self.get_recordset.update(update_data.keys, update_data.values)
55
+ end
55
56
  end
56
57
  end
57
58
 
58
59
  # Mixin for destroying records in bulk.
59
60
  module RESTFramework::BulkDestroyModelMixin
60
61
  def destroy_all
61
- raise NotImplementedError, "TODO"
62
+ if params[:_json].is_a?(Array)
63
+ records = self.destroy_all!
64
+ serialized_records = self.bulk_serialize(records)
65
+ return api_response(serialized_records)
66
+ end
67
+
68
+ return api_response(
69
+ {message: "Bulk destroy requires an array of primary keys as input."},
70
+ status: 400,
71
+ )
62
72
  end
63
73
 
64
74
  # Perform the `destroy!` call and return the destroyed (and frozen) record.
65
75
  def destroy_all!
66
- raise NotImplementedError, "TODO"
76
+ pk = self.class.get_model.primary_key
77
+ destroy_data = self.request.request_parameters[:_json]
78
+
79
+ # Perform bulk destroy in a transaction.
80
+ return ActiveRecord::Base.transaction do
81
+ next self.get_recordset.where(pk => destroy_data).destroy_all
82
+ end
67
83
  end
68
84
  end
69
85
 
@@ -79,4 +95,3 @@ module RESTFramework::BulkModelControllerMixin
79
95
  RESTFramework::ModelControllerMixin.included(base)
80
96
  end
81
97
  end
82
- # :nocov:
@@ -66,14 +66,6 @@ module RESTFramework::BaseModelControllerMixin
66
66
 
67
67
  # Control if filtering is done before find.
68
68
  filter_recordset_before_find: true,
69
-
70
- # Control if bulk operations are done in a transaction and rolled back on error, or if all bulk
71
- # operations are attempted and errors simply returned in the response.
72
- bulk_transactional: false,
73
-
74
- # Control if bulk operations should be done in "batch" mode, using efficient queries, but also
75
- # skipping model validations/callbacks.
76
- bulk_batch_mode: false,
77
69
  }
78
70
 
79
71
  module ClassMethods
@@ -502,15 +494,22 @@ module RESTFramework::BaseModelControllerMixin
502
494
  end
503
495
 
504
496
  # Use strong parameters to filter the request body using the configured allowed parameters.
505
- def get_body_params(data: nil)
506
- data ||= request.request_parameters
507
-
508
- # Filter the request body with strong params.
509
- body_params = ActionController::Parameters.new(data).permit(*self.get_allowed_parameters)
497
+ def get_body_params(data: nil, bulk_mode: nil)
498
+ data ||= self.request.request_parameters
499
+ pk = self.class.get_model&.primary_key
500
+
501
+ # Filter the request body with strong params. If `bulk` is true, then we apply allowed
502
+ # parameters to the `_json` key of the request body.
503
+ body_params = if bulk_mode
504
+ pk = bulk_mode == :update ? [pk] : []
505
+ ActionController::Parameters.new(data).permit({_json: self.get_allowed_parameters + pk})
506
+ else
507
+ ActionController::Parameters.new(data).permit(*self.get_allowed_parameters)
508
+ end
510
509
 
511
510
  # Filter primary key if configured.
512
- if self.class.filter_pk_from_request_body
513
- body_params.delete(self.class.get_model&.primary_key)
511
+ if self.class.filter_pk_from_request_body && bulk_mode != :update
512
+ body_params.delete(pk)
514
513
  end
515
514
 
516
515
  # Filter fields in `exclude_body_fields`.
@@ -593,6 +592,7 @@ module RESTFramework::BaseModelControllerMixin
593
592
 
594
593
  recordset = self.get_recordset
595
594
  find_by_key = self.class.get_model.primary_key
595
+ is_pk = true
596
596
 
597
597
  # Find by another column if it's permitted.
598
598
  if find_by_param = self.class.find_by_query_param.presence
@@ -600,6 +600,7 @@ module RESTFramework::BaseModelControllerMixin
600
600
  find_by_fields = self.get_find_by_fields&.map(&:to_s)
601
601
 
602
602
  if !find_by_fields || find_by.in?(find_by_fields)
603
+ is_pk = false unless find_by_key == find_by
603
604
  find_by_key = find_by
604
605
  end
605
606
  end
@@ -611,18 +612,41 @@ module RESTFramework::BaseModelControllerMixin
611
612
  end
612
613
 
613
614
  # Return the record. Route key is always `:id` by Rails convention.
614
- return @record = recordset.find_by!(find_by_key => request.path_parameters[:id])
615
+ if is_pk
616
+ return @record = recordset.find(request.path_parameters[:id])
617
+ else
618
+ return @record = recordset.find_by!(find_by_key => request.path_parameters[:id])
619
+ end
615
620
  end
616
621
 
617
- # Create a transaction around the passed block, if configured. This is used primarily for bulk
618
- # actions, but we include it here so it's always available.
619
- def self._rrf_bulk_transaction(&block)
620
- if self.bulk_transactional
621
- ActiveRecord::Base.transaction(&block)
622
+ # Determine what collection to call `create` on.
623
+ def get_create_from
624
+ if self.class.create_from_recordset
625
+ # Create with any properties inherited from the recordset. We exclude any `select` clauses
626
+ # in case model callbacks need to call `count` on this collection, which typically raises a
627
+ # SQL `SyntaxError`.
628
+ self.get_recordset.except(:select)
622
629
  else
623
- yield
630
+ # Otherwise, perform a "bare" insert_all.
631
+ self.class.get_model
624
632
  end
625
633
  end
634
+
635
+ # Serialize the records, but also include any errors that might exist. This is used for bulk
636
+ # actions, however we include it here so the helper is available everywhere.
637
+ def bulk_serialize(records)
638
+ # This is kinda slow, so perhaps we should eventually integrate `errors` serialization into
639
+ # the serializer directly. This would fail for active model serializers, but maybe we don't
640
+ # care?
641
+ serializer_class = self.get_serializer_class
642
+ serialized_records = records.map do |record|
643
+ serializer_class.new(record, controller: self).serialize.merge!(
644
+ {errors: record.errors.presence}.compact,
645
+ )
646
+ end
647
+
648
+ return serialized_records
649
+ end
626
650
  end
627
651
 
628
652
  # Mixin for listing records.
@@ -668,17 +692,7 @@ module RESTFramework::CreateModelMixin
668
692
 
669
693
  # Perform the `create!` call and return the created record.
670
694
  def create!
671
- create_from = if self.create_from_recordset && self.get_recordset.respond_to?(:create!)
672
- # Create with any properties inherited from the recordset. We exclude any `select` clauses in
673
- # case model callbacks need to call `count` on this collection, which typically raises a SQL
674
- # `SyntaxError`.
675
- self.get_recordset.except(:select)
676
- else
677
- # Otherwise, perform a "bare" create.
678
- self.class.get_model
679
- end
680
-
681
- return create_from.create!(self.get_create_params)
695
+ return self.get_create_from.create!(self.get_create_params)
682
696
  end
683
697
  end
684
698
 
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rest_framework
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.17
4
+ version: 0.9.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Gregory N. Schmit
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2023-04-06 00:00:00.000000000 Z
11
+ date: 2023-04-09 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails