aboutsummaryrefslogtreecommitdiffstats
path: root/apps/webhook_listeners/lib/Service/PHPMongoQuery.php
blob: ecb8e819780a8c2dc00810d7f516eec6cde4ea82 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
<?php

/**
 * SPDX-FileCopyrightText: 2013 Akkroo Solutions Ltd
 * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
 * SPDX-License-Identifier: AGPL-3.0-or-later
 */

namespace OCA\WebhookListeners\Service;

use Exception;

/**
 * PHPMongoQuery implements MongoDB queries in PHP, allowing developers to query
 * a 'document' (an array containing data) against a Mongo query object,
 * returning a boolean value for pass or fail
 */
abstract class PHPMongoQuery {
	/**
	 * Execute a mongo query on a set of documents and return the documents that pass the query
	 *
	 * @param array $query A boolean value or an array defining a query
	 * @param array $documents The document to query
	 * @param array $options Any options:
	 *                       'debug' - boolean - debug mode, verbose logging
	 *                       'logger' - \Psr\LoggerInterface - A logger instance that implements {@link https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-3-logger-interface.md#3-psrlogloggerinterface PSR-3}
	 *                       'unknownOperatorCallback' - a callback to be called if an operator can't be found.  The function definition is function($operator, $operatorValue, $field, $document). return true or false.
	 * @throws Exception
	 */
	public static function find(array $query, array $documents, array $options = []): array {
		if (empty($documents) || empty($query)) {
			return [];
		}
		$ret = [];
		$options['_shouldLog'] = !empty($options['logger']) && $options['logger'] instanceof \Psr\Log\LoggerInterface;
		$options['_debug'] = !empty($options['debug']);
		foreach ($documents as $doc) {
			if (static::_executeQuery($query, $doc, $options)) {
				$ret[] = $doc;
			}
		}
		return $ret;
	}

	/**
	 * Execute a Mongo query on a document
	 *
	 * @param mixed $query A boolean value or an array defining a query
	 * @param array $document The document to query
	 * @param array $options Any options:
	 *                       'debug' - boolean - debug mode, verbose logging
	 *                       'logger' - \Psr\LoggerInterface - A logger instance that implements {@link https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-3-logger-interface.md#3-psrlogloggerinterface PSR-3}
	 *                       'unknownOperatorCallback' - a callback to be called if an operator can't be found.  The function definition is function($operator, $operatorValue, $field, $document). return true or false.
	 * @throws Exception
	 */
	public static function executeQuery($query, array &$document, array $options = []): bool {
		$options['_shouldLog'] = !empty($options['logger']) && $options['logger'] instanceof \Psr\Log\LoggerInterface;
		$options['_debug'] = !empty($options['debug']);
		if ($options['_debug'] && $options['_shouldLog']) {
			$options['logger']->debug('executeQuery called', ['query' => $query, 'document' => $document, 'options' => $options]);
		}

		if (!is_array($query)) {
			return (bool)$query;
		}

		return self::_executeQuery($query, $document, $options);
	}

