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