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 $ok = $ok && $this->sqlite->query(array_shift($query), $query); 336 } 337 return $ok; 338 } 339 340 /** 341 * String template for multi-value table 342 * 343 * @return string 344 */ 345 abstract protected function getMultiSql(); 346 347 /** 348 * Values for non-input columns to be inserted into SQL query 349 * for multi-value tables 350 * @return array 351 */ 352 abstract protected function getMultiNoninputValues(); 353 354 355 /** 356 * Should empty or invisible (inpage) fields be returned? 357 * 358 * Defaults to false 359 * 360 * @param null|bool $set new value, null to read only 361 * @return bool current value (after set) 362 */ 363 public function optionSkipEmpty($set = null) 364 { 365 if (!is_null($set)) { 366 $this->opt_skipempty = $set; 367 } 368 return $this->opt_skipempty; 369 } 370 371 /** 372 * Get the value of a single column 373 * 374 * @param Column $column 375 * @return Value|null 376 */ 377 public function getDataColumn($column) 378 { 379 $data = $this->getData(); 380 foreach ($data as $value) { 381 if ($value->getColumn() == $column) { 382 return $value; 383 } 384 } 385 return null; 386 } 387 388 /** 389 * returns the data saved for the page 390 * 391 * @return Value[] a list of values saved for the current page 392 */ 393 public function getData() 394 { 395 $data = $this->getDataFromDB(); 396 $data = $this->consolidateData($data, false); 397 return $data; 398 } 399 400 /** 401 * returns the data saved for the page as associative array 402 * 403 * The array returned is in the same format as used in @return array 404 * @see saveData() 405 * 406 * It always returns raw Values! 407 * 408 * @return array 409 */ 410 public function getDataArray() 411 { 412 $data = $this->getDataFromDB(); 413 $data = $this->consolidateData($data, true); 414 return $data; 415 } 416 417 /** 418 * Return the data in pseudo syntax 419 */ 420 public function getDataPseudoSyntax() 421 { 422 $result = ''; 423 $data = $this->getData(); 424 425 foreach ($data as $value) { 426 $key = $value->getColumn()->getFullQualifiedLabel(); 427 $value = $value->getDisplayValue(); 428 if (is_array($value)) $value = join(', ', $value); 429 $result .= sprintf("% -20s : %s\n", $key, $value); 430 } 431 return $result; 432 } 433 434 /** 435 * retrieve the data saved for the page from the database. Usually there is no need to call this function. 436 * Call @see SchemaData::getData instead. 437 */ 438 protected function getDataFromDB() 439 { 440 $idColumn = self::isTypePage($this->pid, $this->ts) ? 'pid' : 'rid'; 441 list($sql, $opt) = $this->buildGetDataSQL($idColumn); 442 443 return $this->sqlite->queryAll($sql, $opt); 444 } 445 446 /** 447 * Creates a proper result array from the database data 448 * 449 * @param array $DBdata the data as it is retrieved from the database, i.e. by SchemaData::getDataFromDB 450 * @param bool $asarray return data as associative array (true) or as array of Values (false) 451 * @return array|Value[] 452 */ 453 protected function consolidateData($DBdata, $asarray = false) 454 { 455 $data = array(); 456 457 $sep = Search::CONCAT_SEPARATOR; 458 459 foreach ($this->schema->getColumns(false) as $col) { 460 // if no data saved yet, return empty strings 461 if ($DBdata) { 462 $val = (string) $DBdata[0]['out' . $col->getColref()]; 463 } else { 464 $val = ''; 465 } 466 467 // multi val data is concatenated 468 if ($col->isMulti()) { 469 $val = explode($sep, $val); 470 $val = array_filter($val); 471 } 472 473 $value = new Value($col, $val); 474 475 if ($this->opt_skipempty && $value->isEmpty()) continue; 476 if ($this->opt_skipempty && !$col->isVisibleInPage()) continue; //FIXME is this a correct assumption? 477 478 // for arrays, we return the raw value only 479 if ($asarray) { 480 $data[$col->getLabel()] = $value->getRawValue(); 481 } else { 482 $data[$col->getLabel()] = $value; 483 } 484 } 485 486 return $data; 487 } 488 489 /** 490 * Builds the SQL statement to select the data for this page and schema 491 * 492 * @return array Two fields: the SQL string and the parameters array 493 */ 494 protected function buildGetDataSQL($idColumn = 'pid') 495 { 496 $sep = Search::CONCAT_SEPARATOR; 497 $stable = 'data_' . $this->schema->getTable(); 498 $mtable = 'multi_' . $this->schema->getTable(); 499 500 $QB = new QueryBuilder(); 501 $QB->addTable($stable, 'DATA'); 502 $QB->addSelectColumn('DATA', $idColumn, strtoupper($idColumn)); 503 $QB->addGroupByStatement("DATA.$idColumn"); 504 505 foreach ($this->schema->getColumns(false) as $col) { 506 $colref = $col->getColref(); 507 $colname = 'col' . $colref; 508 $outname = 'out' . $colref; 509 510 if ($col->getType()->isMulti()) { 511 $tn = 'M' . $colref; 512 $QB->addLeftJoin( 513 'DATA', 514 $mtable, 515 $tn, 516 "DATA.$idColumn = $tn.$idColumn AND DATA.rev = $tn.rev AND $tn.colref = $colref" 517 ); 518 $col->getType()->select($QB, $tn, 'value', $outname); 519 $sel = $QB->getSelectStatement($outname); 520 $QB->addSelectStatement("GROUP_CONCAT($sel, '$sep')", $outname); 521 } else { 522 $col->getType()->select($QB, 'DATA', $colname, $outname); 523 $QB->addGroupByStatement($outname); 524 } 525 } 526 527 $pl = $QB->addValue($this->{$idColumn}); 528 $QB->filters()->whereAnd("DATA.$idColumn = $pl"); 529 $pl = $QB->addValue($this->getLastRevisionTimestamp()); 530 $QB->filters()->whereAnd("DATA.rev = $pl"); 531 532 return $QB->getSQL(); 533 } 534 535 /** 536 * @param int $ts 537 */ 538 public function setTimestamp($ts) 539 { 540 if ($ts && $ts < $this->schema->getTimeStamp()) { 541 throw new StructException('Given timestamp is not valid for current Schema'); 542 } 543 544 $this->ts = $ts; 545 } 546 547 /** 548 * Returns the timestamp from the current data 549 * @return int 550 */ 551 public function getTimestamp() 552 { 553 return $this->ts; 554 } 555 556 /** 557 * Return the last time an edit happened for this table for the currently set 558 * time and pid. Used in 559 * @see buildGetDataSQL() 560 * 561 * @return int 562 */ 563 abstract protected function getLastRevisionTimestamp(); 564 565 /** 566 * Check if the given data validates against the current types. 567 * 568 * @param array $data 569 * @return AccessDataValidator 570 */ 571 public function getValidator($data) 572 { 573 return new AccessDataValidator($this, $data); 574 } 575 576 /** 577 * Returns true if data is of type "page" 578 * 579 * @param string $pid 580 * @param int $rev 581 * @param int $rid 582 * @return bool 583 */ 584 public static function isTypePage($pid, $rev) 585 { 586 return $rev > 0; 587 } 588 589 /** 590 * Returns true if data is of type "global" 591 * 592 * @param string $pid 593 * @param int $rev 594 * @param int $rid 595 * @return bool 596 */ 597 public static function isTypeGlobal($pid, $rev) 598 { 599 return $pid === ''; 600 } 601 602 /** 603 * Returns true if data is of type "serial" 604 * 605 * @param string $pid 606 * @param int $rev 607 * @param int $rid 608 * @return bool 609 */ 610 public static function isTypeSerial($pid, $rev) 611 { 612 return $pid !== '' && $rev === 0; 613 } 614 615 /** 616 * Global and serial data require additional queries. They are put into query queue 617 * in descendants of this method. 618 * 619 * @param string $pid 620 * @param int $rid 621 * @param int $colref 622 */ 623 protected function handleEmptyMulti($pid, $rid, $colref) 624 { 625 } 626 627 /** 628 * Clears all multi_ values for the current row. 629 * Executed when updating global and serial data. Otherwise removed (deselected) values linger in database. 630 * 631 * @return bool|\SQLiteResult 632 */ 633 protected function clearMulti() 634 { 635 $colrefs = array_unique(array_map(function ($val) { 636 return $val[0]; 637 }, $this->multiValues)); 638 return $this->sqlite->query( 639 "DELETE FROM $this->mtable WHERE pid = ? AND rid = $this->rid AND rev = 0 AND colref IN (" . 640 implode(',', $colrefs) . ")", 641 [$this->pid] 642 ); 643 } 644} 645