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