	/**
	 * Internal execute query
	 *
	 * This expects an array from the query and has an additional logical operator (for the root query object the logical operator is always $and so this is not required)
	 *
	 * @throws Exception
	 */
	private static function _executeQuery(array $query, array &$document, array $options = [], string $logicalOperator = '$and'): bool {
		if ($logicalOperator !== '$and' && (!count($query) || !isset($query[0]))) {
			throw new Exception($logicalOperator . ' requires nonempty array');
		}
		if ($options['_debug'] && $options['_shouldLog']) {
			$options['logger']->debug('_executeQuery called', ['query' => $query, 'document' => $document, 'logicalOperator' => $logicalOperator]);
		}

		// for the purpose of querying documents, we are going to specify that an indexed array is an array which
		// only contains numeric keys, is sequential, the first key is zero, and not empty. This will allow us
		// to detect an array of key->vals that have numeric IDs vs an array of queries (where keys were not specified)
		$queryIsIndexedArray = !empty($query) && array_is_list($query);

		foreach ($query as $k => $q) {
			$pass = true;
			if (is_string($k) && substr($k, 0, 1) === '$') {
				// key is an operator at this level, except $not, which can be at any level
				if ($k === '$not') {
					$pass = !self::_executeQuery($q, $document, $options);
				} else {
					$pass = self::_executeQuery($q, $document, $options, $k);
				}
			} elseif ($logicalOperator === '$and') { // special case for $and
				if ($queryIsIndexedArray) { // $q is an array of query objects
					$pass = self::_executeQuery($q, $document, $options);
				} elseif (is_array($q)) { // query is array, run all queries on field.  All queries must match. e.g { 'age': { $gt: 24, $lt: 52 } }
					$pass = self::_executeQueryOnElement($q, $k, $document, $options);
				} else {
					// key value means equality
					$pass = self::_executeOperatorOnElement('$e', $q, $k, $document, $options);
				}
			} else { // $q is array of query objects e.g '$or' => [{'fullName' => 'Nick'}]
				$pass = self::_executeQuery($q, $document, $options, '$and');
			}
			switch ($logicalOperator) {
				case '$and': // if any fail, query fails
					if (!$pass) {
						return false;
					}
					break;
				case '$or': // if one succeeds, query succeeds
					if ($pass) {
						return true;
					}
					break;
				case '$nor': // if one succeeds, query fails
					if ($pass) {
						return false;
					}
					break;
				default:
					if ($options['_shouldLog']) {
						$options['logger']->warning('_executeQuery could not find logical operator', ['query' => $query, 'document' => $document, 'logicalOperator' => $logicalOperator]);
					}
					return false;
			}
		}
		switch ($logicalOperator) {
			case '$and': // all succeeded, query succeeds
				return true;
			case '$or': // all failed, query fails
				return false;
			case '$nor': // all failed, query succeeded
				return true;
			default:
				if ($options['_shouldLog']) {
					$options['logger']->warning('_executeQuery could not find logical operator', ['query' => $query, 'document' => $document, 'logicalOperator' => $logicalOperator]);
				}
				return false;
		}
	}

	/**
	 * Execute a query object on an element
	 *
	 * @throws Exception
	 */
	private static function _executeQueryOnElement(array $query, string $element, array &$document, array $options = []): bool {
		if ($options['_debug'] && $options['_shouldLog']) {
			$options['logger']->debug('_executeQueryOnElement called', ['query' => $query, 'element' => $element, 'document' => $document]);
		}
		// iterate through query operators
		foreach ($query as $op => $opVal) {
			if (!self::_executeOperatorOnElement($op, $opVal, $element, $document, $options)) {
				return false;
			}
		}
		return true;
	}

	/**
	 * Check if an operator is equal to a value
	 *
	 * Equality includes direct equality, regular expression match, and checking if the operator value is one of the values in an array value
	 *
	 * @param mixed $v
	 * @param mixed $operatorValue
	 */
	private static function _isEqual($v, $operatorValue): bool {
		if (is_array($v) && is_array($operatorValue)) {
			return $v == $operatorValue;
		}
		if (is_array($v)) {
			return in_array($operatorValue, $v);
		}
		if (is_string($operatorValue) && preg_match('/^\/(.*?)\/([a-z]*)$/i', $operatorValue, $matches)) {
			return (bool)preg_match('/' . $matches[1] . '/' . $matches[2], $v);
		}
		return $operatorValue === $v;
	}

