Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 4.2.x will end 22 April 2024 (12 months).
  • Bug fixes for security issues in 4.2.x will end 7 October 2024 (18 months).
  • PHP version: minimum PHP 8.0.0 Note: minimum PHP version has increased since Moodle 4.1. PHP 8.1.x is supported too.
   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  /**
  18   * Handle simple PHP/CSV/XML datasets to be use with ease by unit tests.
  19   *
  20   * This is a very minimal class, able to load data from PHP arrays and
  21   * CSV/XML files, optionally uploading them to database.
  22   *
  23   * This doesn't aim to be a complex or complete solution, but just a
  24   * utility class to replace old phpunit/dbunit uses, because that package
  25   * is not longer maintained. Note that, ideally, generators should provide
  26   * the needed utilities to proceed with this loading of information to
  27   * database and, if there is any future that should be it.
  28   *
  29   * @package    core
  30   * @category   test
  31   * @copyright  2020 onwards Eloy Lafuente (stronk7) {@link http://stronk7.com}
  32   * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  33   */
  34  
  35  declare(strict_types=1);
  36  
  37  /**
  38   * Lightweight dataset class for phpunit, supports XML, CSV and array datasets.
  39   *
  40   * This is a simple replacement class for the old old phpunit/dbunit, now
  41   * archived. It allows to load CSV, XML and array structures to database.
  42   */
  43  class phpunit_dataset {
  44  
  45      /** @var array tables being handled by the dataset */
  46      protected $tables = [];
  47      /** @var array columns belonging to every table (keys) handled by the dataset */
  48      protected $columns = [];
  49      /** @var array rows belonging to every table (keys) handled by the dataset */
  50      protected $rows = [];
  51  
  52      /**
  53       * Load information from multiple files (XML, CSV) to the dataset.
  54       *
  55       * This method accepts an array of full paths to CSV or XML files to be loaded
  56       * into the dataset. For CSV files, the name of the table which the file belongs
  57       * to needs to be specified. Example:
  58       *
  59       *   $fullpaths = [
  60       *       '/path/to/users.xml',
  61       *       'course' => '/path/to/courses.csv',
  62       *   ];
  63       *
  64       * @param array $fullpaths full paths to CSV or XML files to load.
  65       */
  66      public function from_files(array $fullpaths): void {
  67          foreach ($fullpaths as $table => $fullpath) {
  68              $table = is_int($table) ? null : $table; // Only a table when it's an associative array.
  69              $this->from_file($fullpath, $table);
  70          }
  71      }
  72  
  73      /**
  74       * Load information from one file (XML, CSV) to the dataset.
  75       *
  76       * @param string $fullpath full path to CSV or XML file to load.
  77       * @param string|null $table name of the table which the file belongs to (only for CSV files).
  78       */
  79      public function from_file(string $fullpath, ?string $table = null): void {
  80          if (!file_exists($fullpath)) {
  81              throw new coding_exception('from_file, file not found: ' . $fullpath);
  82          }
  83  
  84          if (!is_readable($fullpath)) {
  85              throw new coding_exception('from_file, file not readable: ' . $fullpath);
  86          }
  87  
  88          $extension = strtolower(pathinfo($fullpath, PATHINFO_EXTENSION));
  89          if (!in_array($extension, ['csv', 'xml'])) {
  90              throw new coding_exception('from_file, cannot handle files with extension: ' . $extension);
  91          }
  92  
  93          $this->from_string(file_get_contents($fullpath), $extension, $table);
  94      }
  95  
  96      /**
  97       * Load information from a string (XML, CSV) to the dataset.
  98       *
  99       * @param string $content contents (CSV or XML) to load.
 100       * @param string $type format of the content to be loaded (csv or xml).
 101       * @param string|null $table name of the table which the file belongs to (only for CSV files).
 102       */
 103      public function from_string(string $content, string $type, ?string $table = null): void {
 104          switch ($type) {
 105              case 'xml':
 106                  $this->load_xml($content);
 107                  break;
 108              case 'csv':
 109                  if (empty($table)) {
 110                      throw new coding_exception('from_string, contents of type "cvs" require a $table to be passed, none found');
 111                  }
 112                  $this->load_csv($content, $table);
 113                  break;
 114              default:
 115                  throw new coding_exception('from_string, cannot handle contents of type: ' . $type);
 116          }
 117      }
 118  
 119      /**
 120       * Load information from a PHP array to the dataset.
 121       *
 122       * The general structure of the PHP array must be
 123       *   [table name] => [array of rows, each one being an array of values or column => values.
 124       * The format of the array must be one of the following:
 125       * - non-associative array, with column names in the first row (pretty much like CSV files are):
 126       *     $structure = [
 127       *         'table 1' => [
 128       *             ['column name 1', 'column name 2'],
 129       *             ['row 1 column 1 value', 'row 1 column 2 value'*,
 130       *             ['row 2 column 1 value', 'row 2 column 2 value'*,
 131       *         ],
 132       *         'table 2' => ...
 133       *     ];
 134       * - associative array, with column names being keys in the array.
 135       *     $structure = [
 136       *         'table 1' => [
 137       *             ['column name 1' => 'row 1 column 1 value', 'column name 2' => 'row 1 column 2 value'],
 138       *             ['column name 1' => 'row 2 column 1 value', 'column name 2' => 'row 2 column 2 value'],
 139       *         ],
 140       *         'table 2' => ...
 141       *     ];
 142       * @param array $structure php array with a valid structure to be loaded to the dataset.
 143       */
 144      public function from_array(array $structure): void {
 145          foreach ($structure as $tablename => $rows) {
 146              if (in_array($tablename, $this->tables)) {
 147                  throw new coding_exception('from_array, table already added to dataset: ' . $tablename);
 148              }
 149  
 150              $this->tables[] = $tablename;
 151              $this->columns[$tablename] = [];
 152              $this->rows[$tablename] = [];
 153  
 154              $isassociative = false;
 155              $firstrow = reset($rows);
 156  
 157              if (array_key_exists(0, $firstrow)) {
 158                  // Columns are the first row (csv-like).
 159                  $this->columns[$tablename] = $firstrow;
 160                  array_shift($rows);
 161              } else {
 162                  // Columns are the keys on every record, first one must have all.
 163                  $this->columns[$tablename] = array_keys($firstrow);
 164                  $isassociative = true;
 165              }
 166  
 167              $countcols = count($this->columns[$tablename]);
 168              foreach ($rows as $row) {
 169                  $countvalues = count($row);
 170                  if ($countcols != $countvalues) {
 171                      throw new coding_exception('from_array, number of columns must match number of values, found: ' .
 172                          $countcols . ' vs ' . $countvalues);
 173                  }
 174                  if ($isassociative && $this->columns[$tablename] != array_keys($row)) {
 175                      throw new coding_exception('from_array, columns in all elements must match first one, found: ' .
 176                          implode(', ', array_keys($row)));
 177                  }
 178                  $this->rows[$tablename][] = array_combine($this->columns[$tablename], array_values($row));
 179              }
 180          }
 181      }
 182  
 183      /**
 184       * Send all the information to the dataset to the database.
 185       *
 186       * This method gets all the information loaded in the dataset, using the from_xxx() methods
 187       * and sends it to the database; table and column names must match.
 188       *
 189       * Note that, if the information to be sent to database contains sequence columns (usually 'id')
 190       * then those values will be preserved (performing an import and adjusting sequences later). Else
 191       * normal inserts will happen and sequence (auto-increment) columns will be fed automatically.
 192       *
 193       * @param string[] $filter Tables to be sent to database. If not specified, all tables are processed.
 194       */
 195      public function to_database(array $filter = []): void {
 196          global $DB;
 197  
 198          // Verify all filter elements are correct.
 199          foreach ($filter as $table) {
 200              if (!in_array($table, $this->tables)) {
 201                  throw new coding_exception('dataset_to_database, table is not in the dataset: ' . $table);
 202              }
 203          }
 204  
 205          $structure = phpunit_util::get_tablestructure();
 206  
 207          foreach ($this->tables as $table) {
 208              // Apply filter.
 209              if (!empty($filter) && !in_array($table, $filter)) {
 210                  continue;
 211              }
 212  
 213              $doimport = false;
 214  
 215              if (isset($structure[$table]['id']) and $structure[$table]['id']->auto_increment) {
 216                  $doimport = in_array('id', $this->columns[$table]);
 217              }
 218  
 219              foreach ($this->rows[$table] as $row) {
 220                  if ($doimport) {
 221                      $DB->import_record($table, $row);
 222                  } else {
 223                      $DB->insert_record($table, $row);
 224                  }
 225              }
 226  
 227              if ($doimport) {
 228                  $DB->get_manager()->reset_sequence(new xmldb_table($table));
 229              }
 230          }
 231      }
 232  
 233      /**
 234       * Returns the rows, for a given table, that the dataset holds.
 235       *
 236       * @param string[] $filter Tables to return rows. If not specified, all tables are processed.
 237       * @return array tables as keys with rows on each as sub array.
 238       */
 239      public function get_rows(array $filter = []): array {
 240          // Verify all filter elements are correct.
 241          foreach ($filter as $table) {
 242              if (!in_array($table, $this->tables)) {
 243                  throw new coding_exception('dataset_get_rows, table is not in the dataset: ' . $table);
 244              }
 245          }
 246  
 247          $result = [];
 248          foreach ($this->tables as $table) {
 249              // Apply filter.
 250              if (!empty($filter) && !in_array($table, $filter)) {
 251                  continue;
 252              }
 253              $result[$table] = $this->rows[$table];
 254          }
 255          return $result;
 256      }
 257  
 258      /**
 259       * Given a CSV content, process and load it as a table into the dataset.
 260       *
 261       * @param string $content CSV content to be loaded (only one table).
 262       * @param string $tablename Name of the table the content belongs to.
 263       */
 264      protected function load_csv(string $content, string $tablename): void {
 265          if (in_array($tablename, $this->tables)) {
 266              throw new coding_exception('csv_dataset_format, table already added to dataset: ' . $tablename);
 267          }
 268  
 269          $this->tables[] = $tablename;
 270          $this->columns[$tablename] = [];
 271          $this->rows[$tablename] = [];
 272  
 273          // Normalise newlines.
 274          $content = preg_replace('#\r\n?#', '\n', $content);
 275  
 276          // Function str_getcsv() is not good for new lines within the data, so lets use temp file and fgetcsv() instead.
 277          $tempfile = tempnam(make_temp_directory('phpunit'), 'csv');
 278          $fh = fopen($tempfile, 'w+b');
 279          fwrite($fh, $content);
 280  
 281          // And let's read it using fgetcsv().
 282          rewind($fh);
 283  
 284          // We just accept default, delimiter = comma, enclosure = double quote.
 285          while ( ($row = fgetcsv($fh) ) !== false ) {
 286              if (empty($this->columns[$tablename])) {
 287                  $this->columns[$tablename] = $row;
 288              } else {
 289                  $this->rows[$tablename][] = array_combine($this->columns[$tablename], $row);
 290              }
 291          }
 292          fclose($fh);
 293          unlink($tempfile);
 294      }
 295  
 296      /**
 297       * Given a XML content, process and load it as tables into the dataset.
 298       *
 299       * @param string $content XML content to be loaded (can be multi-table).
 300       */
 301      protected function load_xml(string $content): void {
 302          $xml = new SimpleXMLElement($content);
 303          // Main element must be dataset.
 304          if ($xml->getName() !== 'dataset') {
 305              throw new coding_exception('xml_dataset_format, main xml element must be "dataset", found: ' . $xml->getName());
 306          }
 307  
 308          foreach ($xml->children() as $table) {
 309              // Only table elements allowed.
 310              if ($table->getName() !== 'table') {
 311                  throw new coding_exception('xml_dataset_format, only "table" elements allowed, found: ' . $table->getName());
 312              }
 313              // Only allowed attribute of table is "name".
 314              if (!isset($table['name'])) {
 315                  throw new coding_exception('xml_dataset_format, "table" element only allows "name" attribute.');
 316              }
 317  
 318              $tablename = (string)$table['name'];
 319              if (in_array($tablename, $this->tables)) {
 320                  throw new coding_exception('xml_dataset_format, table already added to dataset: ' . $tablename);
 321              }
 322  
 323              $this->tables[] = $tablename;
 324              $this->columns[$tablename] = [];
 325              $this->rows[$tablename] = [];
 326  
 327              $countcols = 0;
 328              foreach ($table->children() as $colrow) {
 329                  // Only column and row allowed.
 330                  if ($colrow->getName() !== 'column' && $colrow->getName() !== 'row') {
 331                      throw new coding_exception('xml_dataset_format, only "column or "row" elements allowed, found: ' .
 332                          $colrow->getName());
 333                  }
 334                  // Column always before row.
 335                  if ($colrow->getName() == 'column' && !empty($this->rows[$tablename])) {
 336                      throw new coding_exception('xml_dataset_format, "column" elements always must be before "row" ones');
 337                  }
 338                  // Row always after column.
 339                  if ($colrow->getName() == 'row' && empty($this->columns[$tablename])) {
 340                      throw new coding_exception('xml_dataset_format, "row" elements always must be after "column" ones');
 341                  }
 342  
 343                  // Process column.
 344                  if ($colrow->getName() == 'column') {
 345                      $this->columns[$tablename][] = (string)$colrow;
 346                      $countcols++;
 347                  }
 348  
 349                  // Process row.
 350                  if ($colrow->getName() == 'row') {
 351                      $countvalues = 0;
 352                      $row = [];
 353                      foreach ($colrow->children() as $value) {
 354                          // Only value allowed under row.
 355                          if ($value->getName() !== 'value') {
 356                              throw new coding_exception('xml_dataset_format, only "value" elements allowed, found: ' .
 357                                  $value->getName());
 358                          }
 359                          $row[$this->columns[$tablename][$countvalues]] = (string)$value;
 360                          $countvalues++;
 361                      }
 362                      if ($countcols !== $countvalues) {
 363                          throw new coding_exception('xml_dataset_format, number of columns must match number of values, found: ' .
 364                              $countcols . ' vs ' . $countvalues);
 365                      }
 366                      $this->rows[$tablename][] = $row;
 367                  }
 368              }
 369          }
 370      }
 371  }