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 $res = $this->sqlite->query($sql, $opt); 444 $data = $this->sqlite->res2arr($res); 445 $this->sqlite->res_close($res); 446 return $data; 447 } 448 449 /** 450 * Creates a proper result array from the database data 451 * 452 * @param array $DBdata the data as it is retrieved from the database, i.e. by SchemaData::getDataFromDB 453 * @param bool $asarray return data as associative array (true) or as array of Values (false) 454 * @return array|Value[] 455 */ 456 protected function consolidateData($DBdata, $asarray = false) 457 { 458 $data = array(); 459 460 $sep = Search::CONCAT_SEPARATOR; 461 462 foreach ($this->schema->getColumns(false) as $col) { 463 // if no data saved yet, return empty strings 464 if ($DBdata) { 465 $val = $DBdata[0]['out' . $col->getColref()]; 466 } else { 467 $val = ''; 468 } 469 470 // multi val data is concatenated 471 if ($col->isMulti()) { 472 $val = explode($sep, $val); 473 $val = array_filter($val); 474 } 475 476 $value = new Value($col, $val); 477 478 if ($this->opt_skipempty && $value->isEmpty()) continue; 479 if ($this->opt_skipempty && !$col->isVisibleInPage()) continue; //FIXME is this a correct assumption? 480 481 // for arrays, we return the raw value only 482 if ($asarray) { 483 $data[$col->getLabel()] = $value->getRawValue(); 484 } else { 485 $data[$col->getLabel()] = $value; 486 } 487 } 488 489 return $data; 490 } 491 492 /** 493 * Builds the SQL statement to select the data for this page and schema 494 * 495 * @return array Two fields: the SQL string and the parameters array 496 */ 497 protected function buildGetDataSQL($idColumn = 'pid') 498 { 499 $sep = Search::CONCAT_SEPARATOR; 500 $stable = 'data_' . $this->schema->getTable(); 501 $mtable = 'multi_' . $this->schema->getTable(); 502 503 $QB = new QueryBuilder(); 504 $QB->addTable($stable, 'DATA'); 505 $QB->addSelectColumn('DATA', $idColumn, strtoupper($idColumn)); 506 $QB->addGroupByStatement("DATA.$idColumn"); 507 508 foreach ($this->schema->getColumns(false) as $col) { 509 $colref = $col->getColref(); 510 $colname = 'col' . $colref; 511 $outname = 'out' . $colref; 512 513 if ($col->getType()->isMulti()) { 514 $tn = 'M' . $colref; 515 $QB->addLeftJoin( 516 'DATA', 517 $mtable, 518 $tn, 519 "DATA.$idColumn = $tn.$idColumn AND DATA.rev = $tn.rev AND $tn.colref = $colref" 520 ); 521 $col->getType()->select($QB, $tn, 'value', $outname); 522 $sel = $QB->getSelectStatement($outname); 523 $QB->addSelectStatement("GROUP_CONCAT($sel, '$sep')", $outname); 524 } else { 525 $col->getType()->select($QB, 'DATA', $colname, $outname); 526 $QB->addGroupByStatement($outname); 527 } 528 } 529 530 $pl = $QB->addValue($this->{$idColumn}); 531 $QB->filters()->whereAnd("DATA.$idColumn = $pl"); 532 $pl = $QB->addValue($this->getLastRevisionTimestamp()); 533 $QB->filters()->whereAnd("DATA.rev = $pl"); 534 535 return $QB->getSQL(); 536 } 537 538 /** 539 * @param int $ts 540 */ 541 public function setTimestamp($ts) 542 { 543 if ($ts && $ts < $this->schema->getTimeStamp()) { 544 throw new StructException('Given timestamp is not valid for current Schema'); 545 } 546 547 $this->ts = $ts; 548 } 549 550 /** 551 * Returns the timestamp from the current data 552 * @return int 553 */ 554 public function getTimestamp() 555 { 556 return $this->ts; 557 } 558 559 /** 560 * Return the last time an edit happened for this table for the currently set 561 * time and pid. Used in 562 * @see buildGetDataSQL() 563 * 564 * @return int 565 */ 566 abstract protected function getLastRevisionTimestamp(); 567 568 /** 569 * Check if the given data validates against the current types. 570 * 571 * @param array $data 572 * @return AccessDataValidator 573 */ 574 public function getValidator($data) 575 { 576 return new AccessDataValidator($this, $data); 577 } 578 579 /** 580 * Returns true if data is of type "page" 581 * 582 * @param string $pid 583 * @param int $rev 584 * @param int $rid 585 * @return bool 586 */ 587 public static function isTypePage($pid, $rev) 588 { 589 return $rev > 0; 590 } 591 592 /** 593 * Returns true if data is of type "global" 594 * 595 * @param string $pid 596 * @param int $rev 597 * @param int $rid 598 * @return bool 599 */ 600 public static function isTypeGlobal($pid, $rev) 601 { 602 return $pid === ''; 603 } 604 605 /** 606 * Returns true if data is of type "serial" 607 * 608 * @param string $pid 609 * @param int $rev 610 * @param int $rid 611 * @return bool 612 */ 613 public static function isTypeSerial($pid, $rev) 614 { 615 return $pid !== '' && $rev === 0; 616 } 617 618 /** 619 * Global and serial data require additional queries. They are put into query queue 620 * in descendants of this method. 621 * 622 * @param string $pid 623 * @param int $rid 624 * @param int $colref 625 */ 626 protected function handleEmptyMulti($pid, $rid, $colref) 627 { 628 } 629 630 /** 631 * Clears all multi_ values for the current row. 632 * Executed when updating global and serial data. Otherwise removed (deselected) values linger in database. 633 * 634 * @return bool|\SQLiteResult 635 */ 636 protected function clearMulti() 637 { 638 $colrefs = array_unique(array_map(function ($val) { 639 return $val[0]; 640 }, $this->multiValues)); 641 return $this->sqlite->query( 642 "DELETE FROM $this->mtable WHERE pid = ? AND rid = $this->rid AND rev = 0 AND colref IN (" . 643 implode(',', $colrefs) . ")", 644 $this->pid 645 ); 646 } 647} 648