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