See Release Notes
Long Term Support Release
Differences Between: [Versions 400 and 401] [Versions 401 and 402] [Versions 401 and 403]
1 <?php 2 // This file is part of Moodle - http://moodle.org/ 3 // 4 // Moodle is free software: you can redistribute it and/or modify 5 // it under the terms of the GNU General Public License as published by 6 // the Free Software Foundation, either version 3 of the License, or 7 // (at your option) any later version. 8 // 9 // Moodle is distributed in the hope that it will be useful, 10 // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 // GNU General Public License for more details. 13 // 14 // You should have received a copy of the GNU General Public License 15 // along with Moodle. If not, see <http://www.gnu.org/licenses/>. 16 17 declare(strict_types=1); 18 19 namespace core_reportbuilder\local\report; 20 21 use coding_exception; 22 use lang_string; 23 use core_reportbuilder\local\helpers\aggregation; 24 use core_reportbuilder\local\helpers\database; 25 use core_reportbuilder\local\aggregation\base; 26 use core_reportbuilder\local\models\column as column_model; 27 28 /** 29 * Class to represent a report column 30 * 31 * @package core_reportbuilder 32 * @copyright 2020 Paul Holden <paulh@moodle.com> 33 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 34 */ 35 final class column { 36 37 /** @var int Column type is integer */ 38 public const TYPE_INTEGER = 1; 39 40 /** @var int Column type is text */ 41 public const TYPE_TEXT = 2; 42 43 /** @var int Column type is timestamp */ 44 public const TYPE_TIMESTAMP = 3; 45 46 /** @var int Column type is boolean */ 47 public const TYPE_BOOLEAN = 4; 48 49 /** @var int Column type is float */ 50 public const TYPE_FLOAT = 5; 51 52 /** @var int Column type is long text */ 53 public const TYPE_LONGTEXT = 6; 54 55 /** @var int $index Column index within a report */ 56 private $index; 57 58 /** @var string $columnname Internal reference to name of column */ 59 private $columnname; 60 61 /** @var lang_string $columntitle Used as a title for the column in reports */ 62 private $columntitle; 63 64 /** @var bool $hascustomcolumntitle Used to store if the column has been given a custom title */ 65 private $hascustomcolumntitle = false; 66 67 /** @var string $entityname Name of the entity this column belongs to */ 68 private $entityname; 69 70 /** @var int $type Column data type (one of the TYPE_* class constants) */ 71 private $type = self::TYPE_TEXT; 72 73 /** @var string[] $joins List of SQL joins for this column */ 74 private $joins = []; 75 76 /** @var array $fields */ 77 private $fields = []; 78 79 /** @var array $params */ 80 private $params = []; 81 82 /** @var string $groupbysql */ 83 private $groupbysql; 84 85 /** @var array[] $callbacks Array of [callable, additionalarguments] */ 86 private $callbacks = []; 87 88 /** @var base|null $aggregation Aggregation type to apply to column */ 89 private $aggregation = null; 90 91 /** @var array $disabledaggregation Aggregation types explicitly disabled */ 92 private $disabledaggregation = []; 93 94 /** @var bool $issortable Used to indicate if a column is sortable */ 95 private $issortable = false; 96 97 /** @var array $sortfields Fields to sort the column by */ 98 private $sortfields = []; 99 100 /** @var array $attributes */ 101 private $attributes = []; 102 103 /** @var bool $available Used to know if column is available to the current user or not */ 104 protected $available = true; 105 106 /** @var column_model $persistent */ 107 protected $persistent; 108 109 /** 110 * Column constructor 111 * 112 * For better readability use chainable methods, for example: 113 * 114 * $report->add_column( 115 * (new column('name', new lang_string('name'), 'user')) 116 * ->add_join('left join {table} t on t.id = p.tableid') 117 * ->add_field('t.name') 118 * ->add_callback([format::class, 'format_string'])); 119 * 120 * @param string $name Internal name of the column 121 * @param lang_string|null $title Title of the column used in reports (null for blank) 122 * @param string $entityname Name of the entity this column belongs to. Typically when creating columns within entities 123 * this value should be the result of calling {@see get_entity_name}, however if creating columns inside reports directly 124 * it should be the name of the entity as passed to {@see \core_reportbuilder\local\report\base::annotate_entity} 125 */ 126 public function __construct(string $name, ?lang_string $title, string $entityname) { 127 $this->columnname = $name; 128 $this->columntitle = $title; 129 $this->entityname = $entityname; 130 } 131 132 /** 133 * Set column name 134 * 135 * @param string $name 136 * @return self 137 */ 138 public function set_name(string $name): self { 139 $this->columnname = $name; 140 return $this; 141 } 142 143 /** 144 * Return column name 145 * 146 * @return mixed 147 */ 148 public function get_name(): string { 149 return $this->columnname; 150 } 151 152 /** 153 * Set column title 154 * 155 * @param lang_string|null $title 156 * @return self 157 */ 158 public function set_title(?lang_string $title): self { 159 $this->columntitle = $title; 160 $this->hascustomcolumntitle = true; 161 return $this; 162 } 163 164 /** 165 * Return column title 166 * 167 * @return string 168 */ 169 public function get_title(): string { 170 return $this->columntitle ? (string) $this->columntitle : ''; 171 } 172 173 /** 174 * Check whether this column has been given a custom title 175 * 176 * @return bool 177 */ 178 public function has_custom_title(): bool { 179 return $this->hascustomcolumntitle; 180 } 181 182 /** 183 * Get column entity name 184 * 185 * @return string 186 */ 187 public function get_entity_name(): string { 188 return $this->entityname; 189 } 190 191 192 /** 193 * Return unique identifier for this column 194 * 195 * @return string 196 */ 197 public function get_unique_identifier(): string { 198 return $this->get_entity_name() . ':' . $this->get_name(); 199 } 200 201 /** 202 * Set the column index within the current report 203 * 204 * @param int $index 205 * @return self 206 */ 207 public function set_index(int $index): self { 208 $this->index = $index; 209 return $this; 210 } 211 212 /** 213 * Set the column type, if not called then the type will be assumed to be {@see TYPE_TEXT} 214 * 215 * The type of a column is used to cast the first column field passed to any callbacks {@see add_callback} as well as the 216 * aggregation options available for the column 217 * 218 * @param int $type 219 * @return self 220 * @throws coding_exception 221 */ 222 public function set_type(int $type): self { 223 $allowedtypes = [ 224 self::TYPE_INTEGER, 225 self::TYPE_TEXT, 226 self::TYPE_TIMESTAMP, 227 self::TYPE_BOOLEAN, 228 self::TYPE_FLOAT, 229 self::TYPE_LONGTEXT, 230 ]; 231 if (!in_array($type, $allowedtypes)) { 232 throw new coding_exception('Invalid column type', $type); 233 } 234 235 $this->type = $type; 236 return $this; 237 } 238 239 /** 240 * Return column type, that being one of the TYPE_* class constants 241 * 242 * @return int 243 */ 244 public function get_type(): int { 245 return $this->type; 246 } 247 248 /** 249 * Add join clause required for this column to join to existing tables/entities 250 * 251 * This is necessary in the case where {@see add_field} is selecting data from a table that isn't otherwise queried 252 * 253 * @param string $join 254 * @return self 255 */ 256 public function add_join(string $join): self { 257 $this->joins[trim($join)] = trim($join); 258 return $this; 259 } 260 261 /** 262 * Add multiple join clauses required for this column, passing each to {@see add_join} 263 * 264 * Typically when defining columns in entities, you should pass {@see \core_reportbuilder\local\report\base::get_joins} to 265 * this method, so that all entity joins are included in the report when your column is added to it 266 * 267 * @param string[] $joins 268 * @return self 269 */ 270 public function add_joins(array $joins): self { 271 foreach ($joins as $join) { 272 $this->add_join($join); 273 } 274 return $this; 275 } 276 277 /** 278 * Return column joins 279 * 280 * @return string[] 281 */ 282 public function get_joins(): array { 283 return array_values($this->joins); 284 } 285 286 /** 287 * Adds a field to be queried from the database that is necessary for this column 288 * 289 * Multiple fields can be added per column, this method may be called several times. Field aliases must be unique inside 290 * any given column, but there will be no conflicts if the same aliases are used in other columns in the same report 291 * 292 * @param string $sql SQL query, this may be a simple "tablealias.fieldname" or a complex sub-query that returns only one field 293 * @param string $alias 294 * @param array $params 295 * @return self 296 * @throws coding_exception 297 */ 298 public function add_field(string $sql, string $alias = '', array $params = []): self { 299 database::validate_params($params); 300 301 // SQL ends with a space and a word - this looks like an alias was passed as part of the field. 302 if (preg_match('/ \w+$/', $sql) && empty($alias)) { 303 throw new coding_exception('Column alias must be passed as a separate argument', $sql); 304 } 305 306 // If no alias was specified, auto-detect it based on common patterns ("table.column" or just "column"). 307 if (empty($alias) && preg_match('/^(\w+\.)?(?<fieldname>\w+)$/', $sql, $matches)) { 308 $alias = $matches['fieldname']; 309 } 310 311 if (empty($alias)) { 312 throw new coding_exception('Complex columns must have an alias', $sql); 313 } 314 315 $this->fields[$alias] = $sql; 316 $this->params += $params; 317 318 return $this; 319 } 320 321 /** 322 * Add a list of comma-separated fields 323 * 324 * @param string $sql 325 * @param array $params 326 * @return self 327 */ 328 public function add_fields(string $sql, array $params = []): self { 329 database::validate_params($params); 330 331 // Split SQL into separate fields (separated by comma). 332 $fields = preg_split('/\s*,\s*/', $sql); 333 foreach ($fields as $field) { 334 // Split each field into expression, <field> <as> <alias> where "as" and "alias" are optional. 335 $fieldparts = preg_split('/\s+/', $field); 336 337 if (count($fieldparts) == 2 || (count($fieldparts) == 3 && strtolower($fieldparts[1]) === 'as')) { 338 $sql = reset($fieldparts); 339 $alias = array_pop($fieldparts); 340 $this->add_field($sql, $alias); 341 } else { 342 $this->add_field($field); 343 } 344 } 345 346 $this->params += $params; 347 348 return $this; 349 } 350 351 /** 352 * Given a param name, add a unique prefix to ensure that the same column with params can be added multiple times to a report 353 * 354 * @param string $name 355 * @return string 356 */ 357 private function unique_param_name(string $name): string { 358 return "p{$this->index}_{$name}"; 359 } 360 361 /** 362 * Helper method to take all fields added to the column, and return appropriate SQL and alias 363 * 364 * @return array[] 365 */ 366 private function get_fields_sql_alias(): array { 367 $fields = []; 368 369 foreach ($this->fields as $alias => $sql) { 370 371 // Ensure parameter names within SQL are prefixed with column index. 372 $params = array_keys($this->params); 373 $sql = database::sql_replace_parameter_names($sql, $params, function(string $param): string { 374 return $this->unique_param_name($param); 375 }); 376 377 $fields[$alias] = [ 378 'sql' => $sql, 379 'alias' => substr("c{$this->index}_{$alias}", 0, 30), 380 ]; 381 } 382 383 return $fields; 384 } 385 386 /** 387 * Return array of SQL expressions for each field of this column 388 * 389 * @return array 390 */ 391 public function get_fields(): array { 392 $fieldsalias = $this->get_fields_sql_alias(); 393 394 if (!empty($this->aggregation)) { 395 $fieldsaliassql = array_column($fieldsalias, 'sql'); 396 $field = reset($fieldsalias); 397 398 // If aggregating the column, generate SQL from column fields and use it to generate aggregation SQL. 399 $columnfieldsql = $this->aggregation::get_column_field_sql($fieldsaliassql); 400 $aggregationfieldsql = $this->aggregation::get_field_sql($columnfieldsql, $this->get_type()); 401 402 $fields = ["{$aggregationfieldsql} AS {$field['alias']}"]; 403 } else { 404 $fields = array_map(static function(array $field): string { 405 return "{$field['sql']} AS {$field['alias']}"; 406 }, $fieldsalias); 407 } 408 409 return array_values($fields); 410 } 411 412 /** 413 * Return column parameters, prefixed by the current index to allow the column to be added multiple times to a report 414 * 415 * @return array 416 */ 417 public function get_params(): array { 418 $params = []; 419 420 foreach ($this->params as $name => $value) { 421 $paramname = $this->unique_param_name($name); 422 $params[$paramname] = $value; 423 } 424 425 return $params; 426 } 427 428 /** 429 * Return an alias for this column (the generated alias of it's first field) 430 * 431 * @return string 432 * @throws coding_exception 433 */ 434 public function get_column_alias(): string { 435 if (!$fields = $this->get_fields_sql_alias()) { 436 throw new coding_exception('Column ' . $this->get_unique_identifier() . ' contains no fields'); 437 } 438 439 return reset($fields)['alias']; 440 } 441 442 /** 443 * Define suitable SQL fragment for grouping by the columns fields. This will be returned from {@see get_groupby_sql} if set 444 * 445 * @param string $groupbysql 446 * @return self 447 */ 448 public function set_groupby_sql(string $groupbysql): self { 449 $this->groupbysql = $groupbysql; 450 return $this; 451 } 452 453 /** 454 * Return suitable SQL fragment for grouping by the column fields (during aggregation) 455 * 456 * @return array 457 */ 458 public function get_groupby_sql(): array { 459 global $DB; 460 461 // Return defined value if it's already been set during column definition. 462 if (!empty($this->groupbysql)) { 463 return [$this->groupbysql]; 464 } 465 466 $fieldsalias = $this->get_fields_sql_alias(); 467 468 // Note that we can reference field aliases in GROUP BY only in MySQL/Postgres. 469 $usealias = in_array($DB->get_dbfamily(), ['mysql', 'postgres']); 470 $columnname = $usealias ? 'alias' : 'sql'; 471 472 return array_column($fieldsalias, $columnname); 473 } 474 475 /** 476 * Adds column callback (in the case there are multiple, they will be applied one after another) 477 * 478 * The callback should implement the following signature (where $value is the first column field, $row is all column 479 * fields, and $additionalarguments are those passed on from this method): 480 * 481 * The type of the $value parameter passed to the callback is determined by calling {@see set_type}, this type is preserved 482 * if the column is part of a report source and is being aggregated. 483 * For entities that can to be left joined to a report, the first argument to their column callbacks must be nullable. 484 * 485 * function($value, stdClass $row[, $additionalarguments]): string 486 * 487 * @param callable $callable function that takes arguments ($value, \stdClass $row, $additionalarguments) 488 * @param mixed $additionalarguments 489 * @return self 490 */ 491 public function add_callback(callable $callable, $additionalarguments = null): self { 492 $this->callbacks[] = [$callable, $additionalarguments]; 493 return $this; 494 } 495 496 /** 497 * Sets column callback. This will overwrite any previously added callbacks {@see add_callback} 498 * 499 * @param callable $callable 500 * @param mixed $additionalarguments 501 * @return self 502 */ 503 public function set_callback(callable $callable, $additionalarguments = null): self { 504 $this->callbacks = []; 505 return $this->add_callback($callable, $additionalarguments); 506 } 507 508 /** 509 * Set column aggregation type 510 * 511 * @param string|null $aggregation Type of aggregation, e.g. 'sum', 'count', etc 512 * @return self 513 * @throws coding_exception For invalid aggregation type, or one that is incompatible with column type 514 */ 515 public function set_aggregation(?string $aggregation): self { 516 if (!empty($aggregation)) { 517 $aggregation = aggregation::get_full_classpath($aggregation); 518 if (!aggregation::valid($aggregation) || !$aggregation::compatible($this->get_type())) { 519 throw new coding_exception('Invalid column aggregation', $aggregation); 520 } 521 } 522 523 $this->aggregation = $aggregation; 524 return $this; 525 } 526 527 /** 528 * Get column aggregation type 529 * 530 * @return base|null 531 */ 532 public function get_aggregation(): ?string { 533 return $this->aggregation; 534 } 535 536 /** 537 * Set disabled aggregation methods for the column. Typically only those methods suitable for the current column type are 538 * available: {@see aggregation::get_column_aggregations}, however in some cases we may want to disable specific methods 539 * 540 * @param array $disabledaggregation Array of types, e.g. ['min', 'sum'] 541 * @return self 542 */ 543 public function set_disabled_aggregation(array $disabledaggregation): self { 544 $this->disabledaggregation = $disabledaggregation; 545 return $this; 546 } 547 548 /** 549 * Disable all aggregation methods for the column, for instance when current database can't aggregate fields that contain 550 * sub-queries 551 * 552 * @return self 553 */ 554 public function set_disabled_aggregation_all(): self { 555 $aggregationnames = array_map(static function(string $aggregation): string { 556 return $aggregation::get_class_name(); 557 }, aggregation::get_aggregations()); 558 559 return $this->set_disabled_aggregation($aggregationnames); 560 } 561 562 /** 563 * Return those aggregations methods explicitly disabled for the column 564 * 565 * @return array 566 */ 567 public function get_disabled_aggregation(): array { 568 return $this->disabledaggregation; 569 } 570 571 /** 572 * Sets the column as sortable 573 * 574 * @param bool $issortable 575 * @param array $sortfields Define the fields that should be used when the column is sorted, typically a subset of the fields 576 * selected for the column, via {@see add_field}. If omitted then the first selected field is used 577 * @return self 578 */ 579 public function set_is_sortable(bool $issortable, array $sortfields = []): self { 580 $this->issortable = $issortable; 581 $this->sortfields = $sortfields; 582 return $this; 583 } 584 585 /** 586 * Return sortable status of column 587 * 588 * @return bool 589 */ 590 public function get_is_sortable(): bool { 591 592 // Defer sortable status to aggregation type if column is being aggregated. 593 if (!empty($this->aggregation)) { 594 return $this->aggregation::sortable($this->issortable); 595 } 596 597 return $this->issortable; 598 } 599 600 /** 601 * Return fields to use for sorting of the column, where available the field aliases will be returned 602 * 603 * @return array 604 */ 605 public function get_sort_fields(): array { 606 $fieldsalias = $this->get_fields_sql_alias(); 607 608 return array_map(static function(string $sortfield) use ($fieldsalias): string { 609 610 // Check whether sortfield refers to a defined field alias. 611 if (array_key_exists($sortfield, $fieldsalias)) { 612 return $fieldsalias[$sortfield]['alias']; 613 } 614 615 // Check whether sortfield refers to field SQL. 616 foreach ($fieldsalias as $field) { 617 if (strcasecmp($sortfield, $field['sql']) === 0) { 618 $sortfield = $field['alias']; 619 break; 620 } 621 } 622 623 return $sortfield; 624 }, $this->sortfields); 625 } 626 627 /** 628 * Extract all values from given row for this column 629 * 630 * @param array $row 631 * @return array 632 */ 633 private function get_values(array $row): array { 634 $values = []; 635 636 // During aggregation we only get a single alias back, subsequent aliases won't exist. 637 foreach ($this->get_fields_sql_alias() as $alias => $field) { 638 $values[$alias] = $row[$field['alias']] ?? null; 639 } 640 641 return $values; 642 } 643 644 /** 645 * Return the default column value, that being the value of it's first field 646 * 647 * @param array $values 648 * @param int $columntype 649 * @return mixed 650 */ 651 public static function get_default_value(array $values, int $columntype) { 652 $value = reset($values); 653 if ($value === null) { 654 return $value; 655 } 656 657 // Ensure default value is cast to it's strict type. 658 switch ($columntype) { 659 case self::TYPE_INTEGER: 660 case self::TYPE_TIMESTAMP: 661 $value = (int) $value; 662 break; 663 case self::TYPE_FLOAT: 664 $value = (float) $value; 665 break; 666 case self::TYPE_BOOLEAN: 667 $value = (bool) $value; 668 break; 669 } 670 671 return $value; 672 } 673 674 /** 675 * Return column value based on complete table row 676 * 677 * @param array $row 678 * @return mixed 679 */ 680 public function format_value(array $row) { 681 $values = $this->get_values($row); 682 $value = self::get_default_value($values, $this->type); 683 684 // If column is being aggregated then defer formatting to them, otherwise loop through all column callbacks. 685 if (!empty($this->aggregation)) { 686 $value = $this->aggregation::format_value($value, $values, $this->callbacks, $this->type); 687 } else { 688 foreach ($this->callbacks as $callback) { 689 [$callable, $arguments] = $callback; 690 $value = ($callable)($value, (object) $values, $arguments); 691 } 692 } 693 694 return $value; 695 } 696 697 /** 698 * Add column attributes (data-, class, etc.) that will be included in HTML when column is displayed 699 * 700 * @param array $attributes 701 * @return self 702 */ 703 public function add_attributes(array $attributes): self { 704 $this->attributes = $attributes + $this->attributes; 705 return $this; 706 } 707 708 /** 709 * Returns the column HTML attributes 710 * 711 * @return array 712 */ 713 public function get_attributes(): array { 714 return $this->attributes; 715 } 716 717 /** 718 * Return available state of the column for the current user. For instance the column may be added to a report with the 719 * expectation that only some users are able to see it 720 * 721 * @return bool 722 */ 723 public function get_is_available(): bool { 724 return $this->available; 725 } 726 727 /** 728 * Conditionally set whether the column is available. 729 * 730 * @param bool $available 731 * @return self 732 */ 733 public function set_is_available(bool $available): self { 734 $this->available = $available; 735 return $this; 736 } 737 738 /** 739 * Set column persistent 740 * 741 * @param column_model $persistent 742 * @return self 743 */ 744 public function set_persistent(column_model $persistent): self { 745 $this->persistent = $persistent; 746 return $this; 747 } 748 749 /** 750 * Return column persistent 751 * 752 * @return mixed 753 */ 754 public function get_persistent(): column_model { 755 return $this->persistent; 756 } 757 }
title
Description
Body
title
Description
Body
title
Description
Body
title
Body