rest_framework 0.8.17 → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/VERSION +1 -1
- data/app/views/layouts/rest_framework.html.erb +24 -18
- data/app/views/rest_framework/_head.html.erb +71 -57
- data/app/views/rest_framework/_html_form.html.erb +1 -1
- data/app/views/rest_framework/_raw_form.html.erb +6 -3
- data/docs/assets/js/rest_framework.js +20 -15
- data/lib/rest_framework/controller_mixins/bulk.rb +52 -37
- data/lib/rest_framework/controller_mixins/models.rb +47 -33
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: '036384f3209aba594f8af47997294c73c4ca95294601d1abf46ee31086610296'
|
|
4
|
+
data.tar.gz: 52424a145b6c959eed3818c23a3e21cd952cb8cb9dc0520506803c08ba5b63b3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 28d9e2ec67ae6ac76f9709a78f7818f96336b87956e447095e48af59386c4cf5375d2fbc9ff243bc5bb915d982a868849a32bda8f3b5406c6fd853e6d2245a58
|
|
7
|
+
data.tar.gz: f8618a040b3ea3cdf44017a2b7dbedd465546f6b073750925635124b97a11e15486883d0902a8259ac8f4d94f4c0a582f6687f1f8148f7332c76342a67c9ff11
|
data/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.
|
|
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
|
-
<% @
|
|
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
|
-
<%
|
|
162
|
+
<% if @_rrf_form_routes_raw.present? %>
|
|
157
163
|
<li class="nav-item">
|
|
158
|
-
<a class="nav-link" href="#
|
|
164
|
+
<a class="nav-link" href="#tabRawForm" data-bs-toggle="tab" role="tab">
|
|
159
165
|
Raw Form
|
|
160
166
|
</a>
|
|
161
167
|
</li>
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
</
|
|
168
|
-
|
|
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
|
-
<%
|
|
177
|
-
<div class="tab-pane fade" id="
|
|
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
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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: .
|
|
139
|
-
right: .
|
|
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
|
-
|
|
182
|
-
modeComponent
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
224
|
-
|
|
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
|
|
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
|
|
275
|
+
document.querySelectorAll(".rrf-copy code").forEach((el) => {
|
|
267
276
|
el.innerHTML = rrfLinkify(el.innerHTML)
|
|
268
277
|
})
|
|
269
278
|
|
|
270
|
-
// Insert copy
|
|
271
|
-
document.querySelectorAll("rrf-copy").forEach((el
|
|
279
|
+
// Insert copy links.
|
|
280
|
+
document.querySelectorAll(".rrf-copy").forEach((el) => {
|
|
272
281
|
el.insertAdjacentHTML(
|
|
273
282
|
"afterbegin",
|
|
274
|
-
|
|
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
|
|
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
|
-
<% @
|
|
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
|
-
<% @
|
|
5
|
+
<% @_rrf_form_routes_raw.each do |route| %>
|
|
6
6
|
<% path = @route_props[:with_path_args].call(route[:route]) %>
|
|
7
|
-
<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"
|
|
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
|
-
|
|
34
|
-
modeComponent
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
76
|
-
|
|
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
|
-
|
|
9
|
-
|
|
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`
|
|
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
|
-
|
|
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
|
-
|
|
29
|
-
|
|
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
|
-
|
|
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
|
|
42
|
+
# Perform the `update` call and return the collection of (possibly) updated records.
|
|
53
43
|
def update_all!
|
|
54
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
509
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
#
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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-
|
|
11
|
+
date: 2023-04-09 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rails
|