Files
moodle/reportbuilder/classes/local/report/column.php
T
Paul Holden 907ff8555f MDL-70794 reportbuilder: implement report column and action classes.
The column class encapsulates all data related to a report column. It
allows developers to define the type of data contained within it, in
addition to how that data is retrieved and formatted for users.

Report actions are used to define action elements that are added to
each report row. They define an icon and link, and can be used to
direct users to related pages according to the current row data.
2021-07-28 16:40:50 +01:00

545 lines
16 KiB
PHP

<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
declare(strict_types=1);
namespace core_reportbuilder\local\report;
use coding_exception;
use lang_string;
use core_reportbuilder\local\helpers\database;
/**
* Class to represent a report column
*
* @package core_reportbuilder
* @copyright 2020 Paul Holden <paulh@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
final class column {
/** @var int Column type is integer */
public const TYPE_INTEGER = 1;
/** @var int Column type is text */
public const TYPE_TEXT = 2;
/** @var int Column type is timestamp */
public const TYPE_TIMESTAMP = 3;
/** @var int Column type is boolean */
public const TYPE_BOOLEAN = 4;
/** @var int Column type is float */
public const TYPE_FLOAT = 5;
/** @var int Column type is long text */
public const TYPE_LONGTEXT = 6;
/** @var int $index Column index within a report */
private $index;
/** @var string $columnname Internal reference to name of column */
private $columnname;
/** @var lang_string $columntitle Used as a title for the column in reports */
private $columntitle;
/** @var string $entityname Name of the entity this column belongs to */
private $entityname;
/** @var int $type Column data type (one of the TYPE_* class constants) */
private $type = null;
/** @var string[] $joins List of SQL joins for this column */
private $joins = [];
/** @var array $fields */
private $fields = [];
/** @var array $params */
private $params = [];
/** @var array[] $callbacks Array of [callable, additionalarguments] */
private $callbacks = [];
/** @var bool $issortable Used to indicate if a column is sortable */
private $issortable = false;
/** @var array $attributes */
private $attributes = [];
/** @var bool $available Used to know if column is available to the current user or not */
protected $available = true;
/**
* Column constructor
*
* For better readability use chainable methods, for example:
*
* $report->add_column(
* (new column('name', new lang_string('name'), 'user'))
* ->add_join('left join {table} t on t.id = p.tableid')
* ->add_field('t.name')
* ->add_callback([format::class, 'format_string']));
*
* @param string $name Internal name of the column
* @param lang_string|null $title Title of the column used in reports (null for blank)
* @param string $entityname Name of the entity this column belongs to. Typically when creating columns within entities
* this value should be the result of calling {@see get_entity_name}, however if creating columns inside reports directly
* it should be the name of the entity as passed to {@see \core_reportbuilder\local\report\base::annotate_entity}
*/
public function __construct(string $name, ?lang_string $title, string $entityname) {
$this->columnname = $name;
$this->columntitle = $title;
$this->entityname = $entityname;
}
/**
* Set column name
*
* @param string $name
* @return self
*/
public function set_name(string $name): self {
$this->columnname = $name;
return $this;
}
/**
* Return column name
*
* @return mixed
*/
public function get_name(): string {
return $this->columnname;
}
/**
* Set column title
*
* @param lang_string|null $title
* @return self
*/
public function set_title(?lang_string $title): self {
$this->columntitle = $title;
return $this;
}
/**
* Return column title
*
* @return string
*/
public function get_title(): string {
return $this->columntitle ? (string) $this->columntitle : '';
}
/**
* Get column entity name
*
* @return string
*/
public function get_entity_name(): string {
return $this->entityname;
}
/**
* Return unique identifier for this column
*
* @return string
*/
public function get_unique_identifier(): string {
return $this->get_entity_name() . ':' . $this->get_name();
}
/**
* Set the column index within the current report
*
* @param int $index
* @return column
*/
public function set_index(int $index): self {
$this->index = $index;
return $this;
}
/**
* Set the column type
*
* @param int $type
* @return column
* @throws coding_exception
*/
public function set_type(int $type): self {
$allowedtypes = [
self::TYPE_INTEGER,
self::TYPE_TEXT,
self::TYPE_TIMESTAMP,
self::TYPE_BOOLEAN,
self::TYPE_FLOAT,
self::TYPE_LONGTEXT,
];
if (!in_array($type, $allowedtypes)) {
throw new coding_exception('Invalid column type', $type);
}
$this->type = $type;
return $this;
}
/**
* Return column type
*
* @return int|null
*/
public function get_type(): ?int {
return $this->type;
}
/**
* Add join clause required for this column to join to existing tables/entities
*
* This is necessary in the case where {@see add_field} is selecting data from a table that isn't otherwise queried
*
* @param string $join
* @return self
*/
public function add_join(string $join): self {
$this->joins[trim($join)] = trim($join);
return $this;
}
/**
* Add multiple join clauses required for this column, passing each to {@see add_join}
*
* Typically when defining columns in entities, you should pass {@see \core_reportbuilder\local\report\base::get_joins} to
* this method, so that all entity joins are included in the report when your column is added to it
*
* @param string[] $joins
* @return self
*/
public function add_joins(array $joins): self {
foreach ($joins as $join) {
$this->add_join($join);
}
return $this;
}
/**
* Return column joins
*
* @return string[]
*/
public function get_joins(): array {
return array_values($this->joins);
}
/**
* Adds a field to be queried from the database that is necessary for this column
*
* Multiple fields can be added per column, this method may be called several times. Field aliases must be unique inside
* any given column, but there will be no conflicts if the same aliases are used in other columns in the same report
*
* @param string $sql SQL query, this may be a simple "tablealias.fieldname" or a complex sub-query that returns only one field
* @param string $alias
* @param array $params
* @return self
* @throws coding_exception
*/
public function add_field(string $sql, string $alias = '', array $params = []): self {
database::validate_params($params);
// SQL ends with a space and a word - this looks like an alias was passed as part of the field.
if (preg_match('/ \w+$/', $sql) && empty($alias)) {
throw new coding_exception('Column alias must be passed as a separate argument', $sql);
}
// If no alias was specified, auto-detect it based on common patterns ("table.column" or just "column").
if (empty($alias) && preg_match('/^(\w+\.)?(?<fieldname>\w+)$/', $sql, $matches)) {
$alias = $matches['fieldname'];
}
if (empty($alias)) {
throw new coding_exception('Complex columns must have an alias', $sql);
}
$this->fields[$alias] = $sql;
$this->params += $params;
return $this;
}
/**
* Add a list of comma-separated fields
*
* @param string $sql
* @param array $params
* @return self
*/
public function add_fields(string $sql, array $params = []): self {
database::validate_params($params);
// Split SQL into separate fields (separated by comma).
$fields = preg_split('/\s*,\s*/', $sql);
foreach ($fields as $field) {
// Split each field into expression, <field> <as> <alias> where "as" and "alias" are optional.
$fieldparts = preg_split('/\s+/', $field);
if (count($fieldparts) == 2 || (count($fieldparts) == 3 && strtolower($fieldparts[1]) === 'as')) {
$sql = reset($fieldparts);
$alias = array_pop($fieldparts);
$this->add_field($sql, $alias);
} else {
$this->add_field($field);
}
}
$this->params += $params;
return $this;
}
/**
* Given a param name, add a unique prefix to ensure that the same column with params can be added multiple times to a report
*
* @param string $name
* @return string
*/
private function unique_param_name(string $name): string {
return "p{$this->index}_{$name}";
}
/**
* Helper method to take all fields added to the column, and return appropriate SQL and alias
*
* @return array[]
*/
private function get_fields_sql_alias(): array {
$fields = [];
foreach ($this->fields as $alias => $sql) {
// Ensure params within SQL are prefixed with column index.
foreach ($this->params as $name => $value) {
$sql = preg_replace_callback('/:(?<param>' . preg_quote($name, '\b/') . ')/', function(array $matches): string {
return ':' . $this->unique_param_name($matches['param']);
}, $sql);
}
$fields[$alias] = [
'sql' => $sql,
'alias' => substr("c{$this->index}_{$alias}", 0, 30),
];
}
return $fields;
}
/**
* Return array of SQL expressions for each field of this column
*
* @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());
return array_values($fields);
}
/**
* Return column parameters, prefixed by the current index to allow the column to be added multiple times to a report
*
* @return array
*/
public function get_params(): array {
$params = [];
foreach ($this->params as $name => $value) {
$paramname = $this->unique_param_name($name);
$params[$paramname] = $value;
}
return $params;
}
/**
* Return an alias for this column (the generated alias of it's first field)
*
* @return string
* @throws coding_exception
*/
public function get_column_alias(): string {
if (!$fields = $this->get_fields_sql_alias()) {
throw new coding_exception('Column ' . $this->get_unique_identifier() . ' contains no fields');
}
return reset($fields)['alias'];
}
/**
* Adds column callback (in the case there are multiple, they will be applied one after another)
*
* 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):
*
* function($value, stdClass $row[, $additionalarguments]): string
*
* @param callable $callable function that takes arguments ($value, \stdClass $row, $additionalarguments)
* @param mixed $additionalarguments
* @return self
*/
public function add_callback(callable $callable, $additionalarguments = null): self {
$this->callbacks[] = [$callable, $additionalarguments];
return $this;
}
/**
* Sets column callback. This will overwrite any previously added callbacks {@see add_callback}
*
* @param callable $callable
* @param mixed $additionalarguments
* @return self
*/
public function set_callback(callable $callable, $additionalarguments = null): self {
$this->callbacks = [];
return $this->add_callback($callable, $additionalarguments);
}
/**
* Sets the column as sortable
*
* @param bool $issortable
* @return self
*/
public function set_is_sortable(bool $issortable): self {
$this->issortable = $issortable;
return $this;
}
/**
* Return sortable status of column
*
* @return bool
*/
public function get_is_sortable(): bool {
return $this->issortable;
}
/**
* Extract all values from given row for this column
*
* @param array $row
* @return array
*/
private function get_values(array $row): array {
$values = [];
foreach ($this->get_fields_sql_alias() as $alias => $field) {
$values[$alias] = $row[$field['alias']];
}
return $values;
}
/**
* Return the default column value, that being the value of it's first field
*
* @param array $values
* @return mixed
*/
private function get_default_value(array $values) {
$value = reset($values);
// Ensure default value is cast to it's strict type.
switch ($this->get_type()) {
case self::TYPE_INTEGER:
case self::TYPE_TIMESTAMP:
$value = (int) $value;
break;
case self::TYPE_FLOAT:
$value = (float) $value;
break;
case self::TYPE_BOOLEAN:
$value = (bool) $value;
break;
}
return $value;
}
/**
* Return column value based on complete table row
*
* @param array $row
* @return mixed
*/
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]);
}
return $value;
}
/**
* Add column attributes (data-, class, etc.) that will be included in HTML when column is displayed
*
* @param array $attributes
* @return self
*/
public function add_attributes(array $attributes): self {
$this->attributes = $attributes + $this->attributes;
return $this;
}
/**
* Returns the column HTML attributes
*
* @return array
*/
public function get_attributes(): array {
return $this->attributes;
}
/**
* Return available state of the column for the current user. For instance the column may be added to a report with the
* expectation that only some users are able to see it
*
* @return bool
*/
public function get_is_available(): bool {
return $this->available;
}
/**
* Conditionally set whether the column is available.
*
* @param bool $available
* @return self
*/
public function set_is_available(bool $available): self {
$this->available = $available;
return $this;
}
}