Merge branch 'MDL-72663' of git://github.com/paulholden/moodle

This commit is contained in:
Ilya Tregubov
2021-10-26 15:11:49 +02:00
19 changed files with 592 additions and 133 deletions
@@ -94,7 +94,9 @@ class custom_report_columns_sorting_exporter extends exporter {
$reportid = $report->get_report_persistent()->get('id');
$activecolumns = column::get_records(['reportid' => $reportid], 'sortorder');
$sortablecolumns = array_filter($activecolumns, function(column $persistent) use($report) {
$column = $report->get_column($persistent->get('uniqueidentifier'));
$column = $report->get_column($persistent->get('uniqueidentifier'))
->set_aggregation($persistent->get('aggregation'));
return $column && $column->get_is_sortable();
});
@@ -56,6 +56,26 @@ abstract class base {
*/
abstract public static function compatible(int $columntype): bool;
/**
* Whether the aggregation is sortable, by default return the sortable status of the column itself
*
* @param bool $columnsortable
* @return bool
*/
public static function sortable(bool $columnsortable): bool {
return $columnsortable;
}
/**
* Return SQL suitable for using within {@see get_field_sql} for column fields, by default just the first one
*
* @param string[] $sqlfields
* @return string
*/
public static function get_column_field_sql(array $sqlfields): string {
return reset($sqlfields);
}
/**
* Return the aggregated field SQL
*
@@ -66,12 +86,22 @@ abstract class base {
abstract public static function get_field_sql(string $field, int $columntype): string;
/**
* Return formatted value for column when applying aggregation
* Return formatted value for column when applying aggregation, by default executing all callbacks on the value
*
* Should be overridden in child classes that need to format the column value differently (e.g. 'sum' would just show
* a numeric count value)
*
* @param mixed $value
* @param array $values
* @param array $callbacks
* @param array $callbacks Array of column callbacks, {@see column::add_callback} for definition
* @return mixed
*/
abstract public static function format_value($value, array $values, array $callbacks);
public static function format_value($value, array $values, array $callbacks) {
foreach ($callbacks as $callback) {
[$callable, $arguments] = $callback;
$value = ($callable)($value, (object) $values, $arguments);
}
return $value;
}
}
@@ -19,6 +19,7 @@ declare(strict_types=1);
namespace core_reportbuilder\local\aggregation;
use lang_string;
use core_reportbuilder\local\helpers\database;
use core_reportbuilder\local\report\column;
/**
@@ -30,6 +31,9 @@ use core_reportbuilder\local\report\column;
*/
class groupconcat extends base {
/** @var string Character to use as a delimeter between column fields */
private const DELIMETER = '|';
/**
* Return aggregation name
*
@@ -51,6 +55,51 @@ class groupconcat extends base {
]);
}
/**
* We cannot sort this aggregation type
*
* @param bool $columnsortable
* @return bool
*/
public static function sortable(bool $columnsortable): bool {
return false;
}
/**
* Override base method to ensure all SQL fields are concatenated together if there are multiple
*
* @param array $sqlfields
* @return string
*/
public static function get_column_field_sql(array $sqlfields): string {
global $DB;
if (count($sqlfields) === 1) {
return parent::get_column_field_sql($sqlfields);
}
// Coalesce all the SQL fields, to remove all nulls.
$concatfields = [];
foreach ($sqlfields as $sqlfield) {
// We need to ensure all values are char (this ought to be done in the DML drivers, see MDL-72184).
switch ($DB->get_dbfamily()) {
case 'postgres' :
$sqlfield = "CAST({$sqlfield} AS VARCHAR)";
break;
case 'oracle' :
$sqlfield = "TO_CHAR({$sqlfield})";
break;
}
$concatfields[] = "COALESCE({$sqlfield}, ' ')";
$concatfields[] = "'" . self::DELIMETER . "'";
}
// Slice off the last delimeter.
return $DB->sql_concat(...array_slice($concatfields, 0, -1));
}
/**
* Return the aggregated field SQL
*
@@ -61,11 +110,14 @@ class groupconcat extends base {
public static function get_field_sql(string $field, int $columntype): string {
global $DB;
return $DB->sql_group_concat($field);
$fieldsort = database::sql_group_concat_sort($field);
return $DB->sql_group_concat($field, ', ', $fieldsort);
}
/**
* Return formatted value for column when applying aggregation
* Return formatted value for column when applying aggregation, note we need to split apart the concatenated string
* and apply callbacks to each concatenated value separately
*
* @param mixed $value
* @param array $values
@@ -73,6 +125,21 @@ class groupconcat extends base {
* @return mixed
*/
public static function format_value($value, array $values, array $callbacks) {
return $value;
$formattedvalues = [];
// Store original names of all values that would be present without aggregation.
$valuenames = array_keys($values);
$values = explode(', ', (string) reset($values));
// Loop over each extracted value from the concatenated string.
foreach ($values as $value) {
$originalvalue = array_combine($valuenames, explode(self::DELIMETER, $value));
$originalfirstvalue = reset($originalvalue);
// Once we've re-constructed each value, we can apply callbacks to it.
$formattedvalues[] = parent::format_value($originalfirstvalue, $originalvalue, $callbacks);
}
return implode(', ', $formattedvalues);
}
}
@@ -19,7 +19,7 @@ declare(strict_types=1);
namespace core_reportbuilder\local\aggregation;
use lang_string;
use core_reportbuilder\local\report\column;
use core_reportbuilder\local\helpers\database;
/**
* Column group concatenation distinct aggregation type
@@ -28,7 +28,7 @@ use core_reportbuilder\local\report\column;
* @copyright 2021 Paul Holden <[email protected]>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class groupconcatdistinct extends base {
class groupconcatdistinct extends groupconcat {
/**
* Return aggregation name
@@ -53,9 +53,7 @@ class groupconcatdistinct extends base {
'postgres',
]);
return $dbsupportedtype && !in_array($columntype, [
column::TYPE_TIMESTAMP,
]);
return $dbsupportedtype && parent::compatible($columntype);
}
/**
@@ -69,22 +67,15 @@ class groupconcatdistinct extends base {
global $DB;
// DB limitations mean we only support MySQL and Postgres, and each handle it differently.
$fieldsort = database::sql_group_concat_sort($field);
if ($DB->get_dbfamily() === 'postgres') {
return "STRING_AGG(DISTINCT CAST({$field} AS VARCHAR), ', ')";
if ($fieldsort !== '') {
$fieldsort = "ORDER BY {$fieldsort}";
}
return "STRING_AGG(DISTINCT CAST({$field} AS VARCHAR), ', ' {$fieldsort})";
} else {
return $DB->sql_group_concat("DISTINCT {$field}");
return $DB->sql_group_concat("DISTINCT {$field}", ', ', $fieldsort);
}
}
/**
* 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;
}
}
@@ -64,20 +64,4 @@ class max extends base {
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;
}
}
@@ -64,20 +64,4 @@ class min extends base {
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;
}
}
+28 -2
View File
@@ -128,6 +128,12 @@ class user extends base {
return '';
}
// Ensure we populate all required name properties.
$namefields = fields::get_name_fields();
foreach ($namefields as $namefield) {
$row->{$namefield} = $row->{$namefield} ?? '';
}
return fullname($row, $viewfullnames);
});
@@ -155,6 +161,12 @@ class user extends base {
return '';
}
// Ensure we populate all required name properties.
$namefields = fields::get_name_fields();
foreach ($namefields as $namefield) {
$row->{$namefield} = $row->{$namefield} ?? '';
}
if ($fullnamefield === 'fullnamewithlink') {
return html_writer::link(new moodle_url('/user/profile.php', ['id' => $row->id]),
fullname($row, $viewfullnames));
@@ -190,7 +202,9 @@ class user extends base {
->add_fields($userpictureselect)
->set_type(column::TYPE_INTEGER)
->set_is_sortable($this->is_sortable('picture'))
->add_callback(static function (int $value, stdClass $row): string {
// It doesn't make sense to offer integer aggregation methods for this column.
->set_disabled_aggregation(['avg', 'max', 'min', 'sum'])
->add_callback(static function ($value, stdClass $row): string {
global $OUTPUT;
return !empty($row->id) ? $OUTPUT->user_picture($row, ['link' => false, 'alttext' => false]) : '';
@@ -264,17 +278,29 @@ class user extends base {
/**
* Returns a SQL statement to select all user fields necessary for fullname() function
*
* Note the implementation here is similar to {@see fields::get_sql_fullname} but without concatenation
*
* @param string $usertablealias
* @return string
*/
public static function get_name_fields_select(string $usertablealias = 'u'): string {
$namefields = fields::get_name_fields(true);
// Create a dummy user object containing all name fields.
$dummyuser = (object) array_combine($namefields, $namefields);
$dummyfullname = fullname($dummyuser, true);
// Extract any name fields from the fullname format in the order that they appear.
$matchednames = array_values(order_in_string($namefields, $dummyfullname));
$userfields = array_map(static function(string $userfield) use ($usertablealias): string {
if (!empty($usertablealias)) {
$userfield = "{$usertablealias}.{$userfield}";
}
return $userfield;
}, fields::get_name_fields(true));
}, $matchednames);
return implode(', ', $userfields);
}
@@ -52,24 +52,33 @@ class aggregation {
return class_exists($aggregationclass) && is_subclass_of($aggregationclass, base::class);
}
/**
* Return list of all available/valid aggregation types
*
* @return base[]
*/
public static function get_aggregations(): array {
$classes = core_component::get_component_classes_in_namespace('core_reportbuilder', 'local\\aggregation');
return array_filter(array_keys($classes), static function(string $class): bool {
return static::valid($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
* @param array $exclude List of types to exclude, e.g. ['min', 'sum']
* @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();
$classes = static::get_aggregations();
foreach ($classes as $class) {
if ($class::compatible($columntype) && !in_array($class::get_class_name(), $exclude)) {
$types[$class::get_class_name()] = (string) $class::get_name();
}
}
@@ -19,6 +19,7 @@ declare(strict_types=1);
namespace core_reportbuilder\local\helpers;
use coding_exception;
use core_text;
/**
* Helper functions for DB manipulations
@@ -74,4 +75,43 @@ class database {
return true;
}
/**
* Generate SQL expression for sorting group concatenated fields
*
* @param string $field The original field or SQL expression
* @param string|null $sort A valid SQL ORDER BY to sort the concatenated fields, if omitted then $field will be used
* @return string
*/
public static function sql_group_concat_sort(string $field, string $sort = null): string {
global $DB;
// Fallback to sorting by the specified field, unless it contains parameters which would be duplicated.
if ($sort === null && !preg_match('/[:?$]/', $field)) {
$fieldsort = $field;
} else {
$fieldsort = $sort;
}
// Nothing to sort by.
if ($fieldsort === null) {
return '';
}
// If the sort specifies a direction, we need to handle that differently in Postgres.
if ($DB->get_dbfamily() === 'postgres') {
$fieldsortdirection = '';
preg_match('/(?<direction>ASC|DESC)?$/i', $fieldsort, $matches);
if (array_key_exists('direction', $matches)) {
$fieldsortdirection = $matches['direction'];
$fieldsort = core_text::substr($fieldsort, 0, -(core_text::strlen($fieldsortdirection)));
}
// Cast sort, stick the direction on the end.
$fieldsort = "CAST({$fieldsort} AS VARCHAR) {$fieldsortdirection}";
}
return $fieldsort;
}
}
+10 -7
View File
@@ -21,7 +21,10 @@ namespace core_reportbuilder\local\helpers;
use stdClass;
/**
* Class containing helper methods for format columns data as callbacks.
* Class containing helper methods for formatting column data via callbacks
*
* Note that type hints for each $value argument are avoided to allow for these callbacks to be executed when columns are
* aggregated using one of the "Group concatenation" methods, where the value is typically stringified
*
* @package core_reportbuilder
* @copyright 2021 Sara Arjona <[email protected]> based on Alberto Lara Hernández <[email protected]> code.
@@ -37,8 +40,8 @@ class format {
* @param string|null $format Format string for strftime
* @return string
*/
public static function userdate(int $value, stdClass $row, ?string $format = null): string {
return $value ? userdate($value, $format) : '';
public static function userdate($value, stdClass $row, ?string $format = null): string {
return $value ? userdate((int) $value, $format) : '';
}
/**
@@ -47,8 +50,8 @@ class format {
* @param bool $value
* @return string
*/
public static function boolean_as_text(bool $value): string {
return $value ? get_string('yes') : get_string('no');
public static function boolean_as_text($value): string {
return (bool) $value ? get_string('yes') : get_string('no');
}
/**
@@ -57,7 +60,7 @@ class format {
* @param float $value
* @return string
*/
public static function percent(float $value): string {
return sprintf('%.1f', $value) . '%';
public static function percent($value): string {
return sprintf('%.1f%%', (float) $value);
}
}
+91 -21
View File
@@ -76,12 +76,18 @@ final class column {
/** @var array $params */
private $params = [];
/** @var string $groupbysql */
private $groupbysql;
/** @var array[] $callbacks Array of [callable, additionalarguments] */
private $callbacks = [];
/** @var base|null $aggregation Aggregation type to apply to column */
private $aggregation = null;
/** @var array $disabledaggregation Aggregation types explicitly disabled */
private $disabledaggregation = [];
/** @var bool $issortable Used to indicate if a column is sortable */
private $issortable = false;
@@ -369,10 +375,15 @@ final class column {
public function get_fields(): array {
$fieldsalias = $this->get_fields_sql_alias();
// We aggregate the first field only.
if (!empty($this->aggregation)) {
$fieldsaliassql = array_column($fieldsalias, 'sql');
$field = reset($fieldsalias);
$fields = [$this->aggregation::get_field_sql($field['sql'], $this->get_type()) . " AS {$field['alias']}"];
// If aggregating the column, generate SQL from column fields and use it to generate aggregation SQL.
$columnfieldsql = $this->aggregation::get_column_field_sql($fieldsaliassql);
$aggregationfieldsql = $this->aggregation::get_field_sql($columnfieldsql, $this->get_type());
$fields = ["{$aggregationfieldsql} AS {$field['alias']}"];
} else {
$fields = array_map(static function(array $field): string {
return "{$field['sql']} AS {$field['alias']}";
@@ -382,23 +393,6 @@ final class column {
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
*
@@ -429,6 +423,39 @@ final class column {
return reset($fields)['alias'];
}
/**
* Define suitable SQL fragment for grouping by the columns fields. This will be returned from {@see get_groupby_sql} if set
*
* @param string $groupbysql
* @return self
*/
public function set_groupby_sql(string $groupbysql): self {
$this->groupbysql = $groupbysql;
return $this;
}
/**
* Return suitable SQL fragment for grouping by the column fields (during aggregation)
*
* @return array
*/
public function get_groupby_sql(): array {
global $DB;
// Return defined value if it's already been set during column definition.
if (!empty($this->groupbysql)) {
return [$this->groupbysql];
}
$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);
}
/**
* Adds column callback (in the case there are multiple, they will be applied one after another)
*
@@ -488,6 +515,41 @@ final class column {
return $this->aggregation;
}
/**
* Set disabled aggregation methods for the column. Typically only those methods suitable for the current column type are
* available: {@see aggregation::get_column_aggregations}, however in some cases we may want to disable specific methods
*
* @param array $disabledaggregation Array of types, e.g. ['min', 'sum']
* @return self
*/
public function set_disabled_aggregation(array $disabledaggregation): self {
$this->disabledaggregation = $disabledaggregation;
return $this;
}
/**
* Disable all aggregation methods for the column, for instance when current database can't aggregate fields that contain
* sub-queries
*
* @return self
*/
public function set_disabled_aggregation_all(): self {
$aggregationnames = array_map(static function(string $aggregation): string {
return $aggregation::get_class_name();
}, aggregation::get_aggregations());
return $this->set_disabled_aggregation($aggregationnames);
}
/**
* Return those aggregations methods explicitly disabled for the column
*
* @return array
*/
public function get_disabled_aggregation(): array {
return $this->disabledaggregation;
}
/**
* Sets the column as sortable
*
@@ -505,6 +567,12 @@ final class column {
* @return bool
*/
public function get_is_sortable(): bool {
// Defer sortable status to aggregation type if column is being aggregated.
if (!empty($this->aggregation)) {
return $this->aggregation::sortable($this->issortable);
}
return $this->issortable;
}
@@ -517,8 +585,9 @@ final class column {
private function get_values(array $row): array {
$values = [];
// During aggregation we only get a single alias back, subsequent aliases won't exist.
foreach ($this->get_fields_sql_alias() as $alias => $field) {
$values[$alias] = $row[$field['alias']];
$values[$alias] = $row[$field['alias']] ?? null;
}
return $values;
@@ -565,7 +634,8 @@ final class column {
$value = $this->aggregation::format_value($value, $values, $this->callbacks);
} else {
foreach ($this->callbacks as $callback) {
$value = ($callback[0])($value, (object) $values, $callback[1]);
[$callable, $arguments] = $callback;
$value = ($callable)($value, (object) $values, $arguments);
}
}
@@ -53,10 +53,14 @@ class column_aggregation_editable extends inplace_editable {
$currentvalue = (string) $column->get('aggregation');
$editlabel = get_string('aggregatecolumn', 'core_reportbuilder', $columninstance->get_title());
parent::__construct('core_reportbuilder', 'columnaggregation', $column->get('id'), $editable, null, $currentvalue,
get_string('aggregatecolumn', 'core_reportbuilder', $columninstance->get_title()));
$editlabel, $editlabel);
// List of available aggregation methods for the column type, minus any specifically disabled.
$options = aggregation::get_column_aggregations($columninstance->get_type(),
$columninstance->get_disabled_aggregation());
$options = aggregation::get_column_aggregations($columninstance->get_type());
$this->set_type_select(['' => get_string('aggregationnone', 'core_reportbuilder')] + $options);
}
@@ -155,7 +155,7 @@ class custom_report_table extends base_report_table {
foreach ($instances as $instance) {
$column = $columns[$instance->get('id')] ?? null;
if ($column !== null && $column->get_is_available()) {
if ($column !== null && $column->get_is_sortable()) {
$sortcolumns[$column->get_column_alias()] = $instance->get('sortdirection');
}
}
@@ -19,7 +19,9 @@ declare(strict_types=1);
// NOTE: no MOODLE_INTERNAL test here, this file may be required by behat before including /config.php.
require_once(__DIR__ . '/../../../lib/behat/behat_base.php');
use core_reportbuilder\local\aggregation\groupconcatdistinct;
use core_reportbuilder\local\models\report;
use core_reportbuilder\local\report\column;
/**
* Behat step definitions for Report builder
@@ -71,4 +73,30 @@ class behat_reportbuilder extends behat_base {
]),
];
}
/**
* Set aggregation for given column in report editor (proxied so we can skip if aggregation type not available)
*
* @When I set the :column column aggregation to :aggregation
*
* @param string $column
* @param string $aggregation
*
* @throws \Moodle\BehatExtension\Exception\SkippedException
*/
public function i_set_the_column_aggregation_to(string $column, string $aggregation): void {
// Skip if aggregation type unavailable.
$aggregationgroupconcatdistinct = (string) groupconcatdistinct::get_name();
if ($aggregation === $aggregationgroupconcatdistinct && !groupconcatdistinct::compatible(column::TYPE_TEXT)) {
throw new \Moodle\BehatExtension\Exception\SkippedException("{$aggregationgroupconcatdistinct} not available");
}
$editlabel = get_string('aggregatecolumn', 'core_reportbuilder', $column);
// Work around for MDL-72696, which treats all inplace_editable elements as text boxes and causes unpredictable
// behaviour in Chrome when it types characters into an inplace_editable of type select (it selects the wrong option).
$this->execute('behat_general::i_click_on', [$this->escape($editlabel), 'link']);
$this->execute('behat_forms::i_set_the_field_to', [$this->escape($editlabel), $this->escape($aggregation)]);
}
}
@@ -0,0 +1,96 @@
@core_reportbuilder @javascript
Feature: Manage custom report columns aggregation
In order to manage the aggregation for columns of custom reports
As an admin
I need to select an aggregation for columns
Background:
Given the following "users" exist:
| username | firstname | lastname | email | confirmed | lastaccess |
| user01 | Bill | Richie | user01@example.com | 1 | ##2 days ago## |
| user02 | Ben | Richie | user02@example.com | 1 | ##3 days ago## |
| user03 | Bill | Richie | user03@example.com | 0 | ##3 days ago## |
Scenario Outline: Aggregate a text column
Given the following "core_reportbuilder > Reports" exist:
| name | source | default |
| My report | core_user\reportbuilder\datasource\users | 0 |
And the following "core_reportbuilder > Columns" exist:
| report | uniqueidentifier |
| My report | user:lastname |
| My report | user:firstname |
And I am on the "My report" "reportbuilder > Editor" page logged in as "admin"
And I change window size to "large"
When I set the "First name" column aggregation to "<aggregation>"
Then I should see "Aggregated column 'First name'"
And I should see "<output>" in the "Richie" "table_row"
Examples:
| aggregation | output |
| Comma separated distinct values | Ben, Bill |
| Comma separated values | Ben, Bill, Bill |
| Count | 3 |
| Count distinct | 2 |
Scenario Outline: Aggregate a text column containing multiple fields
Given the following "core_reportbuilder > Reports" exist:
| name | source | default |
| My report | core_user\reportbuilder\datasource\users | 0 |
And the following "core_reportbuilder > Columns" exist:
| report | uniqueidentifier |
| My report | user:lastname |
| My report | user:fullname |
And I am on the "My report" "reportbuilder > Editor" page logged in as "admin"
And I change window size to "large"
When I set the "Full name" column aggregation to "<aggregation>"
Then I should see "Aggregated column 'Full name'"
And I should see "<output>" in the "Richie" "table_row"
Examples:
| aggregation | output |
| Comma separated distinct values | Ben Richie, Bill Richie |
| Comma separated values | Ben Richie, Bill Richie, Bill Richie |
| Count | 3 |
| Count distinct | 2 |
Scenario Outline: Aggregate a time column
Given the following "core_reportbuilder > Reports" exist:
| name | source | default |
| My report | core_user\reportbuilder\datasource\users | 0 |
And the following "core_reportbuilder > Columns" exist:
| report | uniqueidentifier |
| My report | user:lastname |
| My report | user:lastaccess |
And I am on the "My report" "reportbuilder > Editor" page logged in as "admin"
And I change window size to "large"
When I set the "Last access" column aggregation to "<aggregation>"
Then I should see "Aggregated column 'Last access'"
And I should see "<output>" in the "Richie" "table_row"
Examples:
| aggregation | output |
| Count | 3 |
| Count distinct | 2 |
| Maximum | ##2 days ago##%A, %d %B %Y## |
| Minimum | ##3 days ago##%A, %d %B %Y## |
Scenario Outline: Aggregate a boolean column
Given the following "core_reportbuilder > Reports" exist:
| name | source | default |
| My report | core_user\reportbuilder\datasource\users | 0 |
And the following "core_reportbuilder > Columns" exist:
| report | uniqueidentifier |
| My report | user:lastname |
| My report | user:confirmed |
And I am on the "My report" "reportbuilder > Editor" page logged in as "admin"
And I change window size to "large"
When I set the "Confirmed" column aggregation to "<aggregation>"
Then I should see "Aggregated column 'Confirmed'"
And I should see "<output>" in the "Richie" "table_row"
Examples:
| aggregation | output |
| Comma separated distinct values | No, Yes |
| Comma separated values | No, Yes, Yes |
| Count | 3 |
| Count distinct | 2 |
| Maximum | Yes |
| Minimum | No |
| Percentage | 66.7% |
| Sum | 2 |
@@ -45,8 +45,8 @@ class groupconcat_test extends core_reportbuilder_testcase {
$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' => 'Apple']);
$this->getDataGenerator()->create_user(['firstname' => 'Bob', 'lastname' => 'Banana']);
/** @var core_reportbuilder_generator $generator */
@@ -63,18 +63,56 @@ class groupconcat_test extends core_reportbuilder_testcase {
->set('aggregation', groupconcat::get_class_name())
->update();
[$firstrow, $secondrow] = $this->get_custom_report_content($report->get('id'));
// Assert lastname column was aggregated, and sorted predictably.
$content = $this->get_custom_report_content($report->get('id'));
$this->assertEquals([
'c0_firstname' => 'Admin',
'c1_lastname' => 'User',
], $firstrow);
[
'c0_firstname' => 'Admin',
'c1_lastname' => 'User',
],
[
'c0_firstname' => 'Bob',
'c1_lastname' => 'Apple, Banana, Banana',
],
], $content);
}
// Currently the aggregated field sort order is undefined.
$this->assertEquals('Bob', $secondrow['c0_firstname']);
$this->assertEqualsCanonicalizing([
'Apple',
'Banana',
'Banana',
], explode(', ', $secondrow['c1_lastname']));
/**
* Test aggregation when applied to column with callback
*/
public function test_column_aggregation_with_callback(): void {
$this->resetAfterTest();
// Test subjects.
$this->getDataGenerator()->create_user(['firstname' => 'Bob', 'confirmed' => 1]);
$this->getDataGenerator()->create_user(['firstname' => 'Bob', 'confirmed' => 0]);
$this->getDataGenerator()->create_user(['firstname' => 'Bob', 'confirmed' => 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:confirmed'])
->set('aggregation', groupconcat::get_class_name())
->update();
// Assert confirmed column was aggregated, and sorted predictably with callback applied.
$content = $this->get_custom_report_content($report->get('id'));
$this->assertEquals([
[
'c0_firstname' => 'Admin',
'c1_confirmed' => 'Yes',
],
[
'c0_firstname' => 'Bob',
'c1_confirmed' => 'No, Yes, Yes',
],
], $content);
}
}
@@ -57,8 +57,8 @@ class groupconcatdistinct_test extends core_reportbuilder_testcase {
$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' => 'Apple']);
$this->getDataGenerator()->create_user(['firstname' => 'Bob', 'lastname' => 'Banana']);
/** @var core_reportbuilder_generator $generator */
@@ -75,17 +75,56 @@ class groupconcatdistinct_test extends core_reportbuilder_testcase {
->set('aggregation', groupconcatdistinct::get_class_name())
->update();
[$firstrow, $secondrow] = $this->get_custom_report_content($report->get('id'));
// Assert lastname column was aggregated, and sorted predictably.
$content = $this->get_custom_report_content($report->get('id'));
$this->assertEquals([
'c0_firstname' => 'Admin',
'c1_lastname' => 'User',
], $firstrow);
[
'c0_firstname' => 'Admin',
'c1_lastname' => 'User',
],
[
'c0_firstname' => 'Bob',
'c1_lastname' => 'Apple, Banana',
],
], $content);
}
// Currently the aggregated field sort order is undefined.
$this->assertEquals('Bob', $secondrow['c0_firstname']);
$this->assertEqualsCanonicalizing([
'Apple',
'Banana',
], explode(', ', $secondrow['c1_lastname']));
/**
* Test aggregation when applied to column with callback
*/
public function test_column_aggregation_with_callback(): void {
$this->resetAfterTest();
// Test subjects.
$this->getDataGenerator()->create_user(['firstname' => 'Bob', 'confirmed' => 1]);
$this->getDataGenerator()->create_user(['firstname' => 'Bob', 'confirmed' => 0]);
$this->getDataGenerator()->create_user(['firstname' => 'Bob', 'confirmed' => 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:confirmed'])
->set('aggregation', groupconcatdistinct::get_class_name())
->update();
// Assert confirmed column was aggregated, and sorted predictably with callback applied.
$content = $this->get_custom_report_content($report->get('id'));
$this->assertEquals([
[
'c0_firstname' => 'Admin',
'c1_confirmed' => 'Yes',
],
[
'c0_firstname' => 'Bob',
'c1_confirmed' => 'No, Yes',
],
], $content);
}
}
@@ -252,23 +252,38 @@ class user_testcase extends advanced_testcase {
}
/**
* Tests the helper method for selecting all of a users' name fields
* Data provider for {@see test_get_name_fields_select}
*
* @return array
*/
public function test_get_name_fields_select(): void {
public function get_name_fields_select_provider(): array {
return [
['firstname lastname', ['firstname', 'lastname']],
['firstname middlename lastname', ['firstname', 'middlename', 'lastname']],
['alternatename lastname firstname', ['alternatename', 'lastname', 'firstname']],
];
}
/**
* Tests the helper method for selecting all of a users' name fields
*
* @param string $fullnamedisplay
* @param string[] $expecteduserfields
*
* @dataProvider get_name_fields_select_provider
*/
public function test_get_name_fields_select(string $fullnamedisplay, array $expecteduserfields): void {
global $DB;
$this->resetAfterTest(true);
set_config('alternativefullnameformat', $fullnamedisplay);
$fields = user::get_name_fields_select('u');
$user = $DB->get_record_sql("SELECT {$fields} FROM {user} u WHERE username = :username", ['username' => 'admin']);
// Ensure we received back all name fields.
$this->assertEqualsCanonicalizing([
'firstname',
'lastname',
'firstnamephonetic',
'lastnamephonetic',
'middlename',
'alternatename',
], array_keys((array) $user));
$this->assertEquals($expecteduserfields, array_keys((array) $user));
}
/**
@@ -264,6 +264,39 @@ class column_test extends advanced_testcase {
$column->add_field($column->get_column_alias());
}
/**
* Test setting column group by SQL
*/
public function test_set_groupby_sql(): void {
$column = $this->create_column('test')
->set_index(1)
->add_field('COALESCE(t.foo, t.bar)', 'lionel')
->set_groupby_sql('t.id');
$this->assertEquals(['t.id'], $column->get_groupby_sql());
}
/**
* Test getting default column group by SQL
*/
public function test_get_groupby_sql(): void {
global $DB;
$column = $this->create_column('test')
->set_index(1)
->add_fields('t.foo, t.bar');
// The behaviour of this method differs due to DB limitations.
$usealias = in_array($DB->get_dbfamily(), ['mysql', 'postgres']);
if ($usealias) {
$expected = ['c1_foo', 'c1_bar'];
} else {
$expected = ['t.foo', 't.bar'];
}
$this->assertEquals($expected, $column->get_groupby_sql());
}
/**
* Data provider for {@see test_format_value}
*