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