1<?php 2 3namespace dokuwiki\plugin\struct\meta; 4 5/** 6 * Class AccessTable 7 * 8 * Base class for data accessors 9 * 10 * @package dokuwiki\plugin\struct\meta 11 */ 12abstract class AccessTable 13{ 14 public const DEFAULT_REV = 0; 15 public const DEFAULT_LATEST = 1; 16 17 /** @var Schema */ 18 protected $schema; 19 protected $pid; 20 protected $rid; 21 protected $labels = []; 22 protected $ts = 0; 23 /** @var \helper_plugin_sqlite */ 24 protected $sqlite; 25 26 // options on how to retrieve data 27 protected $opt_skipempty = false; 28 29 protected $optQueries = []; 30 31 /** 32 * @var string Name of single-value table 33 */ 34 protected $stable; 35 36 /** 37 * @var string Name of multi-value table 38 */ 39 protected $mtable; 40 41 /** 42 * @var array Column names for the single-value insert/update 43 */ 44 protected $singleCols; 45 46 /** 47 * @var array Input values for the single-value insert/update 48 */ 49 protected $singleValues; 50 51 /** 52 * @var array Input values for the multi-value inserts/updates 53 */ 54 protected $multiValues; 55 56 public static function getPageAccess($tablename, $pid, $ts = 0) 57 { 58 $schema = new Schema($tablename, $ts); 59 return new AccessTablePage($schema, $pid, $ts, 0); 60 } 61 62 public static function getSerialAccess($tablename, $pid, $rid = 0) 63 { 64 $schema = new Schema($tablename, 0); 65 return new AccessTableSerial($schema, $pid, 0, $rid); 66 } 67 68 public static function getGlobalAccess($tablename, $rid = 0) 69 { 70 $schema = new Schema($tablename, 0); 71 return new AccessTableGlobal($schema, '', 0, $rid); 72 } 73 74 /** 75 * Factory method returning the appropriate data accessor (page, global or serial) 76 * 77 * @param Schema $schema schema to load 78 * @param string $pid Page id to access 79 * @param int $ts Time at which the data should be read or written 80 * @param int $rid Row id, 0 for page type data, otherwise autoincrement 81 * @return AccessTablePage|AccessTableGlobal 82 * @deprecated 83 */ 84 public static function bySchema(Schema $schema, $pid, $ts = 0, $rid = 0) 85 { 86 if (self::isTypePage($pid, $ts)) { 87 return new AccessTablePage($schema, $pid, $ts, $rid); 88 } 89 return new AccessTableGlobal($schema, $pid, $ts, $rid); 90 } 91 92 /** 93 * Factory Method to access data 94 * 95 * @param string $tablename schema to load 96 * @param string $pid Page id to access 97 * @param int $ts Time at which the data should be read or written 98 * @param int $rid Row id, 0 for page type data, otherwise autoincrement 99 * @return AccessTablePage|AccessTableGlobal 100 * @deprecated Use specific methods since we can no longer 101 * guarantee instantiating the required descendant class 102 */ 103 public static function byTableName($tablename, $pid, $ts = 0, $rid = 0) 104 { 105 // force loading the latest schema for anything other than page data, 106 // for which we might actually need the history 107 if (!self::isTypePage($pid, $ts)) { 108 $schema = new Schema($tablename, time()); 109 } else { 110 $schema = new Schema($tablename, $ts); 111 } 112 return self::bySchema($schema, $pid, $ts, $rid); 113 } 114 115 /** 116 * AccessTable constructor 117 * 118 * @param Schema $schema The schema valid at $ts 119 * @param string $pid Page id 120 * @param int $ts Time at which the data should be read or written, 0 for now 121 * @param int $rid Row id: 0 for pages, autoincremented for other types 122 */ 123 public function __construct($schema, $pid, $ts = 0, $rid = 0) 124 { 125 /** @var \helper_plugin_struct_db $helper */ 126 $helper = plugin_load('helper', 'struct_db'); 127 $this->sqlite = $helper->getDB(); 128 129 if (!$schema->getId()) { 130 throw new StructException('Schema does not exist. Only data of existing schemas can be accessed'); 131 } 132 133 $this->schema = $schema; 134 $this->pid = $pid; 135 $this->rid = $rid; 136 $this->setTimestamp($ts); 137 foreach ($this->schema->getColumns() as $col) { 138 $this->labels[$col->getColref()] = $col->getType()->getLabel(); 139 } 140 } 141 142 /** 143 * gives access to the schema 144 * 145 * @return Schema 146 */ 147 public function getSchema() 148 { 149 return $this->schema; 150 } 151 152 /** 153 * The current pid 154 * 155 * @return string 156 */ 157 public function getPid() 158 { 159 return $this->pid; 160 } 161 162 /** 163 * The current rid 164 * 165 * @return int 166 */ 167 public function getRid() 168 { 169 return $this->rid; 170 } 171 172 /** 173 * Should remove the current data, by either deleting or ovewriting it 174 * 175 * @return bool if the delete succeeded 176 */ 177 abstract public function clearData(); 178 179 /** 180 * Save the data to the database. 181 * 182 * We differentiate between single-value-column and multi-value-column by the value to the respective column-name, 183 * i.e. depending on if that is a string or an array, respectively. 184 * 185 * @param array $data typelabel => value for single fields or typelabel => array(value, value, ...) for multi fields 186 * @return bool success of saving the data to the database 187 */ 188 public function saveData($data) 189 { 190 if (!$this->validateTypeData($data)) { 191 return false; 192 } 193 194 $this->stable = 'data_' . $this->schema->getTable(); 195 $this->mtable = 'multi_' . $this->schema->getTable(); 196 197 $colrefs = array_flip($this->labels); 198 199 foreach ($data as $colname => $value) { 200 if (!isset($colrefs[$colname])) { 201 throw new StructException("Unknown column %s in schema.", hsc($colname)); 202 } 203 204 $this->singleCols[] = 'col' . $colrefs[$colname]; 205 if (is_array($value)) { 206 foreach ($value as $index => $multivalue) { 207 $this->multiValues[] = [$colrefs[$colname], $index + 1, $multivalue]; 208 } 209 // copy first value to the single column 210 if (isset($value[0])) { 211 $this->singleValues[] = $value[0]; 212 if ($value[0] === '') { 213 $this->handleEmptyMulti($this->pid, $this->rid, $colrefs[$colname]); 214 } 215 } else { 216 $this->singleValues[] = null; 217 } 218 } else { 219 $this->singleValues[] = $value; 220 } 221 } 222 223 $this->sqlite->query('BEGIN TRANSACTION'); 224 225 $ok = $this->beforeSave(); 226 227 // insert single values 228 $ok = $ok && $this->sqlite->query( 229 $this->getSingleSql(), 230 array_merge($this->getSingleNoninputValues(), $this->singleValues) 231 ); 232 233 $ok = $ok && $this->afterSingleSave(); 234 235 // insert multi values 236 if ($ok && $this->multiValues) { 237 $multisql = $this->getMultiSql(); 238 $multiNoninputValues = $this->getMultiNoninputValues(); 239 foreach ($this->multiValues as $value) { 240 $ok = $ok && $this->sqlite->query( 241 $multisql, 242 array_merge($multiNoninputValues, $value) 243 ); 244 } 245 } 246 247 $ok = $ok && $this->afterSave(); 248 249 if (!$ok) { 250 $this->sqlite->query('ROLLBACK TRANSACTION'); 251 return false; 252 } 253 $this->sqlite->query('COMMIT TRANSACTION'); 254 return true; 255 } 256 257 /** 258 * Check whether all required data is present 259 * 260 * @param array $data 261 * @return bool 262 */ 263 abstract protected function validateTypeData($data); 264 265 /** 266 * Names of non-input columns to be inserted into SQL query 267 * 268 * @return array 269 */ 270 abstract protected function getSingleNoninputCols(); 271 272 /** 273 * Values for non-input columns to be inserted into SQL query 274 * for single-value tables 275 * 276 * @return array 277 */ 278 abstract protected function getSingleNoninputValues(); 279 280 /** 281 * String template for single-value table 282 * 283 * @return string 284 */ 285 protected function getSingleSql() 286 { 287 $cols = array_merge($this->getSingleNoninputCols(), $this->singleCols); 288 $cols = join(',', $cols); 289 $vals = array_merge($this->getSingleNoninputValues(), $this->singleValues); 290 291 return "INSERT INTO $this->stable ($cols) VALUES (" . trim(str_repeat('?,', count($vals)), ',') . ');'; 292 } 293 294 /** 295 * Optional operations to be executed before saving data 296 * 297 * @return bool False if any of the operations failed and transaction should be rolled back 298 */ 299 protected function beforeSave() 300 { 301 return true; 302 } 303 304 /** 305 * Optional operations to be executed after saving data to single-value table, 306 * before saving multivalues 307 * 308 * @return bool False if anything goes wrong and transaction should be rolled back 309 */ 310 protected function afterSingleSave() 311 { 312 return true; 313 } 314 315 /** 316 * Executes final optional queries. 317 * 318 * @return bool False if anything goes wrong and transaction should be rolled back 319 */ 320 protected function afterSave() 321 { 322 $ok = true; 323 foreach ($this->optQueries as $query) { 324 $ok = $ok && $this->sqlite->query(array_shift($query), $query); 325 } 326 return $ok; 327 } 328 329 /** 330 * String template for multi-value table 331 * 332 * @return string 333 */ 334 abstract protected function getMultiSql(); 335 336 /** 337 * Values for non-input columns to be inserted into SQL query 338 * for multi-value tables 339 * @return array 340 */ 341 abstract protected function getMultiNoninputValues(); 342 343 344 /** 345 * Should empty or invisible (inpage) fields be returned? 346 * 347 * Defaults to false 348 * 349 * @param null|bool $set new value, null to read only 350 * @return bool current value (after set) 351 */ 352 public function optionSkipEmpty($set = null) 353 { 354 if (!is_null($set)) { 355 $this->opt_skipempty = $set; 356 } 357 return $this->opt_skipempty; 358 } 359 360 /** 361 * Get the value of a single column 362 * 363 * @param Column $column 364 * @return Value|null 365 */ 366 public function getDataColumn($column) 367 { 368 $data = $this->getData(); 369 foreach ($data as $value) { 370 if ($value->getColumn() == $column) { 371 return $value; 372 } 373 } 374 return null; 375 } 376 377 /** 378 * returns the data saved for the page 379 * 380 * @return Value[] a list of values saved for the current page 381 */ 382 public function getData() 383 { 384 $data = $this->getDataFromDB(); 385 $data = $this->consolidateData($data, false); 386 return $data; 387 } 388 389 /** 390 * returns the data saved for the page as associative array 391 * 392 * The array returned is in the same format as used in @return array 393 * @see saveData() 394 * 395 * It always returns raw Values! 396 * 397 */ 398 public function getDataArray() 399 { 400 $data = $this->getDataFromDB(); 401 $data = $this->consolidateData($data, true); 402 return $data; 403 } 404 405 /** 406 * Return the data in pseudo syntax 407 */ 408 public function getDataPseudoSyntax() 409 { 410 $result = ''; 411 $data = $this->getData(); 412 413 foreach ($data as $value) { 414 $key = $value->getColumn()->getFullQualifiedLabel(); 415 $value = $value->getDisplayValue(); 416 if (is_array($value)) $value = join(', ', $value); 417 $result .= sprintf("% -20s : %s\n", $key, $value); 418 } 419 return $result; 420 } 421 422 /** 423 * retrieve the data saved for the page from the database. Usually there is no need to call this function. 424 * Call @see SchemaData::getData instead. 425 */ 426 protected function getDataFromDB() 427 { 428 $idColumn = self::isTypePage($this->pid, $this->ts) ? 'pid' : 'rid'; 429 list($sql, $opt) = $this->buildGetDataSQL($idColumn); 430 431 $res = $this->sqlite->query($sql, $opt); 432 $data = $this->sqlite->res2arr($res); 433 $this->sqlite->res_close($res); 434 return $data; 435 } 436 437 /** 438 * Creates a proper result array from the database data 439 * 440 * @param array $DBdata the data as it is retrieved from the database, i.e. by SchemaData::getDataFromDB 441 * @param bool $asarray return data as associative array (true) or as array of Values (false) 442 * @return array|Value[] 443 */ 444 protected function consolidateData($DBdata, $asarray = false) 445 { 446 $data = array(); 447 448 $sep = Search::CONCAT_SEPARATOR; 449 450 foreach ($this->schema->getColumns(false) as $col) { 451 // if no data saved yet, return empty strings 452 if ($DBdata) { 453 $val = $DBdata[0]['out' . $col->getColref()]; 454 } else { 455 $val = ''; 456 } 457 458 // multi val data is concatenated 459 if ($col->isMulti()) { 460 $val = explode($sep, $val); 461 $val = array_filter($val); 462 } 463 464 $value = new Value($col, $val); 465 466 if ($this->opt_skipempty && $value->isEmpty()) continue; 467 if ($this->opt_skipempty && !$col->isVisibleInPage()) continue; //FIXME is this a correct assumption? 468 469 // for arrays, we return the raw value only 470 if ($asarray) { 471 $data[$col->getLabel()] = $value->getRawValue(); 472 } else { 473 $data[$col->getLabel()] = $value; 474 } 475 } 476 477 return $data; 478 } 479 480 /** 481 * Builds the SQL statement to select the data for this page and schema 482 * 483 * @return array Two fields: the SQL string and the parameters array 484 */ 485 protected function buildGetDataSQL($idColumn = 'pid') 486 { 487 $sep = Search::CONCAT_SEPARATOR; 488 $stable = 'data_' . $this->schema->getTable(); 489 $mtable = 'multi_' . $this->schema->getTable(); 490 491 $QB = new QueryBuilder(); 492 $QB->addTable($stable, 'DATA'); 493 $QB->addSelectColumn('DATA', $idColumn, strtoupper($idColumn)); 494 $QB->addGroupByStatement("DATA.$idColumn"); 495 496 foreach ($this->schema->getColumns(false) as $col) { 497 $colref = $col->getColref(); 498 $colname = 'col' . $colref; 499 $outname = 'out' . $colref; 500 501 if ($col->getType()->isMulti()) { 502 $tn = 'M' . $colref; 503 $QB->addLeftJoin( 504 'DATA', 505 $mtable, 506 $tn, 507 "DATA.$idColumn = $tn.$idColumn AND DATA.rev = $tn.rev AND $tn.colref = $colref" 508 ); 509 $col->getType()->select($QB, $tn, 'value', $outname); 510 $sel = $QB->getSelectStatement($outname); 511 $QB->addSelectStatement("GROUP_CONCAT($sel, '$sep')", $outname); 512 } else { 513 $col->getType()->select($QB, 'DATA', $colname, $outname); 514 $QB->addGroupByStatement($outname); 515 } 516 } 517 518 $pl = $QB->addValue($this->{$idColumn}); 519 $QB->filters()->whereAnd("DATA.$idColumn = $pl"); 520 $pl = $QB->addValue($this->getLastRevisionTimestamp()); 521 $QB->filters()->whereAnd("DATA.rev = $pl"); 522 523 return $QB->getSQL(); 524 } 525 526 /** 527 * @param int $ts 528 */ 529 public function setTimestamp($ts) 530 { 531 if ($ts && $ts < $this->schema->getTimeStamp()) { 532 throw new StructException('Given timestamp is not valid for current Schema'); 533 } 534 535 $this->ts = $ts; 536 } 537 538 /** 539 * Returns the timestamp from the current data 540 * @return int 541 */ 542 public function getTimestamp() 543 { 544 return $this->ts; 545 } 546 547 /** 548 * Return the last time an edit happened for this table for the currently set 549 * time and pid. Used in @return int 550 * @see buildGetDataSQL() 551 * 552 */ 553 abstract protected function getLastRevisionTimestamp(); 554 555 /** 556 * Check if the given data validates against the current types. 557 * 558 * @param array $data 559 * @return AccessDataValidator 560 */ 561 public function getValidator($data) 562 { 563 return new AccessDataValidator($this, $data); 564 } 565 566 /** 567 * Returns true if data is of type "page" 568 * 569 * @param string $pid 570 * @param int $rev 571 * @param int $rid 572 * @return bool 573 */ 574 public static function isTypePage($pid, $rev) 575 { 576 return $rev > 0; 577 } 578 579 /** 580 * Returns true if data is of type "global" 581 * 582 * @param string $pid 583 * @param int $rev 584 * @param int $rid 585 * @return bool 586 */ 587 public static function isTypeGlobal($pid, $rev) 588 { 589 return $pid === ''; 590 } 591 592 /** 593 * Returns true if data is of type "serial" 594 * 595 * @param string $pid 596 * @param int $rev 597 * @param int $rid 598 * @return bool 599 */ 600 public static function isTypeSerial($pid, $rev) 601 { 602 return $pid !== '' && $rev === 0; 603 } 604 605 /** 606 * Global and serial data require additional queries. They are put into query queue 607 * in descendants of this method. 608 * 609 * @param string $pid 610 * @param int $rid 611 * @param int $colref 612 */ 613 protected function handleEmptyMulti($pid, $rid, $colref) 614 { 615 } 616 617 /** 618 * Clears all multi_ values for the current row. 619 * Executed when updating global and serial data. Otherwise removed (deselected) values linger in database. 620 * 621 * @return bool|\SQLiteResult 622 */ 623 protected function clearMulti() 624 { 625 $colrefs = array_unique(array_map(function ($val) { 626 return $val[0]; 627 }, $this->multiValues)); 628 return $this->sqlite->query( 629 "DELETE FROM $this->mtable WHERE pid = ? AND rid = $this->rid AND rev = 0 AND colref IN (" . 630 implode(',', $colrefs) . ")", 631 $this->pid 632 ); 633 } 634} 635