Permalink
Switch branches/tags
Find file
Fetching contributors…
Cannot retrieve contributors at this time
1586 lines (1452 sloc) 45.6 KB
<?php
/**
* This class objectifies a MongoDB document and can be used with one of the following design patterns:
*
* 1. Table Data Gateway pattern:
* <code>
* class Model_Post extends Mongo_Document {
* protected $name = 'posts';
* // All model-related code here
* }
*
* $post = Mongo_Document::factory('post', $post_id);
* </code>
*
* 2. Row Data Gateway pattern:
* <code>
* class Model_Post_Collection extends Mongo_Collection {
* protected $name = 'posts';
* // Collection-related code here
* }
*
* class Model_Post extends Mongo_Document {
* // Document-related code here
* }
*
* $post = Mongo_Document::factory('post', $post_id);
* </code>
*
* The following examples could be used with either pattern with no differences in usage. The Row Data Gateway pattern is recommended
* for more complex models to improve code organization while the Table Data Gateway pattern is recommended for simpler models.
*
* <code>
* class Model_Document extends Mongo_Document {
* protected $name = 'test';
* }
*
* $document = new Model_Document(); // or Mongo_Document::factory('document');
* $document->name = 'Mongo';
* $document->type = 'db';
* $document->save();
* // db.test.save({"name":"Mongo","type":"db"});
* </code>
*
* The _id is aliased to id by default. Other aliases can also be defined using the _aliases protected property. Aliases can be used
* anywhere that a field name can be used including dot-notation for nesting.
*
* <code>
* $id = $document->id; // MongoId
* </code>
*
* All methods that take query parameters support JSON strings as input in addition to PHP arrays. The JSON parser is more lenient
* than usual.
*
* <code>
* $document->load('{name:"Mongo"}');
* // db.test.findOne({"name":"Mongo"});
* </code>
*
* Methods which are intended to be overridden are {before,after}_{save,load,delete} so that special actions may be
* taken when these events occur:
*
* <code>
* public function before_save()
* {
* $this->inc('visits');
* $this->last_visit = time();
* }
* </code>
*
* When a document is saved, update will be used if the document already exists, otherwise insert will be used, determined
* by the presence of an _id. A document can be modified without being loaded from the database if an _id is passed to the constructor:
*
* <code>
* $doc = new Model_Document($id);
* </code>
*
* Atomic operations and updates are not executed until save() is called and operations are chainable. Example:
*
* <code>
* $doc->inc('uses.boing')
* ->push('used', array('type' => 'sound', 'desc' => 'boing'));
*
* $doc->inc('uses.bonk')
* ->push('used', array('type' => 'sound', 'desc' => 'bonk'))
* ->save();
*
* // db.test.update(
* // {"_id": "some-id-here"},
* // {"$inc":
* // {"uses.boing": 1, "uses.bonk": 1},
* // "$pushAll":
* // {"used": [
* // {"type": "sound", "desc": "boing"},
* // {"type": "sound", "desc": "bonk"}
* // ]
* // }
* // }
* // );
* </code>
*
* Documents are loaded lazily so if a property is accessed and the document is not yet loaded, it will be loaded on the first property access:
*
* <code>
* echo "$doc->name rocks!";
* // Mongo rocks!
* </code>
*
* Documents are reloaded when accessing a property that was modified with an operator and then saved:
*
* <code>
* in_array($doc->roles, 'admin'); // TRUE
*
* $doc->pull('roles', 'admin');
* in_array($doc->roles, 'admin'); // TRUE
*
* $doc->save();
* in_array($doc->roles, 'admin'); // FALSE
* </code>
*
* Documents can have references to other documents which will be loaded lazily and saved automatically.
*
* <code>
* class Model_Post extends Mongo_Document {
* protected $name = 'posts';
* protected $_references = array('user' => array('model' => 'user'));
* }
*
* class Model_User extends Mongo_Document {
* protected $name = 'users';
* }
*
* $user = Mongo_Document::factory('user')
* ->set('id', 'colin')
* ->set('email', 'colin@mollenhour.com');
* $post = Mongo_Document::factory('post');
*
* $post->user = $user;
* $post->title = 'MongoDB';
* $post->save()
* // db.users.save({"_id": "colin", "email": "colin@mollenhour.com"})
* // db.posts.save({"_id": Object, "_user": "colin", "title":"MongoDb"})
*
* $post = new Model_Post($id);
*
* $post->_user;
* // "colin" - the post was loaded lazily.
*
* $post->user->id;
* // "colin" - the user object was created lazily but not loaded.
*
* $post->user->email;
* // "colin@mollenhour.com" - now the user document was loaded as well.
* </code>
*
* @author Colin Mollenhour
* @package Mongo_Database
*/
abstract class Mongo_Document implements ArrayAccess {
const SAVE_INSERT = 'insert';
const SAVE_UPDATE = 'update';
const SAVE_UPSERT = 'upsert';
public static $models = array ();
/**
* Instantiate an object conforming to Mongo_Document conventions.
* The document is not loaded until load() is called.
*
* @param string $name Model name
* @param mixed $load _id of the document to operate on or criteria used to load [Optional]
* @return Mongo_Document
*/
public static function factory($name, $load = NULL)
{
if (isset(self::$models[$name]))
{
$class = self::$models[$name];
}
else if (strpos($name, '\\') !== FALSE)
{
$class = $name;
}
else
{
$class = 'Model_' . implode('_', array_map('ucfirst', explode('_', $name)));
}
return new $class($load);
}
/** The name of the collection within the database or the gridFS prefix if gridFS is TRUE
*
* If using a corresponding Mongo_Collection subclass, set this only in the Mongo_Collection subclass.
*
* @var string */
protected $name;
/** The database configuration name (passed to Mongo_Database::instance() )
*
* If using a corresponding Mongo_Collection subclass, set this only in the Mongo_Collection subclass.
*
* @var string */
protected $db;
/** Whether or not this collection is a gridFS collection
*
* If using a corresponding Mongo_Collection subclass, set this only in the Mongo_Collection subclass.
*
* @var boolean */
protected $gridFS = FALSE;
/** Definition of references existing in this document.
* If 'model' is not specified it defaults to the reference name.
* If 'field' is not specified it defaults to the reference name prefixed with an '_'.
*
* <pre>
* Example Document:
* {_id: 1, user_id: 2, _token: 3}
*
* protected $_references = array(
* 'user' => array('model' => 'user', 'field' => 'user_id'),
* 'token' => NULL,
* );
* </pre>
*
* You can also specify getter and setter functions:
* 'getter' - NULL will make the value write-only,
* string will call $this->{$getter}($name),
* callable will be called as $getter($this, $name)
* 'setter' - NULL will make the value read-only,
* string will call $this->{$setter}($value),
* callable will be called as $setter($value, $this, $name)
*
* @var array */
protected $_references = array();
/** Definition of predefined searches for use with __call. This instantiates a collection for the target model
* and initializes the search with the specified field being equal to the _id of the current object.
*
* <pre>
* $_searches
* {events: {model: 'event', field: '_user'}}
* // db.event.find({_user: <_id>})
* </pre>
*
* @var array */
protected $_searches = array();
/** Field name aliases. '_id' is automatically aliased to 'id'.
* E.g.: {created_at: ca}
* @var array */
protected $_aliases = array();
/** If set to TRUE, operator functions (set, inc, push etc.) will emulate database functions for eventual consistency.
* For example this will print '2':
* <code>
* $doc = new Mongo_Document();
* $doc->_emulate = TRUE;
* $doc->number = 1;
* $doc->inc('number');
* echo $doc->number;
* </code>
*
* If set to FALSE, the field will be marked as dirty, and the new value will only be available after reload.
* This will print '1':
* <code>
* $doc = new Mongo_Document();
* $doc->_emulate = FALSE;
* $doc->number = 1;
* $doc->inc('number');
* echo $doc->number;
* </code>
*
* @var boolean */
protected $_emulate = FALSE;
/** Designated place for non-persistent data storage (will not be saved to the database or after sleep)
* @var array */
public $__data = array();
/** Internal storage of object data
* @var array */
protected $_object = array();
/** Keep track of fields changed using __set or load_values
* @var array */
protected $_changed = array();
/** Set of operations to perform on update/insert
* @var array */
protected $_operations = array();
/** Keep track of data that is dirty (changed by an operation but not yet updated from database)
* @var array */
protected $_dirty = array();
/** Storage for referenced objects
* @var array */
protected $_related_objects = array();
/** Document loaded status:
* <pre>
* NULL not attempted
* FALSE failed
* TRUE succeeded
* </pre>
*
* @var boolean */
protected $_loaded = NULL;
/** A cache of Mongo_Collection instances for performance
* @var array */
protected static $collections = array();
/**
* Instantiate a new Document object. If an id or other data is passed then it will be assumed that the
* document exists in the database and updates will be performed without loading the document first.
*
* @param mixed $id _id of the document to operate on or criteria used to load
* @return Mongo_Document
*/
public function __construct($id = NULL)
{
if(!empty($id))
{
if(is_array($id) || $id instanceof Traversable)
{
foreach($id as $key => $value)
{
$this->_object[$this->get_field_name($key)] = $value;
}
}
else
{
$this->_object['_id'] = $this->_cast('_id', $id);
}
}
}
/**
* Override to cast values when they are set with untrusted data
*
* @param mixed $field The field name being set
* @param mixed $value The value being set
* @return mixed|\MongoId|string
*/
protected function _cast($field, $value)
{
switch($field)
{
case '_id':
// Cast _id strings to MongoIds if they convert back and forth without changing
if ($value instanceof MongoId)
return $value;
if ((is_string($value) || ctype_xdigit($value) || (is_object($value) && method_exists($value, '__toString'))) && strlen($value) == 24)
{
$id = new MongoId($value);
if( (string) $id == $value)
return $id;
}
}
return $value;
}
/**
* This function translates an alias to a database field name.
* Aliases are defined in $this->_aliases, and id is always aliased to _id.
* You can override this to disable aliases or define your own aliasing technique.
*
* @param string $name The aliased field name
* @param boolean $dot_allowed Use FALSE if a dot is not allowed in the field name for better performance
* @return string The field name used within the database
*/
public function get_field_name($name, $dot_allowed = TRUE)
{
if($name == 'id' || $name == '_id') return '_id';
if( ! $dot_allowed || ! strpos($name, '.'))
{
return (isset($this->_aliases[$name])
? $this->_aliases[$name]
: $name
);
}
$parts = explode('.', $name, 2);
$parts[0] = $this->get_field_name($parts[0], FALSE);
return implode('.', $parts);
}
/**
* Returns the attributes that should be serialized.
*
* @return array
*/
public function __sleep()
{
return array('_references', '_aliases', '_object', '_changed', '_operations', '_loaded', '_dirty');
}
/**
* Checks if a field is set
*
* @param string $name Field name
* @return boolean field is set
*/
public function __isset($name)
{
$name = $this->get_field_name($name, FALSE);
if (isset($this->_object[$name])) return TRUE;
// check for dirties...
if ($this->get($name)) return TRUE;
return isset($this->_object[$name]);
}
/**
* Unset a field
*
* @param string $name Field name
* @return void
*/
public function __unset($name)
{
$this->_unset($name);
}
/**
* Clear the document data
*
* @return Mongo_Document
*/
public function clear()
{
$this->_object = $this->_changed = $this->_operations = $this->_dirty = $this->_related_objects = array();
$this->_loaded = NULL;
return $this;
}
/**
* Return TRUE if field has been changed
*
* @param string $name field name (no parameter returns TRUE if there are *any* changes)
* @return boolean field has been changed
*/
public function is_changed($name = NULL)
{
if($name === NULL)
{
return ($this->_changed || $this->_operations);
}
else
{
$name = $this->get_field_name($name);
return isset($this->_changed[$name]) || isset($this->_dirty[$name]);
}
}
/** Return TRUE if document is being created */
public function is_new()
{
return !isset($this->_object['_id']) || isset($this->_changed['_id']);
}
/** Changes emulation mode. See $this->_emulate */
public function set_emulation($emulate)
{
$this->_emulate = $emulate;
}
/** Returns emulation mode. See $this->_emulate */
public function get_emulation()
{
return $this->_emulate;
}
/**
* Return the Mongo_Database reference (proxy to the collection's db() method)
*
* @return Mongo_Database
*/
public function db()
{
return $this->collection()->db();
}
/**
* Get a corresponding collection singleton
*
* @param boolean $fresh Pass TRUE if you don't want to get the singleton instance
* @return Mongo_Collection
*/
public function collection($fresh = FALSE)
{
if($fresh === TRUE)
{
if($this->name)
{
if ($this->db === NULL)
{
$this->db = Mongo_Database::$default;
}
return new Mongo_Collection($this->name, $this->db, $this->gridFS, get_class($this));
}
else
{
$class_name = $this->get_collection_class_name();
return new $class_name(NULL, NULL, NULL, get_class($this));
}
}
if($this->name)
{
$name = "$this->db.$this->name.$this->gridFS";
if( ! isset(self::$collections[$name]))
{
if ($this->db === NULL)
{
$this->db = Mongo_Database::$default;
}
self::$collections[$name] = new Mongo_Collection($this->name, $this->db, $this->gridFS, get_class($this));
}
return self::$collections[$name];
}
else
{
$name = $this->get_collection_class_name();
if( ! isset(self::$collections[$name]))
{
self::$collections[$name] = new $name(NULL, NULL, NULL, get_class($this));
}
return self::$collections[$name];
}
}
/**
* Generates the collection name
* @return string
*/
protected function get_collection_class_name()
{
return get_class($this).'_Collection';
}
/**
* Current magic methods supported:
*
* find_<search>() - Perform predefined search (using key from $_searches)
*
* @param string $name
* @param array $arguments
* @throws Exception
* @return Mongo_Collection|Mongo_Document
*/
public function __call($name, $arguments)
{
// Workaround Reserved Keyword 'unset'
// http://php.net/manual/en/reserved.keywords.php
if($name == 'unset')
{
return $this->_unset($arguments[0]);
}
$parts = explode('_', $name, 2);
if( ! isset($parts[1]))
{
throw new Exception('Method not found by '.get_class($this).': '.$name);
}
switch($parts[0])
{
case 'find':
$search = $parts[1];
if( ! isset($this->_searches[$search])){
throw new Exception('Predefined search not found by '.get_class($this).': '.$search);
}
return Mongo_Document::factory($this->_searches[$search]['model'])
->collection(TRUE)
->find(array($this->_searches[$search]['field'] => $this->_id));
break;
default:
throw new Exception('Method not found by '.get_class($this).': '.$name);
break;
}
}
/**
* Gets one of the following:
*
* - A referenced object
* - A search() result
* - A field's value
*
* @param string $name field name
* @return mixed
* @throws \Exception
*/
public function __get($name)
{
$name = $this->get_field_name($name, FALSE);
// Auto-loading for special references
if(array_key_exists($name, $this->_references))
{
if (isset($this->_references[$name]['getter']))
{
if ($this->_references[$name]['getter'] == null) throw new \Exception("'$name' is write only!");
else if (is_string($this->_references[$name]['getter'])) return call_user_func(array($this, $this->_references[$name]['getter']), $name);
else return call_user_func(array($this, $this->_references[$name]['getter']), $this, $name);
}
if( ! isset($this->_related_objects[$name]))
{
$model = isset($this->_references[$name]['model']) ? $this->_references[$name]['model'] : $name;
$foreign_field = isset($this->_references[$name]['foreign_field']) ? $this->_references[$name]['foreign_field'] : FALSE;
if ($foreign_field) {
$this->_related_objects[$name] = Mongo_Document::factory($model)
->collection(TRUE)
->find($foreign_field, $this->id);
return $this->_related_objects[$name];
}
$id_field = isset($this->_references[$name]['field']) ? $this->_references[$name]['field'] : "_$name";
$value = $this->__get($id_field);
if( ! empty($this->_references[$name]['multiple']))
{
$this->_related_objects[$name] = Mongo_Document::factory($model)
->collection(TRUE)
->find(array('_id' => array('$in' => (array) $value)));
}
else
{
// Extract just id if value is a DBRef
if(is_array($value) && isset($value['$id']))
{
$value = $value['$id'];
}
$this->_related_objects[$name] = Mongo_Document::factory($model, $value);
}
}
return $this->_related_objects[$name];
}
$this->load_if_needed($name);
return isset($this->_object[$name]) ? $this->_object[$name] : NULL;
}
/**
* Reload document only if there is need for it
* @param string $name Name of the field to check for (no dot notation)
* @return bool
*/
protected function load_if_needed($name)
{
// Reload when retrieving dirty data
if ($this->_loaded && empty($this->_operations) && !empty($this->_dirty[$name]))
{
return $this->load();
}
// Lazy loading!
else if ($this->_loaded === NULL && isset($this->_object['_id']) && !isset($this->_changed['_id']) && $name != '_id')
{
return $this->load();
}
return FALSE;
}
/**
* Magic method for setting the value of a field. In order to set the value of a nested field,
* you must use the "set" method, not the magic method. Examples:
*
* <code>
* // Works
* $doc->set('address.city', 'Knoxville');
*
* // Does not work
* $doc->address['city'] = 'Knoxville';
* </code>
*
* @param string $name field name
* @param mixed $value
* @throws Exception
* @return mixed
*/
public function __set($name, $value)
{
$name = $this->get_field_name($name, FALSE);
// Automatically save references to other Mongo_Document objects
if(array_key_exists($name, $this->_references))
{
if (isset($this->_references[$name]['setter']))
{
if ($this->_references[$name]['setter'] == null) throw new \Exception("'$name' is read only!");
else if (is_string($this->_references[$name]['setter'])) return call_user_func(array($this, $this->_references[$name]['setter']), $value, $name);
else return call_user_func(array($this, $this->_references[$name]['setter']), $value, $this, $name);
}
if( ! $value instanceof Mongo_Document)
{
throw new Exception('Cannot set reference to object that is not a Mongo_Document');
}
$this->_related_objects[$name] = $value;
if(isset($value->_id))
{
$id_field = isset($this->_references[$name]['field']) ? $this->_references[$name]['field'] : "_$name";
$this->__set($id_field, $value->_id);
}
return;
}
// Do not save sets that result in no change
$value = $this->_cast($name, $value);
if ( isset($this->_object[$name]) && $this->_object[$name] === $value)
{
return;
}
$this->_object[$name] = $value;
$this->_changed[$name] = TRUE;
}
/**
* @param $name
* @return Mongo_Document
*/
protected function _set_dirty($name)
{
if($pos = strpos($name, '.'))
{
$name = substr($name, 0, $pos);
}
$this->_dirty[$name] = TRUE;
return $this;
}
/** Get the value for a key (using dot notation) */
public function get($name, $default = null)
{
$name = $this->get_field_name($name);
$dotPos = strpos($name, '.');
if (!$dotPos && $default === null)
{
return $this->__get($name);
}
$this->load_if_needed($dotPos ? substr($name, 0, $dotPos) : $name);
$ref = $this->get_field_reference($name, FALSE, $default);
return $ref;
}
/** Get the raw value for a key (using dot notation), without lazy loading, aliasing and references */
public function get_raw($name, $default = null)
{
$ref = $this->get_field_reference($name, FALSE, $default);
return $ref;
}
/**
* Set the value for a key. This function must be used when updating nested documents.
*
* @param string $name The key of the data to update (use dot notation for embedded objects)
* @param mixed $value The data to be saved
* @param boolean $emulate TRUE will emulate the database function for eventual consistency, FALSE will not change the object until save & reload. @see $_emulate
* @return Mongo_Document
*/
public function set($name, $value, $emulate = null)
{
if (!strpos($name, '.'))
{
$this->__set($name, $value);
return $this;
}
$name = $this->get_field_name($name);
$this->_operations['$set'][$name] = $value;
if ($emulate === FALSE || ($emulate === null && $this->_emulate === FALSE))
{
return $this->_set_dirty($name);
}
else
{
$ref = & $this->get_field_reference($name, TRUE);
$ref = $value;
return $this;
}
}
/**
* Unset a key
*
* Note: unset() method call for _unset() is defined in __call() method since 'unset' method name
* is reserved in PHP. ( Requires PHP > 5.2.3. - http://php.net/manual/en/reserved.keywords.php )
*
* @param string $name The key of the data to update (use dot notation for embedded objects)
* @param boolean $emulate TRUE will emulate the database function for eventual consistency, FALSE will not change the object until save & reload.
* @see $_emulate
* @return Mongo_Document
*/
public function _unset($name, $emulate = null)
{
$name = $this->get_field_name($name);
$this->_operations['$unset'][$name] = 1;
if ($emulate === FALSE || ($emulate === null && $this->_emulate === FALSE))
{
return $this->_set_dirty($name);
}
else
{
self::unset_named_reference($this->_object, $name);
return $this;
}
}
/**
* Increment a value atomically
*
* @param string $name The key of the data to update (use dot notation for embedded objects)
* @param mixed $value The amount to increment by (default is 1)
* @param boolean $emulate TRUE will emulate the database function for eventual consistency, FALSE will not change the object until save & reload. @see $_emulate
* @return Mongo_Document
*/
public function inc($name, $value = 1, $emulate = null)
{
$name = $this->get_field_name($name);
if(isset($this->_operations['$inc'][$name]))
{
$this->_operations['$inc'][$name] += $value;
}
else
{
$this->_operations['$inc'][$name] = $value;
}
if ($emulate === FALSE || ($emulate === null && $this->_emulate === FALSE))
{
return $this->_set_dirty($name);
}
else
{
$ref = & $this->get_field_reference($name, TRUE, 0);
$ref += $value;
return $this;
}
}
/**
* Push a vlaue to an array atomically. Can be called multiple times.
*
* @param string $name The key of the data to update (use dot notation for embedded objects)
* @param mixed $value The value to push
* @param boolean $emulate TRUE will emulate the database function for eventual consistency, FALSE will not change the object until save & reload. @see $_emulate
* @return Mongo_Document
*/
public function push($name, $value, $emulate = null)
{
$name = $this->get_field_name($name);
if(isset($this->_operations['$pushAll'][$name]))
{
$this->_operations['$pushAll'][$name][] = $value;
}
else if(isset($this->_operations['$push'][$name]))
{
$this->_operations['$pushAll'][$name] = array($this->_operations['$push'][$name],$value);
unset($this->_operations['$push'][$name]);
if( ! count($this->_operations['$push']))
unset($this->_operations['$push']);
}
else
{
$this->_operations['$push'][$name] = $value;
}
if ($emulate === FALSE || ($emulate === null && $this->_emulate === FALSE))
{
return $this->_set_dirty($name);
}
else
{
$ref = & $this->get_field_reference($name, TRUE, array());
if (!is_array($ref)) throw new MongoException("Value '$name' cannot be used as an array");
array_push($ref, $value);
return $this;
}
}
/**
* Push an array of values to an array in the document
*
* @param string $name The key of the data to update (use dot notation for embedded objects)
* @param array $value An array of values to push
* @param boolean $emulate TRUE will emulate the database function for eventual consistency, FALSE will not change the object until save & reload. @see $_emulate
* @return Mongo_Document
*/
public function pushAll($name, $value, $emulate = null)
{
$name = $this->get_field_name($name);
if(isset($this->_operations['$pushAll'][$name]))
{
$this->_operations['$pushAll'][$name] += $value;
}
else
{
$this->_operations['$pushAll'][$name] = $value;
}
if ($emulate === FALSE || ($emulate === null && $this->_emulate === FALSE))
{
return $this->_set_dirty($name);
}
else
{
$ref = & $this->get_field_reference($name, TRUE, array());
if (!is_array($ref)) throw new MongoException("Value '$name' cannot be used as an array");
foreach ($value as $v) array_push($ref, $v);
return $this;
}
}
/**
* Pop a value from the end of an array
*
* @param string $name The key of the data to update (use dot notation for embedded objects)
* @param boolean $last Pass TRUE to pop last element
* @param boolean $emulate TRUE will emulate the database function for eventual consistency, FALSE will not change the object until save & reload. @see $_emulate
* @return Mongo_Document
*/
public function pop($name, $last = TRUE, $emulate = null)
{
$name = $this->get_field_name($name);
$this->_operations['$pop'][$name] = $last ? 1 : -1;
if ($emulate === FALSE || ($emulate === null && $this->_emulate === FALSE))
{
return $this->_set_dirty($name);
}
else
{
$ref = & $this->get_field_reference($name, TRUE, null);
if ($ref === null) return $this;
if (!is_array($ref)) throw new MongoException("Value '$name' cannot be used as an array");
if ($last) array_pop($ref);
else array_shift($ref);
return $this;
}
}
/**
* Pop a value from the beginning of an array
*
* @param string $name The key of the data to update (use dot notation for embedded objects)
* @param boolean $emulate TRUE will emulate the database function for eventual consistency, FALSE will not change the object until save & reload. @see $_emulate
* @return Mongo_Document
*/
public function shift($name, $emulate = null)
{
return $this->pop($name, FALSE, $emulate);
}
/**
* Pull (delete) a value from an array
*
* @param string $name The key of the data to update (use dot notation for embedded objects)
* @param mixed $value
* @param boolean $emulate TRUE will emulate the database function for eventual consistency, FALSE will not change the object until save & reload. @see $_emulate
* @return Mongo_Document
*/
public function pull($name, $value, $emulate = null)
{
$name = $this->get_field_name($name);
if(isset($this->_operations['$pullAll'][$name]))
{
$this->_operations['$pullAll'][$name][] = $value;
}
else if(isset($this->_operations['$pull'][$name]))
{
$this->_operations['$pullAll'][$name] = array($this->_operations['$pull'][$name],$value);
unset($this->_operations['$pull'][$name]);
if( ! count($this->_operations['$pull']))
unset($this->_operations['$pull']);
}
else
{
$this->_operations['$pull'][$name] = $value;
}
if ($emulate === FALSE || ($emulate === null && $this->_emulate === FALSE))
{
return $this->_set_dirty($name);
}
else
{
$ref = & $this->get_field_reference($name, TRUE, null);
if ($ref === null) return $this;
if (!is_array($ref)) throw new Exception("Value '$name' cannot be used as an array");
$ref = array_filter($ref, function($v) use ($value) {
return $v !== $value;
});
return $this;
}
}
/**
* Pull (delete) all of the given values from an array
*
* @param string $name The key of the data to update (use dot notation for embedded objects)
* @param array $value An array of value to pull from the array
* @param boolean $emulate TRUE will emulate the database function for eventual consistency, FALSE will not change the object until save & reload. @see $_emulate
* @return Mongo_Document
*/
public function pullAll($name, array $value, $emulate = null)
{
$name = $this->get_field_name($name);
if(isset($this->_operations['$pullAll'][$name]))
{
$this->_operations['$pullAll'][$name] += $value;
}
else
{
$this->_operations['$pullAll'][$name] = $value;
}
if ($emulate === FALSE || ($emulate === null && $this->_emulate === FALSE))
{
return $this->_set_dirty($name);
}
else
{
$ref = & $this->get_field_reference($name, TRUE, null);
if ($ref === null) return $this;
if (!is_array($ref)) throw new Exception("Value '$name' cannot be used as an array");
$ref = array_filter($ref, function($v) use ($value) {
return !in_array($v, $value, TRUE);
});
return $this;
}
}
/**
* Bit operators
*
* @param string $name The key of the data to update (use dot notation for embedded objects)
* @param array $value
* @todo some emulation love
* @return Mongo_Document
*/
public function bit($name,$value)
{
$name = $this->get_field_name($name);
$this->_operations['$bit'][$name] = $value;
return $this->_set_dirty($name);
}
/**
* Adds value to the array only if its not in the array already.
*
* @param string $name The key of the data to update (use dot notation for embedded objects)
* @param mixed $value The value to add to the set
* @param boolean $emulate TRUE will emulate the database function for eventual consistency, FALSE will not change the object until save & reload. @see $_emulate
* @return Mongo_Document
*/
public function addToSet($name, $value, $emulate = null)
{
$name = $this->get_field_name($name);
if(isset($this->_operations['$addToSet'][$name]))
{
if( ! isset($this->_operations['$addToSet'][$name]['$each']))
{
$this->_operations['$addToSet'][$name] = array('$each' => array($this->_operations['$addToSet'][$name]));
}
if(isset($value['$each']))
{
foreach($value['$each'] as $val)
{
$this->_operations['$addToSet'][$name]['$each'][] = $val;
}
}
else
{
$this->_operations['$addToSet'][$name]['$each'][] = $value;
}
}
else
{
$this->_operations['$addToSet'][$name] = $value;
}
if ($emulate === FALSE || ($emulate === null && $this->_emulate === FALSE))
{
return $this->_set_dirty($name);
}
else
{
$ref = & $this->get_field_reference($name, TRUE);
if ($ref === null)
{
$ref = array($value);
}
else
{
if (!is_array($ref)) throw new Exception("Value '$name' cannot be set as an array"); // $ref = array($ref);
if (!in_array($value, $ref, TRUE))
{
array_push($ref, $value);
}
}
return $this;
}
}
/**
* Load all of the values in an associative array. Ignores all fields
* not in the model.
*
* @param array $values field => value pairs
* @param boolean $clean values are clean (from database)?
* @return Mongo_Document
*/
public function load_values(array $values, $clean = FALSE)
{
if($clean === TRUE)
{
$this->before_load();
$this->_object = $values;
$this->_loaded = ! empty($this->_object);
$this->after_load();
}
else
{
foreach ($values as $field => $value)
{
$this->__set($field, $value);
}
}
return $this;
}
/**
* Get the model data as an associative array.
*
* @param boolean $clean retrieve values directly from _object
* @return array field => value
*/
public function as_array( $clean = FALSE )
{
if($clean === TRUE)
{
$array = $this->_object;
}
else
{
$array = array();
foreach($this->_object as $name => $value)
{
$array[$name] = isset($this->_object[$name]) ? $this->_object[$name] : NULL;
}
foreach($this->_aliases as $alias => $name)
{
if(isset($array[$name]))
{
$array[$alias] = $array[$name];
unset($array[$name]);
}
}
}
return $array;
}
/**
* Return TRUE if the document is loaded.
*
* @return boolean
*/
public function loaded()
{
if($this->_loaded === NULL)
{
$this->load();
}
return $this->_loaded;
}
/**
* Load the document from the database. The first parameter may be one of:
*
* a FALSE value - the object data will be used to construct the query
* a JSON string - will be parsed and used for the query
* an non-array value - the query will be assumed to be for an _id of this value
* an array - the array will be used for the query
*
* @param string|array $criteria specify additional criteria
* @param array $fields specify the fields to return
* @throws MongoException
* @return boolean TRUE if the load succeeded
*/
public function load($criteria = array(), array $fields = array())
{
$keepId = null;
// Use of json for querying is allowed
if(is_string($criteria) && $criteria[0] == "{")
{
$criteria = JSON::arr($criteria);
}
else if($criteria && ! is_array($criteria))
{
$keepId = $criteria; // in case if we won't load it, we should set this object to this id
$criteria = array('_id' => $criteria);
}
else if(isset($this->_object['_id']))
{
$keepId = $this->_object['_id']; // in case if we won't load it, we should set this object to this id
$criteria = array('_id' => $this->_object['_id']);
}
else if(isset($criteria['id']))
{
$keepId = $criteria['id']; // in case if we won't load it, we should set this object to this id
$criteria = array('_id' => $criteria['id']);
}
else if( ! $criteria)
{
$criteria = $this->_object;
}
if( ! $criteria)
{
throw new MongoException('Cannot find '.get_class($this).' without _id or other search criteria.');
}
// Cast query values to the appropriate types and translate aliases
$new = array();
foreach($criteria as $key => $value)
{
$key = $this->get_field_name($key);
$new[$key] = $this->_cast($key, $value);
}
$criteria = $new;
// Translate field aliases
$fields = array_map(array($this,'get_field_name'), $fields);
$values = $this->collection()->__call('findOne', array($criteria, $fields));
// Only clear the object if necessary
if($this->_loaded !== NULL || $this->_changed || $this->_operations)
{
$this->clear();
}
$this->load_values($values ? $values : array(), TRUE);
if (!$this->_loaded && $keepId) $this->id = $keepId; // restore the id previously set on this object...
return $this->_loaded;
}
/**
* Save the document to the database. For newly created documents the _id will be retrieved.
*
* @param array|bool $options Insert options
* @throws MongoException
* @return Mongo_Document
*/
public function save($options = TRUE)
{
// Update references to referenced models
$this->_update_references();
// Convert old bool argument to options array
if (is_bool($options)) {
$options = array('w' => 1);
}
if (isset($options['safe'])) {
$options['w'] = (int) $options['safe']; $options['j'] = (bool) $options['safe'];
unset($options['safe']);
}
if ( ! isset($options['w'])) {
$options['w'] = $this->db()->db()->w == 0 ? 1 : $this->db()->db()->w;
}
// Insert new record if no _id or _id was set by user
if( ! isset($this->_object['_id']) || isset($this->_changed['_id']))
{
$action = self::SAVE_INSERT;
$this->before_save($action);
$values = array();
foreach($this->_changed as $name => $_true)
{
$values[$name] = $this->_object[$name];
}
if(empty($values))
{
throw new MongoException('Cannot insert empty array.');
}
$err = $this->collection()->insert($values, $options);
if($err['err'] && (! empty($options['w']) || ! empty($options['j'])))
{
throw new MongoException('Unable to insert '.get_class($this).': '.$err['err']);
}
if ( ! isset($this->_object['_id']))
{
// Store (assigned) MongoID in object
$this->_object['_id'] = $values['_id'];
$this->_loaded = TRUE;
}
// Save any additional operations
/** @todo Combine operations into the insert when possible to avoid this update */
if($this->_operations)
{
if( ! $this->collection()->update(array('_id' => $this->_object['_id']), $this->_operations))
{
$err = $this->db()->lastError();
throw new MongoException('Update of '.get_class($this).' failed: '.$err['err']);
}
}
}
// Update assumed existing document
else
{
$action = self::SAVE_UPDATE;
$this->before_save($action);
if($this->_changed)
{
foreach($this->_changed as $name => $_true)
{
$this->_operations['$set'][$name] = $this->_object[$name];
}
}
if($this->_operations)
{
if( ! $this->collection()->update(array('_id' => $this->_object['_id']), $this->_operations))
{
$err = $this->db()->lastError();
throw new MongoException('Update of '.get_class($this).' failed: '.$err['err']);
}
}
}
$this->_changed = $this->_operations = array();
$this->after_save($action);
return $this;
}
/**
* Updates references but does not save models to avoid infinite loops
*/
protected function _update_references()
{
foreach($this->_references as $name => $ref)
{
if(isset($this->_related_objects[$name]) && $this->_related_objects[$name] instanceof Mongo_Document)
{
$model = $this->_related_objects[$name];
$id_field = isset($ref['field']) ? $ref['field'] : "_$name";
if( ! $this->__isset($id_field) || $this->__get($id_field) != $model->_id)
{
$this->__set($id_field, $model->_id);
}
}
}
}
/**
* Override this method to take certain actions before the data is saved
*
* @param string $action The type of save action, one of Mongo_Document::SAVE_*
*/
protected function before_save($action){}
/**
* Override this method to take actions after data is saved
*
* @param string $action The type of save action, one of Mongo_Document::SAVE_*
*/
protected function after_save($action){}
/**
* Override this method to take actions before the values are loaded
*/
protected function before_load(){}
/**
* Override this method to take actions after the values are loaded
*/
protected function after_load(){}
/**
* Override this method to take actions before the document is deleted
*/
protected function before_delete(){}
/**
* Override this method to take actions after the document is deleted
*/
protected function after_delete(){}
/**
* Upsert the document, does not retrieve the _id of the upserted document.
*
* @param array $operations
* @throws MongoException
* @return Mongo_Document
*/
public function upsert($operations = array())
{
if( ! $this->_object)
{
throw new MongoException('Cannot upsert '.get_class($this).': no criteria');
}
$this->before_save(self::SAVE_UPSERT);
$operations = self::array_merge_recursive_distinct($this->_operations, $operations);
if( ! $this->collection()->update($this->_object, $operations, array('upsert' => TRUE)))
{
$err = $this->db()->lastError();
throw new MongoException('Upsert of '.get_class($this).' failed: '.$err['err']);
}
$this->_changed = $this->_operations = array();
$this->after_save(self::SAVE_UPSERT);
return $this;
}
/**
* Delete the current document using the current data. The document does not have to be loaded.
* Use $doc->collection()->remove($criteria) to delete multiple documents.
*
* @throws MongoException
* @return Mongo_Document
*/
public function delete()
{
if( ! isset($this->_object['_id']))
{
throw new MongoException('Cannot delete '.get_class($this).' without the _id.');
}
$this->before_delete();
$criteria = array('_id' => $this->_object['_id']);
if( ! $this->collection()->remove($criteria, array('justOne' => TRUE)))
{
throw new MongoException('Failed to delete '.get_class($this));
}
$this->clear();
$this->after_delete();
return $this;
}
/**
* array_merge_recursive_distinct does not change the datatypes of the values in the arrays.
* @param array $array1
* @param array $array2
* @return array
* @author Daniel <daniel (at) danielsmedegaardbuus (dot) dk>
* @author Gabriel Sobrinho <gabriel (dot) sobrinho (at) gmail (dot) com>
*/
protected static function array_merge_recursive_distinct ( array &$array1, array &$array2 )
{
$merged = $array1;
foreach ( $array2 as $key => &$value )
{
if ( is_array ( $value ) && isset ( $merged [$key] ) && is_array ( $merged [$key] ) )
{
$merged [$key] = self::array_merge_recursive_distinct ( $merged [$key], $value );
}
else
{
$merged [$key] = $value;
}
}
return $merged;
}
public function offsetExists($name)
{
return $this->__isset($name);
}
public function offsetGet($name)
{
return $this->get($name);
}
public function offsetSet($name, $value)
{
$this->set($name, $value, TRUE);
}
public function offsetUnset($name)
{
$this->_unset($name, TRUE);
}
/** Returns direct reference to a field, using dot notation.
* @param string $name Dot notation name
* @param bool $create TRUE to create a field if it's missing
* @param mixed $default Use default value to create missing fields, or return it if $create == FALSE
* @return mixed
* */
public function &get_field_reference($name, $create = FALSE, $default = null)
{
return self::get_named_reference($this->_object, $name, $create, $default);
}
/** Returns direct reference to a field, using dot notation.
* @param array $arr Array with data
* @param string $name Dot notation name
* @param bool $create TRUE to create a field if it's missing
* @param mixed $default Use default value to create missing fields, or return it if $create == FALSE
* @return mixed
* */
public static function &get_named_reference(&$arr, $name, $create = FALSE, $default = null)
{
$keys = explode('.', $name);
$data = & $arr;
foreach ($keys as $i => $key)
{
if (!isset($data[$key]))
{
if ($create)
{
$data[$key] = $i == count($keys) - 1 ? $default : array();
}
else
{
$nil = $default;
return $nil;
}
}
$data = & $data[$key];
}
return $data;
}
/**
* Unsets a field using dot notation
*
* @param array $arr Array with data
* @param string $name Dot notation name
*/
public static function unset_named_reference(&$arr, $name)
{
$keys = explode('.', $name);
$data = & $arr;
foreach ($keys as $i => $key)
{
if (isset($data[$key]))
{
if ($i == count($keys) - 1)
{
unset($data[$key]);
return;
}
$data = & $data[$key];
}
else
{
return;
}
}
}
}