From f47e89a9bd6bb5dfc1b36c0dcab0ac4fdca5c2c1 Mon Sep 17 00:00:00 2001 From: Paul Holden Date: Tue, 10 Aug 2021 17:44:31 +0100 Subject: [PATCH] MDL-70795 reportbuilder: editor elements to set column aggregation. Aggregation of report columns allows the report editor to perform common types of data aggregation (concatenation, sum, count, etc) on given columns. --- lang/en/reportbuilder.php | 13 +++ .../amd/build/local/editor/columns.min.js | 2 +- .../amd/build/local/editor/columns.min.js.map | 2 +- reportbuilder/amd/src/local/editor/columns.js | 18 ++++ .../classes/local/aggregation/avg.php | 77 +++++++++++++++ .../classes/local/aggregation/base.php | 77 +++++++++++++++ .../classes/local/aggregation/count.php | 80 ++++++++++++++++ .../local/aggregation/countdistinct.php | 80 ++++++++++++++++ .../classes/local/aggregation/groupconcat.php | 78 +++++++++++++++ .../local/aggregation/groupconcatdistinct.php | 90 ++++++++++++++++++ .../classes/local/aggregation/max.php | 83 ++++++++++++++++ .../classes/local/aggregation/min.php | 83 ++++++++++++++++ .../classes/local/aggregation/percent.php | 77 +++++++++++++++ .../classes/local/aggregation/sum.php | 78 +++++++++++++++ .../classes/local/helpers/aggregation.php | 80 ++++++++++++++++ .../classes/local/helpers/format.php | 10 ++ reportbuilder/classes/local/report/column.php | 94 ++++++++++++++++--- .../output/column_aggregation_editable.php | 85 +++++++++++++++++ .../classes/table/custom_report_table.php | 20 +++- reportbuilder/lib.php | 3 + reportbuilder/tests/helpers.php | 54 +++++++++++ .../tests/local/aggregation/count_test.php | 78 +++++++++++++++ .../local/aggregation/countdistinct_test.php | 78 +++++++++++++++ .../local/aggregation/groupconcat_test.php | 80 ++++++++++++++++ .../aggregation/groupconcatdistinct_test.php | 91 ++++++++++++++++++ .../tests/local/aggregation/max_test.php | 77 +++++++++++++++ .../tests/local/aggregation/min_test.php | 77 +++++++++++++++ .../tests/local/aggregation/percent_test.php | 77 +++++++++++++++ .../tests/local/aggregation/sum_test.php | 77 +++++++++++++++ .../tests/local/report/column_test.php | 14 ++- 30 files changed, 1812 insertions(+), 21 deletions(-) create mode 100644 reportbuilder/classes/local/aggregation/avg.php create mode 100644 reportbuilder/classes/local/aggregation/base.php create mode 100644 reportbuilder/classes/local/aggregation/count.php create mode 100644 reportbuilder/classes/local/aggregation/countdistinct.php create mode 100644 reportbuilder/classes/local/aggregation/groupconcat.php create mode 100644 reportbuilder/classes/local/aggregation/groupconcatdistinct.php create mode 100644 reportbuilder/classes/local/aggregation/max.php create mode 100644 reportbuilder/classes/local/aggregation/min.php create mode 100644 reportbuilder/classes/local/aggregation/percent.php create mode 100644 reportbuilder/classes/local/aggregation/sum.php create mode 100644 reportbuilder/classes/local/helpers/aggregation.php create mode 100644 reportbuilder/classes/output/column_aggregation_editable.php create mode 100644 reportbuilder/tests/helpers.php create mode 100644 reportbuilder/tests/local/aggregation/count_test.php create mode 100644 reportbuilder/tests/local/aggregation/countdistinct_test.php create mode 100644 reportbuilder/tests/local/aggregation/groupconcat_test.php create mode 100644 reportbuilder/tests/local/aggregation/groupconcatdistinct_test.php create mode 100644 reportbuilder/tests/local/aggregation/max_test.php create mode 100644 reportbuilder/tests/local/aggregation/min_test.php create mode 100644 reportbuilder/tests/local/aggregation/percent_test.php create mode 100644 reportbuilder/tests/local/aggregation/sum_test.php diff --git a/lang/en/reportbuilder.php b/lang/en/reportbuilder.php index 48a87d1203306..71b31ea5d70ac 100644 --- a/lang/en/reportbuilder.php +++ b/lang/en/reportbuilder.php @@ -24,7 +24,20 @@ $string['actions'] = 'Actions'; $string['addcolumn'] = 'Add column \'{$a}\''; +$string['aggregatecolumn'] = 'Aggregate column \'{$a}\''; +$string['aggregationavg'] = 'Average'; +$string['aggregationcount'] = 'Count'; +$string['aggregationcountdistinct'] = 'Count distinct'; +$string['aggregationgroupconcat'] = 'Comma separated values'; +$string['aggregationgroupconcatdistinct'] = 'Comma separated distinct values'; +$string['aggregationmax'] = 'Maximum'; +$string['aggregationmin'] = 'Minimum'; +$string['aggregationnone'] = 'No aggregation'; +$string['aggregationpercent'] = 'Percentage'; +$string['aggregationsum'] = 'Sum'; $string['apply'] = 'Apply'; +$string['columnadded'] = 'Added column \'{$a}\''; +$string['columnaggregated'] = 'Aggregated column \'{$a}\''; $string['columnsortdirectionasc'] = 'Sort column \'{$a}\' ascending'; $string['columnsortdirectiondesc'] = 'Sort column \'{$a}\' descending'; $string['columnsortdisable'] = 'Disable sorting for column \'{$a}\''; diff --git a/reportbuilder/amd/build/local/editor/columns.min.js b/reportbuilder/amd/build/local/editor/columns.min.js index 3326bf362f745..c0be3fff1a655 100644 --- a/reportbuilder/amd/build/local/editor/columns.min.js +++ b/reportbuilder/amd/build/local/editor/columns.min.js @@ -1,2 +1,2 @@ -function _typeof(a){"@babel/helpers - typeof";if("function"==typeof Symbol&&"symbol"==typeof Symbol.iterator){_typeof=function(a){return typeof a}}else{_typeof=function(a){return a&&"function"==typeof Symbol&&a.constructor===Symbol&&a!==Symbol.prototype?"symbol":typeof a}}return _typeof(a)}define ("core_reportbuilder/local/editor/columns",["exports","jquery","core/event_dispatcher","core/inplace_editable","core/notification","core/pending","core/pubsub","core/sortable_list","core/str","core/toast","core_reportbuilder/local/events","core_reportbuilder/local/selectors","core_reportbuilder/local/repository/columns"],function(a,b,c,d,e,f,g,h,i,j,k,l,m){"use strict";Object.defineProperty(a,"__esModule",{value:!0});a.init=void 0;b=p(b);e=p(e);f=p(f);h=p(h);k=o(k);l=o(l);function n(){if("function"!=typeof WeakMap)return null;var a=new WeakMap;n=function(){return a};return a}function o(a){if(a&&a.__esModule){return a}if(null===a||"object"!==_typeof(a)&&"function"!=typeof a){return{default:a}}var b=n();if(b&&b.has(a)){return b.get(a)}var c={},d=Object.defineProperty&&Object.getOwnPropertyDescriptor;for(var e in a){if(Object.prototype.hasOwnProperty.call(a,e)){var f=d?Object.getOwnPropertyDescriptor(a,e):null;if(f&&(f.get||f.set)){Object.defineProperty(c,e,f)}else{c[e]=a[e]}}}c.default=a;if(b){b.set(a,c)}return c}function p(a){return a&&a.__esModule?a:{default:a}}function q(a,b){return v(a)||u(a,b)||s(a,b)||r()}function r(){throw new TypeError("Invalid attempt to destructure non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}function s(a,b){if(!a)return;if("string"==typeof a)return t(a,b);var c=Object.prototype.toString.call(a).slice(8,-1);if("Object"===c&&a.constructor)c=a.constructor.name;if("Map"===c||"Set"===c)return Array.from(c);if("Arguments"===c||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(c))return t(a,b)}function t(a,b){if(null==b||b>a.length)b=a.length;for(var c=0,d=Array(b);cn){o--}(0,m.reorderColumn)(a.dataset.reportId,h,o).then(function(){return(0,i.get_string)("columnmoved","core_reportbuilder",l)}).then(j.add).then(function(){(0,c.dispatchEvent)(k.tableReload,{preservePagination:!0},a);return g.resolve()}).catch(e.default.exception)}})};a.init=w}); +function _typeof(a){"@babel/helpers - typeof";if("function"==typeof Symbol&&"symbol"==typeof Symbol.iterator){_typeof=function(a){return typeof a}}else{_typeof=function(a){return a&&"function"==typeof Symbol&&a.constructor===Symbol&&a!==Symbol.prototype?"symbol":typeof a}}return _typeof(a)}define ("core_reportbuilder/local/editor/columns",["exports","jquery","core/event_dispatcher","core/inplace_editable","core/local/inplace_editable/events","core/notification","core/pending","core/pubsub","core/sortable_list","core/str","core/toast","core_reportbuilder/local/events","core_reportbuilder/local/selectors","core_reportbuilder/local/repository/columns"],function(a,b,c,d,e,f,g,h,i,j,k,l,m,n){"use strict";Object.defineProperty(a,"__esModule",{value:!0});a.init=void 0;b=q(b);f=q(f);g=q(g);i=q(i);l=p(l);m=p(m);function o(){if("function"!=typeof WeakMap)return null;var a=new WeakMap;o=function(){return a};return a}function p(a){if(a&&a.__esModule){return a}if(null===a||"object"!==_typeof(a)&&"function"!=typeof a){return{default:a}}var b=o();if(b&&b.has(a)){return b.get(a)}var c={},d=Object.defineProperty&&Object.getOwnPropertyDescriptor;for(var e in a){if(Object.prototype.hasOwnProperty.call(a,e)){var f=d?Object.getOwnPropertyDescriptor(a,e):null;if(f&&(f.get||f.set)){Object.defineProperty(c,e,f)}else{c[e]=a[e]}}}c.default=a;if(b){b.set(a,c)}return c}function q(a){return a&&a.__esModule?a:{default:a}}function r(a,b){return w(a)||v(a,b)||t(a,b)||s()}function s(){throw new TypeError("Invalid attempt to destructure non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}function t(a,b){if(!a)return;if("string"==typeof a)return u(a,b);var c=Object.prototype.toString.call(a).slice(8,-1);if("Object"===c&&a.constructor)c=a.constructor.name;if("Map"===c||"Set"===c)return Array.from(c);if("Arguments"===c||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(c))return u(a,b)}function u(a,b){if(null==b||b>a.length)b=a.length;for(var c=0,d=Array(b);cm){o--}(0,n.reorderColumn)(a.dataset.reportId,h,o).then(function(){return(0,j.get_string)("columnmoved","core_reportbuilder",i)}).then(k.add).then(function(){(0,c.dispatchEvent)(l.tableReload,{preservePagination:!0},a);return e.resolve()}).catch(f.default.exception)}});a.addEventListener(e.eventTypes.elementUpdated,function(b){var d=b.target.closest("[data-itemtype=\"columnaggregation\"]");if(d){var e=d.closest(m.regions.columnHeader);(0,j.get_string)("columnaggregated","core_reportbuilder",e.dataset.columnName).then(k.add).then(function(){(0,c.dispatchEvent)(l.tableReload,{},a)}).catch(f.default.exception)}})};a.init=x}); //# sourceMappingURL=columns.min.js.map diff --git a/reportbuilder/amd/build/local/editor/columns.min.js.map b/reportbuilder/amd/build/local/editor/columns.min.js.map index 102b737e28ecf..e7f413bfafcb3 100644 --- a/reportbuilder/amd/build/local/editor/columns.min.js.map +++ b/reportbuilder/amd/build/local/editor/columns.min.js.map @@ -1 +1 @@ -{"version":3,"sources":["../../../src/local/editor/columns.js"],"names":["init","reportElement","initialized","addEventListener","event","reportAddColumn","target","closest","reportSelectors","actions","preventDefault","pendingPromise","Pending","dataset","reportId","uniqueIdentifier","then","data","reportEvents","publish","reportColumnsUpdated","name","addToast","tableReload","preservePagination","resolve","catch","Notification","exception","reportRemoveColumn","columnHeader","regions","columnName","key","component","param","confirmTitle","confirmText","confirmButton","confirm","columnId","columnSortableList","SortableList","reportTable","isHorizontal","getElementName","element","Promise","on","EVENTS","DRAG","info","columnPosition","targetColumnPosition","targetNextElement","find","each","cell","children","beforeCell","insertBefore","appendChild","DROP","positionChanged","siblings","length"],"mappings":"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","sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * Report builder columns editor\n *\n * @module core_reportbuilder/local/editor/columns\n * @package core_reportbuilder\n * @copyright 2021 Paul Holden \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\n\"use strict\";\n\nimport $ from 'jquery';\nimport {dispatchEvent} from 'core/event_dispatcher';\nimport 'core/inplace_editable';\nimport Notification from 'core/notification';\nimport Pending from 'core/pending';\nimport {publish} from 'core/pubsub';\nimport SortableList from 'core/sortable_list';\nimport {get_string as getString, get_strings as getStrings} from 'core/str';\nimport {add as addToast} from 'core/toast';\nimport * as reportEvents from 'core_reportbuilder/local/events';\nimport * as reportSelectors from 'core_reportbuilder/local/selectors';\nimport {addColumn, deleteColumn, reorderColumn} from 'core_reportbuilder/local/repository/columns';\n\n/**\n * Initialise module\n *\n * @param {Element} reportElement\n * @param {Boolean} initialized Ensure we only add our listeners once\n */\nexport const init = (reportElement, initialized) => {\n if (initialized) {\n return;\n }\n\n reportElement.addEventListener('click', event => {\n\n // Add column to report.\n const reportAddColumn = event.target.closest(reportSelectors.actions.reportAddColumn);\n if (reportAddColumn) {\n event.preventDefault();\n\n const pendingPromise = new Pending('core_reportbuilder/columns:add');\n\n addColumn(reportElement.dataset.reportId, reportAddColumn.dataset.uniqueIdentifier)\n .then(data => publish(reportEvents.publish.reportColumnsUpdated, data))\n .then(() => getString('columnadded', 'core_reportbuilder', reportAddColumn.dataset.name))\n .then(addToast)\n .then(() => {\n dispatchEvent(reportEvents.tableReload, {preservePagination: true}, reportElement);\n return pendingPromise.resolve();\n })\n .catch(Notification.exception);\n }\n\n // Remove column from report.\n const reportRemoveColumn = event.target.closest(reportSelectors.actions.reportRemoveColumn);\n if (reportRemoveColumn) {\n event.preventDefault();\n\n const columnHeader = reportRemoveColumn.closest(reportSelectors.regions.columnHeader);\n const columnName = columnHeader.dataset.columnName;\n\n getStrings([\n {key: 'deletecolumn', component: 'core_reportbuilder', param: columnName},\n {key: 'deletecolumnconfirm', component: 'core_reportbuilder', param: columnName},\n {key: 'delete', component: 'moodle'},\n ]).then(([confirmTitle, confirmText, confirmButton]) => {\n Notification.confirm(confirmTitle, confirmText, confirmButton, null, () => {\n const pendingPromise = new Pending('core_reportbuilder/columns:remove');\n\n deleteColumn(reportElement.dataset.reportId, columnHeader.dataset.columnId)\n .then(data => publish(reportEvents.publish.reportColumnsUpdated, data))\n .then(() => getString('columndeleted', 'core_reportbuilder', columnName))\n .then(addToast)\n .then(() => {\n dispatchEvent(reportEvents.tableReload, {preservePagination: true}, reportElement);\n return pendingPromise.resolve();\n })\n .catch(Notification.exception);\n });\n return;\n }).catch(Notification.exception);\n }\n });\n\n // Initialize sortable list to handle column moving (note JQuery dependency, see MDL-72293 for resolution).\n var columnSortableList = new SortableList(`${reportSelectors.regions.reportTable} thead tr`, {isHorizontal: true});\n columnSortableList.getElementName = element => Promise.resolve(element.data('columnName'));\n\n $(reportElement).on(SortableList.EVENTS.DRAG, 'th[data-column-id]', (event, info) => {\n const columnPosition = info.element.data('columnPosition');\n const targetColumnPosition = info.targetNextElement.data('columnPosition');\n\n $(reportElement).find('tbody tr').each(function() {\n const cell = $(this).children(`td.c${columnPosition - 1}`)[0];\n if (targetColumnPosition) {\n var beforeCell = $(this).children(`td.c${targetColumnPosition - 1}`)[0];\n this.insertBefore(cell, beforeCell);\n } else {\n this.appendChild(cell);\n }\n });\n });\n\n $(reportElement).on(SortableList.EVENTS.DROP, 'th[data-column-id]', (event, info) => {\n if (info.positionChanged) {\n const pendingPromise = new Pending('core_reportbuilder/columns:reorder');\n\n const columnId = info.element.data('columnId');\n const columnName = info.element.data('columnName');\n const columnPosition = info.element.data('columnPosition');\n\n // Select target position, if moving to the end then count number of element siblings.\n let targetColumnPosition = info.targetNextElement.data('columnPosition') || info.element.siblings().length + 2;\n if (targetColumnPosition > columnPosition) {\n targetColumnPosition--;\n }\n\n reorderColumn(reportElement.dataset.reportId, columnId, targetColumnPosition)\n .then(() => getString('columnmoved', 'core_reportbuilder', columnName))\n .then(addToast)\n .then(() => {\n dispatchEvent(reportEvents.tableReload, {preservePagination: true}, reportElement);\n return pendingPromise.resolve();\n })\n .catch(Notification.exception);\n }\n });\n};\n"],"file":"columns.min.js"} \ No newline at end of file +{"version":3,"sources":["../../../src/local/editor/columns.js"],"names":["init","reportElement","initialized","addEventListener","event","reportAddColumn","target","closest","reportSelectors","actions","preventDefault","pendingPromise","Pending","dataset","reportId","uniqueIdentifier","then","data","reportEvents","publish","reportColumnsUpdated","name","addToast","tableReload","preservePagination","resolve","catch","Notification","exception","reportRemoveColumn","columnHeader","regions","columnName","key","component","param","confirmTitle","confirmText","confirmButton","confirm","columnId","columnSortableList","SortableList","reportTable","isHorizontal","getElementName","element","Promise","on","EVENTS","DRAG","info","columnPosition","targetColumnPosition","targetNextElement","find","each","cell","children","beforeCell","insertBefore","appendChild","DROP","positionChanged","siblings","length","inplaceEditableEvents","elementUpdated","columnAggregation"],"mappings":"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","sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * Report builder columns editor\n *\n * @module core_reportbuilder/local/editor/columns\n * @package core_reportbuilder\n * @copyright 2021 Paul Holden \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\n\"use strict\";\n\nimport $ from 'jquery';\nimport {dispatchEvent} from 'core/event_dispatcher';\nimport 'core/inplace_editable';\nimport {eventTypes as inplaceEditableEvents} from 'core/local/inplace_editable/events';\nimport Notification from 'core/notification';\nimport Pending from 'core/pending';\nimport {publish} from 'core/pubsub';\nimport SortableList from 'core/sortable_list';\nimport {get_string as getString, get_strings as getStrings} from 'core/str';\nimport {add as addToast} from 'core/toast';\nimport * as reportEvents from 'core_reportbuilder/local/events';\nimport * as reportSelectors from 'core_reportbuilder/local/selectors';\nimport {addColumn, deleteColumn, reorderColumn} from 'core_reportbuilder/local/repository/columns';\n\n/**\n * Initialise module\n *\n * @param {Element} reportElement\n * @param {Boolean} initialized Ensure we only add our listeners once\n */\nexport const init = (reportElement, initialized) => {\n if (initialized) {\n return;\n }\n\n reportElement.addEventListener('click', event => {\n\n // Add column to report.\n const reportAddColumn = event.target.closest(reportSelectors.actions.reportAddColumn);\n if (reportAddColumn) {\n event.preventDefault();\n\n const pendingPromise = new Pending('core_reportbuilder/columns:add');\n\n addColumn(reportElement.dataset.reportId, reportAddColumn.dataset.uniqueIdentifier)\n .then(data => publish(reportEvents.publish.reportColumnsUpdated, data))\n .then(() => getString('columnadded', 'core_reportbuilder', reportAddColumn.dataset.name))\n .then(addToast)\n .then(() => {\n dispatchEvent(reportEvents.tableReload, {preservePagination: true}, reportElement);\n return pendingPromise.resolve();\n })\n .catch(Notification.exception);\n }\n\n // Remove column from report.\n const reportRemoveColumn = event.target.closest(reportSelectors.actions.reportRemoveColumn);\n if (reportRemoveColumn) {\n event.preventDefault();\n\n const columnHeader = reportRemoveColumn.closest(reportSelectors.regions.columnHeader);\n const columnName = columnHeader.dataset.columnName;\n\n getStrings([\n {key: 'deletecolumn', component: 'core_reportbuilder', param: columnName},\n {key: 'deletecolumnconfirm', component: 'core_reportbuilder', param: columnName},\n {key: 'delete', component: 'moodle'},\n ]).then(([confirmTitle, confirmText, confirmButton]) => {\n Notification.confirm(confirmTitle, confirmText, confirmButton, null, () => {\n const pendingPromise = new Pending('core_reportbuilder/columns:remove');\n\n deleteColumn(reportElement.dataset.reportId, columnHeader.dataset.columnId)\n .then(data => publish(reportEvents.publish.reportColumnsUpdated, data))\n .then(() => getString('columndeleted', 'core_reportbuilder', columnName))\n .then(addToast)\n .then(() => {\n dispatchEvent(reportEvents.tableReload, {preservePagination: true}, reportElement);\n return pendingPromise.resolve();\n })\n .catch(Notification.exception);\n });\n return;\n }).catch(Notification.exception);\n }\n });\n\n // Initialize sortable list to handle column moving (note JQuery dependency, see MDL-72293 for resolution).\n var columnSortableList = new SortableList(`${reportSelectors.regions.reportTable} thead tr`, {isHorizontal: true});\n columnSortableList.getElementName = element => Promise.resolve(element.data('columnName'));\n\n $(reportElement).on(SortableList.EVENTS.DRAG, 'th[data-column-id]', (event, info) => {\n const columnPosition = info.element.data('columnPosition');\n const targetColumnPosition = info.targetNextElement.data('columnPosition');\n\n $(reportElement).find('tbody tr').each(function() {\n const cell = $(this).children(`td.c${columnPosition - 1}`)[0];\n if (targetColumnPosition) {\n var beforeCell = $(this).children(`td.c${targetColumnPosition - 1}`)[0];\n this.insertBefore(cell, beforeCell);\n } else {\n this.appendChild(cell);\n }\n });\n });\n\n $(reportElement).on(SortableList.EVENTS.DROP, 'th[data-column-id]', (event, info) => {\n if (info.positionChanged) {\n const pendingPromise = new Pending('core_reportbuilder/columns:reorder');\n\n const columnId = info.element.data('columnId');\n const columnName = info.element.data('columnName');\n const columnPosition = info.element.data('columnPosition');\n\n // Select target position, if moving to the end then count number of element siblings.\n let targetColumnPosition = info.targetNextElement.data('columnPosition') || info.element.siblings().length + 2;\n if (targetColumnPosition > columnPosition) {\n targetColumnPosition--;\n }\n\n reorderColumn(reportElement.dataset.reportId, columnId, targetColumnPosition)\n .then(() => getString('columnmoved', 'core_reportbuilder', columnName))\n .then(addToast)\n .then(() => {\n dispatchEvent(reportEvents.tableReload, {preservePagination: true}, reportElement);\n return pendingPromise.resolve();\n })\n .catch(Notification.exception);\n }\n });\n\n // Initialize inplace editable listeners for column aggregation.\n reportElement.addEventListener(inplaceEditableEvents.elementUpdated, event => {\n\n const columnAggregation = event.target.closest('[data-itemtype=\"columnaggregation\"]');\n if (columnAggregation) {\n const columnHeader = columnAggregation.closest(reportSelectors.regions.columnHeader);\n\n getString('columnaggregated', 'core_reportbuilder', columnHeader.dataset.columnName)\n .then(addToast)\n .then(() => {\n dispatchEvent(reportEvents.tableReload, {}, reportElement);\n return;\n })\n .catch(Notification.exception);\n }\n });\n};\n"],"file":"columns.min.js"} \ No newline at end of file diff --git a/reportbuilder/amd/src/local/editor/columns.js b/reportbuilder/amd/src/local/editor/columns.js index d51e395cd9596..ff6f50c8b6c6d 100644 --- a/reportbuilder/amd/src/local/editor/columns.js +++ b/reportbuilder/amd/src/local/editor/columns.js @@ -27,6 +27,7 @@ import $ from 'jquery'; import {dispatchEvent} from 'core/event_dispatcher'; import 'core/inplace_editable'; +import {eventTypes as inplaceEditableEvents} from 'core/local/inplace_editable/events'; import Notification from 'core/notification'; import Pending from 'core/pending'; import {publish} from 'core/pubsub'; @@ -142,4 +143,21 @@ export const init = (reportElement, initialized) => { .catch(Notification.exception); } }); + + // Initialize inplace editable listeners for column aggregation. + reportElement.addEventListener(inplaceEditableEvents.elementUpdated, event => { + + const columnAggregation = event.target.closest('[data-itemtype="columnaggregation"]'); + if (columnAggregation) { + const columnHeader = columnAggregation.closest(reportSelectors.regions.columnHeader); + + getString('columnaggregated', 'core_reportbuilder', columnHeader.dataset.columnName) + .then(addToast) + .then(() => { + dispatchEvent(reportEvents.tableReload, {}, reportElement); + return; + }) + .catch(Notification.exception); + } + }); }; diff --git a/reportbuilder/classes/local/aggregation/avg.php b/reportbuilder/classes/local/aggregation/avg.php new file mode 100644 index 0000000000000..09e79b9ee2519 --- /dev/null +++ b/reportbuilder/classes/local/aggregation/avg.php @@ -0,0 +1,77 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder\local\aggregation; + +use lang_string; +use core_reportbuilder\local\report\column; + +/** + * Column average aggregation type + * + * @package core_reportbuilder + * @copyright 2021 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class avg extends base { + + /** + * Return aggregation name + * + * @return lang_string + */ + public static function get_name(): lang_string { + return new lang_string('aggregationavg', 'core_reportbuilder'); + } + + /** + * This aggregation can be performed on all numeric columns + * + * @param int $columntype + * @return bool + */ + public static function compatible(int $columntype): bool { + return in_array($columntype, [ + column::TYPE_INTEGER, + column::TYPE_FLOAT, + ]); + } + + /** + * Return the aggregated field SQL + * + * @param string $field + * @param int $columntype + * @return string + */ + public static function get_field_sql(string $field, int $columntype): string { + return "AVG(1.0 * {$field})"; + } + + /** + * Return formatted value for column when applying aggregation + * + * @param mixed $value + * @param array $values + * @param array $callbacks + * @return mixed + */ + public static function format_value($value, array $values, array $callbacks) { + return sprintf('%.1f', (float) reset($values)); + } +} diff --git a/reportbuilder/classes/local/aggregation/base.php b/reportbuilder/classes/local/aggregation/base.php new file mode 100644 index 0000000000000..828514c6b3d7e --- /dev/null +++ b/reportbuilder/classes/local/aggregation/base.php @@ -0,0 +1,77 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder\local\aggregation; + +use lang_string; +use core_reportbuilder\local\report\column; + +/** + * Base class for column aggregation types + * + * @package core_reportbuilder + * @copyright 2021 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +abstract class base { + + /** + * Return the class name of the aggregation type + * + * @return string + */ + final public static function get_class_name(): string { + $namespacedclass = explode('\\', get_called_class()); + + return end($namespacedclass); + } + + /** + * Return the display name of the aggregation + * + * @return lang_string + */ + abstract public static function get_name(): lang_string; + + /** + * Whether the aggregation is compatible with the given column type + * + * @param int $columntype The type as defined by the {@see column::set_type} method + * @return bool + */ + abstract public static function compatible(int $columntype): bool; + + /** + * Return the aggregated field SQL + * + * @param string $field + * @param int $columntype + * @return string + */ + abstract public static function get_field_sql(string $field, int $columntype): string; + + /** + * Return formatted value for column when applying aggregation + * + * @param mixed $value + * @param array $values + * @param array $callbacks + * @return mixed + */ + abstract public static function format_value($value, array $values, array $callbacks); +} diff --git a/reportbuilder/classes/local/aggregation/count.php b/reportbuilder/classes/local/aggregation/count.php new file mode 100644 index 0000000000000..e3bb2108088d3 --- /dev/null +++ b/reportbuilder/classes/local/aggregation/count.php @@ -0,0 +1,80 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder\local\aggregation; + +use lang_string; +use core_reportbuilder\local\report\column; + +/** + * Column count aggregation type + * + * @package core_reportbuilder + * @copyright 2021 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class count extends base { + + /** + * Return aggregation name + * + * @return lang_string + */ + public static function get_name(): lang_string { + return new lang_string('aggregationcount', 'core_reportbuilder'); + } + + /** + * This aggregation can be performed on all column types + * + * @param int $columntype + * @return bool + */ + public static function compatible(int $columntype): bool { + return true; + } + + /** + * Return the aggregated field SQL + * + * @param string $field + * @param int $columntype + * @return string + */ + public static function get_field_sql(string $field, int $columntype): string { + global $DB; + + if ($columntype === column::TYPE_LONGTEXT && $DB->get_dbfamily() === 'oracle') { + $field = $DB->sql_compare_text($field, 255); + } + + return "COUNT({$field})"; + } + + /** + * Return formatted value for column when applying aggregation + * + * @param mixed $value + * @param array $values + * @param array $callbacks + * @return mixed + */ + public static function format_value($value, array $values, array $callbacks) { + return (int) reset($values); + } +} diff --git a/reportbuilder/classes/local/aggregation/countdistinct.php b/reportbuilder/classes/local/aggregation/countdistinct.php new file mode 100644 index 0000000000000..7d936e0ebc8bb --- /dev/null +++ b/reportbuilder/classes/local/aggregation/countdistinct.php @@ -0,0 +1,80 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder\local\aggregation; + +use lang_string; +use core_reportbuilder\local\report\column; + +/** + * Column count distinct aggregation type + * + * @package core_reportbuilder + * @copyright 2021 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class countdistinct extends base { + + /** + * Return aggregation name + * + * @return lang_string + */ + public static function get_name(): lang_string { + return new lang_string('aggregationcountdistinct', 'core_reportbuilder'); + } + + /** + * This aggregation can be performed on all column types + * + * @param int $columntype + * @return bool + */ + public static function compatible(int $columntype): bool { + return true; + } + + /** + * Return the aggregated field SQL + * + * @param string $field + * @param int $columntype + * @return string + */ + public static function get_field_sql(string $field, int $columntype): string { + global $DB; + + if ($columntype === column::TYPE_LONGTEXT && $DB->get_dbfamily() === 'oracle') { + $field = $DB->sql_compare_text($field, 255); + } + + return "COUNT(DISTINCT {$field})"; + } + + /** + * Return formatted value for column when applying aggregation + * + * @param mixed $value + * @param array $values + * @param array $callbacks + * @return mixed + */ + public static function format_value($value, array $values, array $callbacks) { + return (int) reset($values); + } +} diff --git a/reportbuilder/classes/local/aggregation/groupconcat.php b/reportbuilder/classes/local/aggregation/groupconcat.php new file mode 100644 index 0000000000000..4367aa1abad99 --- /dev/null +++ b/reportbuilder/classes/local/aggregation/groupconcat.php @@ -0,0 +1,78 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder\local\aggregation; + +use lang_string; +use core_reportbuilder\local\report\column; + +/** + * Column group concatenation aggregation type + * + * @package core_reportbuilder + * @copyright 2021 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class groupconcat extends base { + + /** + * Return aggregation name + * + * @return lang_string + */ + public static function get_name(): lang_string { + return new lang_string('aggregationgroupconcat', 'core_reportbuilder'); + } + + /** + * This aggregation can be performed on all non-timestamp columns + * + * @param int $columntype + * @return bool + */ + public static function compatible(int $columntype): bool { + return !in_array($columntype, [ + column::TYPE_TIMESTAMP, + ]); + } + + /** + * Return the aggregated field SQL + * + * @param string $field + * @param int $columntype + * @return string + */ + public static function get_field_sql(string $field, int $columntype): string { + global $DB; + + return $DB->sql_group_concat($field); + } + + /** + * Return formatted value for column when applying aggregation + * + * @param mixed $value + * @param array $values + * @param array $callbacks + * @return mixed + */ + public static function format_value($value, array $values, array $callbacks) { + return $value; + } +} diff --git a/reportbuilder/classes/local/aggregation/groupconcatdistinct.php b/reportbuilder/classes/local/aggregation/groupconcatdistinct.php new file mode 100644 index 0000000000000..831a9175bf19e --- /dev/null +++ b/reportbuilder/classes/local/aggregation/groupconcatdistinct.php @@ -0,0 +1,90 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder\local\aggregation; + +use lang_string; +use core_reportbuilder\local\report\column; + +/** + * Column group concatenation distinct aggregation type + * + * @package core_reportbuilder + * @copyright 2021 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class groupconcatdistinct extends base { + + /** + * Return aggregation name + * + * @return lang_string + */ + public static function get_name(): lang_string { + return new lang_string('aggregationgroupconcatdistinct', 'core_reportbuilder'); + } + + /** + * This aggregation can be performed on all non-timestamp columns in MySQL and Postgres only + * + * @param int $columntype + * @return bool + */ + public static function compatible(int $columntype): bool { + global $DB; + + $dbsupportedtype = in_array($DB->get_dbfamily(), [ + 'mysql', + 'postgres', + ]); + + return $dbsupportedtype && !in_array($columntype, [ + column::TYPE_TIMESTAMP, + ]); + } + + /** + * Return the aggregated field SQL + * + * @param string $field + * @param int $columntype + * @return string + */ + public static function get_field_sql(string $field, int $columntype): string { + global $DB; + + // DB limitations mean we only support MySQL and Postgres, and each handle it differently. + if ($DB->get_dbfamily() === 'postgres') { + return "STRING_AGG(DISTINCT CAST({$field} AS VARCHAR), ', ')"; + } else { + return $DB->sql_group_concat("DISTINCT {$field}"); + } + } + + /** + * Return formatted value for column when applying aggregation + * + * @param mixed $value + * @param array $values + * @param array $callbacks + * @return mixed + */ + public static function format_value($value, array $values, array $callbacks) { + return $value; + } +} diff --git a/reportbuilder/classes/local/aggregation/max.php b/reportbuilder/classes/local/aggregation/max.php new file mode 100644 index 0000000000000..c801b9101e8bc --- /dev/null +++ b/reportbuilder/classes/local/aggregation/max.php @@ -0,0 +1,83 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder\local\aggregation; + +use lang_string; +use core_reportbuilder\local\report\column; + +/** + * Column max aggregation type + * + * @package core_reportbuilder + * @copyright 2021 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class max extends base { + + /** + * Return aggregation name + * + * @return lang_string + */ + public static function get_name(): lang_string { + return new lang_string('aggregationmax', 'core_reportbuilder'); + } + + /** + * This aggregation can be performed on all numeric/date/boolean types + * + * @param int $columntype + * @return bool + */ + public static function compatible(int $columntype): bool { + return in_array($columntype, [ + column::TYPE_INTEGER, + column::TYPE_FLOAT, + column::TYPE_TIMESTAMP, + column::TYPE_BOOLEAN, + ]); + } + + /** + * Return the aggregated field SQL + * + * @param string $field + * @param int $columntype + * @return string + */ + public static function get_field_sql(string $field, int $columntype): string { + return "MAX({$field})"; + } + + /** + * Return formatted value for column when applying aggregation + * + * @param mixed $value + * @param array $values + * @param array $callbacks + * @return mixed + */ + public static function format_value($value, array $values, array $callbacks) { + foreach ($callbacks as $callback) { + $value = ($callback[0])($value, (object) $values, $callback[1]); + } + + return $value; + } +} diff --git a/reportbuilder/classes/local/aggregation/min.php b/reportbuilder/classes/local/aggregation/min.php new file mode 100644 index 0000000000000..efde00200683b --- /dev/null +++ b/reportbuilder/classes/local/aggregation/min.php @@ -0,0 +1,83 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder\local\aggregation; + +use lang_string; +use core_reportbuilder\local\report\column; + +/** + * Column min aggregation type + * + * @package core_reportbuilder + * @copyright 2021 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class min extends base { + + /** + * Return aggregation name + * + * @return lang_string + */ + public static function get_name(): lang_string { + return new lang_string('aggregationmin', 'core_reportbuilder'); + } + + /** + * This aggregation can be performed on all numeric/date/boolean types + * + * @param int $columntype + * @return bool + */ + public static function compatible(int $columntype): bool { + return in_array($columntype, [ + column::TYPE_INTEGER, + column::TYPE_FLOAT, + column::TYPE_TIMESTAMP, + column::TYPE_BOOLEAN, + ]); + } + + /** + * Return the aggregated field SQL + * + * @param string $field + * @param int $columntype + * @return string + */ + public static function get_field_sql(string $field, int $columntype): string { + return "MIN({$field})"; + } + + /** + * Return formatted value for column when applying aggregation + * + * @param mixed $value + * @param array $values + * @param array $callbacks + * @return mixed + */ + public static function format_value($value, array $values, array $callbacks) { + foreach ($callbacks as $callback) { + $value = ($callback[0])($value, (object) $values, $callback[1]); + } + + return $value; + } +} diff --git a/reportbuilder/classes/local/aggregation/percent.php b/reportbuilder/classes/local/aggregation/percent.php new file mode 100644 index 0000000000000..62fae8afa5955 --- /dev/null +++ b/reportbuilder/classes/local/aggregation/percent.php @@ -0,0 +1,77 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder\local\aggregation; + +use lang_string; +use core_reportbuilder\local\helpers\format; +use core_reportbuilder\local\report\column; + +/** + * Column percent aggregation type + * + * @package core_reportbuilder + * @copyright 2021 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class percent extends base { + + /** + * Return aggregation name + * + * @return lang_string + */ + public static function get_name(): lang_string { + return new lang_string('aggregationpercent', 'core_reportbuilder'); + } + + /** + * This aggregation can be performed on all boolean columns + * + * @param int $columntype + * @return bool + */ + public static function compatible(int $columntype): bool { + return in_array($columntype, [ + column::TYPE_BOOLEAN, + ]); + } + + /** + * Return the aggregated field SQL + * + * @param string $field + * @param int $columntype + * @return string + */ + public static function get_field_sql(string $field, int $columntype): string { + return "AVG(1.0 * {$field}) * 100.0"; + } + + /** + * Return formatted value for column when applying aggregation + * + * @param mixed $value + * @param array $values + * @param array $callbacks + * @return mixed + */ + public static function format_value($value, array $values, array $callbacks) { + return format::percent((float) reset($values)); + } +} diff --git a/reportbuilder/classes/local/aggregation/sum.php b/reportbuilder/classes/local/aggregation/sum.php new file mode 100644 index 0000000000000..119fb52f5082f --- /dev/null +++ b/reportbuilder/classes/local/aggregation/sum.php @@ -0,0 +1,78 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder\local\aggregation; + +use lang_string; +use core_reportbuilder\local\report\column; + +/** + * Column sum aggregation type + * + * @package core_reportbuilder + * @copyright 2021 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class sum extends base { + + /** + * Return aggregation name + * + * @return lang_string + */ + public static function get_name(): lang_string { + return new lang_string('aggregationsum', 'core_reportbuilder'); + } + + /** + * This aggregation can be performed on all numeric and boolean columns + * + * @param int $columntype + * @return bool + */ + public static function compatible(int $columntype): bool { + return in_array($columntype, [ + column::TYPE_INTEGER, + column::TYPE_FLOAT, + column::TYPE_BOOLEAN, + ]); + } + + /** + * Return the aggregated field SQL + * + * @param string $field + * @param int $columntype + * @return string + */ + public static function get_field_sql(string $field, int $columntype): string { + return "SUM({$field})"; + } + + /** + * Return formatted value for column when applying aggregation + * + * @param mixed $value + * @param array $values + * @param array $callbacks + * @return mixed + */ + public static function format_value($value, array $values, array $callbacks) { + return (int) reset($values); + } +} diff --git a/reportbuilder/classes/local/helpers/aggregation.php b/reportbuilder/classes/local/helpers/aggregation.php new file mode 100644 index 0000000000000..8eaa9878440c2 --- /dev/null +++ b/reportbuilder/classes/local/helpers/aggregation.php @@ -0,0 +1,80 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder\local\helpers; + +use core_collator; +use core_component; +use core_reportbuilder\local\aggregation\base; + +/** + * Helper class for column aggregation related methods + * + * @package core_reportbuilder + * @copyright 2021 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class aggregation { + + /** + * Helper method to convert aggregation class name into fully qualified namespaced class + * + * @param string $aggregation + * @return string + */ + public static function get_full_classpath(string $aggregation): string { + return "\\core_reportbuilder\\local\\aggregation\\{$aggregation}"; + } + + /** + * Validate whether given class is a valid aggregation type + * + * @param string $aggregationclass Fully qualified namespaced class, see {@see get_full_classpath} for converting value + * stored in column persistent to full path + * @return bool + */ + public static function valid(string $aggregationclass): bool { + return class_exists($aggregationclass) && is_subclass_of($aggregationclass, base::class); + } + + /** + * Get available aggregation types for given column type + * + * @param int $columntype + * @param array $exclude List of types to exclude, if omitted then include all available types + * @return string[] Aggregation types indexed by [shortname => name] + */ + public static function get_column_aggregations(int $columntype, array $exclude = []): array { + $types = []; + + $classes = core_component::get_component_classes_in_namespace('core_reportbuilder', 'local\\aggregation'); + foreach ($classes as $class => $path) { + /** @var base $aggregationclass */ + $aggregationclass = $class; + if (static::valid($aggregationclass) && $aggregationclass::compatible($columntype) && + !in_array($aggregationclass::get_class_name(), $exclude)) { + + $types[$aggregationclass::get_class_name()] = (string) $aggregationclass::get_name(); + } + } + + core_collator::asort($types, core_collator::SORT_STRING); + + return $types; + } +} diff --git a/reportbuilder/classes/local/helpers/format.php b/reportbuilder/classes/local/helpers/format.php index 660a3f1554c65..8eae420a6b8b1 100644 --- a/reportbuilder/classes/local/helpers/format.php +++ b/reportbuilder/classes/local/helpers/format.php @@ -50,4 +50,14 @@ public static function userdate(int $value, stdClass $row, ?string $format = nul public static function boolean_as_text(bool $value): string { return $value ? get_string('yes') : get_string('no'); } + + /** + * Returns float value as a percentage + * + * @param float $value + * @return string + */ + public static function percent(float $value): string { + return sprintf('%.1f', $value) . '%'; + } } diff --git a/reportbuilder/classes/local/report/column.php b/reportbuilder/classes/local/report/column.php index a854f7976d873..0a43f2a308fa1 100644 --- a/reportbuilder/classes/local/report/column.php +++ b/reportbuilder/classes/local/report/column.php @@ -20,7 +20,9 @@ use coding_exception; use lang_string; +use core_reportbuilder\local\helpers\aggregation; use core_reportbuilder\local\helpers\database; +use core_reportbuilder\local\aggregation\base; use core_reportbuilder\local\models\column as column_model; /** @@ -63,7 +65,7 @@ final class column { private $entityname; /** @var int $type Column data type (one of the TYPE_* class constants) */ - private $type = null; + private $type = self::TYPE_TEXT; /** @var string[] $joins List of SQL joins for this column */ private $joins = []; @@ -77,6 +79,9 @@ final class column { /** @var array[] $callbacks Array of [callable, additionalarguments] */ private $callbacks = []; + /** @var base|null $aggregation Aggregation type to apply to column */ + private $aggregation = null; + /** @var bool $issortable Used to indicate if a column is sortable */ private $issortable = false; @@ -175,7 +180,7 @@ public function get_unique_identifier(): string { * Set the column index within the current report * * @param int $index - * @return column + * @return self */ public function set_index(int $index): self { $this->index = $index; @@ -183,10 +188,13 @@ public function set_index(int $index): self { } /** - * Set the column type + * Set the column type, if not called then the type will be assumed to be {@see TYPE_TEXT} + * + * The type of a column is used to cast the first column field passed to any callbacks {@see add_callback} as well as the + * aggregation options available for the column * * @param int $type - * @return column + * @return self * @throws coding_exception */ public function set_type(int $type): self { @@ -207,11 +215,11 @@ public function set_type(int $type): self { } /** - * Return column type + * Return column type, that being one of the TYPE_* class constants * - * @return int|null + * @return int */ - public function get_type(): ?int { + public function get_type(): int { return $this->type; } @@ -359,13 +367,38 @@ private function get_fields_sql_alias(): array { * @return array */ public function get_fields(): array { - $fields = array_map(static function(array $field): string { - return "{$field['sql']} AS {$field['alias']}"; - }, $this->get_fields_sql_alias()); + $fieldsalias = $this->get_fields_sql_alias(); + + // We aggregate the first field only. + if (!empty($this->aggregation)) { + $field = reset($fieldsalias); + $fields = [$this->aggregation::get_field_sql($field['sql'], $this->get_type()) . " AS {$field['alias']}"]; + } else { + $fields = array_map(static function(array $field): string { + return "{$field['sql']} AS {$field['alias']}"; + }, $fieldsalias); + } return array_values($fields); } + /** + * Return suitable SQL fragment for grouping by the column fields (during aggregation) + * + * @return array + */ + public function get_groupby_sql(): array { + global $DB; + + $fieldsalias = $this->get_fields_sql_alias(); + + // Note that we can reference field aliases in GROUP BY only in MySQL/Postgres. + $usealias = in_array($DB->get_dbfamily(), ['mysql', 'postgres']); + $columnname = $usealias ? 'alias' : 'sql'; + + return array_column($fieldsalias, $columnname); + } + /** * Return column parameters, prefixed by the current index to allow the column to be added multiple times to a report * @@ -402,6 +435,8 @@ public function get_column_alias(): string { * The callback should implement the following signature (where $value is the first column field, $row is all column * fields, and $additionalarguments are those passed on from this method): * + * The type of the $value parameter passed to the callback is determined by calling {@see set_type} + * * function($value, stdClass $row[, $additionalarguments]): string * * @param callable $callable function that takes arguments ($value, \stdClass $row, $additionalarguments) @@ -425,6 +460,34 @@ public function set_callback(callable $callable, $additionalarguments = null): s return $this->add_callback($callable, $additionalarguments); } + /** + * Set column aggregation type + * + * @param string|null $aggregation Type of aggregation, e.g. 'sum', 'count', etc + * @return self + * @throws coding_exception For invalid aggregation type, or one that is incompatible with column type + */ + public function set_aggregation(?string $aggregation): self { + if (!empty($aggregation)) { + $aggregation = aggregation::get_full_classpath($aggregation); + if (!aggregation::valid($aggregation) || !$aggregation::compatible($this->get_type())) { + throw new coding_exception('Invalid column aggregation', $aggregation); + } + } + + $this->aggregation = $aggregation; + return $this; + } + + /** + * Get column aggregation type + * + * @return base|null + */ + public function get_aggregation(): ?string { + return $this->aggregation; + } + /** * Sets the column as sortable * @@ -497,9 +560,13 @@ public function format_value(array $row) { $values = $this->get_values($row); $value = $this->get_default_value($values); - // Loop through, and apply any defined callbacks. - foreach ($this->callbacks as $callback) { - $value = ($callback[0])($value, (object) $values, $callback[1]); + // If column is being aggregated then defer formatting to them, otherwise loop through all column callbacks. + if (!empty($this->aggregation)) { + $value = $this->aggregation::format_value($value, $values, $this->callbacks); + } else { + foreach ($this->callbacks as $callback) { + $value = ($callback[0])($value, (object) $values, $callback[1]); + } } return $value; @@ -546,7 +613,6 @@ public function set_is_available(bool $available): self { return $this; } - /** * Set column persistent * diff --git a/reportbuilder/classes/output/column_aggregation_editable.php b/reportbuilder/classes/output/column_aggregation_editable.php new file mode 100644 index 0000000000000..c2bf76eb99d53 --- /dev/null +++ b/reportbuilder/classes/output/column_aggregation_editable.php @@ -0,0 +1,85 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder\output; + +use core_external; +use core\output\inplace_editable; +use core_reportbuilder\manager; +use core_reportbuilder\permission; +use core_reportbuilder\local\helpers\aggregation; +use core_reportbuilder\local\models\column; + +/** + * Column aggregation editable component + * + * @package core_reportbuilder + * @copyright 2021 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class column_aggregation_editable extends inplace_editable { + + /** + * Class constructor + * + * @param int $columnid + * @param column|null $column + */ + public function __construct(int $columnid, ?column $column = null) { + if ($column === null) { + $column = new column($columnid); + } + + $report = $column->get_report(); + $editable = permission::can_edit_report($report); + + $columninstance = manager::get_report_from_persistent($report) + ->get_column($column->get('uniqueidentifier')); + + $currentvalue = (string) $column->get('aggregation'); + + parent::__construct('core_reportbuilder', 'columnaggregation', $column->get('id'), $editable, null, $currentvalue, + get_string('aggregatecolumn', 'core_reportbuilder', $columninstance->get_title())); + + $options = aggregation::get_column_aggregations($columninstance->get_type()); + $this->set_type_select(['' => get_string('aggregationnone', 'core_reportbuilder')] + $options); + } + + /** + * Update column persistent and return self, called from inplace_editable callback + * + * @param int $columnid + * @param string $value + * @return self + */ + public static function update(int $columnid, string $value): self { + $column = new column($columnid); + + $report = $column->get_report(); + + core_external::validate_context($report->get_context()); + permission::require_can_edit_report($report); + + $value = clean_param($value, PARAM_TEXT); + $column + ->set('aggregation', $value) + ->update(); + + return new self(0, $column); + } +} diff --git a/reportbuilder/classes/table/custom_report_table.php b/reportbuilder/classes/table/custom_report_table.php index 1a9b78c059869..deeb7f4249fe1 100644 --- a/reportbuilder/classes/table/custom_report_table.php +++ b/reportbuilder/classes/table/custom_report_table.php @@ -81,10 +81,21 @@ public function __construct(string $uniqueid) { return; } + // If we are aggregating any columns, we should group by the remaining ones. + $aggregatedcolumns = array_filter($columns, static function(column $column): bool { + return !empty($column->get_aggregation()); + }); + $hasaggregatedcolumns = !empty($aggregatedcolumns); + $columnheaders = []; foreach ($columns as $column) { $columnheaders[$column->get_column_alias()] = $column->get_persistent()->get('heading') ?: $column->get_title(); + $columnaggregation = $column->get_aggregation(); + if ($hasaggregatedcolumns && empty($columnaggregation)) { + $groupby = array_merge($groupby, $column->get_groupby_sql()); + } + // Add each columns fields, joins and params to our report. $fields = array_merge($fields, $column->get_fields()); $joins = array_merge($joins, $column->get_joins()); @@ -189,13 +200,17 @@ private function get_active_columns(): array { $column->set_persistent($instance); // We should clone the report column to ensure if it's added twice to a report, each operates independently. $columns[$instance->get('id')] = clone $column - ->set_index($index); + ->set_index($index) + ->set_aggregation($instance->get('aggregation')); } } return $columns; } + /** + * Override parent method for printing headers so we can render our custom controls in each cell + */ public function print_headers() { global $OUTPUT, $PAGE; @@ -214,13 +229,14 @@ public function print_headers() { $column = $columns[$index]; $headingeditable = new column_heading_editable(0, $column->get_persistent()); + $aggregationeditable = new column_aggregation_editable(0, $column->get_persistent()); // Render table header cell, with all editing controls. $headercell = $OUTPUT->render_from_template('core_reportbuilder/table_header_cell', [ 'entityname' => $this->report->get_entity_title($column->get_entity_name()), 'name' => $column->get_title(), 'headingeditable' => $headingeditable->render($renderer), - 'aggregationeditable' => 'Aggregation', + 'aggregationeditable' => $aggregationeditable->render($renderer), 'movetitle' => get_string('movecolumn', 'core_reportbuilder', $column->get_title()), ]); diff --git a/reportbuilder/lib.php b/reportbuilder/lib.php index a3ef881635d5d..e303e9d09e815 100644 --- a/reportbuilder/lib.php +++ b/reportbuilder/lib.php @@ -61,6 +61,9 @@ function core_reportbuilder_inplace_editable($itemtype, $itemid, $newvalue) { case 'columnheading': return \core_reportbuilder\output\column_heading_editable::update($itemid, $newvalue); + case 'columnaggregation': + return \core_reportbuilder\output\column_aggregation_editable::update($itemid, $newvalue); + case 'filterheading': return \core_reportbuilder\output\filter_heading_editable::update($itemid, $newvalue); diff --git a/reportbuilder/tests/helpers.php b/reportbuilder/tests/helpers.php new file mode 100644 index 0000000000000..8e68fee7adef7 --- /dev/null +++ b/reportbuilder/tests/helpers.php @@ -0,0 +1,54 @@ +. + +declare(strict_types=1); + +use core_reportbuilder\table\custom_report_table_view; + +/** + * Helper base class for reportbuilder unit tests + * + * @package core_reportbuilder + * @copyright 2021 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +abstract class core_reportbuilder_testcase extends advanced_testcase { + + /** + * Retrieve content for given report as array of report data + * + * @param int $reportid + * @param int $pagesize + * @return array[] + */ + protected function get_custom_report_content(int $reportid, int $pagesize = 30): array { + $records = []; + + // Create table instance. + $table = custom_report_table_view::create($reportid); + $table->setup(); + $table->query_db($pagesize, false); + + // Extract raw data. + foreach ($table->rawdata as $record) { + $records[] = $table->format_row($record); + } + + $table->close_recordset(); + + return $records; + } +} diff --git a/reportbuilder/tests/local/aggregation/count_test.php b/reportbuilder/tests/local/aggregation/count_test.php new file mode 100644 index 0000000000000..f12623ad7dfdb --- /dev/null +++ b/reportbuilder/tests/local/aggregation/count_test.php @@ -0,0 +1,78 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder\local\aggregation; + +use core_reportbuilder_testcase; +use core_reportbuilder_generator; +use core_user\reportbuilder\datasource\users; + +defined('MOODLE_INTERNAL') || die(); + +global $CFG; +require_once("{$CFG->dirroot}/reportbuilder/tests/helpers.php"); + +/** + * Unit tests for count aggregation + * + * @package core_reportbuilder + * @covers \core_reportbuilder\local\aggregation\base + * @covers \core_reportbuilder\local\aggregation\count + * @copyright 2021 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class count_test extends core_reportbuilder_testcase { + + /** + * Test aggregation when applied to column + */ + public function test_column_aggregation(): void { + $this->resetAfterTest(); + + // Test subjects. + $this->getDataGenerator()->create_user(['firstname' => 'Bob', 'lastname' => 'Apple']); + $this->getDataGenerator()->create_user(['firstname' => 'Bob', 'lastname' => 'Banana']); + $this->getDataGenerator()->create_user(['firstname' => 'Bob', 'lastname' => 'Banana']); + + /** @var core_reportbuilder_generator $generator */ + $generator = $this->getDataGenerator()->get_plugin_generator('core_reportbuilder'); + $report = $generator->create_report(['name' => 'Users', 'source' => users::class, 'default' => 0]); + + // First column, sorted. + $generator->create_column(['reportid' => $report->get('id'), 'uniqueidentifier' => 'user:firstname']) + ->set('sortenabled', true) + ->update(); + + // This is the column we'll aggregate. + $generator->create_column(['reportid' => $report->get('id'), 'uniqueidentifier' => 'user:lastname']) + ->set('aggregation', count::get_class_name()) + ->update(); + + $content = $this->get_custom_report_content($report->get('id')); + $this->assertEquals([ + [ + 'c0_firstname' => 'Admin', + 'c1_lastname' => 1, + ], + [ + 'c0_firstname' => 'Bob', + 'c1_lastname' => 3, + ], + ], $content); + } +} diff --git a/reportbuilder/tests/local/aggregation/countdistinct_test.php b/reportbuilder/tests/local/aggregation/countdistinct_test.php new file mode 100644 index 0000000000000..201f7fca23a21 --- /dev/null +++ b/reportbuilder/tests/local/aggregation/countdistinct_test.php @@ -0,0 +1,78 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder\local\aggregation; + +use core_reportbuilder_testcase; +use core_reportbuilder_generator; +use core_user\reportbuilder\datasource\users; + +defined('MOODLE_INTERNAL') || die(); + +global $CFG; +require_once("{$CFG->dirroot}/reportbuilder/tests/helpers.php"); + +/** + * Unit tests for count distinct aggregation + * + * @package core_reportbuilder + * @covers \core_reportbuilder\local\aggregation\base + * @covers \core_reportbuilder\local\aggregation\countdistinct + * @copyright 2021 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class countdistinct_test extends core_reportbuilder_testcase { + + /** + * Test aggregation when applied to column + */ + public function test_column_aggregation(): void { + $this->resetAfterTest(); + + // Test subjects. + $this->getDataGenerator()->create_user(['firstname' => 'Bob', 'lastname' => 'Apple']); + $this->getDataGenerator()->create_user(['firstname' => 'Bob', 'lastname' => 'Banana']); + $this->getDataGenerator()->create_user(['firstname' => 'Bob', 'lastname' => 'Banana']); + + /** @var core_reportbuilder_generator $generator */ + $generator = $this->getDataGenerator()->get_plugin_generator('core_reportbuilder'); + $report = $generator->create_report(['name' => 'Users', 'source' => users::class, 'default' => 0]); + + // First column, sorted. + $generator->create_column(['reportid' => $report->get('id'), 'uniqueidentifier' => 'user:firstname']) + ->set('sortenabled', true) + ->update(); + + // This is the column we'll aggregate. + $generator->create_column(['reportid' => $report->get('id'), 'uniqueidentifier' => 'user:lastname']) + ->set('aggregation', countdistinct::get_class_name()) + ->update(); + + $content = $this->get_custom_report_content($report->get('id')); + $this->assertEquals([ + [ + 'c0_firstname' => 'Admin', + 'c1_lastname' => 1, + ], + [ + 'c0_firstname' => 'Bob', + 'c1_lastname' => 2, + ], + ], $content); + } +} diff --git a/reportbuilder/tests/local/aggregation/groupconcat_test.php b/reportbuilder/tests/local/aggregation/groupconcat_test.php new file mode 100644 index 0000000000000..e8dde3931cbfe --- /dev/null +++ b/reportbuilder/tests/local/aggregation/groupconcat_test.php @@ -0,0 +1,80 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder\local\aggregation; + +use core_reportbuilder_testcase; +use core_reportbuilder_generator; +use core_user\reportbuilder\datasource\users; + +defined('MOODLE_INTERNAL') || die(); + +global $CFG; +require_once("{$CFG->dirroot}/reportbuilder/tests/helpers.php"); + +/** + * Unit tests for group concatenation aggregation + * + * @package core_reportbuilder + * @covers \core_reportbuilder\local\aggregation\base + * @covers \core_reportbuilder\local\aggregation\groupconcat + * @copyright 2021 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class groupconcat_test extends core_reportbuilder_testcase { + + /** + * Test aggregation when applied to column + */ + public function test_column_aggregation(): void { + $this->resetAfterTest(); + + // Test subjects. + $this->getDataGenerator()->create_user(['firstname' => 'Bob', 'lastname' => 'Apple']); + $this->getDataGenerator()->create_user(['firstname' => 'Bob', 'lastname' => 'Banana']); + $this->getDataGenerator()->create_user(['firstname' => 'Bob', 'lastname' => 'Banana']); + + /** @var core_reportbuilder_generator $generator */ + $generator = $this->getDataGenerator()->get_plugin_generator('core_reportbuilder'); + $report = $generator->create_report(['name' => 'Users', 'source' => users::class, 'default' => 0]); + + // First column, sorted. + $generator->create_column(['reportid' => $report->get('id'), 'uniqueidentifier' => 'user:firstname']) + ->set('sortenabled', true) + ->update(); + + // This is the column we'll aggregate. + $generator->create_column(['reportid' => $report->get('id'), 'uniqueidentifier' => 'user:lastname']) + ->set('aggregation', groupconcat::get_class_name()) + ->update(); + + [$firstrow, $secondrow] = $this->get_custom_report_content($report->get('id')); + $this->assertEquals([ + 'c0_firstname' => 'Admin', + 'c1_lastname' => 'User', + ], $firstrow); + + // Currently the aggregated field sort order is undefined. + $this->assertEquals('Bob', $secondrow['c0_firstname']); + $this->assertEqualsCanonicalizing([ + 'Apple', + 'Banana', + 'Banana', + ], explode(', ', $secondrow['c1_lastname'])); + } +} diff --git a/reportbuilder/tests/local/aggregation/groupconcatdistinct_test.php b/reportbuilder/tests/local/aggregation/groupconcatdistinct_test.php new file mode 100644 index 0000000000000..1f930720afd06 --- /dev/null +++ b/reportbuilder/tests/local/aggregation/groupconcatdistinct_test.php @@ -0,0 +1,91 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder\local\aggregation; + +use core_reportbuilder_testcase; +use core_reportbuilder_generator; +use core_user\reportbuilder\datasource\users; + +defined('MOODLE_INTERNAL') || die(); + +global $CFG; +require_once("{$CFG->dirroot}/reportbuilder/tests/helpers.php"); + +/** + * Unit tests for group concatenation distinct aggregation + * + * @package core_reportbuilder + * @covers \core_reportbuilder\local\aggregation\base + * @covers \core_reportbuilder\local\aggregation\groupconcatdistinct + * @copyright 2021 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class groupconcatdistinct_test extends core_reportbuilder_testcase { + + /** + * Test setup, we need to skip these tests on non-supported databases + */ + public function setUp(): void { + global $DB; + + $dbfamily = $DB->get_dbfamily(); + if (!in_array($dbfamily, ['mysql', 'postgres'])) { + $this->markTestSkipped("Distinct group concatenation not supported in {$dbfamily}"); + } + } + + /** + * Test aggregation when applied to column + */ + public function test_column_aggregation(): void { + $this->resetAfterTest(); + + // Test subjects. + $this->getDataGenerator()->create_user(['firstname' => 'Bob', 'lastname' => 'Apple']); + $this->getDataGenerator()->create_user(['firstname' => 'Bob', 'lastname' => 'Banana']); + $this->getDataGenerator()->create_user(['firstname' => 'Bob', 'lastname' => 'Banana']); + + /** @var core_reportbuilder_generator $generator */ + $generator = $this->getDataGenerator()->get_plugin_generator('core_reportbuilder'); + $report = $generator->create_report(['name' => 'Users', 'source' => users::class, 'default' => 0]); + + // First column, sorted. + $generator->create_column(['reportid' => $report->get('id'), 'uniqueidentifier' => 'user:firstname']) + ->set('sortenabled', true) + ->update(); + + // This is the column we'll aggregate. + $generator->create_column(['reportid' => $report->get('id'), 'uniqueidentifier' => 'user:lastname']) + ->set('aggregation', groupconcatdistinct::get_class_name()) + ->update(); + + [$firstrow, $secondrow] = $this->get_custom_report_content($report->get('id')); + $this->assertEquals([ + 'c0_firstname' => 'Admin', + 'c1_lastname' => 'User', + ], $firstrow); + + // Currently the aggregated field sort order is undefined. + $this->assertEquals('Bob', $secondrow['c0_firstname']); + $this->assertEqualsCanonicalizing([ + 'Apple', + 'Banana', + ], explode(', ', $secondrow['c1_lastname'])); + } +} diff --git a/reportbuilder/tests/local/aggregation/max_test.php b/reportbuilder/tests/local/aggregation/max_test.php new file mode 100644 index 0000000000000..132b186ee000d --- /dev/null +++ b/reportbuilder/tests/local/aggregation/max_test.php @@ -0,0 +1,77 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder\local\aggregation; + +use core_reportbuilder_testcase; +use core_reportbuilder_generator; +use core_user\reportbuilder\datasource\users; + +defined('MOODLE_INTERNAL') || die(); + +global $CFG; +require_once("{$CFG->dirroot}/reportbuilder/tests/helpers.php"); + +/** + * Unit tests for max aggregation + * + * @package core_reportbuilder + * @covers \core_reportbuilder\local\aggregation\base + * @covers \core_reportbuilder\local\aggregation\max + * @copyright 2021 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class max_test extends core_reportbuilder_testcase { + + /** + * Test aggregation when applied to column + */ + public function test_column_aggregation(): void { + $this->resetAfterTest(); + + // Test subjects. + $this->getDataGenerator()->create_user(['firstname' => 'Bob', 'suspended' => 1]); + $this->getDataGenerator()->create_user(['firstname' => 'Bob', 'suspended' => 0]); + + /** @var core_reportbuilder_generator $generator */ + $generator = $this->getDataGenerator()->get_plugin_generator('core_reportbuilder'); + $report = $generator->create_report(['name' => 'Users', 'source' => users::class, 'default' => 0]); + + // First column, sorted. + $generator->create_column(['reportid' => $report->get('id'), 'uniqueidentifier' => 'user:firstname']) + ->set('sortenabled', true) + ->update(); + + // This is the column we'll aggregate. + $generator->create_column(['reportid' => $report->get('id'), 'uniqueidentifier' => 'user:suspended']) + ->set('aggregation', max::get_class_name()) + ->update(); + + $content = $this->get_custom_report_content($report->get('id')); + $this->assertEquals([ + [ + 'c0_firstname' => 'Admin', + 'c1_suspended' => 'No', + ], + [ + 'c0_firstname' => 'Bob', + 'c1_suspended' => 'Yes', + ], + ], $content); + } +} diff --git a/reportbuilder/tests/local/aggregation/min_test.php b/reportbuilder/tests/local/aggregation/min_test.php new file mode 100644 index 0000000000000..9a67fa076a662 --- /dev/null +++ b/reportbuilder/tests/local/aggregation/min_test.php @@ -0,0 +1,77 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder\local\aggregation; + +use core_reportbuilder_testcase; +use core_reportbuilder_generator; +use core_user\reportbuilder\datasource\users; + +defined('MOODLE_INTERNAL') || die(); + +global $CFG; +require_once("{$CFG->dirroot}/reportbuilder/tests/helpers.php"); + +/** + * Unit tests for min aggregation + * + * @package core_reportbuilder + * @covers \core_reportbuilder\local\aggregation\base + * @covers \core_reportbuilder\local\aggregation\min + * @copyright 2021 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class min_test extends core_reportbuilder_testcase { + + /** + * Test aggregation when applied to column + */ + public function test_column_aggregation(): void { + $this->resetAfterTest(); + + // Test subjects. + $this->getDataGenerator()->create_user(['firstname' => 'Bob', 'suspended' => 1]); + $this->getDataGenerator()->create_user(['firstname' => 'Bob', 'suspended' => 0]); + + /** @var core_reportbuilder_generator $generator */ + $generator = $this->getDataGenerator()->get_plugin_generator('core_reportbuilder'); + $report = $generator->create_report(['name' => 'Users', 'source' => users::class, 'default' => 0]); + + // First column, sorted. + $generator->create_column(['reportid' => $report->get('id'), 'uniqueidentifier' => 'user:firstname']) + ->set('sortenabled', true) + ->update(); + + // This is the column we'll aggregate. + $generator->create_column(['reportid' => $report->get('id'), 'uniqueidentifier' => 'user:suspended']) + ->set('aggregation', min::get_class_name()) + ->update(); + + $content = $this->get_custom_report_content($report->get('id')); + $this->assertEquals([ + [ + 'c0_firstname' => 'Admin', + 'c1_suspended' => 'No', + ], + [ + 'c0_firstname' => 'Bob', + 'c1_suspended' => 'No', + ], + ], $content); + } +} diff --git a/reportbuilder/tests/local/aggregation/percent_test.php b/reportbuilder/tests/local/aggregation/percent_test.php new file mode 100644 index 0000000000000..b31cad570ce26 --- /dev/null +++ b/reportbuilder/tests/local/aggregation/percent_test.php @@ -0,0 +1,77 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder\local\aggregation; + +use core_reportbuilder_testcase; +use core_reportbuilder_generator; +use core_user\reportbuilder\datasource\users; + +defined('MOODLE_INTERNAL') || die(); + +global $CFG; +require_once("{$CFG->dirroot}/reportbuilder/tests/helpers.php"); + +/** + * Unit tests for sum aggregation + * + * @package core_reportbuilder + * @covers \core_reportbuilder\local\aggregation\base + * @covers \core_reportbuilder\local\aggregation\percent + * @copyright 2021 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class percent_test extends core_reportbuilder_testcase { + + /** + * Test aggregation when applied to column + */ + public function test_column_aggregation(): void { + $this->resetAfterTest(); + + // Test subjects. + $this->getDataGenerator()->create_user(['firstname' => 'Bob', 'suspended' => 1]); + $this->getDataGenerator()->create_user(['firstname' => 'Bob', 'suspended' => 0]); + + /** @var core_reportbuilder_generator $generator */ + $generator = $this->getDataGenerator()->get_plugin_generator('core_reportbuilder'); + $report = $generator->create_report(['name' => 'Users', 'source' => users::class, 'default' => 0]); + + // First column, sorted. + $generator->create_column(['reportid' => $report->get('id'), 'uniqueidentifier' => 'user:firstname']) + ->set('sortenabled', true) + ->update(); + + // This is the column we'll aggregate. + $generator->create_column(['reportid' => $report->get('id'), 'uniqueidentifier' => 'user:suspended']) + ->set('aggregation', percent::get_class_name()) + ->update(); + + $content = $this->get_custom_report_content($report->get('id')); + $this->assertEquals([ + [ + 'c0_firstname' => 'Admin', + 'c1_suspended' => '0.0%', + ], + [ + 'c0_firstname' => 'Bob', + 'c1_suspended' => '50.0%', + ], + ], $content); + } +} diff --git a/reportbuilder/tests/local/aggregation/sum_test.php b/reportbuilder/tests/local/aggregation/sum_test.php new file mode 100644 index 0000000000000..8d5fb393c23db --- /dev/null +++ b/reportbuilder/tests/local/aggregation/sum_test.php @@ -0,0 +1,77 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder\local\aggregation; + +use core_reportbuilder_testcase; +use core_reportbuilder_generator; +use core_user\reportbuilder\datasource\users; + +defined('MOODLE_INTERNAL') || die(); + +global $CFG; +require_once("{$CFG->dirroot}/reportbuilder/tests/helpers.php"); + +/** + * Unit tests for percent aggregation + * + * @package core_reportbuilder + * @covers \core_reportbuilder\local\aggregation\base + * @covers \core_reportbuilder\local\aggregation\sum + * @copyright 2021 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class sum_test extends core_reportbuilder_testcase { + + /** + * Test aggregation when applied to column + */ + public function test_column_aggregation(): void { + $this->resetAfterTest(); + + // Test subjects. + $this->getDataGenerator()->create_user(['firstname' => 'Bob', 'suspended' => 1]); + $this->getDataGenerator()->create_user(['firstname' => 'Bob', 'suspended' => 1]); + + /** @var core_reportbuilder_generator $generator */ + $generator = $this->getDataGenerator()->get_plugin_generator('core_reportbuilder'); + $report = $generator->create_report(['name' => 'Users', 'source' => users::class, 'default' => 0]); + + // First column, sorted. + $generator->create_column(['reportid' => $report->get('id'), 'uniqueidentifier' => 'user:firstname']) + ->set('sortenabled', true) + ->update(); + + // This is the column we'll aggregate. + $generator->create_column(['reportid' => $report->get('id'), 'uniqueidentifier' => 'user:suspended']) + ->set('aggregation', sum::get_class_name()) + ->update(); + + $content = $this->get_custom_report_content($report->get('id')); + $this->assertEquals([ + [ + 'c0_firstname' => 'Admin', + 'c1_suspended' => 0, + ], + [ + 'c0_firstname' => 'Bob', + 'c1_suspended' => 2, + ], + ], $content); + } +} diff --git a/reportbuilder/tests/local/report/column_test.php b/reportbuilder/tests/local/report/column_test.php index e1f48d6ec2b09..692472c40d9ea 100644 --- a/reportbuilder/tests/local/report/column_test.php +++ b/reportbuilder/tests/local/report/column_test.php @@ -32,7 +32,7 @@ * @copyright 2020 Paul Holden * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ -class column_testcase extends advanced_testcase { +class column_test extends advanced_testcase { /** * Test column name getter/setter @@ -86,11 +86,19 @@ public function test_get_unique_identifier(): void { */ public function test_type(): void { $column = $this->create_column('test'); - $this->assertEquals(column::TYPE_TEXT, $column - ->set_type(column::TYPE_TEXT) + $this->assertEquals(column::TYPE_INTEGER, $column + ->set_type(column::TYPE_INTEGER) ->get_type()); } + /** + * Test column default type + */ + public function test_type_default(): void { + $column = $this->create_column('test'); + $this->assertEquals(column::TYPE_TEXT, $column->get_type()); + } + /** * Test column type with invalid value */