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 35declare(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 */ 43class 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} 372