Added References, TODO: Dynamic references
[activemongo.git] / ActiveMongo.php
blob97253f378c5dc9b182d60fdd7fc634a0b0ea2806
1 <?php
2 /*
3 +---------------------------------------------------------------------------------+
4 | Copyright (c) 2010 ActiveMongo |
5 +---------------------------------------------------------------------------------+
6 | Redistribution and use in source and binary forms, with or without |
7 | modification, are permitted provided that the following conditions are met: |
8 | 1. Redistributions of source code must retain the above copyright |
9 | notice, this list of conditions and the following disclaimer. |
10 | |
11 | 2. Redistributions in binary form must reproduce the above copyright |
12 | notice, this list of conditions and the following disclaimer in the |
13 | documentation and/or other materials provided with the distribution. |
14 | |
15 | 3. All advertising materials mentioning features or use of this software |
16 | must display the following acknowledgement: |
17 | This product includes software developed by César D. Rodas. |
18 | |
19 | 4. Neither the name of the César D. Rodas nor the |
20 | names of its contributors may be used to endorse or promote products |
21 | derived from this software without specific prior written permission. |
22 | |
23 | THIS SOFTWARE IS PROVIDED BY CÉSAR D. RODAS ''AS IS'' AND ANY |
24 | EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED |
25 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE |
26 | DISCLAIMED. IN NO EVENT SHALL CÉSAR D. RODAS BE LIABLE FOR ANY |
27 | DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES |
28 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; |
29 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND |
30 | ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT |
31 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS |
32 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE |
33 +---------------------------------------------------------------------------------+
34 | Authors: César Rodas <crodas@php.net> |
35 +---------------------------------------------------------------------------------+
39 // Class FilterException {{{
40 /**
41 * FilterException
43 * This is Exception is thrown if any validation
44 * fails when save() is called.
47 final class FilterException extends Exception
50 // }}}
52 // array get_object_vars_ex(stdobj $obj) {{{
53 /**
54 * Simple hack to avoid get private and protected variables
56 * @param obj
58 * @return array
60 function get_object_vars_ex($obj)
62 return get_object_vars($obj);
64 // }}}
66 /**
67 * ActiveMongo
69 * Simple ActiveRecord pattern built on top of MongoDB. This class
70 * aims to provide easy iteration, data validation before update,
71 * and efficient update.
73 * @author César D. Rodas <crodas@php.net>
74 * @license PHP License
75 * @package ActiveMongo
76 * @version 1.0
79 abstract class ActiveMongo implements Iterator
82 // properties {{{
83 /**
84 * Current databases objects
86 * @type array
88 private static $_dbs;
89 /**
90 * Current collections objects
92 * @type array
94 private static $_collections;
95 /**
96 * Current connection to MongoDB
98 * @type MongoConnection
100 private static $_conn;
102 * Database name
104 * @type string
106 private static $_db;
108 * Host name
110 * @type string
112 private static $_host;
114 * Current document
116 * @type array
118 private $_current = array();
120 * Result cursor
122 * @type MongoCursor
124 private $_cursor = null;
126 * Current document ID
128 * @type MongoID
130 private $_id;
132 private $_cloned = false;
133 // }}}
135 // string getCollectionName() {{{
137 * Get Collection Name, by default the class name,
138 * but you it can be override at the class itself to give
139 * a custom name.
141 * @return string Collection Name
143 protected function getCollectionName()
145 return strtolower(get_class($this));
147 // }}}
149 // string getDatabaseName() {{{
151 * Get Database Name, by default it is used
152 * the db name set by ActiveMong::connect()
154 * @return string DB Name
156 protected function getDatabaseName()
158 if (is_null(self::$_db)) {
159 throw new MongoException("There is no information about the default DB name");
161 return self::$_db;
163 // }}}
165 // void install() {{{
167 * Install.
169 * This static method iterate over the classes lists,
170 * and execute the setup() method on every ActiveMongo
171 * subclass. You should do this just once.
174 final public static function install()
176 $classes = array_reverse(get_declared_classes());
177 foreach ($classes as $class)
179 if ($class == __CLASS__) {
180 break;
182 if (is_subclass_of($class, __CLASS__)) {
183 $obj = new $class;
184 $obj->setup();
188 // }}}
190 // void connection($db, $host) {{{
192 * Connect
194 * This method setup parameters to connect to a MongoDB
195 * database. The connection is done when it is needed.
197 * @param string $db Database name
198 * @param string $host Host to connect
200 * @return void
202 final public static function connect($db, $host='localhost')
204 self::$_host = $host;
205 self::$_db = $db;
207 // }}}
209 // MongoConnection _getConnection() {{{
211 * Get Connection
213 * Get a valid database connection
215 * @return MongoConnection
217 final protected function _getConnection()
219 if (is_null(self::$_conn)) {
220 if (is_null(self::$_host)) {
221 self::$_host = 'localhost';
223 self::$_conn = new Mongo(self::$_host);
225 $dbname = $this->getDatabaseName();
226 if (!isSet(self::$_dbs[$dbname])) {
227 self::$_dbs[$dbname] = self::$_conn->selectDB($dbname);
229 return self::$_dbs[$dbname];
231 // }}}
233 // MongoCollection _getCollection() {{{
235 * Get Collection
237 * Get a collection connection.
239 * @return MongoCollection
241 final protected function _getCollection()
243 $colName = $this->getCollectionName();
244 if (!isset(self::$_collections[$colName])) {
245 self::$_collections[$colName] = self::_getConnection()->selectCollection($colName);
247 return self::$_collections[$colName];
249 // }}}
251 // bool getCurrentSubDocument(array &$document, string $parent_key, array $values, array $past_values) {{{
253 * Generate Sub-document
255 * This method build the difference between the current sub-document,
256 * and the origin one. If there is no difference, it would do nothing,
257 * otherwise it would build a document containing the differences.
259 * @param array &$document Document target
260 * @param string $parent_key Parent key name
261 * @param array $values Current values
262 * @param array $past_values Original values
264 * @return false
266 final function getCurrentSubDocument(&$document, $parent_key, Array $values, Array $past_values)
269 * The current property is a embedded-document,
270 * now we're looking for differences with the
271 * previous value (because we're on an update).
273 * It behaves exactly as getCurrentDocument,
274 * but this is simples (it doesn't support
275 * yet filters)
277 foreach ($values as $key => $value) {
278 $super_key = "{$parent_key}.{$key}";
279 if (is_array($value)) {
281 * Inner document detected
283 if (!isset($past_values[$key]) || !is_array($past_values[$key])) {
285 * We're lucky, it is a new sub-document,
286 * we simple add it
288 $document['$set'][$super_key] = $value;
289 } else {
291 * This is a document like this, we need
292 * to find out the differences to avoid
293 * network overhead.
295 if (!$this->getCurrentSubDocument($document, $super_key, $value, $past_values[$key])) {
296 return false;
299 continue;
300 } else if (!isset($past_values[$key]) || $past_values[$key] != $value) {
301 $document['$set'][$super_key] = $value;
305 foreach (array_diff(array_keys($past_values), array_keys($values)) as $key) {
306 $super_key = "{$parent_key}.{$key}";
307 $document['$unset'][$super_key] = 1;
310 return true;
312 // }}}
314 // array getCurrentDocument(bool $update) {{{
316 * Get Current Document
318 * Based on this object properties a new document (Array)
319 * is returned. If we're modifying an document, just the modified
320 * properties are included in this document, which uses $set,
321 * $unset, $pushAll and $pullAll.
324 * @param bool $update
326 * @return array
328 final protected function getCurrentDocument($update=false, $current=false)
330 $document = array();
331 $object = get_object_vars_ex($this);
333 if (!$current) {
334 $current = (array)$this->_current;
337 $this->findReferences($object);
339 foreach ($object as $key => $value) {
340 if (!$value) {
341 continue;
343 if ($update) {
344 if (is_array($value) && isset($current[$key])) {
346 * If the Field to update is an array, it has a different
347 * behaviour other than $set and $unset. Fist, we need
348 * need to check if it is an array or document, because
349 * they can't be mixed.
352 if (!is_array($current[$key])) {
354 * We're lucky, the field wasn't
355 * an array previously.
357 $this->_call_filter($key, $value, $current[$key]);
358 $document['$set'][$key] = $value;
359 continue;
362 if (!$this->getCurrentSubDocument($document, $key, $value, $current[$key])) {
363 throw new Exception("{$key}: Array and documents are not compatible");
365 } else if(!isset($current[$key]) || $value !== $current[$key]) {
367 * It is 'linear' field that has changed, or
368 * has been modified.
370 $past_value = isset($current[$key]) ? $current[$key] : null;
371 $this->_call_filter($key, $value, $past_value);
372 $document['$set'][$key] = $value;
374 } else {
376 * It is a document insertation, so we
377 * create the document.
379 $this->_call_filter($key, $value, null);
380 $document[$key] = $value;
384 /* Updated behaves in a diff. way */
385 if ($update) {
386 foreach (array_diff(array_keys($this->_current), array_keys($object)) as $property) {
387 if ($property == '_id') {
388 continue;
390 $document['$unset'][$property] = 1;
394 if (count($document) == 0) {
395 return array();
397 return $document;
399 // }}}
401 // void _call_filter(string $key, mixed &$value, mixed $past_value) {{{
403 * *Internal Method*
405 * This method check if the current document property has
406 * a filter method, if so, call it.
408 * If the filter returns false, throw an Exception.
410 * @return void
412 private function _call_filter($key, &$value, $past_value)
414 $filter = array($this, "{$key}_filter");
415 if (is_callable($filter)) {
416 $filter = call_user_func_array($filter, array(&$value, $past_value));
417 if ($filter===false) {
418 throw new FilterException("{$key} filter failed");
420 $this->$key = $value;
423 // }}}
425 // void setCursor(MongoCursor $obj) {{{
427 * Set Cursor
429 * This method receive a MongoCursor and make
430 * it iterable.
432 * @param MongoCursor $obj
434 * @return void
436 final protected function setCursor(MongoCursor $obj)
438 $this->_cursor = $obj;
439 $this->setResult($obj->getNext());
441 // }}}
443 // void reset() {{{
445 * Reset our Object, delete the current cursor if any, and reset
446 * unsets the values.
448 * @return void
450 final function reset()
452 $this->_count = 0;
453 $this->_cursor = null;
454 $this->setResult(array());
456 // }}}
458 // void setResult(Array $obj) {{{
460 * Set Result
462 * This method takes an document and copy it
463 * as properties in this object.
465 * @param Array $obj
467 * @return void
469 final protected function setResult($obj)
471 /* Unsetting previous results, if any */
472 foreach (array_keys((array)$this->_current) as $key) {
473 unset($this->$key);
476 /* Add our current resultset as our object's property */
477 foreach ((array)$obj as $key => $value) {
478 if ($key[0] == '$') {
479 continue;
481 $this->$key = $value;
484 /* Save our record */
485 $this->_current = $obj;
487 // }}}
489 // this find([$_id]) {{{
491 * Simple find.
493 * Really simple find, which uses this object properties
494 * for fast filtering
496 * @return object this
498 final function find($_id = null)
500 $vars = get_object_vars_ex($this);
501 foreach ($vars as $key => $value) {
502 if (!$value) {
503 unset($vars[$key]);
505 if ($value InstanceOf ActiveMongo) {
506 $this->getColumnDeference($vars, $key, $value);
507 unset($vars[$key]); /* delete old value */
510 if ($_id != null) {
511 if (is_array($_id)) {
512 $vars['_id'] = array('$in' => $_id);
513 } else {
514 $vars['_id'] = $_id;
517 $res = $this->_getCollection()->find($vars);
518 $this->setCursor($res);
519 return $this;
521 // }}}
523 // void save(bool $async) {{{
525 * Save
527 * This method save the current document in MongoDB. If
528 * we're modifying a document, a update is performed, otherwise
529 * the document is inserted.
531 * On updates, special operations such as $set, $pushAll, $pullAll
532 * and $unset in order to perform efficient updates
534 * @param bool $async
536 * @return void
538 final function save($async=true)
540 $update = isset($this->_id) && $this->_id InstanceOf MongoID;
541 $conn = $this->_getCollection();
542 $obj = $this->getCurrentDocument($update);
543 if (count($obj) == 0) {
544 return; /*nothing to do */
547 /* PRE-save hook */
548 $this->pre_save($update ? 'update' : 'create', $obj);
549 if ($update) {
550 $conn->update(array('_id' => $this->_id), $obj);
551 foreach ($obj as $key => $value) {
552 if ($key[0] == '$') {
553 continue;
555 $this->_current[$key] = $value;
557 $this->on_update();
558 } else {
559 $conn->insert($obj, $async);
560 $this->_id = $obj['_id'];
561 $this->_current = $obj;
562 $this->on_save();
565 // }}}
567 // bool delete() {{{
569 * Delete the current document
571 * @return bool
573 final function delete()
575 if ($this->valid()) {
576 return $this->_getCollection()->remove(array('_id' => $this->_id));
578 return false;
580 // }}}
582 // void drop() {{{
584 * Delete the current colleciton and all its documents
586 * @return void
588 final static function drop()
590 $class = get_called_class();
591 if ($class == __CLASS__) {
592 return false;
594 $obj = new $class;
595 return $obj->_getCollection()->drop();
597 // }}}
599 // int count() {{{
601 * Return the number of documents in the actual request. If
602 * we're not in a request, it will return 0.
604 * @return int
606 final function count()
608 if ($this->valid()) {
609 return $this->_cursor->count();
611 return 0;
613 // }}}
615 // ITERATOR {{{
617 // bool valid() {{{
619 * Valid
621 * Return if we're on an iteration and if it is still valid
623 * @return true
625 final function valid()
627 return $this->_cursor InstanceOf MongoCursor && $this->_cursor->valid();
629 // }}}
631 // bool next() {{{
633 * Move to the next document
635 * @return bool
637 final function next()
639 if ($this->_cloned) {
640 throw new MongoException("Cloned objects can't iterate");
642 return $this->_cursor->next();
644 // }}}
646 // this current() {{{
648 * Return the current object, and load the current document
649 * as this object property
651 * @return object
653 final function current()
655 $this->setResult($this->_cursor->current());
656 return $this;
658 // }}}
660 // bool rewind() {{{
662 * Go to the first document
664 final function rewind()
666 return $this->_cursor->rewind();
668 // }}}
670 // }}}
672 // REFERENCES {{{
674 // array getReference() {{{
676 * ActiveMongo extended the Mongo references, adding
677 * the concept of 'dynamic' requests, saving in the database
678 * the current query with its options (sort, limit, etc).
680 * This is useful to associate a document with a given
681 * request. To undestand this better please see the 'reference'
682 * example.
684 * @return array
686 final function getReference($dynamic=false)
688 if (!$this->getID()) {
689 return null;
692 $document = array(
693 '$ref' => $this->getCollectionName(),
694 '$id' => $this->getID(),
695 '$db' => $this->getDatabaseName(),
696 'class' => get_class($this),
699 if ($dynamic && $this->valid()) {
700 $cursor = $this->_cursor;
701 if (!is_callable(array($cursor, "getQuery"))) {
702 throw new Exception("Please upgrade your PECL/Mongo module to use this feature");
704 $document['dynamic'] = array();
705 $query = $cursor->getQuery();
706 foreach ($query as $type => $value) {
707 $document['dynamic'][$type] = $value;
710 return $document;
712 // }}}
714 // void getCurrentReferences($document, &$refs) {{{
716 * Get Current References
718 * Inspect the current document trying to get any references,
719 * if any.
721 * @param array $document Current document
722 * @param array &$refs References found in the document.
723 * @param array $parent_key Parent key
725 * @return void
727 final protected function getCurrentReferences($document, &$refs, $parent_key=null)
729 foreach ($document as $key => $value) {
730 if (is_array($value)) {
731 if (MongoDBRef::isRef($value)) {
732 $pkey = $parent_key;
733 $pkey[] = $key;
734 $refs[] = array('ref' => $value, 'key' => $pkey);
735 } else {
736 $parent_key[] = $key;
737 $this->getCurrentReferences($value, $refs, $parent_key);
742 // }}}
744 // void doDeferencing() {{{
746 * Perform a deferencing in the current document, if there is
747 * any reference.
749 * ActiveMongo will do its best to group references queries as much
750 * as possible, in order to perform as less request as possible.
752 * ActiveMongo doesn't rely on MongoDB references, but it can support
753 * it, but it is prefered to use our referencing.
755 * @experimental
757 final function doDeferencing($refs=array())
759 /* Get current document */
760 $document = get_object_vars_ex($this);
762 if (count($refs)==0) {
763 /* Inspect the whole document */
764 $this->getCurrentReferences($document, $refs);
767 $db = $this->_getConnection();
769 /* Gather information about ActiveMongo Objects
770 * that we need to create
772 $classes = array();
773 foreach ($refs as $ref) {
774 if (!isset($ref['ref']['class'])) {
776 /* Support MongoDBRef {{{ */
777 /* MongoDB 'normal' reference */
778 /* Offset the current document to the right spot */
779 /* Very inefficient, never use it, instead use ActiveMongo References */
780 $obj = & $document;
781 foreach ($ref['key'] as $key) {
782 $obj = & $obj[$key];
784 $obj = MongoDBRef::get($db, $ref['ref']);
786 /* Dirty hack, override our current document
787 * property with the value itself, in order to
788 * avoid replace a MongoDB reference by its content
790 $_obj = & $this->_current;
791 foreach ($ref['key'] as $key) {
792 $_obj = & $_obj[$key];
794 $_obj = MongoDBRef::get($db, $ref['ref']);
797 /* Delete reference variable */
798 unset($obj, $_obj);
799 /* }}} */
801 } else {
802 /* ActiveMongo Reference FTW! */
803 $classes[$ref['ref']['class']][] = $ref;
807 /* {{{ Create needed objects to query MongoDB and replace
808 * our references by its objects documents.
811 foreach ($classes as $class => $refs) {
812 $req = new $class;
814 /* Load list of IDs */
815 $ids = array();
816 foreach ($refs as $ref) {
817 $ids[] = $ref['ref']['$id'];
820 /* Search to MongoDB once for all IDs found */
821 $req->find($ids);
823 if ($req->count() != count($refs)) {
824 $total = $req->count();
825 $expected = count($refs);
826 throw new MongoException("Dereferencing error, MongoDB replied {$total} objects, we expected {$expected}");
829 /* Replace our references by its objects */
830 foreach ($refs as $ref) {
831 $id = $ref['ref']['$id'];
832 $place = $ref['key'];
833 $req->rewind();
834 while ($req->getID() != $id && $req->next());
836 assert($req->getID() == $id);
838 $obj = & $document;
839 foreach ($ref['key'] as $key) {
840 $obj = & $obj[$key];
842 $obj = clone $req;
844 /* Delete reference variable */
845 unset($obj);
848 /* Release request, remember we
849 * safely cloned it,
851 unset($req);
853 // }}}
855 /* Replace the current document by the new deferenced objects */
856 foreach ($document as $key => $value) {
857 $this->$key = $value;
860 // }}}
862 // void getColumnDeference(&$document, $propety, ActiveMongo Obj) {{{
864 * Prepare a "selector" document to search treaing the property
865 * as a reference to the given ActiveMongo object.
868 final function getColumnDeference(&$document, $property, ActiveMongo $obj)
870 $document["{$property}.\$id"] = $obj->getID();
872 // }}}
874 // void findReferences(&$document) {{{
876 * Check if in the current document to insert or update
877 * exists any references to other ActiveMongo Objects.
879 * @return void
881 final function findReferences(&$document)
883 if (!is_array($document)) {
884 return;
886 foreach($document as &$value) {
887 if ($value InstanceOf ActiveMongo) {
888 $value = $value->getReference();
889 } else if (is_array($value)) {
890 $this->findReferences($value);
893 /* trick: delete last var. reference */
894 unset($value);
896 // }}}
898 // void __clone() {{{
899 /**
900 * Cloned objects are rarely used, but ActiveMongo
901 * uses it to create different objects per everyrecord,
902 * which is used at deferencing. Therefore cloned object
903 * do not contains the recordset, just the actual document,
904 * so iterations are not allowed.
907 final function __clone()
909 unset($this->_cursor);
910 $this->_cloned = true;
912 // }}}
914 // }}}
916 // GET DOCUMENT ID {{{
918 // getID() {{{
920 * Return the current document ID. If there is
921 * no document it would return false.
923 * @return object|false
925 final public function getID()
927 if ($this->_id instanceof MongoID) {
928 return $this->_id;
930 return false;
932 // }}}
934 // string key() {{{
936 * Return the current key
938 * @return string
940 final function key()
942 return $this->getID();
944 // }}}
946 // }}}
948 // HOOKS {{{
950 // void pre_save($action, & $document) {{{
952 * PRE-save Hook,
954 * This method is fired just before an insert or updated. The document
955 * is passed by reference, so it can be modified. Also if for instance
956 * one property is missing an Exception could be thrown to avoid
957 * the insert.
960 * @param string $action Update or Create
961 * @param array &$document Document that will be sent to MongoDB.
963 * @return void
965 protected function pre_save($action, Array &$document)
968 // }}}
970 // void on_save() {{{
972 * On Save hook
974 * This method is fired right after an insert is performed.
976 * @return void
978 protected function on_save()
981 // }}}
983 // void on_update() {{{
985 * On Update hook
987 * This method is fired right after an update is performed.
989 * @return void
991 protected function on_update()
994 // }}}
996 // void on_iterate() {{{
998 * On Iterate Hook
1000 * This method is fired right after a new document is loaded
1001 * from the recorset, it could be useful to load references to other
1002 * documents.
1004 * @return void
1006 protected function on_iterate()
1009 // }}}
1011 // }}}
1013 // void setup() {{{
1015 * This method should contain all the indexes, and shard keys
1016 * needed by the current collection. This try to make
1017 * installation on development environments easier.
1019 function setup()
1022 // }}}
1024 // bool addIndex(array $columns, array $options) {{{
1026 * addIndex
1028 * Create an Index in the current collection.
1030 * @param array $columns L ist of columns
1031 * @param array $options Options
1033 * @return bool
1035 final function addIndex($columns, $options=array())
1037 $default_options = array(
1038 'background' => 1,
1041 foreach ($default_options as $option => $value) {
1042 if (!isset($options[$option])) {
1043 $options[$option] = $value;
1047 $collection = $this->_getCollection();
1049 return $collection->ensureIndex($columns, $options);
1051 // }}}
1053 // string __toString() {{{
1055 * To String
1057 * If this object is treated as a string,
1058 * it would return its ID.
1060 * @return string
1062 final function __toString()
1064 return (string)$this->getID();
1066 // }}}
1068 // array sendCmd(array $cmd) {{{
1070 * This method sends a command to the current
1071 * database.
1073 * @param array $cmd Current command
1075 * @return array
1077 final protected function sendCmd($cmd)
1079 return $this->_getConnection()->command($cmd);
1081 // }}}
1086 * Local variables:
1087 * tab-width: 4
1088 * c-basic-offset: 4
1089 * End:
1090 * vim600: sw=4 ts=4 fdm=marker
1091 * vim<600: sw=4 ts=4