diff --git a/include/class.orm.php b/include/class.orm.php index 765bc7ff7cf7e701b379d26375a39c6f99fa6a03..88360580853d8ad350ba6c335399898cdd4e2d5c 100644 --- a/include/class.orm.php +++ b/include/class.orm.php @@ -391,6 +391,49 @@ class VerySimpleModel { } } +/** + * 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 { + + var $model; + var $annotations; + + function __construct($model, $annotations) { + $this->model = $model; + $this->annotations = $annotations; + } + + function __get($what) { + return $this->get($what); + } + function get($what) { + if (isset($this->annotations[$what])) + return $this->annotations[$what]; + return $this->model->get($what, null); + } + function __set($what, $to) { + return $this->set($what, $to); + } + function set($what, $to) { + if (isset($this->annotations[$what])) + throw new OrmException('Annotated fields are read-only'); + return $this->model->set($what, $to); + } + + // Delegate everything else to the model + function __call($what, $how) { + return call_user_func_array(array($this->model, $what), $how); + } + static function __callStatic($what, $how) { + return call_user_func_array(array($this->model, $what), $how); + } +} + class SqlFunction { var $alias; @@ -404,6 +447,9 @@ class SqlFunction { $alias && $this->alias ? ' AS '.$compiler->quote($this->alias) : ''); } + function getAlias() { + return $this->alias; + } function setAlias($alias) { $this->alias = $alias; } @@ -416,8 +462,31 @@ class SqlFunction { } class Aggregate extends SqlFunction { + + var $func; + var $expr; + var $distinct=false; + var $constraint=false; + + function __construct($func, $expr, $distinct=false, $constraint=false) { + $this->func = $func; + $this->expr = $expr; + $this->distinct = $distinct; + if ($constraint instanceof Q) + $this->constraint = $constraint; + elseif ($constraint) + $this->constraint = new Q($constraint); + } + + static function __callStatic($func, $args) { + $distinct = @$args[1] ?: false; + $constraint = @$args[2] ?: false; + return new static($func, $args[0], $distinct, $constraint); + } + function toSql($compiler, $model=false, $alias=false) { - list($field) = $compiler->getField($this->args[0], $model); + $options = array('constraint'=>$this->constraint); + list($field) = $compiler->getField($this->expr, $model, $options); return sprintf('%s(%s)%s', $this->func, $field, $alias && $this->alias ? ' AS '.$compiler->quote($this->alias) : ''); } @@ -543,6 +612,30 @@ class QuerySet implements IteratorAggregate, ArrayAccess { return $compiler->compileCount($this); } + /** + * exists + * + * Determines if there are any rows in this QuerySet. This can be + * achieved either by evaluating a SELECT COUNT(*) query or by + * attempting to fetch the first row from the recordset and return + * boolean success. + * + * Parameters: + * $fetch - (bool) TRUE if a compile and fetch should be attempted + * instead of a SELECT COUNT(*). This would be recommended if an + * accurate count is not required and the records would be fetched + * if this method returns TRUE. + * + * Returns: + * (bool) TRUE if there would be at least one record in this QuerySet + */ + function exists($fetch=false) { + if ($fetch) { + return (bool) $this[0]; + } + return $this->count() > 0; + } + function annotate($annotations) { if (!is_array($annotations)) $annotations = func_get_args(); @@ -557,13 +650,10 @@ class QuerySet implements IteratorAggregate, ArrayAccess { return $this; } - function exists() { - return $this->count() > 0; - } - function delete() { $class = $this->compiler; $compiler = new $class(); + // XXX: Mark all in-memory cached objects as deleted $ex = $compiler->compileBulkDelete($this); $ex->execute(); return $ex->affected_rows(); @@ -729,17 +819,52 @@ class ModelInstanceManager extends ResultSet { return @self::$objectCache[$key]; } + /** + * getOrBuild + * + * Builds a new model from the received fields or returns the model + * already stashed in the model cache. Caching helps to ensure that + * multiple lookups for the same model identified by primary key will + * fetch the exact same model. Therefore, changes made to the model + * anywhere in the project will be reflected everywhere. + * + * For annotated models (models build from querysets with annotations), + * the built or cached model is wrapped in an AnnotatedModel instance. + * The annotated fields are in the AnnotatedModel instance and the + * database-backed fields are managed by the Model instance. + */ function getOrBuild($modelClass, $fields) { + $annotations = $this->queryset->annotations; + $extras = array(); + // For annotations, drop them from the $fields list and add them to + // an $extras list. The fields passed to the root model should only + // be the root model's fields. The annotated fields will be wrapped + // using an AnnotatedModel instance. + if ($annotations && $modelClass == $this->model) { + foreach ($annotations as $A) { + $name = $A->getAlias(); + if (isset($fields[$name])) { + $extras[$name] = $fields[$name]; + unset($fields[$name]); + } + } + } // Check the cache for the model instance first - if ($m = self::checkCache($modelClass, $fields)) { - // TODO: If the model has deferred fields which are in $fields, - // those can be resolved here - return $m; + if (!($m = self::checkCache($modelClass, $fields))) { + // Construct and cache the object + $m = new $modelClass($fields); + // XXX: defer may refer to fields not in this model + $m->__deferred__ = $this->queryset->defer; + $m->__onload(); + if ($cache) + $this->cache($m); + } + // Wrap annotations in an AnnotatedModel + if ($extras) { + $m = new AnnotatedModel($m, $extras); } - // Construct and cache the object - $this->cache($m = new $modelClass($fields)); - $m->__deferred__ = $this->queryset->defer; - $m->__onload(); + // TODO: If the model has deferred fields which are in $fields, + // those can be resolved here return $m; } @@ -893,6 +1018,9 @@ class InstrumentedList extends ModelInstanceManager { function filter() { return call_user_func_array(array($this->objects(), 'filter'), func_get_args()); } + function exclude() { + return call_user_func_array(array($this->objects(), 'exclude'), func_get_args()); + } function order_by() { return call_user_func_array(array($this->objects(), 'order_by'), func_get_args()); } @@ -962,10 +1090,22 @@ class SqlCompiler { * * If no comparison function is declared in the field descriptor, * 'exact' is assumed. + * + * Parameters: + * $field - (string) name of the field to join + * $model - (VerySimpleModel) root model for references in the $field + * parameter + * $options - (array) extra options for the compiler + * 'table' => return the table alias rather than the field-name + * 'model' => return the target model class rather than the operator + * 'constraint' => extra constraint for join clause + * + * Returns: + * (mixed) Usually array<field-name, operator> where field-name is the + * name of the field in the destination model, and operator is the + * requestion comparison method. */ function getField($field, $model, $options=array()) { - $joins = array(); - // Break apart the field descriptor by __ (double-underbars). The // first part is assumed to be the root field in the given model. // The parts after each of the __ pieces are links to other tables. @@ -982,35 +1122,45 @@ class SqlCompiler { } $path = array(); - $crumb = ''; + + // Determine the alias for the root model table $alias = (isset($this->joins[''])) ? $this->joins['']['alias'] : $this->quote($model::$meta['table']); - // Traverse through the parts and establish joins between the tables - // if the field is joined to a foreign model - if (count($parts) && isset($model::$meta['joins'][$parts[0]])) { - // Call pushJoin for each segment in the join path. A new - // JOIN fragment will need to be emitted and/or cached - foreach ($parts as $p) { - $model::_inspect(); - if (!($info = $model::$meta['joins'][$p])) { - throw new OrmException(sprintf( - 'Model `%s` does not have a relation called `%s`', - $model, $p)); - } - $path[] = $p; - $tip = implode('__', $path); - $alias = $this->pushJoin($crumb, $tip, $model, $info); - // Roll to foreign model - foreach ($info['constraint'] as $local => $foreign) { - list($model, $f) = explode('.', $foreign); - if (class_exists($model)) - break; - } - $crumb = $tip; + // Call pushJoin for each segment in the join path. A new JOIN + // fragment will need to be emitted and/or cached + $push = function($p, $path, $extra=false) use (&$model) { + $model::_inspect(); + if (!($info = $model::$meta['joins'][$p])) { + throw new OrmException(sprintf( + 'Model `%s` does not have a relation called `%s`', + $model, $p)); + } + $crumb = implode('__', $path); + $path[] = $p; + $tip = implode('__', $path); + $alias = $this->pushJoin($crumb, $tip, $model, $info, $extra); + // Roll to foreign model + foreach ($info['constraint'] as $local => $foreign) { + list($model, $f) = explode('.', $foreign); + if (class_exists($model)) + break; } + return array($alias, $f); + }; + + foreach ($parts as $i=>$p) { + list($alias) = $push($p, $path, @$options['constraint']); + $path[] = $p; + } + + // If comparing a relationship, join the foreign table + // This is a comparison with a relationship — use the foreign key + if (isset($model::$meta['joins'][$field])) { + list($alias, $field) = $push($field, $path, @$options['constraint']); } + if (isset($options['table']) && $options['table']) $field = $alias; elseif (isset($this->annotations[$field])) @@ -1032,14 +1182,14 @@ class SqlCompiler { * algorithm is short-circuited and the originally-assigned table alias * is returned immediately. */ - function pushJoin($tip, $path, $model, $info) { + function pushJoin($tip, $path, $model, $info, $constraint=false) { // TODO: Build the join statement fragment and return the table // alias. The table alias will be useful where the join is used in // the WHERE and ORDER BY clauses // If the join already exists for the statement-being-compiled, just // return the alias being used. - if (isset($this->joins[$path])) + if (!$constraint && isset($this->joins[$path])) return $this->joins[$path]['alias']; // TODO: Support only using aliases if necessary. Use actual table @@ -1057,29 +1207,60 @@ class SqlCompiler { // TODO: Always use a table alias. This will further help with // coordination between the data returned from the database (where // table alias is available) and the corresponding data. - $this->joins[$path] = array( - 'alias' => $alias, - 'sql'=> $this->compileJoin($tip, $model, $alias, $info), - ); + $T = array('alias' => $alias); + $this->joins[$path] = &$T; + $T['sql'] = $this->compileJoin($tip, $model, $alias, $info, $constraint); return $alias; } - function compileQ(Q $Q, $model) { + /** + * compileQ + * + * Build a constraint represented in an arbitrarily nested Q instance. + * The placement of the compiled constraint is also considered and + * represented in the resulting CompiledExpression instance. + * + * Parameters: + * $Q - (Q) constraint represented in a Q instance + * $model - (VerySimpleModel) root model for all the field references in + * the Q instance + * $slot - (int) slot for inputs to be placed. Useful to differenciate + * inputs placed in the joins and where clauses for SQL engines + * which do not support named parameters. + * + * Returns: + * (CompiledExpression) object containing the compiled expression (with + * AND, OR, and NOT operators added). Furthermore, the $type attribute + * of the CompiledExpression will allow the compiler to place the + * constraint properly in the WHERE or HAVING clause appropriately. + */ + function compileQ(Q $Q, $model, $slot=false) { $filter = array(); $type = CompiledExpression::TYPE_WHERE; foreach ($Q->constraints as $field=>$value) { + // Handle nested constraints if ($value instanceof Q) { - $filter[] = $T = $this->compileQ($value, $model); + $filter[] = $T = $this->compileQ($value, $model, $slot); // Bubble up HAVING constraints if ($T instanceof CompiledExpression && $T->type == CompiledExpression::TYPE_HAVING) $type = $T->type; } + // Handle relationship comparisons with model objects + elseif ($value instanceof VerySimpleModel) { + $criteria = array(); + foreach ($value->pk as $f=>$v) { + $f = $field . '__' . $f; + $criteria[$f] = $v; + } + $filter[] = $this->compileQ(new Q($criteria), $model, $slot); + } + // Handle simple field = <value> constraints else { list($field, $op) = $this->getField($field, $model); if ($field instanceof Aggregate) { + // This constraint has to go in the HAVING clause $field = $field->toSql($this, $model); - // This clause has to go in the HAVING clause $type = CompiledExpression::TYPE_HAVING; } if ($value === null) @@ -1089,7 +1270,7 @@ class SqlCompiler { elseif (is_callable($op)) $filter[] = call_user_func($op, $field, $value, $model); else - $filter[] = sprintf($op, $field, $this->input($value)); + $filter[] = sprintf($op, $field, $this->input($value, $slot)); } } $glue = $Q->isOred() ? ' OR ' : ' AND '; @@ -1097,7 +1278,7 @@ class SqlCompiler { if (count($filter) > 1) $clause = '(' . $clause . ')'; if ($Q->isNegated()) - $clause = ' NOT '.$clause; + $clause = 'NOT '.$clause; return new CompiledExpression($clause, $type); } @@ -1177,6 +1358,12 @@ class DbEngine { class MySqlCompiler extends SqlCompiler { + // Consts for ::input() + const SLOT_JOINS = 1; + const SLOT_WHERE = 2; + + protected $input_join_count = 0; + static $operators = array( 'exact' => '%1$s = %2$s', 'contains' => array('self', '__contains'), @@ -1213,7 +1400,7 @@ class MySqlCompiler extends SqlCompiler { : sprintf('%s IS NOT NULL', $a); } - function compileJoin($tip, $model, $alias, $info) { + function compileJoin($tip, $model, $alias, $info, $extra=false) { $constraints = array(); $join = ' JOIN '; if (isset($info['null']) && $info['null']) @@ -1229,7 +1416,7 @@ class MySqlCompiler extends SqlCompiler { if ($local[0] == "'") { $constraints[] = sprintf("%s.%s = %s", $alias, $this->quote($right), - $this->input(trim($local, '\'"')) + $this->input(trim($local, '\'"'), self::SLOT_JOINS) ); } else { @@ -1239,11 +1426,34 @@ class MySqlCompiler extends SqlCompiler { ); } } + // Support extra join constraints + if ($extra instanceof Q) { + $constraints[] = $this->compileQ($extra, $model, self::SLOT_JOINS); + } return $join.$this->quote($rmodel::$meta['table']) .' '.$alias.' ON ('.implode(' AND ', $constraints).')'; } - function input(&$what) { + /** + * input + * + * Generate a parameterized input for a database query. Input value is + * received by reference to avoid copying. + * + * Parameters: + * $what - (mixed) value to be sent to the database. No escaping is + * necessary. Pass a raw value here. + * $slot - (int) clause location of the input in compiled SQL statement. + * Currently, SLOT_JOINS and SLOT_WHERE is supported. SLOT_JOINS + * inputs are inserted ahead of the SLOT_WHERE inputs as the joins + * come logically before the where claused in the finalized + * statement. + * + * Returns: + * (string) token to be placed into the compiled SQL statement. For + * MySQL, this is always the string '?'. + */ + function input(&$what, $slot=false) { if ($what instanceof QuerySet) { $q = $what->getQuery(array('nosort'=>true)); $this->params = array_merge($q->params); @@ -1253,7 +1463,15 @@ class MySqlCompiler extends SqlCompiler { return $what->toSql($this); } else { - $this->params[] = $what; + switch ($slot) { + case self::SLOT_JOINS: + // This should be inserted before the WHERE inputs + array_splice($this->params, $this->input_join_count++, 0, + array($what)); + break; + default: + $this->params[] = $what; + } return '?'; } } @@ -1300,23 +1518,25 @@ class MySqlCompiler extends SqlCompiler { function compileSelect($queryset) { $model = $queryset->model; // Use an alias for the root model table - $table = $model::$meta['table']; $this->joins[''] = array('alias' => ($rootAlias = $this->nextAlias())); // Compile the WHERE clause $this->annotations = $queryset->annotations ?: array(); list($where, $having) = $this->getWhereHavingClause($queryset); + // Compile the ORDER BY clause $sort = ''; if ($queryset->ordering && !isset($this->options['nosort'])) { $orders = array(); foreach ($queryset->ordering as $sort) { $dir = 'ASC'; - if (substr($sort, 0, 1) == '-') { + if ($sort[0] == '-') { $dir = 'DESC'; $sort = substr($sort, 1); } list($field) = $this->getField($sort, $model); + // TODO: Throw exception if $field can be indentified as + // invalid $orders[] = $field.' '.$dir; } $sort = ' ORDER BY '.implode(', ', $orders); @@ -1324,7 +1544,7 @@ class MySqlCompiler extends SqlCompiler { // Compile the field listing $fields = array(); - $table = $this->quote($table).' '.$rootAlias; + $table = $this->quote($model::$meta['table']).' '.$rootAlias; // Handle related tables if ($queryset->related) { $count = 0; @@ -1394,8 +1614,10 @@ class MySqlCompiler extends SqlCompiler { // Add in annotations if ($queryset->annotations) { foreach ($queryset->annotations as $A) { - $fields[] = $A->toSql($this, $model, true); + $fields[] = $T = $A->toSql($this, $model, true); // TODO: Add to last fieldset in fieldMap + if ($fieldMap) + $fieldMap[0][0][] = $A->getAlias(); } $group_by = array(); foreach ($model::$meta['pk'] as $pk) @@ -1660,6 +1882,8 @@ class Q { var $ored = false; function __construct($filter, $flags=0) { + if (!is_array($filter)) + $filter = array($filter); $this->constraints = $filter; $this->negated = $flags & self::NEGATED; $this->ored = $flags & self::ANY;