1<?php 2// (c) Copyright by authors of the Tiki Wiki CMS Groupware Project 3// 4// All Rights Reserved. See copyright.txt for details and a complete list of authors. 5// Licensed under the GNU LESSER GENERAL PUBLIC LICENSE. See license.txt for details. 6// $Id$ 7 8use Tiki\TikiDb\SanitizeEncoding; 9 10class TikiDb_Table 11{ 12 /** @var TikiDb_Pdo|TikiDb_Adodb $db */ 13 protected $db; 14 protected $tableName; 15 protected $autoIncrement; 16 protected $errorMode = TikiDb::ERR_DIRECT; 17 18 protected static $utf8FieldsCache = []; 19 20 function __construct($db, $tableName, $autoIncrement = true) 21 { 22 $this->db = $db; 23 $this->tableName = $tableName; 24 $this->autoIncrement = $autoIncrement; 25 } 26 27 function useExceptions() 28 { 29 $this->errorMode = TikiDb::ERR_EXCEPTION; 30 } 31 32 /** 33 * Inserts a row in the table by building the SQL query from an array of values. 34 * The target table is defined by the instance. Argument names are not validated 35 * against the schema. This is only a helper method to improve code readability. 36 * 37 * @param $values array Key-value pairs to insert. 38 * @param $ignore boolean Insert as ignore statement 39 * @return array|bool|mixed 40 */ 41 function insert(array $values, $ignore = false) 42 { 43 $bindvars = []; 44 $query = $this->buildInsert($values, $ignore, $bindvars); 45 46 $result = $this->db->queryException($query, $bindvars); 47 48 if ($this->autoIncrement) { 49 if ($insertedId = $this->db->lastInsertId()) { 50 return $insertedId; 51 } else { 52 return false; 53 } 54 } else { 55 return $result; 56 } 57 } 58 59 /** 60 * @param array $data 61 * @param array $keys 62 * @return array|bool|mixed 63 */ 64 function insertOrUpdate(array $data, array $keys) 65 { 66 $insertData = array_merge($data, $keys); 67 68 $bindvars = []; 69 $query = $this->buildInsert($insertData, false, $bindvars); 70 $query .= ' ON DUPLICATE KEY UPDATE '; 71 $query .= $this->buildUpdateList($data, $bindvars); 72 73 $result = $this->db->queryException($query, $bindvars); 74 75 if ($this->autoIncrement) { 76 if ($insertedId = $this->db->lastInsertId()) { 77 return $insertedId; 78 //Multiple actions in a query (e.g., INSERT + UPDATE) returns result class instead of the id number 79 } elseif (is_object($result)) { 80 return $result; 81 } else { 82 return false; 83 } 84 } else { 85 return $result; 86 } 87 } 88 89 /** 90 * Deletes a single record from the table matching the provided conditions. 91 * Conditions use exact matching. Multiple conditions will result in AND matching. 92 * @param array $conditions 93 * @return TikiDb_Pdo_Result|TikiDb_Adodb_Result 94 */ 95 function delete(array $conditions) 96 { 97 $bindvars = []; 98 $query = $this->buildDelete($conditions, $bindvars) . ' LIMIT 1'; 99 100 return $this->db->queryException($query, $bindvars); 101 } 102 103 /** 104 * Builds and performs and SQL update query on the table defined by the instance. 105 * This query will update a single record. 106 * @param array $values 107 * @param array $conditions 108 * @return TikiDb_Pdo_Result|TikiDb_Adodb_Result 109 */ 110 function update(array $values, array $conditions) 111 { 112 return $this->updateMultiple($values, $conditions, 1); 113 } 114 115 /** 116 * @param array $values 117 * @param array $conditions 118 * @param null $limit 119 * @return TikiDb_Pdo_Result|TikiDb_Adodb_Result 120 */ 121 function updateMultiple(array $values, array $conditions, $limit = null) 122 { 123 $bindvars = []; 124 $query = $this->buildUpdate($values, $conditions, $bindvars); 125 126 if (! is_null($limit)) { 127 $query .= ' LIMIT ' . (int)$limit; 128 } 129 130 return $this->db->queryException($query, $bindvars); 131 } 132 133 134 /** 135 * Deletes a multiple records from the table matching the provided conditions. 136 * Conditions use exact matching. Multiple conditions will result in AND matching. 137 * 138 * The method works just like delete, except that it does not have the one record 139 * limitation. 140 * @param array $conditions 141 * @return TikiDb_Pdo_Result|TikiDb_Adodb_Result 142 */ 143 function deleteMultiple(array $conditions) 144 { 145 $bindvars = []; 146 $query = $this->buildDelete($conditions, $bindvars); 147 148 return $this->db->queryException($query, $bindvars); 149 } 150 151 function fetchOne($field, array $conditions, $orderClause = null) 152 { 153 if ($result = $this->fetchRow([$field], $conditions, $orderClause)) { 154 return reset($result); 155 } 156 157 return false; 158 } 159 160 /** 161 * Provides the result count only 162 * @param array $conditions 163 * 164 * @return bool|mixed 165 */ 166 function fetchCount(array $conditions) 167 { 168 return $this->fetchOne($this->count(), $conditions); 169 } 170 171 /** 172 * Retrieve all fields from a single row 173 * @param array $conditions 174 * @param null $orderClause 175 * 176 * @return mixed 177 */ 178 function fetchFullRow(array $conditions, $orderClause = null) 179 { 180 return $this->fetchRow($this->all(), $conditions, $orderClause); 181 } 182 183 /** 184 * Retrieve the selected fields from a single row 185 * @param array $fields 186 * @param array $conditions 187 * @param null $orderClause 188 * 189 * @return mixed 190 */ 191 192 function fetchRow(array $fields, array $conditions, $orderClause = null) 193 { 194 $result = $this->fetchAll($fields, $conditions, 1, 0, $orderClause); 195 196 return reset($result); 197 } 198 199 /** 200 * Provides all the matched values from a single column 201 * @param $field 202 * @param array $conditions 203 * @param int $numrows 204 * @param int $offset 205 * @param null $order 206 * 207 * @return array 208 */ 209 function fetchColumn($field, array $conditions, $numrows = -1, $offset = -1, $order = null) 210 { 211 if (is_string($order)) { 212 $order = [$field => $order]; 213 } 214 215 $result = $this->fetchAll([$field], $conditions, $numrows, $offset, $order); 216 217 $output = []; 218 219 foreach ($result as $row) { 220 $output[] = reset($row); 221 } 222 223 return $output; 224 } 225 226 /** 227 * Retrieves the two values from the table and generates a map from the key and the value 228 * @param $keyField 229 * @param $valueField 230 * @param array $conditions 231 * @param int $numrows 232 * @param int $offset 233 * @param null $order 234 * 235 * @return array 236 */ 237 function fetchMap($keyField, $valueField, array $conditions, $numrows = -1, $offset = -1, $order = null) 238 { 239 $result = $this->fetchAll([$keyField, $valueField], $conditions, $numrows, $offset, $order); 240 241 $map = []; 242 243 foreach ($result as $row) { 244 $key = $row[$keyField]; 245 $value = $row[$valueField]; 246 247 $map[ $key ] = $value; 248 } 249 250 return $map; 251 } 252 253 /** 254 * Test if a condition exists in the database. 255 * 256 * @param array $conditions List of conditions that will be tested 257 * 258 * @return bool True if the condition exists, false otherwise 259 */ 260 261 function fetchBool(array $conditions = []): bool 262 { 263 264 $query = 'SELECT 1 FROM ' . $this->escapeIdentifier($this->tableName); 265 $query .= $this->buildConditions($conditions, $bindvars); 266 267 $result = $this->db->fetchAll($query, $bindvars, 1, -1, $this->errorMode); 268 return !empty($result[0][1]); 269 } 270 271 /** 272 * Fully-customizable fetch providing an array of associative arrays. 273 * @param array $fields 274 * @param array $conditions 275 * @param int $numrows 276 * @param int $offset 277 * @param null $orderClause 278 * 279 * @return array|bool 280 */ 281 function fetchAll(array $fields = [], array $conditions = [], $numrows = -1, $offset = -1, $orderClause = null) 282 { 283 $bindvars = []; 284 285 $fieldDescription = ''; 286 287 foreach ($fields as $k => $f) { 288 if ($f instanceof TikiDB_Expr) { 289 $fieldDescription .= $f->getQueryPart(null); 290 $bindvars = array_merge($bindvars, $f->getValues()); 291 } else { 292 $fieldDescription .= $this->escapeIdentifier($f); 293 } 294 295 if (is_string($k)) { 296 $fieldDescription .= ' AS ' . $this->escapeIdentifier($k); 297 } 298 299 $fieldDescription .= ', '; 300 } 301 302 $query = 'SELECT '; 303 $query .= (! empty($fieldDescription)) ? rtrim($fieldDescription, ', ') : '*'; 304 $query .= ' FROM ' . $this->escapeIdentifier($this->tableName); 305 $query .= $this->buildConditions($conditions, $bindvars); 306 $query .= $this->buildOrderClause($orderClause); 307 308 return $this->db->fetchAll($query, $bindvars, $numrows, $offset, $this->errorMode); 309 } 310 311 /** 312 * Most generic usage, allows to insert SQL in many places. 313 * In update for the data, they are used for the values. 314 * In conditions, they represent the whole condition. 315 * In a select query, they represent a single field. 316 * An expression can be used instead of the sort array to replace the entire order by argument. 317 * Within the fragment, $$ will be replaced by the field for conditions. 318 * All other expressions are just shorthands for this one. 319 * @param $string 320 * @param array $arguments 321 * 322 * @return TikiDb_Expr 323 */ 324 325 function expr($string, $arguments = []) 326 { 327 return new TikiDb_Expr($string, $arguments); 328 } 329 330 /** 331 * For all fields, not a specific field, returns an array of expressions 332 * @return array 333 */ 334 function all() 335 { 336 return [$this->expr('*')]; 337 } 338 339 function count() 340 { 341 return $this->expr('COUNT(*)'); 342 } 343 344 function sum($field) 345 { 346 return $this->expr("SUM(`$field`)"); 347 } 348 349 function max($field) 350 { 351 return $this->expr("MAX(`$field`)"); 352 } 353 354 function min($field) 355 { 356 return $this->expr("MIN(`$field`)"); 357 } 358 359 function increment($count) 360 { 361 return $this->expr('$$ + ?', [$count]); 362 } 363 364 function decrement($count) 365 { 366 return $this->expr('$$ - ?', [$count]); 367 } 368 369 function greaterThan($value) 370 { 371 return $this->expr('$$ > ?', [$value]); 372 } 373 374 function lesserThan($value) 375 { 376 return $this->expr('$$ < ?', [$value]); 377 } 378 379 /** 380 * Retrieve values within a range. The vales given will be included. 381 * 382 * @param $values array Must be an array containing 2 strings 383 * 384 * @return TikiDb_Expr 385 */ 386 function between($values) 387 { 388 return $this->expr('$$ BETWEEN ? AND ?', $values); 389 } 390 391 function not($value) 392 { 393 if (empty($value)) { 394 return $this->expr('($$ <> ? AND $$ IS NOT NULL)', [$value]); 395 } else { 396 return $this->expr('$$ <> ?', [$value]); 397 } 398 } 399 400 /** 401 * String comparison using a formula. 402 403 * @param $value string A pattern where % represents zero, one, or multiple characters and _ represents a single character. 404 * eg. ['a%'] matches anything that starts with an 'a'. 405 * 406 * @return TikiDb_Expr 407 */ 408 function like($value) 409 { 410 return $this->expr('$$ LIKE ?', [$value]); 411 } 412 413 /** 414 * Negative string comparision. See like() 415 * @param $value string 416 * 417 * @return TikiDb_Expr 418 */ 419 function unlike($value) 420 { 421 return $this->expr('$$ NOT LIKE ?', [$value]); 422 } 423 424 /** 425 * Search for a substring. (a common LIKE statement) 426 * @param $value string Containing a string to search for. 427 * 428 * @return TikiDb_Expr 429 */ 430 431 function contains($value) 432 { 433 $value = '%' . $value . '%'; 434 return $this->expr('$$ LIKE ?', [$value]); 435 } 436 /** 437 * binary safe compare 438 * @param $value string 439 * 440 * @return TikiDb_Expr 441 */ 442 function exactly($value) 443 { 444 return $this->expr('BINARY $$ = ?', [$value]); 445 } 446 447 function in(array $values, $caseSensitive = false) 448 { 449 if (empty($values)) { 450 return $this->expr('1=0', []); 451 } else { 452 return $this->expr(($caseSensitive ? 'BINARY ' : '') . '$$ IN(' . rtrim(str_repeat('?, ', count($values)), ', ') . ')', $values); 453 } 454 } 455 456 function notIn(array $values, $caseSensitive = false) 457 { 458 if (empty($values)) { 459 return $this->expr('1=0', []); 460 } else { 461 return $this->expr(($caseSensitive ? 'BINARY ' : '') . '$$ NOT IN(' . rtrim(str_repeat('?, ', count($values)), ', ') . ')', $values); 462 } 463 } 464 465 function findIn($value, array $fields) 466 { 467 $expr = $this->like("%$value%"); 468 469 return $this->any(array_fill_keys($fields, $expr)); 470 } 471 472 function concatFields(array $fields) 473 { 474 $fields = array_map([$this, 'escapeIdentifier'], $fields); 475 $fields = implode(', ', $fields); 476 477 $expr = ''; 478 if ($fields) { 479 $expr = "CONCAT($fields)"; 480 } 481 482 return $this->expr($expr); 483 } 484 485 function any(array $conditions) 486 { 487 $binds = []; 488 $parts = []; 489 490 foreach ($conditions as $field => $expr) { 491 $parts[] = $expr->getQueryPart($this->escapeIdentifier($field)); 492 $binds = array_merge($binds, $expr->getValues()); 493 } 494 495 return $this->expr('(' . implode(' OR ', $parts) . ')', $binds); 496 } 497 498 function sortMode($sortMode) 499 { 500 return $this->expr($this->db->convertSortMode($sortMode)); 501 } 502 503 private function buildDelete(array $conditions, & $bindvars) 504 { 505 $query = "DELETE FROM {$this->escapeIdentifier($this->tableName)}"; 506 $query .= $this->buildConditions($conditions, $bindvars); 507 508 return $query; 509 } 510 511 private function buildConditions(array $conditions, & $bindvars) 512 { 513 $query = " WHERE 1=1"; 514 515 foreach ($conditions as $key => $value) { 516 $field = $this->escapeIdentifier($key); 517 if ($value instanceof TikiDb_Expr) { 518 $query .= " AND {$value->getQueryPart($field)}"; 519 $bindvars = array_merge($bindvars, $value->getValues()); 520 } elseif (empty($value)) { 521 $query .= " AND ($field = ? OR $field IS NULL)"; 522 $bindvars[] = $value; 523 } else { 524 $query .= " AND $field = ?"; 525 $bindvars[] = $value; 526 } 527 } 528 529 return $query; 530 } 531 532 private function buildOrderClause($orderClause) 533 { 534 if ($orderClause instanceof TikiDb_Expr) { 535 return ' ORDER BY ' . $orderClause->getQueryPart(null); 536 } elseif (is_array($orderClause) && ! empty($orderClause)) { 537 $part = ' ORDER BY '; 538 539 foreach ($orderClause as $key => $direction) { 540 $part .= "`$key` $direction, "; 541 } 542 543 return rtrim($part, ', '); 544 } 545 } 546 547 private function buildUpdate(array $values, array $conditions, & $bindvars) 548 { 549 $query = "UPDATE {$this->escapeIdentifier($this->tableName)} SET "; 550 551 $query .= $this->buildUpdateList($values, $bindvars); 552 $query .= $this->buildConditions($conditions, $bindvars); 553 554 return $query; 555 } 556 557 private function buildUpdateList($values, & $bindvars) 558 { 559 $query = ''; 560 561 foreach ($values as $key => $value) { 562 $field = $this->escapeIdentifier($key); 563 if ($value instanceof TikiDb_Expr) { 564 $query .= "$field = {$value->getQueryPart($field)}, "; 565 $bindvars = array_merge($bindvars, SanitizeEncoding::filterMysqlUtf8($value->getValues(), $this->getUtf8Fields(), $key)); 566 } else { 567 $query .= "$field = ?, "; 568 $bindvars[] = SanitizeEncoding::filterMysqlUtf8($value, $this->getUtf8Fields(), $key); 569 } 570 } 571 572 return rtrim($query, ' ,'); 573 } 574 575 private function buildInsert($values, $ignore, & $bindvars) 576 { 577 $fieldDefinition = implode(', ', array_map([$this, 'escapeIdentifier'], array_keys($values))); 578 $fieldPlaceholders = rtrim(str_repeat('?, ', count($values)), ' ,'); 579 580 if ($ignore) { 581 $ignore = ' IGNORE'; 582 } 583 584 $bindvars = array_merge($bindvars, array_values(SanitizeEncoding::filterMysqlUtf8($values, $this->getUtf8Fields()))); 585 return "INSERT$ignore INTO {$this->escapeIdentifier($this->tableName)} ($fieldDefinition) VALUES ($fieldPlaceholders)"; 586 } 587 588 protected function escapeIdentifier($identifier) 589 { 590 return "`$identifier`"; 591 } 592 593 /** 594 * return the list of fields that have charset utf8 (vs utf8mb4) in the current table 595 * 596 * @return mixed 597 */ 598 public function getUtf8Fields() 599 { 600 if (! isset(self::$utf8FieldsCache[$this->tableName])) { 601 $sql = "SELECT COLUMN_NAME AS col FROM information_schema.`COLUMNS` WHERE table_schema = DATABASE()" 602 . " AND TABLE_NAME = ? AND CHARACTER_SET_NAME = 'utf8'"; 603 $result = $this->db->fetchAll($sql, [$this->tableName]); 604 $shortFormat = is_array($result) ? array_column($result, 'col') : []; 605 self::$utf8FieldsCache[$this->tableName] = array_combine($shortFormat, $shortFormat); 606 } 607 608 return self::$utf8FieldsCache[$this->tableName]; 609 } 610} 611