	/**
	 * Execute a Mongo Operator on an element
	 *
	 * @param string $operator The operator to perform
	 * @param mixed $operatorValue The value to provide the operator
	 * @param string $element The target element.  Can be an object path eg price.shoes
	 * @param array $document The document in which to find the element
	 * @param array $options Options
	 * @throws Exception Exceptions on invalid operators, invalid unknown operator callback, and invalid operator values
	 */
	private static function _executeOperatorOnElement(string $operator, $operatorValue, string $element, array &$document, array $options = []): bool {
		if ($options['_debug'] && $options['_shouldLog']) {
			$options['logger']->debug('_executeOperatorOnElement called', ['operator' => $operator, 'operatorValue' => $operatorValue, 'element' => $element, 'document' => $document]);
		}

		if ($operator === '$not') {
			return !self::_executeQueryOnElement($operatorValue, $element, $document, $options);
		}

		$elementSpecifier = explode('.', $element);
		$v = & $document;
		$exists = true;
		foreach ($elementSpecifier as $index => $es) {
			if (empty($v)) {
				$exists = false;
				break;
			}
			if (isset($v[0])) {
				// value from document is an array, so we need to iterate through array and test the query on all elements of the array
				// if any elements match, then return true
				$newSpecifier = implode('.', array_slice($elementSpecifier, $index));
				foreach ($v as $item) {
					if (self::_executeOperatorOnElement($operator, $operatorValue, $newSpecifier, $item, $options)) {
						return true;
					}
				}
				return false;
			}
			if (isset($v[$es])) {
				$v = & $v[$es];
			} else {
				$exists = false;
				break;
			}
		}

		switch ($operator) {
			case '$all':
				if (!$exists) {
					return false;
				}
				if (!is_array($operatorValue)) {
					throw new Exception('$all requires array');
				}
				if (count($operatorValue) === 0) {
					return false;
				}
				if (!is_array($v)) {
					if (count($operatorValue) === 1) {
						return $v === $operatorValue[0];
					}
					return false;
				}
				return count(array_intersect($v, $operatorValue)) === count($operatorValue);
			case '$e':
				if (!$exists) {
					return false;
				}
				return self::_isEqual($v, $operatorValue);
			case '$in':
				if (!$exists) {
					return false;
				}
				if (!is_array($operatorValue)) {
					throw new Exception('$in requires array');
				}
				if (count($operatorValue) === 0) {
					return false;
				}
				if (is_array($v)) {
					return count(array_intersect($v, $operatorValue)) > 0;
				}
				return in_array($v, $operatorValue);
			case '$lt':		return $exists && $v < $operatorValue;
			case '$lte':	return $exists && $v <= $operatorValue;
			case '$gt':		return $exists && $v > $operatorValue;
			case '$gte':	return $exists && $v >= $operatorValue;
			case '$ne':		return (!$exists && $operatorValue !== null) || ($exists && !self::_isEqual($v, $operatorValue));
			case '$nin':
				if (!$exists) {
					return true;
				}
				if (!is_array($operatorValue)) {
					throw new Exception('$nin requires array');
				}
				if (count($operatorValue) === 0) {
					return true;
				}
				if (is_array($v)) {
					return count(array_intersect($v, $operatorValue)) === 0;
				}
				return !in_array($v, $operatorValue);

			case '$exists':	return ($operatorValue && $exists) || (!$operatorValue && !$exists);
			case '$mod':
				if (!$exists) {
					return false;
				}
				if (!is_array($operatorValue)) {
					throw new Exception('$mod requires array');
				}
				if (count($operatorValue) !== 2) {
					throw new Exception('$mod requires two parameters in array: divisor and remainder');
				}
				return $v % $operatorValue[0] === $operatorValue[1];

			default:
				if (empty($options['unknownOperatorCallback']) || !is_callable($options['unknownOperatorCallback'])) {
					throw new Exception('Operator ' . $operator . ' is unknown');
				}

				$res = call_user_func($options['unknownOperatorCallback'], $operator, $operatorValue, $element, $document);
				if ($res === null) {
					throw new Exception('Operator ' . $operator . ' is unknown');
				}
				if (!is_bool($res)) {
					throw new Exception('Return value of unknownOperatorCallback must be boolean, actual value ' . $res);
				}
				return $res;
		}
		throw new Exception('Didn\'t return in switch');
	}

	/**
	 * Get the fields this query depends on
	 *
	 * @param array query	The query to analyse
	 * @return array An array of fields this query depends on
	 */
	public static function getDependentFields(array $query) {
		$fields = [];
		foreach ($query as $k => $v) {
			if (is_array($v)) {
				$fields = array_merge($fields, static::getDependentFields($v));
			}
			if (is_int($k) || $k[0] === '$') {
				continue;
			}
			$fields[] = $k;
		}
		return array_unique($fields);
	}
}