Newer
Older
<?php
/*********************************************************************
class.orm.php
Simple ORM (Object Relational Mapper) for PHP5 based on Django's ORM,
except that complex filter operations are not supported. The ORM simply
supports ANDed filter operations without any GROUP BY support.
Jared Hancock <jared@osticket.com>
Copyright (c) 2006-2013 osTicket
http://www.osticket.com
Released under the GNU General Public License WITHOUT ANY WARRANTY.
See LICENSE.TXT for details.
vim: expandtab sw=4 ts=4 sts=4:
**********************************************************************/
require_once INCLUDE_DIR . 'class.util.php';
class OrmException extends Exception {}
class OrmConfigurationException extends Exception {}
// Database fields/tables do not match codebase
class InconsistentModelException extends OrmException {
function __construct() {
// Drop the model cache (just incase)
ModelMeta::flushModelCache();
call_user_func_array(array('parent', '__construct'), func_get_args());
}
}
/**
* Meta information about a model including edges (relationships), table
* name, default sorting information, database fields, etc.
*
* This class is constructed and built automatically from the model's
* ::getMeta() method using a class's ::$meta array.
*/
class ModelMeta implements ArrayAccess {
static $base = array(
'pk' => false,
'table' => false,
'defer' => array(),
'select_related' => array(),
'joins' => array(),
'foreign_keys' => array(),
var $meta = array();
var $new;
var $subclasses = array();
function __construct($model) {
// Merge ModelMeta from parent model (if inherited)
$parent = get_parent_class($this->model);
$meta = $model::$meta;
if ($model::$meta instanceof self)
$meta = $meta->meta;
if (is_subclass_of($parent, 'VerySimpleModel')) {
$this->parent = $parent::getMeta();
$meta = $this->parent->extend($this, $meta);
$meta = $meta + self::$base;
// Short circuit the meta-data processing if APCu is available.
// This is preferred as the meta-data is unlikely to change unless
// osTicket is upgraded, (then the upgrader calls the
// flushModelCache method to clear this cache). Also, GIT_VERSION is
// used in the APC key which should be changed if new code is
// deployed.
if (function_exists('apcu_store')) {
$loaded = false;
$apc_key = SECRET_SALT.GIT_VERSION."/orm/{$this->model}";
$this->meta = apcu_fetch($apc_key, $loaded);
if ($loaded)
return;
}
if (!$meta['view']) {
if (!$meta['table'])
throw new OrmConfigurationException(
sprintf(__('%s: Model does not define meta.table'), $this->model));
elseif (!$meta['pk'])
throw new OrmConfigurationException(
sprintf(__('%s: Model does not define meta.pk'), $this->model));
}
// Ensure other supported fields are set and are arrays
foreach (array('pk', 'ordering', 'defer', 'select_related') as $f) {
if (!isset($meta[$f]))
$meta[$f] = array();
elseif (!is_array($meta[$f]))
$meta[$f] = array($meta[$f]);
}
// Break down foreign-key metadata
foreach ($meta['joins'] as $field => &$j) {
if ($j['local'])
$meta['foreign_keys'][$j['local']] = $field;
$this->meta = $meta;
if (function_exists('apcu_store')) {
apcu_store($apc_key, $this->meta, 1800);
/**
* Merge this class's meta-data into the recieved child meta-data.
* When a model extends another model, the meta data for the two models
* is merged to form the child's meta data. Returns the merged, child
* meta-data.
*/
function extend(ModelMeta $child, $meta) {
$this->subclasses[$child->model] = $child;
// Merge 'joins' settings (instead of replacing)
if (isset($this->meta['joins'])) {
$meta['joins'] = array_merge($meta['joins'] ?: array(),
$this->meta['joins']);
}
return $meta + $this->meta + self::$base;
}
function isSuperClassOf($model) {
if (isset($this->subclasses[$model]))
return true;
foreach ($this->subclasses as $M=>$meta)
if ($meta->isSuperClassOf($M))
return true;
}
function isSubclassOf($model) {
if (!isset($this->parent))
return false;
if ($this->parent->model === $model)
return true;
return $this->parent->isSubclassOf($model);
/**
* Adds some more information to a declared relationship. If the
* relationship is a reverse relation, then the information from the
* reverse relation is loaded into the local definition
*
* Compiled-Join-Structure:
* 'constraint' => array(local => array(foreign_field, foreign_class)),
* Constraint used to construct a JOIN in an SQL query
* 'list' => boolean
* TRUE if an InstrumentedList should be employed to fetch a list
* of related items
* 'broker' => Handler for the 'list' property. Usually a subclass of
* 'InstrumentedList'
* 'null' => boolean
* TRUE if relation is nullable
* 'fkey' => array(class, pk)
* Classname and field of the first item in the constraint that
* points to a PK field of a foreign model
* 'local' => string
* The local field corresponding to the 'fkey' property
*/
function processJoin(&$j) {
if (isset($j['reverse'])) {
list($fmodel, $key) = explode('.', $j['reverse']);
// NOTE: It's ok if the forein meta data is not yet inspected.
$info = $fmodel::$meta['joins'][$key];
if (!is_array($info['constraint']))
throw new OrmConfigurationException(sprintf(__(
// `reverse` here is the reverse of an ORM relationship
'%s: Reverse does not specify any constraints'),
$j['reverse']));
foreach ($info['constraint'] as $foreign => $local) {
list($L,$field) = is_array($local) ? $local : explode('.', $local);
$constraint[$field ?: $L] = array($fmodel, $foreign);
}
if (!isset($j['list']))
$j['list'] = true;
if (!isset($j['null']))
// By default, reverse releationships can be empty lists
$j['null'] = true;
else {
foreach ($j['constraint'] as $local => $foreign) {
list($class, $field) = $constraint[$local]
= is_array($foreign) ? $foreign : explode('.', $foreign);
if ($j['list'] && !isset($j['broker'])) {
$j['broker'] = 'InstrumentedList';
}
if ($j['broker'] && !class_exists($j['broker'])) {
throw new OrmException($j['broker'] . ': List broker does not exist');
}
foreach ($constraint as $local => $foreign) {
list($class, $field) = $foreign;
if ($local[0] == "'" || $field[0] == "'" || !class_exists($class))
continue;
function addJoin($name, array $join) {
$this->meta['joins'][$name] = $join;
$this->processJoin($this->meta['joins'][$name]);
/**
* Fetch ModelMeta instance by following a join path from this model
*/
function getByPath($path) {
if (is_string($path))
$path = explode('__', $path);
$root = $this;
foreach ($path as $P) {
if (!($root = $root['joins'][$P]['fkey'][0]))
break;
$root = $root::getMeta();
}
return $root;
}
function offsetGet($field) {
return $this->meta[$field];
}
function offsetSet($field, $what) {
$this->meta[$field] = $what;
}
function offsetExists($field) {
return isset($this->meta[$field]);
}
function offsetUnset($field) {
throw new Exception('Model MetaData is immutable');
}
/**
* Fetch the column names of the table used to persist instances of this
* model in the database.
*/
function getFieldNames() {
if (!isset($this->fields))
$this->fields = self::inspectFields();
return $this->fields;
}
/**
* Create a new instance of the model, optionally hydrating it with the
* given hash table. The constructor is not called, which leaves the
* default constructor free to assume new object status.
*
* Three methods were considered, with runtime for 10000 iterations
* * unserialze('O:9:"ModelBase":0:{}') - 0.0671s
* * new ReflectionClass("ModelBase")->newInstanceWithoutConstructor()
* - 0.0478s
* * and a hybrid by cloning the reflection class instance - 0.0335s
*/
function newInstance($props=false) {
if (!isset($this->new)) {
$rc = new ReflectionClass($this->model);
$this->new = $rc->newInstanceWithoutConstructor();
$instance = clone $this->new;
// Hydrate if props were included
if (is_array($props)) {
$instance->ht = $props;
}
return $instance;
}
function inspectFields() {
if (!isset(self::$model_cache))
self::$model_cache = function_exists('apcu_fetch');
$key = SECRET_SALT.GIT_VERSION."/orm/{$this['table']}";
if ($fields = apcu_fetch($key)) {
return $fields;
}
}
$fields = DbEngine::getCompiler()->inspectTable($this['table']);
static function flushModelCache() {
if (self::$model_cache)
@apcu_clear_cache('user');
class VerySimpleModel {
static $meta = array(
'table' => false,
'ordering' => false,
'pk' => false
);
var $dirty = array();
var $__deferred__ = array();
function __construct($row=false) {
if (is_array($row))
foreach ($row as $field=>$value)
if (!is_array($value))
$this->set($field, $value);
$this->__new__ = true;
/**
* Creates a new instance of the model without calling the constructor.
* If the constructor is required, consider using the PHP `new` keyword.
* The instance returned from this method will not be considered *new*
* and will now result in an INSERT when sent to the database.
*/
static function __hydrate($row=false) {
return static::getMeta()->newInstance($row);
}
function __wakeup() {
// If a model is stashed in a session, refresh the model from the database
$this->refetch();
}
function get($field, $default=false) {
if (array_key_exists($field, $this->ht))
return $this->ht[$field];
elseif (($joins = static::getMeta('joins')) && isset($joins[$field])) {
$j = $joins[$field];
// Support instrumented lists and such
if (isset($j['list']) && $j['list']) {
$class = $j['fkey'][0];
$fkey = array();
// Localize the foreign key constraint
foreach ($j['constraint'] as $local=>$foreign) {
list($_klas,$F) = $foreign;
$fkey[$F ?: $_klas] = ($local[0] == "'")
? trim($local, "'") : $this->ht[$local];
}
$v = $this->ht[$field] = new $j['broker'](
// Send Model, [Foriegn-Field => Local-Id]
array($class, $fkey)
);
return $v;
}
// Support relationships
elseif (isset($j['fkey'])) {
$criteria = array();
foreach ($j['constraint'] as $local => $foreign) {
if (class_exists($klas))
$class = $klas;
if ($local[0] == "'") {
$criteria[$F] = trim($local,"'");
}
// Does not affect the local model
continue;
}
else {
$criteria[$F] = $this->ht[$local];
}
}
$v = $this->ht[$field] = $class::lookup($criteria);
}
catch (DoesNotExist $e) {
$v = null;
}
elseif (isset($this->__deferred__[$field])) {
// Fetch deferred field
$row = static::objects()->filter($this->getPk())
// XXX: Seems like all the deferred fields should be fetched
->values_flat($field)
->one();
if ($row)
return $this->ht[$field] = $row[0];
}
elseif ($field == 'pk') {
return $this->getPk();
}
if (isset($default))
return $default;
// For new objects, assume the field is NULLable
if ($this->__new__)
return null;
// Check to see if the column referenced is actually valid
if (in_array($field, static::getMeta()->getFieldNames()))
throw new OrmException(sprintf(__('%s: %s: Field not defined'),
get_class($this), $field));
}
function __get($field) {
return $this->get($field, null);
}
function getByPath($path) {
if (is_string($path))
$path = explode('__', $path);
$root = $this;
foreach ($path as $P)
$root = $root->get($P);
return $root;
}
function __isset($field) {
|| isset(static::$meta['joins'][$field]);
function __unset($field) {
if ($this->__isset($field))
unset($this->ht[$field]);
else
unset($this->{$field});
// Update of foreign-key by assignment to model instance
$joins = static::getMeta('joins');
if (isset($joins[$field])) {
$j = $joins[$field];
if ($j['list'] && ($value instanceof InstrumentedList)) {
// Magic list property
$this->ht[$field] = $value;
return;
}
if (in_array($j['local'], static::$meta['pk'])) {
// Reverse relationship — don't null out local PK
return;
}
// Pass. Set local field to NULL in logic below
}
elseif ($value instanceof VerySimpleModel) {
// Ensure that the model being assigned as a relationship is
// an instance of the foreign model given in the
// relationship, or is a super class thereof. The super
// class case is used primary for the xxxThread classes
// which all extend from the base Thread class.
if (!$value instanceof $j['fkey'][0]
&& !$value::getMeta()->isSuperClassOf($j['fkey'][0])
) {
throw new InvalidArgumentException(
sprintf(__('Expecting NULL or instance of %s. Got a %s instead'),
$j['fkey'][0], is_object($value) ? get_class($value) : gettype($value)));
}
// Capture the object under the object's field name
$this->ht[$field] = $value;
$value = $value->get($j['fkey'][1]);
// Fall through to the standard logic below
}
// Capture the foreign key id value
$field = $j['local'];
}
// elseif $field is in a relationship, adjust the relationship
elseif (isset(static::$meta['foreign_keys'][$field])) {
// meta->foreign_keys->{$field} points to the property of the
// foreign object. For instance 'object_id' points to 'object'
$related = static::$meta['foreign_keys'][$field];
}
$old = isset($this->ht[$field]) ? $this->ht[$field] : null;
if ($old != $value) {
// isset should not be used here, because `null` should not be
// replaced in the dirty array
if (!array_key_exists($field, $this->dirty))
$this->dirty[$field] = $old;
if ($related)
// $related points to a foreign object propery. If setting a
// new object_id value, the relationship to object should be
// cleared and rebuilt
unset($this->ht[$related]);
$this->ht[$field] = $value;
}
function __set($field, $value) {
return $this->set($field, $value);
}
function setAll($props) {
foreach ($props as $field=>$value)
$this->set($field, $value);
}
function serialize() {
return $this->getPk();
}
function unserialize($data) {
$this->ht = $data;
$this->refetch();
}
static function getMeta($key=false) {
if (!static::$meta instanceof ModelMeta
|| get_called_class() != static::$meta->model
) {
static::$meta = new ModelMeta(get_called_class());
}
$M = static::$meta;
return ($key) ? $M->offsetGet($key) : $M;
static function getOrmFields($recurse=false) {
$fks = $lfields = $fields = array();
$myname = get_called_class();
foreach (static::getMeta('joins') as $name=>$j) {
$fks[$j['local']] = true;
if (!$j['reverse'] && !$j['list'] && $recurse) {
foreach ($j['fkey'][0]::getOrmFields($recurse - 1) as $name2=>$f) {
$fields["{$name}__{$name2}"] = "{$name} / $f";
}
}
}
foreach (static::getMeta('fields') as $f) {
if (isset($fks[$f]))
continue;
if (in_array($f, static::getMeta('pk')))
continue;
$lfields[$f] = "{$f}";
}
return $lfields + $fields;
}
/**
* objects
*
* Retrieve a QuerySet for this model class which can be used to fetch
* models from the connected database. Subclasses can override this
* method to apply forced constraints on the QuerySet.
*/
static function objects() {
return new QuerySet(get_called_class());
}
/**
* lookup
*
* Retrieve a record by its primary key. This method may be short
* circuited by model caching if the record has already been loaded by
* the database. In such a case, the database will not be consulted for
* the model's data.
*
* This method can be called with an array of keyword arguments matching
* the PK of the object or the values of the primary key. Both of these
* usages are correct:
*
* >>> User::lookup(1)
* >>> User::lookup(array('id'=>1))
*
* For composite primary keys and the first usage, pass the values in
* the order they are given in the Model's 'pk' declaration in its meta
* data.
*
* Parameters:
* $criteria - (mixed) primary key for the sought model either as
* arguments or key/value array as the function's first argument
*
* Returns:
* (Object<Model>|null) a single instance of the sought model or null if
* no such instance exists.
// Model::lookup(1), where >1< is the pk value
if (!is_array($criteria)) {
$criteria = array();
$pk = static::getMeta('pk');
// Only consult cache for PK lookup, which is assumed if the
// values are passed as args rather than an array
if ($cached = ModelInstanceManager::checkCache(get_called_class(),
$criteria))
return $cached;
try {
return static::objects()->filter($criteria)->one();
}
catch (DoesNotExist $e) {
return null;
}
$ex = DbEngine::delete($this);
try {
$ex->execute();
if ($ex->affected_rows() != 1)
return false;
Signal::send('model.deleted', $this);
}
catch (OrmException $e) {
return false;
}
if ($this->__deleted__)
throw new OrmException('Trying to update a deleted object');
$pk = static::getMeta('pk');
$wasnew = $this->__new__;
// First, if any foreign properties of this object are connected to
// another *new* object, then save those objects first and set the
// local foreign key field values
foreach (static::getMeta('joins') as $prop => $j) {
if (isset($this->ht[$prop])
&& ($foreign = $this->ht[$prop])
&& $foreign instanceof VerySimpleModel
&& !in_array($j['local'], $pk)
&& null === $this->get($j['local'])
) {
if ($foreign->__new__ && !$foreign->save())
return false;
$this->set($j['local'], $foreign->get($j['fkey'][1]));
}
}
// If there's nothing in the model to be saved, then we're done
$ex = DbEngine::save($this);
try {
$ex->execute();
if ($ex->affected_rows() != 1) {
// This doesn't really signify an error. It just means that
// the database believes that the row did not change. For
// inserts though, it's a deal breaker
if ($this->__new__)
return false;
else
// No need to reload the record if requested — the
// database didn't update anything
$refetch = false;
}
catch (OrmException $e) {
return false;
// XXX: Ensure AUTO_INCREMENT is set for the field
$this->ht[$pk[0]] = $ex->insert_id();
Signal::send('model.created', $this);
}
else {
$data = array('dirty' => $this->dirty);
Signal::send('model.updated', $this, $data);
}
# Refetch row from database
if ($refetch) {
// Preserve non database information such as list relationships
// across the refetch
}
if ($wasnew) {
// Attempt to update foreign, unsaved objects with the PK of
// this newly created object
foreach (static::getMeta('joins') as $prop => $j) {
if (isset($this->ht[$prop])
&& ($foreign = $this->ht[$prop])
&& in_array($j['local'], $pk)
) {
if ($foreign instanceof VerySimpleModel
&& null === $foreign->get($j['fkey'][1])
) {
$foreign->set($j['fkey'][1], $this->get($j['local']));
}
elseif ($foreign instanceof InstrumentedList) {
foreach ($foreign as $item) {
if (null === $item->get($j['fkey'][1]))
$item->set($j['fkey'][1], $this->get($j['local']));
}
}
}
}
try {
$this->ht =
static::objects()->filter($this->getPk())->values()->one()
+ $this->ht;
} catch (DoesNotExist $ex) {}
private function getPk() {
$pk = array();
foreach ($this::getMeta('pk') as $f)
$pk[$f] = $this->ht[$f];
return $pk;
}
/**
* Create a new clone of this model. The primary key will be unset and the
* object will be set as __new__. The __clone() magic method is reserved
* by the buildModel() system, because it clone's a single instance when
* hydrating objects from the database.
*/
function copy() {
// Drop the PK and set as unsaved
$dup = clone $this;
foreach ($dup::getMeta('pk') as $f)
$dup->__unset($f);
$dup->__new__ = true;
return $dup;
}
/**
* AnnotatedModel
*
* Simple wrapper class which allows wrapping and write-protecting of
* annotated fields retrieved from the database. Instances of this class
* will delegate most all of the heavy lifting to the wrapped Model instance.
*/
class AnnotatedModel {
static function wrap(VerySimpleModel $model, $extras=array(), $class=false) {
static $classes;
$class = $class ?: get_class($model);
if ($extras instanceof VerySimpleModel) {
$extra = "Writeable";
}
if (!isset($classes[$class])) {
$classes[$class] = eval(<<<END_CLASS
class {$extra}AnnotatedModel___{$class}
protected \$__overlay__;
use {$extra}AnnotatedModelTrait;
static function __hydrate(\$ht=false, \$annotations=false) {
\$instance = parent::__hydrate(\$ht);
\$instance->__overlay__ = \$annotations;
return \$instance;
return "{$extra}AnnotatedModel___{$class}";
return $classes[$class]::__hydrate($model->ht, $extras);
if (isset($this->__overlay__[$what]))
return $this->__overlay__[$what];
return parent::get($what);
function set($what, $to) {
if (isset($this->__overlay__[$what]))
throw new OrmException('Annotated fields are read-only');
return parent::set($what, $to);
if (isset($this->__overlay__[$what]))
return true;
return parent::__isset($what);
function getDbFields() {
return $this->__overlay__ + parent::getDbFields();
}
}
/**
* Slight variant on the AnnotatedModelTrait, except that the overlay is
* another model. Its fields are preferred over the wrapped model's fields.
* Updates to the overlayed fields are tracked in the overlay model and
* therefore kept separate from the annotated model's fields. ::save() will
* call save on both models. Delete will only delete the overlay model (that
* is, the annotated model will remain).
*/
trait WriteableAnnotatedModelTrait {
if ($this->__overlay__->__isset($what))
return $this->__overlay__->get($what);
return parent::get($what);
}
function set($what, $to) {
if (isset($this->__overlay__)
&& $this->__overlay__->__isset($what)) {
return $this->__overlay__->set($what, $to);
}
return parent::set($what, $to);
}
function __isset($what) {
if (isset($this->__overlay__) && $this->__overlay__->__isset($what))
return true;
return parent::__isset($what);
}
function getDbFields() {
return $this->__overlay__->getDbFields() + parent::getDbFields();
function save($refetch=false) {
$this->__overlay__->save($refetch);
return parent::save($refetch);
}
function delete() {
if ($rv = $this->__overlay__->delete())
// Mark the annotated object as deleted
$this->__deleted__ = true;
return $rv;
$this->func = $name;
$this->args = array_slice(func_get_args(), 1);
}
function input($what, $compiler, $model) {
if ($what instanceof SqlFunction)
$A = $what->toSql($compiler, $model);
elseif ($what instanceof Q)
$A = $compiler->compileQ($what, $model);
else
$A = $compiler->input($what);
return $A;
}
function toSql($compiler, $model=false, $alias=false) {
$args = array();
foreach ($this->args as $A) {
$args[] = $this->input($A, $compiler, $model);
return sprintf('%s(%s)%s', $this->func, implode(', ', $args),
$alias && $this->alias ? ' AS '.$compiler->quote($this->alias) : '');
}
function getAlias() {
return $this->alias;
}
function setAlias($alias) {
$this->alias = $alias;
static function __callStatic($func, $args) {
$I = new static($func);
$I->args = $args;
return $I;
}
function __call($operator, $other) {
array_unshift($other, $this);
return SqlExpression::__callStatic($operator, $other);
}
class SqlCase extends SqlFunction {
var $cases = array();
var $else = false;
static function N() {
return new static('CASE');
}
function when($expr, $result) {
if (is_array($expr))
$expr = new Q($expr);
$this->cases[] = array($expr, $result);
return $this;
}
function otherwise($result) {
$this->else = $result;
return $this;
}
function toSql($compiler, $model=false, $alias=false) {
$cases = array();
foreach ($this->cases as $A) {
list($expr, $result) = $A;
$expr = $this->input($expr, $compiler, $model);
$result = $this->input($result, $compiler, $model);
$cases[] = "WHEN {$expr} THEN {$result}";
}
if ($this->else) {
$else = $this->input($this->else, $compiler, $model);
$cases[] = "ELSE {$else}";
}
return sprintf('CASE %s END%s', implode(' ', $cases),
$alias && $this->alias ? ' AS '.$compiler->quote($this->alias) : '');
}
}
class SqlExpr extends SqlFunction {
function __construct($args) {
$this->args = func_get_args();
if (count($this->args) == 1 && is_array($this->args[0]))
$this->args = $this->args[0];
}
function toSql($compiler, $model=false, $alias=false) {
$O = array();
foreach ($this->args as $field=>$value) {
$ex = $compiler->compileQ($value, $model, false);
$O[] = $ex->text;
}
else {
list($field, $op) = $compiler->getField($field, $model);
if (is_callable($op))
$O[] = call_user_func($op, $field, $value, $model);
else
$O[] = sprintf($op, $field, $compiler->input($value));
}
return implode(' ', $O) . ($alias ? ' AS ' . $compiler->quote($alias) : '');
class SqlExpression extends SqlFunction {
var $operator;
var $operands;
function toSql($compiler, $model=false, $alias=false) {
$O = array();
$O[] = $this->input($operand, $compiler, $model);
return '('.implode(' '.$this->func.' ', $O)
. ($alias ? ' AS '.$compiler->quote($alias) : '')
. ')';
}
static function __callStatic($operator, $operands) {
switch ($operator) {
case 'minus':
$operator = '-'; break;
case 'plus':
$operator = '+'; break;
case 'times':
$operator = '*'; break;
case 'bitand':
$operator = '&'; break;
case 'bitor':
$operator = '|'; break;
throw new InvalidArgumentException($operator.': Invalid operator specified');
}
return parent::__callStatic($operator, $operands);
}