From 89f621c035d65a4d85387d077a299481120b6c0c Mon Sep 17 00:00:00 2001 From: Mumbi Francis Date: Tue, 23 May 2023 18:01:06 +0300 Subject: [PATCH] Add ability to select and perform bulk operations on all records This change affects the recordlist and the bulk operations are edit,delete and undelete --- .../components/PageBlocks/RecordListBase.vue | 90 ++++++++----- .../Public/Record/Exporter/Configurator.vue | 9 ++ client/web/compose/src/mixins/record.js | 4 +- lib/js/src/api-clients/compose.ts | 12 +- locale/en/corteza-webapp-compose/block.yaml | 3 + .../compose/automation/records_handler.gen.go | 77 ++++++----- server/compose/rest.yaml | 16 +-- server/compose/rest/record.go | 56 ++++---- server/compose/rest/request/record.go | 111 +++++++++------- server/compose/service/record.go | 120 ++++++++++++++++-- server/compose/types/record.go | 9 +- server/pkg/slice/ints.go | 4 +- server/pkg/slice/ints_test.go | 3 +- 13 files changed, 343 insertions(+), 171 deletions(-) diff --git a/client/web/compose/src/components/PageBlocks/RecordListBase.vue b/client/web/compose/src/components/PageBlocks/RecordListBase.vue index e63cad206..49c94443d 100644 --- a/client/web/compose/src/components/PageBlocks/RecordListBase.vue +++ b/client/web/compose/src/components/PageBlocks/RecordListBase.vue @@ -72,6 +72,7 @@ :query="query" :prefilter="prefilter" :selection="selected" + :selected-all-records-count="selectedAllRecords ? pagination.count : 0" :processing="processing" :preselected-fields="fields.map(({ moduleField }) => moduleField)" class="mr-1 float-left" @@ -164,37 +165,37 @@
-
- {{ $t('recordList.selected', { count: selected.length, total: items.length }) }} - - ({{ $t('recordList.cancelSelection') }}) - +
+ {{ selectedRecordsDisplayText }}
+ + {{ selectedAllRecords ? $t('recordList.unselectAllRecords') : $t('recordList.selectAllRecords') }} + +
@@ -433,7 +434,7 @@ :extra-options="options" />
id) } else { this.selected = [] + this.selectedAllRecords = isChecked } }, + selectAllRecords () { + this.selectedAllRecords = !this.selectedAllRecords + this.handleSelectAllOnPage({ isChecked: this.selectedAllRecords }) + }, + handleRestoreSelectedRecords () { if (this.inlineEditing) { const sel = new Set(this.selected) @@ -1482,15 +1501,19 @@ export default { } else { const { moduleID, namespaceID } = this.items[0].r - // filter undeletable records from the selected list - const recordIDs = this.items - .filter(({ id, r }) => r.canUndeleteRecord && this.selected.includes(id)) - .map(({ id }) => id) + let query = '' - this.processing = true + if (!this.selectedAllRecords) { + // filter deletable records from the selected list + const recordIDs = this.items + .filter(({ id, r }) => r.canUndeleteRecord && this.selected.includes(id)) + .map(({ id }) => id) + + query = recordIDs.map(r => `recordID='${r}'`).join(' OR ') + } this.$ComposeAPI - .recordBulkUndelete({ moduleID, namespaceID, recordIDs }) + .recordBulkUndelete({ moduleID, namespaceID, query }) .then(() => { this.refresh(true) this.toastSuccess(this.$t('notification:record.undeleteBulkSuccess')) @@ -1498,6 +1521,7 @@ export default { .catch(this.toastErrorHandler(this.$t('notification:record.undeleteBulkFailed'))) .finally(() => { this.processing = false + this.selectedAllRecords = false }) } }, @@ -1521,15 +1545,21 @@ export default { // same module so this should be safe to do. const { moduleID, namespaceID } = this.items[0].r - // filter deletable records from the selected list - const recordIDs = this.items - .filter(({ id, r }) => r.canDeleteRecord && selected.includes(id)) - .map(({ id }) => id) + let query = '' + + if (!this.selectedAllRecords) { + // filter deletable records from the selected list + const recordIDs = this.items + .filter(({ id, r }) => r.canDeleteRecord && selected.includes(id)) + .map(({ id }) => id) + + query = recordIDs.map(r => `recordID='${r}'`).join(' OR ') + } this.processing = true this.$ComposeAPI - .recordBulkDelete({ moduleID, namespaceID, recordIDs }) + .recordBulkDelete({ moduleID, namespaceID, query }) .then(() => this.refresh(true)) .then(() => { this.toastSuccess(this.$t('notification:record.deleteBulkSuccess')) @@ -1537,6 +1567,7 @@ export default { .catch(this.toastErrorHandler(this.$t('notification:record.deleteBulkFailed'))) .finally(() => { this.processing = false + this.selectedAllRecords = false }) } }, @@ -1762,6 +1793,7 @@ export default { }, onBulkUpdate () { + this.selectedAllRecords = false this.refresh(true) }, diff --git a/client/web/compose/src/components/Public/Record/Exporter/Configurator.vue b/client/web/compose/src/components/Public/Record/Exporter/Configurator.vue index 69f7270e4..ed747fd0b 100644 --- a/client/web/compose/src/components/Public/Record/Exporter/Configurator.vue +++ b/client/web/compose/src/components/Public/Record/Exporter/Configurator.vue @@ -232,6 +232,11 @@ export default { required: false, default: () => [], }, + selectedAllRecordsCount: { + type: Number, + required: false, + default: 0, + }, filterRangeType: { type: String, default: 'all', @@ -375,6 +380,10 @@ export default { getExportableCount () { // when exporting selection, only selected records are applicable if (this.rangeType === 'selection') { + if (this.selectedAllRecordsCount !== 0) { + return this.selectedAllRecordsCount + } + return this.selection.length } diff --git a/client/web/compose/src/mixins/record.js b/client/web/compose/src/mixins/record.js index 941c86170..0c1f3f4fc 100644 --- a/client/web/compose/src/mixins/record.js +++ b/client/web/compose/src/mixins/record.js @@ -281,8 +281,10 @@ export default { const { moduleID, namespaceID } = this.module + const query = records.map(r => `recordID='${r}'`).join(' OR ') + return this - .$ComposeAPI.recordPatch({ moduleID, namespaceID, records, values }) + .$ComposeAPI.recordPatch({ moduleID, namespaceID, values, query }) .catch(err => { const { details = undefined } = err if (!!details && Array.isArray(details) && details.length > 0) { diff --git a/lib/js/src/api-clients/compose.ts b/lib/js/src/api-clients/compose.ts index ce5d6286f..48700e50e 100644 --- a/lib/js/src/api-clients/compose.ts +++ b/lib/js/src/api-clients/compose.ts @@ -2185,8 +2185,8 @@ export default class Compose { const { namespaceID, moduleID, - records, values, + query, } = (a as KV) || {} if (!namespaceID) { throw Error('field namespaceID is empty') @@ -2202,8 +2202,8 @@ export default class Compose { }), } cfg.data = { - records, values, + query, } return this.api().request(cfg).then(result => stdResolve(result)) } @@ -2221,8 +2221,8 @@ export default class Compose { const { namespaceID, moduleID, - recordIDs, truncate, + query, } = (a as KV) || {} if (!namespaceID) { throw Error('field namespaceID is empty') @@ -2238,8 +2238,8 @@ export default class Compose { }), } cfg.data = { - recordIDs, truncate, + query, } return this.api().request(cfg).then(result => stdResolve(result)) } @@ -2329,7 +2329,7 @@ export default class Compose { const { namespaceID, moduleID, - recordIDs, + query, } = (a as KV) || {} if (!namespaceID) { throw Error('field namespaceID is empty') @@ -2345,7 +2345,7 @@ export default class Compose { }), } cfg.data = { - recordIDs, + query, } return this.api().request(cfg).then(result => stdResolve(result)) } diff --git a/locale/en/corteza-webapp-compose/block.yaml b/locale/en/corteza-webapp-compose/block.yaml index 71e201a9b..efbb26336 100644 --- a/locale/en/corteza-webapp-compose/block.yaml +++ b/locale/en/corteza-webapp-compose/block.yaml @@ -484,6 +484,9 @@ recordList: label: Parent field selectable: Users will be able to select records selected: '{{count}} of {{total}} records selected' + selectedFromAllPages: 'All {{count}} records from all pages selected' + selectAllRecords: Select all records on all pages + unselectAllRecords: Clear selection sort: tooltip: Sort column showRecords: diff --git a/server/compose/automation/records_handler.gen.go b/server/compose/automation/records_handler.gen.go index f48c5c4c6..3a909fd99 100644 --- a/server/compose/automation/records_handler.gen.go +++ b/server/compose/automation/records_handler.gen.go @@ -81,9 +81,10 @@ func (a recordsLookupArgs) GetRecord() (bool, uint64, *types.Record) { // Lookup function Compose record lookup // // expects implementation of lookup function: -// func (h recordsHandler) lookup(ctx context.Context, args *recordsLookupArgs) (results *recordsLookupResults, err error) { -// return -// } +// +// func (h recordsHandler) lookup(ctx context.Context, args *recordsLookupArgs) (results *recordsLookupResults, err error) { +// return +// } func (h recordsHandler) Lookup() *atypes.Function { return &atypes.Function{ Ref: "composeRecordsLookup", @@ -255,9 +256,10 @@ func (a recordsSearchArgs) GetNamespace() (bool, uint64, string, *types.Namespac // Search function Compose records search // // expects implementation of search function: -// func (h recordsHandler) search(ctx context.Context, args *recordsSearchArgs) (results *recordsSearchResults, err error) { -// return -// } +// +// func (h recordsHandler) search(ctx context.Context, args *recordsSearchArgs) (results *recordsSearchResults, err error) { +// return +// } func (h recordsHandler) Search() *atypes.Function { return &atypes.Function{ Ref: "composeRecordsSearch", @@ -508,9 +510,10 @@ func (a recordsFirstArgs) GetNamespace() (bool, uint64, string, *types.Namespace // First function Compose record lookup (first created) // // expects implementation of first function: -// func (h recordsHandler) first(ctx context.Context, args *recordsFirstArgs) (results *recordsFirstResults, err error) { -// return -// } +// +// func (h recordsHandler) first(ctx context.Context, args *recordsFirstArgs) (results *recordsFirstResults, err error) { +// return +// } func (h recordsHandler) First() *atypes.Function { return &atypes.Function{ Ref: "composeRecordsFirst", @@ -637,9 +640,10 @@ func (a recordsLastArgs) GetNamespace() (bool, uint64, string, *types.Namespace) // Last function Compose record lookup (last created) // // expects implementation of last function: -// func (h recordsHandler) last(ctx context.Context, args *recordsLastArgs) (results *recordsLastResults, err error) { -// return -// } +// +// func (h recordsHandler) last(ctx context.Context, args *recordsLastArgs) (results *recordsLastResults, err error) { +// return +// } func (h recordsHandler) Last() *atypes.Function { return &atypes.Function{ Ref: "composeRecordsLast", @@ -792,9 +796,10 @@ func (a recordsEachArgs) GetNamespace() (bool, uint64, string, *types.Namespace) // Each function Compose records // // expects implementation of each function: -// func (h recordsHandler) each(ctx context.Context, args *recordsEachArgs) (results *recordsEachResults, err error) { -// return -// } +// +// func (h recordsHandler) each(ctx context.Context, args *recordsEachArgs) (results *recordsEachResults, err error) { +// return +// } func (h recordsHandler) Each() *atypes.Function { return &atypes.Function{ Ref: "composeRecordsEach", @@ -959,9 +964,10 @@ func (a recordsNewArgs) GetNamespace() (bool, uint64, string, *types.Namespace) // New function Compose record maker // // expects implementation of new function: -// func (h recordsHandler) new(ctx context.Context, args *recordsNewArgs) (results *recordsNewResults, err error) { -// return -// } +// +// func (h recordsHandler) new(ctx context.Context, args *recordsNewArgs) (results *recordsNewResults, err error) { +// return +// } func (h recordsHandler) New() *atypes.Function { return &atypes.Function{ Ref: "composeRecordsNew", @@ -1072,9 +1078,10 @@ type ( // Validate function Compose record validator // // expects implementation of validate function: -// func (h recordsHandler) validate(ctx context.Context, args *recordsValidateArgs) (results *recordsValidateResults, err error) { -// return -// } +// +// func (h recordsHandler) validate(ctx context.Context, args *recordsValidateArgs) (results *recordsValidateResults, err error) { +// return +// } func (h recordsHandler) Validate() *atypes.Function { return &atypes.Function{ Ref: "composeRecordsValidate", @@ -1152,9 +1159,10 @@ type ( // Create function Compose record create // // expects implementation of create function: -// func (h recordsHandler) create(ctx context.Context, args *recordsCreateArgs) (results *recordsCreateResults, err error) { -// return -// } +// +// func (h recordsHandler) create(ctx context.Context, args *recordsCreateArgs) (results *recordsCreateResults, err error) { +// return +// } func (h recordsHandler) Create() *atypes.Function { return &atypes.Function{ Ref: "composeRecordsCreate", @@ -1229,9 +1237,10 @@ type ( // Update function Compose record update // // expects implementation of update function: -// func (h recordsHandler) update(ctx context.Context, args *recordsUpdateArgs) (results *recordsUpdateResults, err error) { -// return -// } +// +// func (h recordsHandler) update(ctx context.Context, args *recordsUpdateArgs) (results *recordsUpdateResults, err error) { +// return +// } func (h recordsHandler) Update() *atypes.Function { return &atypes.Function{ Ref: "composeRecordsUpdate", @@ -1328,9 +1337,10 @@ func (a recordsDeleteArgs) GetRecord() (bool, uint64, *types.Record) { // Delete function Compose record delete // // expects implementation of delete function: -// func (h recordsHandler) delete(ctx context.Context, args *recordsDeleteArgs) (err error) { -// return -// } +// +// func (h recordsHandler) delete(ctx context.Context, args *recordsDeleteArgs) (err error) { +// return +// } func (h recordsHandler) Delete() *atypes.Function { return &atypes.Function{ Ref: "composeRecordsDelete", @@ -1454,9 +1464,10 @@ func (a recordsReportArgs) GetNamespace() (bool, uint64, string, *types.Namespac // Report function Report // // expects implementation of report function: -// func (h recordsHandler) report(ctx context.Context, args *recordsReportArgs) (results *recordsReportResults, err error) { -// return -// } +// +// func (h recordsHandler) report(ctx context.Context, args *recordsReportArgs) (results *recordsReportResults, err error) { +// return +// } func (h recordsHandler) Report() *atypes.Function { return &atypes.Function{ Ref: "composeRecordsReport", diff --git a/server/compose/rest.yaml b/server/compose/rest.yaml index f453133b5..1247d6ac6 100644 --- a/server/compose/rest.yaml +++ b/server/compose/rest.yaml @@ -1167,22 +1167,22 @@ endpoints: path: "/" parameters: post: - - { name: records, type: "[]string", title: Records to update } - { name: values, type: "types.RecordValueSet", title: Fields to update and their values } + - { name: query, type: "string", title: Search query for records to operate on } - name: bulkDelete method: DELETE title: Delete record row from module section path: "/" parameters: post: - - type: "[]string" - name: recordIDs - required: false - title: IDs of records to delete - type: bool name: truncate required: false title: Remove ALL records of a specified module (pending implementation) + - name: query + type: string + required: false + title: Search query for records to operate on - name: delete method: DELETE title: Delete record row from module section @@ -1209,10 +1209,10 @@ endpoints: path: "/undelete" parameters: post: - - type: "[]string" - name: recordIDs + - name: query + type: string required: false - title: IDs of records to undelete + title: Search query for records to operate on - name: upload path: "/attachment" method: POST diff --git a/server/compose/rest/record.go b/server/compose/rest/record.go index d8b3e4d35..21854bfc9 100644 --- a/server/compose/rest/record.go +++ b/server/compose/rest/record.go @@ -11,9 +11,6 @@ import ( "strings" "time" - "github.com/cortezaproject/corteza/server/pkg/revisions" - "github.com/spf13/cast" - "github.com/cortezaproject/corteza/server/compose/rest/request" "github.com/cortezaproject/corteza/server/compose/service" "github.com/cortezaproject/corteza/server/compose/types" @@ -26,7 +23,7 @@ import ( "github.com/cortezaproject/corteza/server/pkg/envoy/resource" estore "github.com/cortezaproject/corteza/server/pkg/envoy/store" "github.com/cortezaproject/corteza/server/pkg/filter" - "github.com/cortezaproject/corteza/server/pkg/payload" + "github.com/cortezaproject/corteza/server/pkg/revisions" "github.com/cortezaproject/corteza/server/store" ) @@ -233,34 +230,29 @@ func (ctrl *Record) Create(ctx context.Context, r *request.RecordCreate) (interf func (ctrl *Record) Patch(ctx context.Context, req *request.RecordPatch) (interface{}, error) { var ( + f = types.RecordFilter{ + Query: req.Query, + NamespaceID: req.NamespaceID, + ModuleID: req.ModuleID, + Deleted: filter.State(0), + } + err error ) - oo := make([]*types.RecordBulkOperation, 0) - counters := make(map[string]uint) for _, v := range req.Values { v.Place = counters[v.Name] counters[v.Name]++ } - for _, r := range req.Records { - oo = append(oo, &types.RecordBulkOperation{ - RecordID: cast.ToUint64(r), - NamespaceID: req.NamespaceID, - ModuleID: req.ModuleID, + err = ctrl.record.BulkModifyByFilter(ctx, f, req.Values, types.OperationTypePatch) - Record: &types.Record{Values: req.Values}, - Operation: types.OperationTypePatch, - }) - } - - results, err := ctrl.record.Bulk(ctx, true, oo...) if rve := types.IsRecordValueErrorSet(err); rve != nil { return ctrl.handleValidationError(rve), nil } - return ctrl.makeRecordBulkPatchPayload(ctx, results, err) + return api.OK(), err } func (ctrl *Record) Update(ctx context.Context, r *request.RecordUpdate) (interface{}, error) { @@ -329,15 +321,20 @@ func (ctrl *Record) Delete(ctx context.Context, r *request.RecordDelete) (interf } func (ctrl *Record) BulkDelete(ctx context.Context, r *request.RecordBulkDelete) (interface{}, error) { + var ( + f = types.RecordFilter{ + Query: r.Query, + NamespaceID: r.NamespaceID, + ModuleID: r.ModuleID, + Deleted: filter.State(0), + } + ) + if r.Truncate { return nil, fmt.Errorf("pending implementation") } - return api.OK(), ctrl.record.DeleteByID(ctx, - r.NamespaceID, - r.ModuleID, - payload.ParseUint64s(r.RecordIDs)..., - ) + return api.OK(), ctrl.record.BulkModifyByFilter(ctx, f, nil, types.OperationTypeDelete) } func (ctrl *Record) Undelete(ctx context.Context, r *request.RecordUndelete) (interface{}, error) { @@ -345,11 +342,16 @@ func (ctrl *Record) Undelete(ctx context.Context, r *request.RecordUndelete) (in } func (ctrl *Record) BulkUndelete(ctx context.Context, r *request.RecordBulkUndelete) (interface{}, error) { - return api.OK(), ctrl.record.UndeleteByID(ctx, - r.NamespaceID, - r.ModuleID, - payload.ParseUint64s(r.RecordIDs)..., + var ( + f = types.RecordFilter{ + Query: r.Query, + NamespaceID: r.NamespaceID, + ModuleID: r.ModuleID, + Deleted: filter.State(1), + } ) + + return api.OK(), ctrl.record.BulkModifyByFilter(ctx, f, nil, types.OperationTypeUndelete) } func (ctrl *Record) Upload(ctx context.Context, r *request.RecordUpload) (interface{}, error) { diff --git a/server/compose/rest/request/record.go b/server/compose/rest/request/record.go index 64fc58bf2..26f17f4b9 100644 --- a/server/compose/rest/request/record.go +++ b/server/compose/rest/request/record.go @@ -336,15 +336,15 @@ type ( // Module ID ModuleID uint64 `json:",string"` - // Records POST parameter - // - // Records to update - Records []string - // Values POST parameter // // Fields to update and their values Values types.RecordValueSet + + // Query POST parameter + // + // Search query for records to operate on + Query string } RecordBulkDelete struct { @@ -358,15 +358,15 @@ type ( // Module ID ModuleID uint64 `json:",string"` - // RecordIDs POST parameter - // - // IDs of records to delete - RecordIDs []string - // Truncate POST parameter // // Remove ALL records of a specified module (pending implementation) Truncate bool + + // Query POST parameter + // + // Search query for records to operate on + Query string } RecordDelete struct { @@ -414,10 +414,10 @@ type ( // Module ID ModuleID uint64 `json:",string"` - // RecordIDs POST parameter + // Query POST parameter // - // IDs of records to undelete - RecordIDs []string + // Search query for records to operate on + Query string } RecordUpload struct { @@ -1653,8 +1653,8 @@ func (r RecordPatch) Auditable() map[string]interface{} { return map[string]interface{}{ "namespaceID": r.NamespaceID, "moduleID": r.ModuleID, - "records": r.Records, "values": r.Values, + "query": r.Query, } } @@ -1669,13 +1669,13 @@ func (r RecordPatch) GetModuleID() uint64 { } // Auditable returns all auditable/loggable parameters -func (r RecordPatch) GetRecords() []string { - return r.Records +func (r RecordPatch) GetValues() types.RecordValueSet { + return r.Values } // Auditable returns all auditable/loggable parameters -func (r RecordPatch) GetValues() types.RecordValueSet { - return r.Values +func (r RecordPatch) GetQuery() string { + return r.Query } // Fill processes request and fills internal variables @@ -1699,6 +1699,12 @@ func (r *RecordPatch) Fill(req *http.Request) (err error) { } else if err == nil { // Multipart params + if val, ok := req.MultipartForm.Value["query"]; ok && len(val) > 0 { + r.Query, err = val[0], nil + if err != nil { + return err + } + } } } @@ -1709,19 +1715,19 @@ func (r *RecordPatch) Fill(req *http.Request) (err error) { // POST params - //if val, ok := req.Form["records[]"]; ok && len(val) > 0 { - // r.Records, err = val, nil - // if err != nil { - // return err - // } - //} - //if val, ok := req.Form["values[]"]; ok && len(val) > 0 { // r.Values, err = types.RecordValueSet(val), nil // if err != nil { // return err // } //} + + if val, ok := req.Form["query"]; ok && len(val) > 0 { + r.Query, err = val[0], nil + if err != nil { + return err + } + } } { @@ -1755,8 +1761,8 @@ func (r RecordBulkDelete) Auditable() map[string]interface{} { return map[string]interface{}{ "namespaceID": r.NamespaceID, "moduleID": r.ModuleID, - "recordIDs": r.RecordIDs, "truncate": r.Truncate, + "query": r.Query, } } @@ -1771,13 +1777,13 @@ func (r RecordBulkDelete) GetModuleID() uint64 { } // Auditable returns all auditable/loggable parameters -func (r RecordBulkDelete) GetRecordIDs() []string { - return r.RecordIDs +func (r RecordBulkDelete) GetTruncate() bool { + return r.Truncate } // Auditable returns all auditable/loggable parameters -func (r RecordBulkDelete) GetTruncate() bool { - return r.Truncate +func (r RecordBulkDelete) GetQuery() string { + return r.Query } // Fill processes request and fills internal variables @@ -1807,6 +1813,13 @@ func (r *RecordBulkDelete) Fill(req *http.Request) (err error) { return err } } + + if val, ok := req.MultipartForm.Value["query"]; ok && len(val) > 0 { + r.Query, err = val[0], nil + if err != nil { + return err + } + } } } @@ -1817,19 +1830,19 @@ func (r *RecordBulkDelete) Fill(req *http.Request) (err error) { // POST params - //if val, ok := req.Form["recordIDs[]"]; ok && len(val) > 0 { - // r.RecordIDs, err = val, nil - // if err != nil { - // return err - // } - //} - if val, ok := req.Form["truncate"]; ok && len(val) > 0 { r.Truncate, err = payload.ParseBool(val[0]), nil if err != nil { return err } } + + if val, ok := req.Form["query"]; ok && len(val) > 0 { + r.Query, err = val[0], nil + if err != nil { + return err + } + } } { @@ -1981,7 +1994,7 @@ func (r RecordBulkUndelete) Auditable() map[string]interface{} { return map[string]interface{}{ "namespaceID": r.NamespaceID, "moduleID": r.ModuleID, - "recordIDs": r.RecordIDs, + "query": r.Query, } } @@ -1996,8 +2009,8 @@ func (r RecordBulkUndelete) GetModuleID() uint64 { } // Auditable returns all auditable/loggable parameters -func (r RecordBulkUndelete) GetRecordIDs() []string { - return r.RecordIDs +func (r RecordBulkUndelete) GetQuery() string { + return r.Query } // Fill processes request and fills internal variables @@ -2021,6 +2034,12 @@ func (r *RecordBulkUndelete) Fill(req *http.Request) (err error) { } else if err == nil { // Multipart params + if val, ok := req.MultipartForm.Value["query"]; ok && len(val) > 0 { + r.Query, err = val[0], nil + if err != nil { + return err + } + } } } @@ -2031,12 +2050,12 @@ func (r *RecordBulkUndelete) Fill(req *http.Request) (err error) { // POST params - //if val, ok := req.Form["recordIDs[]"]; ok && len(val) > 0 { - // r.RecordIDs, err = val, nil - // if err != nil { - // return err - // } - //} + if val, ok := req.Form["query"]; ok && len(val) > 0 { + r.Query, err = val[0], nil + if err != nil { + return err + } + } } { diff --git a/server/compose/service/record.go b/server/compose/service/record.go index 3a7ef254f..e460c30ef 100644 --- a/server/compose/service/record.go +++ b/server/compose/service/record.go @@ -126,6 +126,8 @@ type ( Update(ctx context.Context, record *types.Record) (*types.Record, *types.RecordValueErrorSet, error) Bulk(ctx context.Context, skipFailed bool, oo ...*types.RecordBulkOperation) ([]types.RecordBulkOperationResult, error) + BulkModifyByFilter(ctx context.Context, f types.RecordFilter, values types.RecordValueSet, operation types.OperationType) (err error) + Validate(ctx context.Context, rec *types.Record) error DeleteByID(ctx context.Context, namespaceID, moduleID uint64, recordID ...uint64) error @@ -714,6 +716,82 @@ func (svc record) Bulk(ctx context.Context, skipFailed bool, oo ...*types.Record } } +// BulkModifyByFilter performs bulk record operations based on the provided filter query. +// It's able to update, delete or undelete records in a single transaction. +func (svc record) BulkModifyByFilter(ctx context.Context, f types.RecordFilter, values types.RecordValueSet, operation types.OperationType) (err error) { + var ( + ns *types.Namespace + m *types.Module + r *types.Record + records types.RecordSet + recordFilter types.RecordFilter + + aProps = &recordActionProps{ + namespace: &types.Namespace{ID: f.NamespaceID}, + module: &types.Module{ID: f.ModuleID}, + } + action func(props ...*recordActionProps) *recordAction + + valueError *types.RecordValueErrorSet + ) + + return store.Tx(ctx, svc.store, func(ctx context.Context, s store.Storer) error { + // load both the namespace and module + if ns, m, err = loadModuleCombo(ctx, s, f.NamespaceID, f.ModuleID); err != nil { + return err + } + + aProps.setNamespace(ns) + aProps.setModule(m) + + f.Limit = 500 + + // performing a batched search for IDs, processing them in batches of 500 for update. + for { + records, recordFilter, err = svc.Find(ctx, f) + if err != nil { + return err + } + + for _, r = range records { + aProps.setRecord(r) + + switch operation { + case types.OperationTypePatch: + action = RecordActionPatch + r, valueError, err = svc.patch(ctx, r, values) + case types.OperationTypeDelete: + action = RecordActionDelete + r, err = svc.processDelete(ctx, r, ns, m) + case types.OperationTypeUndelete: + action = RecordActionUndelete + r, err = svc.processUndelete(ctx, r, ns, m) + } + + aProps.setChanged(r) + + if valueError != nil && !valueError.IsValid() { + return RecordErrValueInput().Wrap(valueError) + } + + _ = svc.recordAction(ctx, aProps, action, err) + + if err != nil { + return err + } + } + + if recordFilter.NextPage == nil { + break + } + + f.NextPage = recordFilter.NextPage + } + + return nil + }) +} + // Raw create function that is responsible for value validation, event dispatching // and creation. func (svc record) create(ctx context.Context, new *types.Record) (rec *types.Record, dd *types.RecordValueErrorSet, err error) { @@ -1316,8 +1394,6 @@ func (svc record) delete(ctx context.Context, namespaceID, moduleID, recordID ui var ( ns *types.Namespace m *types.Module - - invokerID = auth.GetIdentityFromContext(ctx).Identity() ) if namespaceID == 0 { @@ -1335,6 +1411,14 @@ func (svc record) delete(ctx context.Context, namespaceID, moduleID, recordID ui return nil, err } + return svc.processDelete(ctx, del, ns, m) +} + +func (svc record) processDelete(ctx context.Context, del *types.Record, namespace *types.Namespace, module *types.Module) (record *types.Record, err error) { + var ( + invokerID = auth.GetIdentityFromContext(ctx).Identity() + ) + if !svc.ac.CanDeleteRecord(ctx, del) { return nil, RecordErrNotAllowedToDelete() } @@ -1343,7 +1427,7 @@ func (svc record) delete(ctx context.Context, namespaceID, moduleID, recordID ui del.DeletedBy = invokerID // ensure module ref is set before running through records workflows and scripts - del.SetModule(m) + del.SetModule(module) // deleted, revision need to be set when RecordBeforeDelete is triggered del.DeletedAt = nowUTC() @@ -1352,27 +1436,27 @@ func (svc record) delete(ctx context.Context, namespaceID, moduleID, recordID ui { // Calling before-record-delete scripts - if err = svc.eventbus.WaitFor(ctx, event.RecordBeforeDelete(nil, del, m, ns, nil, nil)); err != nil { + if err = svc.eventbus.WaitFor(ctx, event.RecordBeforeDelete(nil, del, module, namespace, nil, nil)); err != nil { return nil, err } } - if m.Config.RecordRevisions.Enabled { + if module.Config.RecordRevisions.Enabled { // Prepare record revision for update if err = svc.revisions.softDeleted(ctx, del); err != nil { return } } - if err = dalutils.ComposeRecordSoftDelete(ctx, svc.dal, m, del); err != nil { + if err = dalutils.ComposeRecordSoftDelete(ctx, svc.dal, module, del); err != nil { return nil, err } // ensure module ref is set before running through records workflows and scripts - del.SetModule(m) + del.SetModule(module) { - _ = svc.eventbus.WaitFor(ctx, event.RecordAfterDeleteImmutable(nil, del, m, ns, nil, nil)) + _ = svc.eventbus.WaitFor(ctx, event.RecordAfterDeleteImmutable(nil, del, module, namespace, nil, nil)) } return del, nil @@ -1389,6 +1473,14 @@ func (svc record) undelete(ctx context.Context, namespaceID, moduleID, recordID return nil, err } + return svc.processUndelete(ctx, undel, ns, m) +} + +func (svc record) processUndelete(ctx context.Context, undel *types.Record, namespace *types.Namespace, module *types.Module) (record *types.Record, err error) { + if err != nil { + return nil, err + } + if !svc.ac.CanUndeleteRecord(ctx, undel) { return nil, RecordErrNotAllowedToUndelete() } @@ -1397,7 +1489,7 @@ func (svc record) undelete(ctx context.Context, namespaceID, moduleID, recordID undel.DeletedBy = 0 // ensure module ref is set before running through records workflows and scripts - undel.SetModule(m) + undel.SetModule(module) undel.DeletedAt = nil undel.DeletedBy = 0 @@ -1405,27 +1497,27 @@ func (svc record) undelete(ctx context.Context, namespaceID, moduleID, recordID { // Calling before-record-undelete scripts - if err = svc.eventbus.WaitFor(ctx, event.RecordBeforeUndelete(nil, undel, m, ns, nil, nil)); err != nil { + if err = svc.eventbus.WaitFor(ctx, event.RecordBeforeUndelete(nil, undel, module, namespace, nil, nil)); err != nil { return nil, err } } - if m.Config.RecordRevisions.Enabled { + if module.Config.RecordRevisions.Enabled { // Prepare record revision for update if err = svc.revisions.undeleted(ctx, undel); err != nil { return } } - if err = dalutils.ComposeRecordUndelete(ctx, svc.dal, m, undel); err != nil { + if err = dalutils.ComposeRecordUndelete(ctx, svc.dal, module, undel); err != nil { return nil, err } // ensure module ref is set before running through records workflows and scripts - undel.SetModule(m) + undel.SetModule(module) { - _ = svc.eventbus.WaitFor(ctx, event.RecordAfterUndeleteImmutable(nil, undel, m, ns, nil, nil)) + _ = svc.eventbus.WaitFor(ctx, event.RecordAfterUndeleteImmutable(nil, undel, module, namespace, nil, nil)) } return undel, nil diff --git a/server/compose/types/record.go b/server/compose/types/record.go index 6d9569bc7..ce3448a81 100644 --- a/server/compose/types/record.go +++ b/server/compose/types/record.go @@ -101,10 +101,11 @@ type ( ) const ( - OperationTypeCreate OperationType = "create" - OperationTypeUpdate OperationType = "update" - OperationTypeDelete OperationType = "delete" - OperationTypePatch OperationType = "patch" + OperationTypeCreate OperationType = "create" + OperationTypeUpdate OperationType = "update" + OperationTypeDelete OperationType = "delete" + OperationTypePatch OperationType = "patch" + OperationTypeUndelete OperationType = "undelete" ) func (f RecordFilter) ToConstraintedFilter(c map[string][]any) filter.Filter { diff --git a/server/pkg/slice/ints.go b/server/pkg/slice/ints.go index a348d233a..fb4a2842b 100644 --- a/server/pkg/slice/ints.go +++ b/server/pkg/slice/ints.go @@ -12,8 +12,8 @@ type ( ) func HasUint64(ss []uint64, s uint64) bool { - for i := range ss { - if ss[i] == s { + for _, value := range ss { + if value == s { return true } } diff --git a/server/pkg/slice/ints_test.go b/server/pkg/slice/ints_test.go index 60f3389d7..58a5f874e 100644 --- a/server/pkg/slice/ints_test.go +++ b/server/pkg/slice/ints_test.go @@ -35,7 +35,8 @@ func TestHasUint64(t *testing.T) { } for _, c := range cases { t.Run(c.name, func(t *testing.T) { - assert.EqualValues(t, HasUint64(c.ss, c.s), c.o) + ok := HasUint64(c.ss, c.s) + assert.EqualValues(t, ok, c.o) }) } }