Orders->orderBy('date_purchased', 'DESC')->limit(5)->find(); * while($order = $orders->getNext()) * // whatever * * It should be noted that while a foreach() on the return of find() will * also work, it loads everything into memory first and is thus a lot more * costly than the above while() example, as that iterates over one result at * a time. So, use while(). * * EXAMPLE 2: Find a single storefront product by SKU: * * $product = StorefrontProductSaurus::finder() * ->where('products_model')->eq('DEV-00666') * ->find()->current(); * * This uses the static finder() method available to all generated records. * So, find() returns a SparkFriendFinder with the first result (the only * one, in this case) preloaded, and current() returns it. */ class SparkFriendFinder extends \SparkLib\Iterator { const NULLS_LAST = 'LAST'; const NULLS_FIRST = 'FIRST'; protected $_recordClass; protected $_relationships; // A class to instantiate with the record instance as its only // parameter and return protected $_wrapperClass = null; // Table and primary key of the record class we're searching for. protected $_table = null; protected $_tableKey = null; protected $_tableRecord = null; protected $_sparkfinder = null; protected $_total = null; // Initial SELECT and WHERE. protected $_default_query = ''; // Full query with constraints. protected $_query = ''; // Those constraints: protected $_distinctOn = ''; protected $_join = ''; protected $_with = ''; protected $_where = ''; protected $_orderBy = ''; protected $_groupBy = ''; protected $_limit = ''; protected $_forUpdate = ''; // For the time being these two will only get set in child classes of this one. protected $_left_join = ''; protected $_extraselects = ''; protected $_key; protected $_id; protected $_buffered = true; protected $_selectIdOnly = false; /** * Get a new finder. Takes a string containing the classname of dinosaur * to instantiate, and an optional key and id. * * If a key and id are passed, we are looking for records in the context of * another one, e.g. getting a customer's orders. Otherwise, we are * (probably) using the static SomeRecord::finder() method. */ public function __construct ($record_class, $key = null, $id = null) { $this->_recordClass = $record_class; $this->_key = $key; $this->_id = $id; // It's worth noting that class_exists() probably invokes the Autoloader here if (! class_exists($this->_recordClass)) throw new InvalidArgumentException("No such record class: {$this->_recordClass}"); // Pull in some metadata from the record class: $this->handleClass(); } /** * Re-run the previous query, starting over from the first record. */ public function rewind () { if (! $this->_sparkfinder) return false; $this->getResults(); } // Simple getters public function key () { return $this->current()->getId(); } public function recordClass () { return $this->_recordClass; } public function tableKey () { return $this->_tableKey; } public function tableRecord () { return $this->_tableRecord; } /** * Return current position within the resultset. */ public function position() { if (isset($this->_sparkfinder)) return $this->_sparkfinder->position(); else return 0; } /** * Check if there's a currently available record. * * @return bool - yay or nay */ public function valid () { return $this->_sparkfinder->valid(); } /** * How many rows in the current statement results? */ public function count () { if (! $this->_sparkfinder) return false; return $this->_sparkfinder->count(); } /** * How many rows total with these params? * * Keeps the joins, as they may affect the total; drops the _with because * that's just overhead. */ public function total () { $dbi = SparkDBI::getInstance(); if($this->_total) return $this->_total; $q = 'SELECT COUNT(' . $this->_table . '.' . $this->_tableKey . ') AS total ' . ' FROM ' . $this->_table . $this->_join . $this->_left_join . ' WHERE true ' . $this->_where; $totalArray = $dbi->fetchRow($q); $this->_total = (int)$totalArray['total']; return $this->_total; } /** * How many rows total with these params? * * use this if you have a group by statement in your query */ public function group_total () { $dbi = SparkDBI::getInstance(); if ($this->_total) return $this->_total; $q = 'SELECT COUNT(0) AS total FROM (SELECT ' . $this->_table . '.' . $this->_tableKey . ' FROM "' . $this->_table . '"' . $this->_join . $this->_left_join . ' WHERE true ' . $this->_where . $this->_groupBy . ') g'; $totalArray = $dbi->fetchRow($q); $this->_total = $totalArray['total']; return $this->_total; } /** * Advance to the next record. */ public function next () { if (! $this->_sparkfinder) throw new Exception('No current results - find() not called?'); return $this->_sparkfinder->next(); } /** * Return an object for the current record. * * @return SomeSaurus - a $this->_recordClass */ public function current () { if (! $this->valid()) return false; if ($this->_wrapperClass) { $wrapper = $this->_wrapperClass; // yo dawg, I heard you like classes return new $wrapper($this->_sparkfinder->current()); } return $this->_sparkfinder->current(); } /** * Load the next record and return an object for it. * * @return SomeSaurus - a $this->_recordClass */ public function getNext () { // Assume we want to go ahead and find() if there aren't // already results to get if (! isset($this->_sparkfinder)) $this->find(); // Will be a record object or false; $result = $this->current(); // If we got an object, we'll advance to the next record. if (false !== $result) $this->next(); return $result; } /** * @return string current query */ public function getQuery () { return $this->_query; } /** * @return true if there are more available. */ public function hasNext () { return $this->count() && ($this->_sparkfinder->position() < $this->count()); } /** * Load some class properties with meta data about $this->_recordClass. */ protected function handleClass () { // Hack our way around some PHP fail: $rc = $this->_recordClass; // If these methods exist, we're probably dealing with a SparkRecord. // Otherwise, it's something else. if (method_exists($rc, 'getTableKey')) { $this->_tableKey = $rc::getDefaultTableKey(); $this->_table = $rc::getDefaultTableName(); $this->_tableRecord = $rc::getDefaults(); } } /** * Set a wrapper class for results. */ public function wrap ($class) { if (class_exists($class)) { $this->_wrapperClass = $class; } else { throw new Exception($class . ' does not exist'); } return $this; } /** * Build the query. * * @return string sql */ public function buildQuery () { // Build and save the query $this->_query = $this->_base_query() . $this->_where . $this->_groupBy . $this->_orderBy . $this->_limit . $this->_forUpdate . "\n"; return $this->_query; } /** * Build the query, execute it, and load the first object. * * @return SparkFriendFinder - $this, for method chains. */ public function find () { // Build and save the query $this->buildQuery(); // clear query parts $this->_join = ''; $this->_where = ''; $this->_orderBy = ''; $this->_limit = ''; $this->_with = ''; // Get a SparkFinder and stash it $this->getResults(); return $this; } /** * Get a database handle. */ protected function getDBH () { return DB::getInstance(); } /** * Turn the current query into a result set and stash it. */ protected function getResults () { try { $this->_sparkfinder = new SparkFinder( $this->_recordClass, $this->_query ); } catch (Exception $e) { throw new Exception('Query fail: ' . $this->_query . ' -- ' . $e->getMessage()); } // PDO TODO: Come up with a graceful way to request an unbuffered resultset. // (Presumably necessitates a connection per resultset.) } /** * Make the query a FOR UPDATE query. * * Essentially what this does is put a row lock on * every row that is part of the SELECT query such that * any other query (with a FOR UPDATE clause) that matches * any of the selected rows for this finder will block * until the current transaction has completed, either by * a commit or rollback. */ public function forUpdate () { $this->_forUpdate = ' FOR UPDATE'; return $this; } /** * Run find() and get the first result. Just a utility to shorten * things up a bit. If find() has already been called, will act * as an alias to current(), rather than re-running a bare query. * * @return SparkRecord */ public function getOne () { if (isset($this->_sparkfinder)) return $this->current(); return $this->find()->current(); } /** * JOIN the given dinosaur by the relationship defined in records.ini */ public function with ($dinosaur) { $record_class = $this->_recordClass; $relationships = $record_class::getRelationships(); if (! isset($relationships[$dinosaur])) throw new Exception('Invalid relationship: ' . $dinosaur); if(in_array($relationships[$dinosaur]['type'], array('has_one', 'has_many'))) { $our_key = $relationships[$dinosaur]['pk']; $their_key = $relationships[$dinosaur]['fk']; } elseif($relationships[$dinosaur]['type'] == 'belongs_to') { $our_key = $relationships[$dinosaur]['fk']; $their_key = $relationships[$dinosaur]['pk']; } // If this is a one to many relationship, use a left join /* $has_many = $record_class::getHasManyDefaults(); if($has_many[$dinosaur]) $this->_join .= ' LEFT'; */ $classname = $relationships[$dinosaur]['class']; $this->_with .= $this->_generate_with($classname); $this->_join .= " JOIN {$classname::getDefaultTableName()} {$classname::getDefaultTableName()} ON {$this->_table}.{$our_key} = {$classname::getDefaultTableName()}.{$their_key} \n"; return $this; } /** * JOIN another dinosaur by the relationship * defined in records.ini * TODO: Maybe merge this with with() or something less verbose? */ public function withOn ($dinosaur, $joinon) { $record_class = $joinon . 'Saurus'; $relationships = $record_class::getRelationships(); if(! isset($relationships[$dinosaur])) throw new Exception('Invalid relationship: ' . $dinosaur . ' on ' . $record_class); if(in_array($relationships[$dinosaur]['type'], array('has_one', 'has_many'))) { $our_key = $relationships[$dinosaur]['pk']; $their_key = $relationships[$dinosaur]['fk']; } elseif($relationships[$dinosaur]['type'] == 'belongs_to') { $our_key = $relationships[$dinosaur]['fk']; $their_key = $relationships[$dinosaur]['pk']; } $classname = $relationships[$dinosaur]['class']; $this->_with .= $this->_generate_with($classname); $this->_join .= " JOIN {$classname::getDefaultTableName()} {$classname::getDefaultTableName()} ON {$record_class::getDefaultTableName()}.{$our_key} = {$classname::getDefaultTableName()}.{$their_key} \n"; return $this; } /** * LEFT JOIN a table * * @param string $sql LEFT JOIN table on pkey = fkey */ public function leftJoin ($sql) { $sql = trim($sql); if (! strlen($sql)) return $this; // make sure it is left join sql if (strtoupper(substr($sql, 0, 9)) != 'LEFT JOIN') throw new Exception('Invalid LEFT JOIN clause: ' . $sql); $this->_left_join = ' ' . $sql . ' '; return $this; } /** * put some extra fields in the SELECT * for use with leftJoin() or sql functions * * BEWARE! If you use this in public-facing code with user inputs, * Brennen WILL murder you. * * @param array $fields fields or sql expressions like COUNT(*) */ public function extraSelects ($fields) { if (! is_array($fields) || ! count($fields)) throw new Exception('extraSelects expects a non-empty array'); $sql = implode(', ', $fields) . ','; // needs trailing comma $this->_extraselects = $sql . ' '; return $this; } /** * To select only the primary table id and any extra selects * (instead of select * from all tables) * */ public function selectIdOnly () { $this->_selectIdOnly = true; return $this; } /** * FIELD table field name * **BEWARE: This function is rendered useless with a join or extraselects. */ public function isValidField ($field) { if ($this->_join === '' && $this->_left_join === '' && $this->_extraselects === '') { return isset($this->_tableRecord[$field]); } else { return true; } } /** * Add to the WHERE clause for a given field. * * Be careful - this may fail if you're doing a ->with() _after_ the where(). * Do with() first. * * @param $field string * @return SparkFriendFinder */ public function where ($field) { if (!$this->isValidField($field)) throw new Exception('Invalid field in where clause: ' . $field); $this->_where .= "\n AND " . $field; return $this; } public function whereSubstring($field, $from, $for) { if (!$this->isValidField($field)) throw new Exception('Invalid field in where clause: ' . $field); if (!is_integer($from) || !is_integer($for)) throw new Exception('Invalid from/for in whereSubstring clause ' . $field); $this->_where .= "\n AND substring(" . $field . " from " . $from . " for " . $for . ")"; return $this; } /** * Add a WHERE lower($field) * * @param $field string * @return SparkFriendFinder */ public function whereLower ($field) { if (!$this->isValidField($field)) throw new Exception('Invalid field in where clause: ' . $field); $this->_where .= "\n AND lower(" . $field . ")"; return $this; } public function orWhere ($field) { if (!$this->isValidField($field)) throw new Exception('Invalid field in where clause: ' . $field); $this->_where .= "\n OR " . $field; return $this; } public function orWhereSubstring($field, $from, $for) { if (!$this->isValidField($field)) throw new Exception('Invalid field in where clause: ' . $field); if (!is_integer($from) || !is_integer($for)) throw new Exception('Invalid from/for in orWhereSubstring clause ' . $field); $this->_where .= "\n OR substring(" . $field . " from " . $from . " for " . $for . ")"; return $this; } /** * Add to the WHERE clause for a given subquery evaluation. * * Like with where() be careful - this may fail if you're doing * a ->with() _after_ the where(). Do with() first. * * @param $subquery string * @return SparkFriendFinder */ public function subquery ($subquery) { $this->_where .= "\n AND (\n" . $subquery . "\n" . ")"; return $this; } /** * startSub - begin a sub-where clause in the query. * * example: * * $orders = OrderSaurus::finder() * $orders->startSub(); * $orders->orWhere('customers_name')->like('%' . $s_query . '%'); * $orders->orWhere('customers_email_address')->like('%' . $s_query . '%'); * $orders->endSub(); * $orders->find(); * * @param $op string of either AND or OR to prepend to the outside of the subclause * @param $subop string of either AND or OR which will affect the subclause */ public function startSub ($op = 'AND', $subop = 'OR') { if('AND' == strtoupper($op)) $this->_where .= "\n AND ( "; if('OR' == strtoupper($op)) $this->_where .= "\n OR ( "; if('AND' == strtoupper($subop)) $this->_where .= ' true '; if('OR' == strtoupper($subop)) $this->_where .= ' false '; return $this; } public function endSub () { $this->_where .= "\n ) "; return $this; } public function orderBy ($field, $direction = 'ASC', $nulls = null) { if ('ASC' != $direction && 'DESC' != $direction) throw new Exception('Invalid direction in "orderBy" clause: ' . $direction); if (isset($nulls) && (('FIRST' !== $nulls) && ('LAST' !== $nulls))) throw new Exception('Invalid NULLS in "orderBy" clause: ' . $nulls); if ($field instanceof \SparkLib\DB\Literal) { // right now, this just handles Random() - might be other things, tho: $field = $field->literal(); } else { // it's a string - check whether it's safe to order by // We're unable to validate fields if a FIND_IN_SET or COALESCE is present. if (!stristr($field, 'FIND_IN_SET') && !stristr($field, 'COALESCE')) { if (!$this->isValidField($field)) throw new Exception('Invalid field in orderBy clause: ' . $field); } } if ('' == $this->_orderBy) $this->_orderBy = ' ORDER BY ' . $field . ' ' . $direction; else $this->_orderBy .= ', ' . $field . ' ' . $direction; if (isset($nulls)) $this->_orderBy .= ' NULLS ' . $nulls; return $this; } public function groupBy ($field) { // We're unable to validate fields if a FIND_IN_SET is present. if (!stristr($field, 'FIND_IN_SET')) { if (!$this->isValidField($field) && $field !== self::RAND) throw new Exception('Invalid field in "groupBy" clause: ' . $field); } if ('' == $this->_groupBy) $this->_groupBy = ' GROUP BY ' . $field; else $this->_groupBy .= ', ' . $field; return $this; } public function distinctOn ($field) { // We're unable to validate fields if a FIND_IN_SET is present. if (!stristr($field, 'FIND_IN_SET')) { if (!$this->isValidField($field) && $field !== self::RAND) throw new Exception('Invalid field in "distinct" clause: ' . $field); } if ('' == $this->_distinctOn) $this->_distinctOn = ' DISTINCT ON (' . $field . ')'; else $this->_distinctOn = str_replace(')', ',', $this->_distinctOn) . $field . ')'; return $this; } /** * PDO TODO: document */ public function limit ($limit, $offset = 0) { $limit = (int) $limit; $offset = (int) $offset; if ('' != $this->_limit) throw new Exception('You may only impose one "limit" clause.'); if($limit > 0) $this->_limit = ' LIMIT ' . $limit; $this->_limit .= ' OFFSET ' . $offset; return $this; } /** * PDO TODO: document */ public function eq ($val) { if (is_array($val)) { $this->_where .= ' IN('; foreach($val as $i => $v) { $this->_where .= $this->render($v); if($i+1 < count($val)) $this->_where .= ','; } $this->_where .= ')'; } else { $this->_where .= ' = ' . $this->render($val); } return $this; } /** * A hack to look for the lower-case version of a string. * * We'll let the database handle lower-casing, since there's a good * chance it's smarter than PHP. */ public function eqLower ($val) { if (! is_string($val)) { throw new \Exception('lowerEq() expects a string, for now'); } $this->_where .= ' = lower(' . $this->render($val) . ')'; return $this; } /** * PDO TODO: document */ public function ne ($val) { if (is_array($val)) { $this->_where .= ' NOT IN('; foreach($val as $i => $v) { $this->_where .= $this->render($v); if($i+1 < count($val)) $this->_where .= ','; } $this->_where .= ')'; } else { $this->_where .= ' <> ' . $this->render($val); } return $this; } /** * IS - use with a DB\Null, for example. */ public function is ($val) { $this->_where .= ' IS ' . $this->render($val); return $this; } /** * IS NOT - use with a DB\Null, for example. */ public function isnot ($val) { $this->_where .= ' IS NOT ' . $this->render($val); return $this; } /** * IN() * Added so we can use sub-selects. NOT ESCAPED. * eq/ne should be used under normal circumstances */ public function in ($val) { // this isn't escaped because we need IN((query)) to be quote-free. // be seriously fucking careful. $this->_where .= ' IN(' . $val . ')'; return $this; } /** * like in, but not. NOT ESCAPED. */ public function nin ($val) { // this isn't escaped because we need NOT IN((query)) to be quote-free. // be really goddamned careful. $this->_where .= ' NOT IN(' . $val . ')'; return $this; } public function gt ($val) { $this->_where .= ' > ' . $this->render($val); return $this; } public function lt ($val) { $this->_where .= ' < ' . $this->render($val); return $this; } public function between($val1, $val2) { $this->_where .= ' BETWEEN ' . $this->render($val1) . ' AND ' . $this->render($val2); return $this; } public function gte ($val) { $this->_where .= ' >= ' . $this->render($val); return $this; } public function lte ($val) { $this->_where .= ' <= ' . $this->render($val); return $this; } public function like ($val) { $this->_where .= ' LIKE ' . $this->render($val); return $this; } public function plus ($val) { $this->_where .= ' + ' . $this->render($val); return $this; } public function minus ($val) { $this->_where .= ' - ' . $this->render($val); return $this; } /** * Case-insensitive like. */ public function ilike ($val) { // MySQL doesn't support ILIKE if ('mysql' !== constant('\DB_SERVER_TYPE')) { $this->_where .= ' ILIKE ' . $this->render($val); return $this; } return $this->like($val); } public function has($val) { return $this->like('%' . $val . '%'); } public function ihas($val) { return $this->ilike('%' . $val . '%'); } public function nlike ($val) { $this->_where .= ' NOT LIKE ' . $this->render($val); return $this; } /** * Case-insensitive nlike. */ public function nilike ($val) { // MySQL doesn't support ILIKE if ('mysql' !== constant('\DB_SERVER_TYPE')) { $this->_where .= ' NOT ILIKE ' . $this->render($val); return $this; } return $this->nlike($val); } /** * Use buffered results. */ public function buffered () { $this->_buffered = true; return $this; } /** * Use unbuffered results. */ public function unbuffered () { // PDO TODO: it would be nice to make unbuffered resultsets available throw new \Exception('unbuffered queries are not yet implemented for SparkFriendFinder'); $this->_buffered = false; return $this; } /** * Custom escape to quote and otherwise render strings. */ protected function render ($value) { $value = $this->typeJuggle($value); if ($value instanceof DB\Literal) { return $value->literal(); } return $this->getDBH()->quote($value); } /** * Normalize values to the scheme expected by render() */ protected function typeJuggle ($val) { // promote actual PHP nulls to SQL nulls if (is_null($val)) $val = new DB\Null; if ($val instanceof DB\Literal) return $val; if (is_string($val)) return $val; if (is_bool($val)) { if ($val) $val = new DB\True; else $val = new DB\False; } return $val; } /** * Initial SELECT and WHERE. * * If a JOIN is present, it will be included. */ protected function _base_query () { $this->_default_query = 'SELECT ' . $this->_distinctOn . ' ' . $this->_extraselects; // if true, only primary table key and extra selects are selected if ($this->_selectIdOnly) { $this->_default_query .= $this->_table . '.' . $this->_tableKey; } else { $this->_default_query .= $this->_table . '.* ' . $this->_with; } $this->_default_query .= ' FROM ' . $this->_table . $this->_join . $this->_left_join . ' WHERE ' . (($this->_key && $this->_id) ? $this->_key . ' = ' . $this->_id : 'true '); return $this->_default_query; } /** * Generates a string which selects and aliases * each column for a given dinosaur. */ protected function _generate_with ($dinosaur) { $with = ''; $table = $dinosaur::getDefaultTableName(); $columns = array_keys($dinosaur::getDefaults()); foreach ($columns as $column) { $with .= ", " . $table . '.' . $column . ' AS "' . $dinosaur::getBaseName() . '__' . $column . '"'; } return $with; } }