1<?php
2// This file is part of Moodle - http://moodle.org/
3//
4// Moodle is free software: you can redistribute it and/or modify
5// it under the terms of the GNU General Public License as published by
6// the Free Software Foundation, either version 3 of the License, or
7// (at your option) any later version.
8//
9// Moodle is distributed in the hope that it will be useful,
10// but WITHOUT ANY WARRANTY; without even the implied warranty of
11// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12// GNU General Public License for more details.
13//
14// You should have received a copy of the GNU General Public License
15// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
16
17/**
18 * The library file for the MongoDB store plugin.
19 *
20 * This file is part of the MongoDB store plugin, it contains the API for interacting with an instance of the store.
21 *
22 * @package    cachestore_mongodb
23 * @copyright  2012 Sam Hemelryk
24 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
25 */
26
27defined('MOODLE_INTERNAL') || die();
28
29require_once('MongoDB/functions.php');
30
31/**
32 * The MongoDB Cache store.
33 *
34 * This cache store uses the MongoDB Native Driver and the MongoDB PHP Library.
35 * For installation instructions have a look at the following two links:
36 *  - {@link http://php.net/manual/en/set.mongodb.php}
37 *  - {@link https://docs.mongodb.com/ecosystem/drivers/php/}
38 *
39 * @copyright  2012 Sam Hemelryk
40 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
41 */
42class cachestore_mongodb extends cache_store implements cache_is_configurable {
43
44    /**
45     * The name of the store
46     * @var string
47     */
48    protected $name;
49
50    /**
51     * The server connection string. Comma separated values.
52     * @var string
53     */
54    protected $server = 'mongodb://127.0.0.1:27017';
55
56    /**
57     * The database connection options
58     * @var array
59     */
60    protected $options = array();
61
62    /**
63     * The name of the database to use.
64     * @var string
65     */
66    protected $databasename = 'mcache';
67
68    /**
69     * The Connection object
70     * @var MongoDB/Client
71     */
72    protected $connection = false;
73
74    /**
75     * The Database Object
76     * @var MongoDB/Database
77     */
78    protected $database;
79
80    /**
81     * The Collection object
82     * @var MongoDB/Collection
83     */
84    protected $collection;
85
86    /**
87     * Determines if and what safe setting is to be used.
88     * @var bool|int
89     */
90    protected $usesafe = true;
91
92    /**
93     * If set to true then multiple identifiers will be requested and used.
94     * @var bool
95     */
96    protected $extendedmode = false;
97
98    /**
99     * The definition has which is used in the construction of the collection.
100     * @var string
101     */
102    protected $definitionhash = null;
103
104    /**
105     * Set to true once this store is ready to be initialised and used.
106     * @var bool
107     */
108    protected $isready = false;
109
110    /**
111     * Constructs a new instance of the Mongo store.
112     *
113     * Noting that this function is not an initialisation. It is used to prepare the store for use.
114     * The store will be initialised when required and will be provided with a cache_definition at that time.
115     *
116     * @param string $name
117     * @param array $configuration
118     */
119    public function __construct($name, array $configuration = array()) {
120        $this->name = $name;
121
122        if (array_key_exists('server', $configuration)) {
123            $this->server = $configuration['server'];
124        }
125
126        if (array_key_exists('replicaset', $configuration)) {
127            $this->options['replicaSet'] = (string)$configuration['replicaset'];
128        }
129        if (array_key_exists('username', $configuration) && !empty($configuration['username'])) {
130            $this->options['username'] = (string)$configuration['username'];
131        }
132        if (array_key_exists('password', $configuration) && !empty($configuration['password'])) {
133            $this->options['password'] = (string)$configuration['password'];
134        }
135        if (array_key_exists('database', $configuration)) {
136            $this->databasename = (string)$configuration['database'];
137        }
138        if (array_key_exists('usesafe', $configuration)) {
139            $this->usesafe = $configuration['usesafe'];
140        }
141        if (array_key_exists('extendedmode', $configuration)) {
142            $this->extendedmode = $configuration['extendedmode'];
143        }
144
145        try {
146            $this->connection = new MongoDB\Client($this->server, $this->options);
147            // Required because MongoDB\Client does not try to connect to the server
148            $rp = new MongoDB\Driver\ReadPreference(MongoDB\Driver\ReadPreference::RP_PRIMARY);
149            $this->connection->getManager()->selectServer($rp);
150            $this->isready = true;
151        } catch (MongoDB\Driver\Exception\RuntimeException $e) {
152            // We only want to catch RuntimeException here.
153        }
154    }
155
156    /**
157     * Returns true if the requirements of this store have been met.
158     * @return bool
159     */
160    public static function are_requirements_met() {
161        return version_compare(phpversion('mongodb'), '1.5', 'ge');
162    }
163
164    /**
165     * Returns the supported features.
166     * @param array $configuration
167     * @return int
168     */
169    public static function get_supported_features(array $configuration = array()) {
170        $supports = self::SUPPORTS_DATA_GUARANTEE + self::DEREFERENCES_OBJECTS;
171        if (array_key_exists('extendedmode', $configuration) && $configuration['extendedmode']) {
172            $supports += self::SUPPORTS_MULTIPLE_IDENTIFIERS;
173        }
174        return $supports;
175    }
176
177    /**
178     * Returns an int describing the supported modes.
179     * @param array $configuration
180     * @return int
181     */
182    public static function get_supported_modes(array $configuration = array()) {
183        return self::MODE_APPLICATION;
184    }
185
186    /**
187     * Initialises the store instance for use.
188     *
189     * Once this has been done the cache is all set to be used.
190     *
191     * @param cache_definition $definition
192     * @throws coding_exception
193     */
194    public function initialise(cache_definition $definition) {
195        if ($this->is_initialised()) {
196            throw new coding_exception('This mongodb instance has already been initialised.');
197        }
198        $this->database = $this->connection->selectDatabase($this->databasename);
199        $this->definitionhash = 'm'.$definition->generate_definition_hash();
200        $this->collection = $this->database->selectCollection($this->definitionhash);
201
202        $options = array('name' => 'idx_key');
203
204        $w = $this->usesafe ? 1 : 0;
205        $wc = new MongoDB\Driver\WriteConcern($w);
206
207        $options['writeConcern'] = $wc;
208
209        $this->collection->createIndex(array('key' => 1), $options);
210    }
211
212    /**
213     * Returns true if this store instance has been initialised.
214     * @return bool
215     */
216    public function is_initialised() {
217        return ($this->database instanceof MongoDB\Database);
218    }
219
220    /**
221     * Returns true if this store instance is ready to use.
222     * @return bool
223     */
224    public function is_ready() {
225        return $this->isready;
226    }
227
228    /**
229     * Returns true if the given mode is supported by this store.
230     * @param int $mode
231     * @return bool
232     */
233    public static function is_supported_mode($mode) {
234        return ($mode == self::MODE_APPLICATION || $mode == self::MODE_SESSION);
235    }
236
237    /**
238     * Returns true if this store is making use of multiple identifiers.
239     * @return bool
240     */
241    public function supports_multiple_identifiers() {
242        return $this->extendedmode;
243    }
244
245    /**
246     * Retrieves an item from the cache store given its key.
247     *
248     * @param string $key The key to retrieve
249     * @return mixed The data that was associated with the key, or false if the key did not exist.
250     */
251    public function get($key) {
252        if (!is_array($key)) {
253            $key = array('key' => $key);
254        }
255
256        $result = $this->collection->findOne($key);
257        // Note $result is really an object, BSONDocument extending ArrayObject,
258        // which implements ArrayAccess. That enables access to its information
259        // using square brackets and some array operations. But, it seems that
260        // it's not enough for array_key_exists() to operate on it. Hence, we
261        // are explicitly casting to array, after having checked that the operation
262        // doesn't incur into any performance penalty.
263        if ($result === null || !array_key_exists('data', (array)$result)) {
264            return false;
265        }
266        $data = @unserialize($result['data']);
267        return $data;
268    }
269
270    /**
271     * Retrieves several items from the cache store in a single transaction.
272     *
273     * If not all of the items are available in the cache then the data value for those that are missing will be set to false.
274     *
275     * @param array $keys The array of keys to retrieve
276     * @return array An array of items from the cache.
277     */
278    public function get_many($keys) {
279        if ($this->extendedmode) {
280            $query = $this->get_many_extendedmode_query($keys);
281            $keyarray = array();
282            foreach ($keys as $key) {
283                $keyarray[] = $key['key'];
284            }
285            $keys = $keyarray;
286            $query = array('key' => array('$in' => $keys));
287        } else {
288            $query = array('key' => array('$in' => $keys));
289        }
290        $cursor = $this->collection->find($query);
291        $results = array();
292        foreach ($cursor as $result) {
293            $id = (string)$result['key'];
294            $results[$id] = unserialize($result['data']);
295        }
296        foreach ($keys as $key) {
297            if (!array_key_exists($key, $results)) {
298                $results[$key] = false;
299            }
300        }
301        return $results;
302    }
303
304    /**
305     * Sets an item in the cache given its key and data value.
306     *
307     * @param string $key The key to use.
308     * @param mixed $data The data to set.
309     * @return bool True if the operation was a success false otherwise.
310     */
311    public function set($key, $data) {
312        if (!is_array($key)) {
313            $record = array(
314                'key' => $key
315            );
316        } else {
317            $record = $key;
318        }
319        $record['data'] = serialize($data);
320        $options = array('upsert' => true);
321
322        $w = $this->usesafe ? 1 : 0;
323        $wc = new MongoDB\Driver\WriteConcern($w);
324
325        $options['writeConcern'] = $wc;
326
327        $this->delete($key);
328        try {
329            $this->collection->insertOne($record, $options);
330        } catch (MongoDB\Exception\Exception $e) {
331            return false;
332        }
333
334        return true;
335    }
336
337    /**
338     * Sets many items in the cache in a single transaction.
339     *
340     * @param array $keyvaluearray An array of key value pairs. Each item in the array will be an associative array with two
341     *      keys, 'key' and 'value'.
342     * @return int The number of items successfully set. It is up to the developer to check this matches the number of items
343     *      sent ... if they care that is.
344     */
345    public function set_many(array $keyvaluearray) {
346        $count = 0;
347        foreach ($keyvaluearray as $pair) {
348            $result = $this->set($pair['key'], $pair['value']);
349            if ($result === true) {
350                 $count++;
351            }
352        }
353        return $count;
354    }
355
356    /**
357     * Deletes an item from the cache store.
358     *
359     * @param string $key The key to delete.
360     * @return bool Returns true if the operation was a success, false otherwise.
361     */
362    public function delete($key) {
363        if (!is_array($key)) {
364            $criteria = array(
365                'key' => $key
366            );
367        } else {
368            $criteria = $key;
369        }
370        $options = array('justOne' => false);
371
372        $w = $this->usesafe ? 1 : 0;
373        $wc = new MongoDB\Driver\WriteConcern($w);
374
375        $options['writeConcern'] = $wc;
376
377        try {
378            $result = $this->collection->deleteOne($criteria, $options);
379        } catch (\MongoDB\Exception $e) {
380            return false;
381        }
382
383        if (empty($result->getDeletedCount())) {
384            return false;
385        }
386
387        return true;
388    }
389
390    /**
391     * Deletes several keys from the cache in a single action.
392     *
393     * @param array $keys The keys to delete
394     * @return int The number of items successfully deleted.
395     */
396    public function delete_many(array $keys) {
397        $count = 0;
398        foreach ($keys as $key) {
399            if ($this->delete($key)) {
400                $count++;
401            }
402        }
403        return $count;
404    }
405
406    /**
407     * Purges the cache deleting all items within it.
408     *
409     * @return boolean True on success. False otherwise.
410     */
411    public function purge() {
412        if ($this->isready) {
413            $this->collection->drop();
414            $this->collection = $this->database->selectCollection($this->definitionhash);
415        }
416
417        return true;
418    }
419
420    /**
421     * Takes the object from the add instance store and creates a configuration array that can be used to initialise an instance.
422     *
423     * @param stdClass $data
424     * @return array
425     */
426    public static function config_get_configuration_array($data) {
427        $return = array(
428            'server' => $data->server,
429            'database' => $data->database,
430            'extendedmode' => (!empty($data->extendedmode))
431        );
432        if (!empty($data->username)) {
433            $return['username'] = $data->username;
434        }
435        if (!empty($data->password)) {
436            $return['password'] = $data->password;
437        }
438        if (!empty($data->replicaset)) {
439            $return['replicaset'] = $data->replicaset;
440        }
441        if (!empty($data->usesafe)) {
442            $return['usesafe'] = true;
443            if (!empty($data->usesafevalue)) {
444                $return['usesafe'] = (int)$data->usesafevalue;
445                $return['usesafevalue'] = $return['usesafe'];
446            }
447        }
448        return $return;
449    }
450
451    /**
452     * Allows the cache store to set its data against the edit form before it is shown to the user.
453     *
454     * @param moodleform $editform
455     * @param array $config
456     */
457    public static function config_set_edit_form_data(moodleform $editform, array $config) {
458        $data = array();
459        if (!empty($config['server'])) {
460            $data['server'] = $config['server'];
461        }
462        if (!empty($config['database'])) {
463            $data['database'] = $config['database'];
464        }
465        if (isset($config['extendedmode'])) {
466            $data['extendedmode'] = (bool)$config['extendedmode'];
467        }
468        if (!empty($config['username'])) {
469            $data['username'] = $config['username'];
470        }
471        if (!empty($config['password'])) {
472            $data['password'] = $config['password'];
473        }
474        if (!empty($config['replicaset'])) {
475            $data['replicaset'] = $config['replicaset'];
476        }
477        if (isset($config['usesafevalue'])) {
478            $data['usesafe'] = true;
479            $data['usesafevalue'] = (int)$data['usesafe'];
480        } else if (isset($config['usesafe'])) {
481            $data['usesafe'] = (bool)$config['usesafe'];
482        }
483        $editform->set_data($data);
484    }
485
486    /**
487     * Performs any necessary clean up when the store instance is being deleted.
488     */
489    public function instance_deleted() {
490        // We can't use purge here that acts upon a collection.
491        // Instead we must drop the named database.
492        if (!$this->is_ready()) {
493            return;
494        }
495        $database = $this->connection->selectDatabase($this->databasename);
496        $database->drop();
497        $connection = null;
498        $database = null;
499        // Explicitly unset things to cause a close.
500        $this->collection = null;
501        $this->database = null;
502        $this->connection = null;
503    }
504
505    /**
506     * Generates an instance of the cache store that can be used for testing.
507     *
508     * @param cache_definition $definition
509     * @return false
510     */
511    public static function initialise_test_instance(cache_definition $definition) {
512        if (!self::are_requirements_met()) {
513            return false;
514        }
515
516        $config = get_config('cachestore_mongodb');
517        if (empty($config->testserver)) {
518            return false;
519        }
520        $configuration = array();
521        $configuration['server'] = $config->testserver;
522        if (!empty($config->testreplicaset)) {
523            $configuration['replicaset'] = $config->testreplicaset;
524        }
525        if (!empty($config->testusername)) {
526            $configuration['username'] = $config->testusername;
527        }
528        if (!empty($config->testpassword)) {
529            $configuration['password'] = $config->testpassword;
530        }
531        if (!empty($config->testdatabase)) {
532            $configuration['database'] = $config->testdatabase;
533        }
534        $configuration['usesafe'] = 1;
535        if (!empty($config->testextendedmode)) {
536            $configuration['extendedmode'] = (bool)$config->testextendedmode;
537        }
538
539        $store = new cachestore_mongodb('Test mongodb', $configuration);
540        if (!$store->is_ready()) {
541            return false;
542        }
543        $store->initialise($definition);
544
545        return $store;
546    }
547
548    /**
549     * Generates an instance of the cache store that can be used for testing.
550     *
551     * @param cache_definition $definition
552     * @return false
553     */
554    public static function unit_test_configuration() {
555        $configuration = array();
556        $configuration['usesafe'] = 1;
557
558        // If the configuration is not defined correctly, return only the configuration know about.
559        if (defined('TEST_CACHESTORE_MONGODB_TESTSERVER')) {
560            $configuration['server'] = TEST_CACHESTORE_MONGODB_TESTSERVER;
561        }
562
563        return $configuration;
564    }
565
566    /**
567     * Returns the name of this instance.
568     * @return string
569     */
570    public function my_name() {
571        return $this->name;
572    }
573
574    /**
575     * Returns true if this cache store instance is both suitable for testing, and ready for testing.
576     *
577     * Cache stores that support being used as the default store for unit and acceptance testing should
578     * override this function and return true if there requirements have been met.
579     *
580     * @return bool
581     */
582    public static function ready_to_be_used_for_testing() {
583        return defined('TEST_CACHESTORE_MONGODB_TESTSERVER');
584    }
585}
586