root/fRecordSet.php

Revision 707, 56.6 kB (checked in by wbond, 1 year ago)

Fixed tickets #317, #318, #320 - Cleaned up >< intersection operator for fRecordSet::build(), added support for NULL second value and added !~, &~, >< operators and OR conditions to fRecordSet::filter()

LineHide Line Numbers
1 <?php
2 /**
3  * A lightweight, iterable set of fActiveRecord-based objects
4  *
5  * @copyright  Copyright (c) 2007-2009 Will Bond
6  * @author     Will Bond [wb] <will@flourishlib.com>
7  * @license    http://flourishlib.com/license
8  *
9  * @package    Flourish
10  * @link       http://flourishlib.com/fRecordSet
11  *
12  * @version    1.0.0b26
13  * @changes    1.0.0b26  Updated the documentation for ::build() and ::filter() to reflect new functionality [wb, 2009-09-21]
14  * @changes    1.0.0b25  Fixed ::map() to work with string-style static method callbacks in PHP 5.1 [wb, 2009-09-18]
15  * @changes    1.0.0b24  Backwards Compatibility Break - renamed ::buildFromRecords() to ::buildFromArray(). Added ::buildFromCall(), ::buildFromMap() and `::build{RelatedRecords}()` [wb, 2009-09-16]
16  * @changes    1.0.0b23  Added an extra parameter to ::diff(), ::filter(), ::intersect(), ::slice() and ::unique() to save the number of records in the current set as the non-limited count for the new set [wb, 2009-09-15]
17  * @changes    1.0.0b22  Changed ::__construct() to accept any Iterator instead of just an fResult object [wb, 2009-08-12]
18  * @changes    1.0.0b21  Added performance tweaks to ::prebuild() and ::precreate() [wb, 2009-07-31]
19  * @changes    1.0.0b20  Changed the class to implement Countable, making the [http://php.net/count `count()`] function work [wb, 2009-07-29]
20  * @changes    1.0.0b19  Fixed bugs with ::diff() and ::intersect() and empty record sets [wb, 2009-07-29]
21  * @changes    1.0.0b18  Added method chaining support to prebuild, precount and precreate methods [wb, 2009-07-15]
22  * @changes    1.0.0b17  Changed ::__call() to pass the parameters to the callback [wb, 2009-07-14]
23  * @changes    1.0.0b16  Updated documentation for the intersection operator `><` [wb, 2009-07-13]
24  * @changes    1.0.0b15  Added the methods ::diff() and ::intersect() [wb, 2009-07-13]
25  * @changes    1.0.0b14  Added the methods ::contains() and ::unique() [wb, 2009-07-09]
26  * @changes    1.0.0b13  Added documentation to ::build() about the intersection operator `><` [wb, 2009-07-09]
27  * @changes    1.0.0b12  Added documentation to ::build() about the `AND LIKE` operator `&~` [wb, 2009-07-09]
28  * @changes    1.0.0b11  Added documentation to ::build() about the `NOT LIKE` operator `!~` [wb, 2009-07-08]
29  * @changes    1.0.0b10  Moved the private method ::checkConditions() to fActiveRecord::checkConditions() [wb, 2009-07-08]
30  * @changes    1.0.0b9   Changed ::build() to only fall back to ordering by primary keys if one exists [wb, 2009-06-26]
31  * @changes    1.0.0b8   Updated ::merge() to accept arrays of fActiveRecords or a single fActiveRecord in addition to an fRecordSet [wb, 2009-06-02]
32  * @changes    1.0.0b7   Backwards Compatibility Break - Removed ::flagAssociate() and ::isFlaggedForAssociation(), callbacks registered via fORM::registerRecordSetMethod() no longer receive the `$associate` parameter [wb, 2009-06-02]
33  * @changes    1.0.0b6   Changed ::tossIfEmpty() to return the record set to allow for method chaining [wb, 2009-05-18]
34  * @changes    1.0.0b5   ::build() now allows NULL for `$where_conditions` and `$order_bys`, added a check to the SQL passed to ::buildFromSQL() [wb, 2009-05-03]
35  * @changes    1.0.0b4   ::__call() was changed to prevent exceptions coming from fGrammar when an unknown method is called [wb, 2009-03-27]
36  * @changes    1.0.0b3   ::sort() and ::sortByCallback() now return the record set to allow for method chaining [wb, 2009-03-23]
37  * @changes    1.0.0b2   Added support for != and <> to ::build() and ::filter() [wb, 2008-12-04]
38  * @changes    1.0.0b    The initial implementation [wb, 2007-08-04]
39  */
40 class fRecordSet implements Iterator, Countable
41 {
42     // The following constants allow for nice looking callbacks to static methods
43     const build          = 'fRecordSet::build';
44     const buildFromArray = 'fRecordSet::buildFromArray';
45     const buildFromSQL   = 'fRecordSet::buildFromSQL';
46    
47    
48     /**
49     * Creates an fRecordSet by specifying the class to create plus the where conditions and order by rules
50     *
51     * The where conditions array can contain `key => value` entries in any of
52     * the following formats:
53     *
54     * {{{
55     * 'column='                    => VALUE,                       // column = VALUE
56     * 'column!'                    => VALUE                        // column <> VALUE
57     * 'column!='                   => VALUE                        // column <> VALUE
58     * 'column<>'                   => VALUE                        // column <> VALUE
59     * 'column~'                    => VALUE                        // column LIKE '%VALUE%'
60     * 'column!~'                   => VALUE                        // column NOT LIKE '%VALUE%'
61     * 'column<'                    => VALUE                        // column < VALUE
62     * 'column<='                   => VALUE                        // column <= VALUE
63     * 'column>'                    => VALUE                        // column > VALUE
64     * 'column>='                   => VALUE                        // column >= VALUE
65     * 'column='                    => array(VALUE, VALUE2, ... )   // column IN (VALUE, VALUE2, ... )
66     * 'column!'                    => array(VALUE, VALUE2, ... )   // column NOT IN (VALUE, VALUE2, ... )
67     * 'column!='                   => array(VALUE, VALUE2, ... )   // column NOT IN (VALUE, VALUE2, ... )
68     * 'column<>'                   => array(VALUE, VALUE2, ... )   // column NOT IN (VALUE, VALUE2, ... )
69     * 'column~'                    => array(VALUE, VALUE2, ... )   // (column LIKE '%VALUE%' OR column LIKE '%VALUE2%' OR column ... )
70     * 'column&~'                   => array(VALUE, VALUE2, ... )   // (column LIKE '%VALUE%' AND column LIKE '%VALUE2%' AND column ... )
71     * 'column!~'                   => array(VALUE, VALUE2, ... )   // (column NOT LIKE '%VALUE%' AND column NOT LIKE '%VALUE2%' AND column ... )
72     * 'column!|column2<|column3='  => array(VALUE, VALUE2, VALUE3) // (column <> '%VALUE%' OR column2 < '%VALUE2%' OR column3 = '%VALUE3%')
73     * 'column|column2><'           => array(VALUE, VALUE2)         // WHEN VALUE === NULL: ((column2 IS NULL AND column = VALUE) OR (column2 IS NOT NULL AND column <= VALUE AND column2 >= VALUE))
74     *                                                              // WHEN VALUE !== NULL: ((column <= VALUE AND column2 >= VALUE) OR (column >= VALUE AND column <= VALUE2))
75     * 'column|column2|column3~'    => VALUE                        // (column LIKE '%VALUE%' OR column2 LIKE '%VALUE%' OR column3 LIKE '%VALUE%')
76     * 'column|column2|column3~'    => array(VALUE, VALUE2, ... )   // ((column LIKE '%VALUE%' OR column2 LIKE '%VALUE%' OR column3 LIKE '%VALUE%') AND (column LIKE '%VALUE2%' OR column2 LIKE '%VALUE2%' OR column3 LIKE '%VALUE2%') AND ... )
77     * }}}
78     *
79     * When creating a condition in the form `column|column2|column3~`, if the
80     * value for the condition is a single string that contains spaces, the
81     * string will be parsed for search terms. The search term parsing will
82     * handle quoted phrases and normal words and will strip punctuation and
83     * stop words (such as "the" and "a").
84     *
85     * The order bys array can contain `key => value` entries in any of the
86     * following formats:
87     *
88     * {{{
89     * 'column'     => 'asc'      // 'first_name' => 'asc'
90     * 'column'     => 'desc'     // 'last_name'  => 'desc'
91     * 'expression' => 'asc'      // "CASE first_name WHEN 'smith' THEN 1 ELSE 2 END" => 'asc'
92     * 'expression' => 'desc'     // "CASE first_name WHEN 'smith' THEN 1 ELSE 2 END" => 'desc'
93     * }}}
94     *
95     * The column in both the where conditions and order bys can be in any of
96     * the formats:
97     *
98     * {{{
99     * 'column'                                                         // e.g. 'first_name'
100     * 'current_table.column'                                           // e.g. 'users.first_name'
101     * 'related_table.column'                                           // e.g. 'user_groups.name'
102     * 'related_table{route}.column'                                    // e.g. 'user_groups{user_group_id}.name'
103     * 'related_table=>once_removed_related_table.column'               // e.g. 'user_groups=>permissions.level'
104     * 'related_table{route}=>once_removed_related_table.column'        // e.g. 'user_groups{user_group_id}=>permissions.level'
105     * 'related_table=>once_removed_related_table{route}.column'        // e.g. 'user_groups=>permissions{read}.level'
106     * 'related_table{route}=>once_removed_related_table{route}.column' // e.g. 'user_groups{user_group_id}=>permissions{read}.level'
107     * 'column||other_column'                                           // e.g. 'first_name||last_name' - this concatenates the column values
108     * }}}
109     *
110     * In addition to using plain column names for where conditions, it is also
111     * possible to pass an aggregate function wrapped around a column in place
112     * of a column name, but only for certain comparison types:
113     *
114     * {{{
115     * 'function(column)='   => VALUE,                       // function(column) = VALUE
116     * 'function(column)!'   => VALUE                        // function(column) <> VALUE
117     * 'function(column)!=   => VALUE                        // function(column) <> VALUE
118     * 'function(column)<>'  => VALUE                        // function(column) <> VALUE
119     * 'function(column)~'   => VALUE                        // function(column) LIKE '%VALUE%'
120     * 'function(column)!~'  => VALUE                        // function(column) NOT LIKE '%VALUE%'
121     * 'function(column)<'   => VALUE                        // function(column) < VALUE
122     * 'function(column)<='  => VALUE                        // function(column) <= VALUE
123     * 'function(column)>'   => VALUE                        // function(column) > VALUE
124     * 'function(column)>='  => VALUE                        // function(column) >= VALUE
125     * 'function(column)='   => array(VALUE, VALUE2, ... )   // function(column) IN (VALUE, VALUE2, ... )
126     * 'function(column)!'   => array(VALUE, VALUE2, ... )   // function(column) NOT IN (VALUE, VALUE2, ... )
127     * 'function(column)!='  => array(VALUE, VALUE2, ... )   // function(column) NOT IN (VALUE, VALUE2, ... )
128     * 'function(column)<>'  => array(VALUE, VALUE2, ... )   // function(column) NOT IN (VALUE, VALUE2, ... )
129     * }}}
130     *
131     * The aggregate functions `AVG()`, `COUNT()`, `MAX()`, `MIN()` and
132     * `SUM()` are supported across all database types.
133     *
134     * Below is an example of using where conditions and order bys. Please note
135     * that values should **not** be escaped for the database, but should just
136     * be normal PHP values.
137     *
138     * {{{
139     * #!php
140     * return fRecordSet::build(
141     *     'User',
142     *     array(
143     *         'first_name='      => 'John',
144     *         'status!'          => 'Inactive',
145     *         'groups.group_id=' => 2
146     *     ),
147     *     array(
148     *         'last_name'   => 'asc',
149     *         'date_joined' => 'desc'
150     *     )
151     * );
152     * }}}
153     *
154     * @param  string  $class             The class to create the fRecordSet of
155     * @param  array   $where_conditions  The `column => value` comparisons for the `WHERE` clause
156     * @param  array   $order_bys         The `column => direction` values to use for the `ORDER BY` clause
157     * @param  integer $limit             The number of records to fetch
158     * @param  integer $page              The page offset to use when limiting records
159     * @return fRecordSet  A set of fActiveRecord objects
160     */
161     static public function build($class, $where_conditions=array(), $order_bys=array(), $limit=NULL, $page=NULL)
162     {
163         self::validateClass($class);
164        
165         // Ensure that the class has been configured
166         fActiveRecord::forceConfigure($class);
167        
168         $table = fORM::tablize($class);
169        
170         $sql = "SELECT " . $table . ".* FROM :from_clause";
171        
172         if ($where_conditions) {
173             $having_conditions = fORMDatabase::splitHavingConditions($where_conditions);
174        
175             $sql .= ' WHERE ' . fORMDatabase::createWhereClause($table, $where_conditions);
176         }
177        
178         $sql .= ' :group_by_clause ';
179        
180         if ($where_conditions && $having_conditions) {
181             $sql .= ' HAVING ' . fORMDatabase::createHavingClause($having_conditions);   
182         }
183        
184         if ($order_bys) {
185             $sql .= ' ORDER BY ' . fORMDatabase::createOrderByClause($table, $order_bys);
186        
187         // If no ordering is specified, order by the primary key
188         } elseif ($primary_keys = fORMSchema::retrieve()->getKeys($table, 'primary')) {
189             $expressions = array();
190             foreach ($primary_keys as $primary_key) {
191                 $expressions[] = $table . '.' . $primary_key . ' ASC';
192             }
193             $sql .= ' ORDER BY ' . join(', ', $expressions);
194         }
195        
196         $sql = fORMDatabase::insertFromAndGroupByClauses($table, $sql);
197        
198         // Add the limit clause and create a query to get the non-limited total
199         $non_limited_count_sql = NULL;
200         if ($limit !== NULL) {
201             $primary_key_fields = fORMSchema::retrieve()->getKeys($table, 'primary');
202             $primary_key_fields = fORMDatabase::addTableToValues($table, $primary_key_fields);
203            
204             $non_limited_count_sql = str_replace('SELECT ' . $table . '.*', 'SELECT ' . join(', ', $primary_key_fields), $sql);
205             $non_limited_count_sql = 'SELECT count(*) FROM (' . $non_limited_count_sql . ') AS sq';
206            
207             $sql .= ' LIMIT ' . $limit;
208            
209             if ($page !== NULL) {
210                
211                 if (!is_numeric($page) || $page < 1) {
212                     throw new fProgrammerException(
213                         'The page specified, %s, is not a number or less than one',
214                         $page
215                     );
216                 }
217                
218                 $sql .= ' OFFSET ' . (($page-1) * $limit);
219             }
220         }
221        
222         return new fRecordSet($class, fORMDatabase::retrieve()->translatedQuery($sql), $non_limited_count_sql);
223     }
224    
225    
226     /**
227     * Creates an fRecordSet from an array of records
228     *
229     * @internal
230      *
231     * @param  string|array $class    The class or classes of the records
232     * @param  array        $records  The records to create the set from, the order of the record set will be the same as the order of the array.
233     * @return fRecordSet  A set of fActiveRecord objects
234     */
235     static public function buildFromArray($class, $records)
236     {
237         if (is_array($class)) {
238             foreach ($class as $_class) {
239                 self::validateClass($_class);   
240             }
241         } else {
242             self::validateClass($class);   
243         }
244        
245         if (!is_array($records)) {
246             throw new fProgrammerException('The records specified are not in an array');   
247         }
248        
249         return new fRecordSet($class, $records);
250     }
251    
252    
253     /**
254     * Creates an fRecordSet from an SQL statement
255     *
256     * The SQL statement should select all columns from a single table with a *
257     * pattern since that is what an fActiveRecord models. If any columns are
258     * left out or added, strange error may happen when loading or saving
259     * records.
260     *
261     * Here is an example of an appropriate SQL statement:
262     *
263     * {{{
264     * #!sql
265     * SELECT users.* FROM users INNER JOIN groups ON users.group_id = groups.group_id WHERE groups.name = 'Public'
266     * }}}
267     *
268     * Here is an example of a SQL statement that will cause errors:
269     *
270     * {{{
271     * #!sql
272     * SELECT users.*, groups.name FROM users INNER JOIN groups ON users.group_id = groups.group_id WHERE groups.group_id = 2
273     * }}}
274     *
275     * The `$non_limited_count_sql` should only be passed when the `$sql`
276     * contains a `LIMIT` clause and should contain a count of the records when
277     * a `LIMIT` is not imposed.
278     *
279     * Here is an example of a `$sql` statement with a `LIMIT` clause and a
280     * corresponding `$non_limited_count_sql`:
281     *
282     * {{{
283     * #!php
284     * fRecordSet::buildFromSQL('User', 'SELECT * FROM users LIMIT 5', 'SELECT count(*) FROM users');
285     * }}}
286     *
287     * The `$non_limited_count_sql` is used when ::count() is called with `TRUE`
288     * passed as the parameter.
289     *
290     * @param  string $class                  The class to create the fRecordSet of
291     * @param  string $sql                    The SQL to create the set from
292     * @param  string $non_limited_count_sql  An SQL statement to get the total number of rows that would have been returned if a `LIMIT` clause had not been used. Should only be passed if a `LIMIT` clause is used.
293     * @return fRecordSet  A set of fActiveRecord objects
294     */
295     static public function buildFromSQL($class, $sql, $non_limited_count_sql=NULL)
296     {
297         self::validateClass($class);
298        
299         if (!preg_match('#^\s*SELECT\s*(DISTINCT|ALL)?\s*(\w+\.)?\*\s*FROM#i', $sql)) {
300             throw new fProgrammerException(
301                 'The SQL statement specified, %s, does not appear to be in the form SELECT * FROM table',
302                 $sql
303             );   
304         }
305        
306         return new fRecordSet(
307             $class,
308             fORMDatabase::retrieve()->translatedQuery($sql),
309             $non_limited_count_sql
310         );
311     }
312    
313    
314     /**
315     * Composes text using fText if loaded
316     *
317     * @param  string  $message    The message to compose
318     * @param  mixed   $component  A string or number to insert into the message
319     * @param  mixed   ...
320     * @return string  The composed and possible translated message
321     */
322     static protected function compose($message)
323     {
324         $args = array_slice(func_get_args(), 1);
325        
326         if (class_exists('fText', FALSE)) {
327             return call_user_func_array(
328                 array('fText', 'compose'),
329                 array($message, $args)
330             );
331         } else {
332             return vsprintf($message, $args);
333         }
334     }
335    
336    
337     /**
338     * Ensures a class extends fActiveRecord
339     *
340     * @param  string $class  The class to verify
341     * @return void
342     */
343     static private function validateClass($class)
344     {
345         $is_active_record = $class == 'fActiveRecord' || is_subclass_of($class, 'fActiveRecord');
346         if (!is_string($class) || !$class || !class_exists($class) || !$is_active_record) {
347             throw new fProgrammerException(
348                 'The class specified, %1$s, does not appear to be a valid %2$s class',
349                 $class,
350                 'fActiveRecord'
351             );   
352         }   
353     }
354    
355    
356     /**
357     * The class of the contained records
358     *
359     * @var string|array
360     */
361     private $class = NULL;
362    
363     /**
364     * The number of rows that would have been returned if a `LIMIT` clause had not been used, or the SQL to get that number
365     *
366     * @var integer|string
367     */
368     private $non_limited_count = NULL;
369    
370     /**
371     * The index of the current record
372     *
373     * @var integer
374     */
375     private $pointer = 0;
376    
377     /**
378     * An array of the records in the set, initially empty
379     *
380     * @var array
381     */
382     private $records = array();
383    
384    
385     /**
386     * Allows for preloading various data related to the record set in single database queries, as opposed to one query per record
387     *
388     * This method will handle methods in the format `verbRelatedRecords()` for
389     * the verbs `build`, `prebuild`, `precount` and `precreate`.
390     *
391     * `build` calls `create{RelatedClass}()` on each record in the set and
392     * returns the result as a new record set. The relationship route can be
393     * passed as an optional parameter.
394     *
395     * `prebuild` builds *-to-many record sets for all records in the record
396     * set. `precount` will count records in *-to-many record sets for every
397     * record in the record set. `precreate` will create a *-to-one record
398     * for every record in the record set.
399    
400     * @param  string $method_name  The name of the method called
401     * @param  string $parameters   The parameters passed
402     * @return void
403     */
404     public function __call($method_name, $parameters)
405     {
406         if ($callback = fORM::getRecordSetMethod($method_name)) {
407             return call_user_func_array(
408                 $callback,
409                 array(
410                     $this,
411                     $this->class,
412                     &$this->records,
413                     &$this->pointer,
414                     $parameters
415                 )
416             );   
417         }
418        
419         list($action, $element) = fORM::parseMethod($method_name);
420        
421         $route = ($parameters) ? $parameters[0] : NULL;
422        
423         // This check prevents fGrammar exceptions being thrown when an unknown method is called
424         if (in_array($action, array('build', 'prebuild', 'precount', 'precreate'))) {
425             $related_class = fGrammar::singularize(fGrammar::camelize($element, TRUE));
426         }
427          
428         switch ($action) {
429             case 'build':
430                 if ($route) {
431                     $this->precreate($related_class, $route);
432                     $this->buildFromCall('create' . $related_class, $route);       
433                 }
434                 $this->precreate($related_class);
435                 return $this->buildFromCall('create' . $related_class);
436            
437             case 'prebuild':
438                 return $this->prebuild($related_class, $route);
439            
440             case 'precount':
441                 return $this->precount($related_class, $route);
442                
443             case 'precreate':
444                 return $this->precreate($related_class, $route);
445         }
446          
447         throw new fProgrammerException(
448             'Unknown method, %s(), called',
449             $method_name
450         );
451     }
452      
453      
454     /**
455     * Sets the contents of the set
456     *
457     * @param  string|array   $class              The type(s) of records the object will contain
458     * @param  Iterator|array $records            The Iterator object of the records to create or an array of records
459     * @param  string|integer $non_limited_count  An SQL statement to get the total number of records sans a `LIMIT` clause or a integer of the total number of records
460     * @return fRecordSet
461     */
462     protected function __construct($class, $records=NULL, $non_limited_count=NULL)
463     {
464         $this->class = (is_array($class) && count($class) == 1) ? current($class) : $class;
465        
466         if ($non_limited_count !== NULL) {
467             $this->non_limited_count = $non_limited_count;
468         }
469        
470         if ($records && is_object($records) && $records instanceof Iterator) {
471             while ($records->valid()) {
472                 $this->records[] = new $class($records);
473                 $records->next();
474             }
475         }
476        
477         if (is_array($records)) {
478             $this->records = $records;   
479         }
480     }
481    
482    
483     /**
484     * All requests that hit this method should be requests for callbacks
485     *
486     * @internal
487      *
488     * @param  string $method  The method to create a callback for
489     * @return callback  The callback for the method requested
490     */
491     public function __get($method)
492     {
493         return array($this, $method);       
494     }
495    
496    
497     /**
498     * Calls a specific method on each object, returning an fRecordSet of the results
499     *
500     * @param  string $method     The method to call
501     * @param  mixed  $parameter  A parameter to pass for each call to the method
502     * @param  mixed  ...
503     * @return fRecordSet  A set of records that resulted from calling the method
504     */
505     public function buildFromCall($method)
506     {
507         $parameters = func_get_args();
508        
509         $result = call_user_func_array($this->call, $parameters);
510        
511         $classes = array();
512         foreach ($result as $record) {
513             if (!$record instanceof fActiveRecord) {
514                 throw new fProgrammerException(
515                     'The method called, %1$s, returned something other than an fActiveRecord object',
516                     $method
517                 );
518             }
519            
520             $class = get_class($record);
521            
522             if (!isset($classes[$class])) {
523                 $classes[$class] = TRUE;   
524             }
525         }
526        
527         // If no objects were returned we need to fake the class
528         if (!$classes) {
529             $classes = array('fActiveRecord' => TRUE);
530         }
531        
532         return new fRecordSet(array_keys($classes), $result);
533     }
534    
535    
536     /**
537     * Maps each record in the set to a callback function, returning an fRecordSet of the results
538     *
539     * @param  callback $callback   The callback to pass the values to
540     * @param  mixed    $parameter  The parameter to pass to the callback - see method description for details
541     * @param  mixed    ...
542     * @return fRecordSet  A set of records that resulted from the mapping operation
543     */
544     public function buildFromMap($callback)
545     {
546         $parameters = func_get_args();
547        
548         $result = call_user_func_array($this->map, $parameters);
549        
550         $classes = array();
551         foreach ($result as $record) {
552             if (!$record instanceof fActiveRecord) {
553                 throw new fProgrammerException(
554                     'The map operation specified, %1$s, returned something other than an fActiveRecord object',
555                     $callback
556                 );
557             }
558            
559             $class = get_class($record);
560            
561             if (!isset($classes[$class])) {
562                 $classes[$class] = TRUE;         
563             }
564         }
565        
566         // If no objects were returned we need to fake the class
567         if (!$classes) {
568             $classes = array('fActiveRecord' => TRUE);
569         }
570        
571         return new fRecordSet(array_keys($classes), $result);
572     }
573    
574    
575     /**
576     * Calls a specific method on each object, returning an array of the results
577     *
578     * @param  string $method     The method to call
579     * @param  mixed  $parameter  A parameter to pass for each call to the method
580     * @param  mixed  ...
581     * @return array  An array the size of the record set with one result from each record/method
582     */
583     public function call($method)
584     {
585         $parameters = array_slice(func_get_args(), 1);
586        
587         $output = array();
588         foreach ($this->records as $record) {
589             $output[] = call_user_func_array(
590                 $record->$method,
591                 $parameters
592             );
593         }
594         return $output;
595     }
596    
597    
598     /**
599     * Creates an `ORDER BY` clause for the primary keys of this record set
600     *
601     * @param  string $route  The route to this table from another table
602     * @return string  The `ORDER BY` clause
603     */
604     private function constructOrderByClause($route=NULL)
605     {
606         $table = fORM::tablize($this->class);
607         $table_with_route = ($route) ? $table . '{' . $route . '}' : $table;
608        
609         $pk_columns      = fORMSchema::retrieve()->getKeys($table, 'primary');
610         $first_pk_column = $pk_columns[0];
611        
612         $sql = '';
613        
614         $number = 0;
615         foreach ($this->getPrimaryKeys() as $primary_key) {
616             $sql .= 'WHEN ';
617              
618             if (is_array($primary_key)) {
619                 $conditions = array();
620                 foreach ($pk_columns as $pk_column) {
621                     $conditions[] = $table_with_route . '.' . $pk_column . fORMDatabase::escapeBySchema($table, $pk_column, $primary_key[$pk_column], '=');
622                 }
623                 $sql .= join(' AND ', $conditions);
624             } else {
625                 $sql .= $table_with_route . '.' . $first_pk_column . fORMDatabase::escapeBySchema($table, $first_pk_column, $primary_key, '=');
626             }
627              
628             $sql .= ' THEN ' . $number . ' ';
629              
630             $number++;
631         }
632        
633         return 'CASE ' . $sql . 'END ASC';
634     }
635    
636    
637     /**
638     * Creates a `WHERE` clause for the primary keys of this record set
639     *
640     * @param  string $route  The route to this table from another table
641     * @return string  The `WHERE` clause
642     */
643     private function constructWhereClause($route=NULL)
644     {
645         $table = fORM::tablize($this->class);
646         $table_with_route = ($route) ? $table . '{' . $route . '}' : $table;
647        
648         $pk_columns = fORMSchema::retrieve()->getKeys($table, 'primary');
649        
650         $sql = '';
651        
652         // We have a multi-field primary key, making things kinda ugly
653         if (sizeof($pk_columns) > 1) {
654            
655             $conditions = array();
656              
657             foreach ($this->getPrimaryKeys() as $primary_key) {
658                 $sub_conditions = array();
659                 foreach ($pk_columns as $pk_column) {
660                     $sub_conditions[] = $table_with_route . '.' . $pk_column . fORMDatabase::escapeBySchema($table, $pk_column, $primary_key[$pk_column], '=');
661                 }
662                 $conditions[] = join(' AND ', $sub_conditions);
663             }
664             $sql .= '(' . join(') OR (', $conditions) . ')';
665          
666         // We have a single primary key field, making things nice and easy
667         } else {
668             $first_pk_column = $pk_columns[0];
669          
670             $values = array();
671             foreach ($this->getPrimaryKeys() as $primary_key) {
672                 $values[] = fORMDatabase::escapeBySchema($table, $first_pk_column, $primary_key);
673             }
674             $sql .= $table_with_route . '.' . $first_pk_column . ' IN (' . join(', ', $values) . ')';
675         }
676        
677         return $sql;
678     }
679    
680    
681     /**
682     * Checks if the record set contains the record specified
683     *
684     * @param  fActiveRecord $record  The record to check, must exist in the database
685     * @return boolean  If the record specified is in this record set
686     */
687     public function contains($record)
688     {
689         $class = get_class($record);
690         if (!in_array($class, (array) $this->class)) {
691             return FALSE;   
692         }
693        
694         if (!$record->exists()) {
695             throw new fProgrammerException(
696                 'Only records that exist can be checked for in the record set'
697             );   
698         }
699        
700         $hash = fActiveRecord::hash($record);
701        
702         foreach ($this->records as $_record) {
703             if ($class != get_class($_record)) {
704                 continue;
705             }   
706             if ($hash == fActiveRecord::hash($_record)) {
707                 return TRUE;   
708             }
709         }
710        
711         return FALSE;
712     }
713    
714    
715     /**
716     * Returns the number of records in the set
717     *
718     * @param  boolean $ignore_limit  If set to `TRUE`, this method will return the number of records that would be in the set if there was no `LIMIT` clause
719     * @return integer  The number of records in the set
720     */
721     public function count($ignore_limit=FALSE)
722     {
723         if ($ignore_limit !== TRUE || $this->non_limited_count === NULL) {
724             return sizeof($this->records);
725         }
726        
727         if (!is_numeric($this->non_limited_count)) {
728             try {
729                 $this->non_limited_count = fORMDatabase::retrieve()->translatedQuery($this->non_limited_count)->fetchScalar();
730             } catch (fExpectedException $e) {
731                 $this->non_limited_count = $this->count();
732             }
733         }
734         return $this->non_limited_count;
735     }
736    
737    
738     /**
739     * Returns the current record in the set (used for iteration)
740     *
741     * @throws fNoRemainingException  When there are no remaining records in the set
742     * @internal
743      *
744     * @return fActiveRecord  The current record
745     */
746     public function current()
747     {
748         if (!$this->valid()) {
749             throw new fNoRemainingException(
750                 'There are no remaining records'
751             );
752         }
753        
754         return $this->records[$this->pointer];
755     }
756    
757    
758     /**
759     * Removes all passed records from the current record set
760     *
761     * @param  fRecordSet|array|fActiveRecord $records                  The record set, array of records, or record to remove from the current record set, all instances will be removed
762     * @param  boolean                        $remember_original_count  If the number of records in the current set should be saved as the non-limited count for the new set
763     * @return fRecordSet  The records not present in the passed records
764     */
765     public function diff($records, $remember_original_count=FALSE)
766     {
767         $remove_records = array();
768        
769         if ($records instanceof fActiveRecord) {
770             $records = array($records);   
771         }
772         foreach ($records as $record) {
773             $class = get_class($record);
774             $hash  = fActiveRecord::hash($record);
775             $remove_records[$class . '::' . $hash] = TRUE;
776         }
777        
778         $new_records = array();
779         $classes     = array();
780        
781         foreach ($this->records as $record) {
782             $class = get_class($record);
783             $hash  = fActiveRecord::hash($record);
784             if (!isset($remove_records[$class . '::' . $hash])) {
785                 $new_records[]   = $record;
786                 $classes[$class] = TRUE;
787             }       
788         }
789        
790         if ($classes) {
791             $class = array_keys($classes);
792         } else {
793             $class = $this->class;
794         }   
795        
796         $set = new fRecordSet($class, $new_records);
797        
798         if ($remember_original_count) {
799             $set->non_limited_count    = $this->count();
800         }
801        
802         return $set;
803     }
804    
805    
806     /**
807     * Filters the records in the record set via a callback
808     *
809     * The `$callback` parameter can be one of three different forms to filter
810     * the records in the set:
811     *
812     *  - A callback that accepts a single record and returns `FALSE` if it should be removed
813     *  - A psuedo-callback in the form `'{record}::methodName'` to filter out any records where the output of `$record->methodName()` is equivalent to `FALSE`
814     *  - A conditions array that will remove any records that don't meet all of the conditions
815     *
816     * The conditions array can use one or more of the following `key => value`
817     * syntaxes to perform various comparisons. The array keys are method
818     * names followed by a comparison operator.
819     *
820     * {{{
821     * // The following forms work for any $value that is not an array
822     * 'methodName='                           => $value  // If the output is equal to $value
823     * 'methodName!'                           => $value  // If the output is not equal to $value
824     * 'methodName!='                          => $value  // If the output is not equal to $value
825     * 'methodName<>'                          => $value  // If the output is not equal to $value
826     * 'methodName<'                           => $value  // If the output is less than $value
827     * 'methodName<='                          => $value  // If the output is less than or equal to $value
828     * 'methodName>'                           => $value  // If the output is greater than $value
829     * 'methodName>='                          => $value  // If the output is greater than or equal to $value
830     * 'methodName~'                           => $value  // If the output contains the $value (case insensitive)
831     * 'methodName!~'                          => $value  // If the output does not contain the $value (case insensitive)
832     * 'methodName|methodName2|methodName3~'   => $value  // Parses $value as a search string and make sure each term is present in at least one output (case insensitive)
833     *
834     * // The following forms work for any $array that is an array
835     * 'methodName='                           => $array  // If the output is equal to at least one value in $array
836     * 'methodName!'                           => $array  // If the output is not equal to any value in $array
837     * 'methodName!='                          => $array  // If the output is not equal to any value in $array
838     * 'methodName<>'                          => $array  // If the output is not equal to any value in $array
839     * 'methodName~'                           => $array  // If the output contains one of the strings in $array (case insensitive)
840     * 'methodName!~'                          => $array  // If the output contains none of the strings in $array (case insensitive)
841     * 'methodName&~'                          => $array  // If the output contains all of the strings in $array (case insensitive)
842     * 'methodName|methodName2|methodName3~'   => $array  // If each value in the array is present in the output of at least one method (case insensitive)
843     *
844     * // The following works for an equal number of methods and values in the array
845     * 'methodName!|methodName2<|methodName3=' => array($value, $value2, $value3) // An OR statement - one of the method to value comparisons must be TRUE
846     *
847     * // The following accepts exactly two methods and two values, although the second value may be NULL
848     * 'methodName|methodName2><'              => array($value, $value2) // If the range of values from the methods intersects the range of $value and $value2 - should be dates, times, timestamps or numbers
849     * }}}
850     *
851     * @param  callback|string|array $procedure                The way in which to filter the records - see method description for possible forms
852     * @param  boolean               $remember_original_count  If the number of records in the current set should be saved as the non-limited count for the new set
853     * @return fRecordSet  A new fRecordSet with the filtered records
854     */
855     public function filter($procedure, $remember_original_count=FALSE)
856     {
857         if (!$this->records) {
858             return clone $this;
859         }
860        
861         if (is_array($procedure) && is_string(key($procedure))) {
862             $type       = 'conditions';
863             $conditions = $procedure;
864            
865         } elseif (is_string($procedure) && preg_match('#^\{record\}::([a-z0-9_\-]+)$#iD', $procedure, $matches)) {
866             $type   = 'psuedo-callback';
867             $method = $matches[1];
868            
869         } else {
870             $type     = 'callback';
871             $callback = $procedure;
872             if (is_string($callback) && strpos($callback, '::') !== FALSE) {
873                 $callback = explode('::', $callback);   
874             }
875         }
876            
877         $new_records = array();
878         $classes     = (!is_array($this->class)) ? array($this->class => TRUE) : array();
879        
880         foreach ($this->records as $record) {
881             switch ($type) {
882                 case 'conditions':
883                     $value = fActiveRecord::checkConditions($record, $conditions);
884                     break;
885                    
886                 case 'psuedo-callback':
887                     $value = $record->$method();
888                     break;
889                    
890                 case 'callback':
891                     $value = call_user_func($callback, $record);
892                     break;
893             }
894            
895             if ($value) {
896                 $classes[get_class($record)] = TRUE;
897                
898                 $new_records[] = $record;
899             }
900         }
901        
902         $set = new fRecordSet(array_keys($classes), $new_records);
903        
904         if ($remember_original_count) {
905             $set->non_limited_count    = $this->count();
906         }
907        
908         return $set;
909     }
910    
911    
912     /**
913     * Returns the current record in the set and moves the pointer to the next
914     *
915     * @throws fNoRemainingException  When there are no remaining records in the set
916     *
917     * @return fActiveRecord  The current record
918     */
919     public function fetchRecord()
920     {
921         $record = $this->current();
922         $this->next();
923         return $record;
924     }
925    
926    
927     /**
928     * Returns the class name of the record being stored
929     *
930     * @return string|array  The class name(s) of the records in the set
931     */
932     public function getClass()
933     {
934         return $this->class;
935     }
936    
937    
938     /**
939     * Returns all of the records in the set
940     *
941     * @return array  The records in the set
942     */
943     public function getRecords()
944     {
945         return $this->records;
946     }
947    
948    
949     /**
950     * Returns the primary keys for all of the records in the set
951     *
952     * @return array  The primary keys of all the records in the set
953     */
954     public function getPrimaryKeys()
955     {
956         if (!sizeof($this->records)) {
957             return array();
958         }
959        
960         $this->validateSingleClass('get primary key');
961        
962         $table           = fORM::tablize($this->class);
963         $pk_columns      = fORMSchema::retrieve()->getKeys($table, 'primary');
964         $first_pk_column = $pk_columns[0];
965        
966         $primary_keys = array();
967        
968         foreach ($this->records as $number => $record) {
969             $keys = array();
970            
971             foreach ($pk_columns as $pk_column) {
972                 $method = 'get' . fGrammar::camelize($pk_column, TRUE);
973                 $keys[$pk_column] = $record->$method();
974             }
975            
976             $primary_keys[$number] = (sizeof($pk_columns) == 1) ? $keys[$first_pk_column] : $keys;
977         }
978        
979         return $primary_keys;
980     }
981    
982    
983     /**
984     * Returns all records in the current record set that are also present in the passed records
985     *
986     * @param  fRecordSet|array|fActiveRecord $records                  The record set, array of records, or record to create an intersection of with the current record set
987     * @param  boolean                        $remember_original_count  If the number of records in the current set should be saved as the non-limited count for the new set
988     * @return fRecordSet  The records present in the current record set that are also present in the passed records
989     */
990     public function intersect($records, $remember_original_count=FALSE)
991     {
992         $hashes = array();
993        
994         if ($records instanceof fActiveRecord) {
995             $records = array($records);   
996         }
997         foreach ($records as $record) {
998             $class = get_class($record);
999             $hash  = fActiveRecord::hash($record);
1000             $hashes[$class . '::' . $hash] = TRUE;
1001         }
1002        
1003         $new_records = array();
1004         $classes     = array();
1005        
1006         foreach ($this->records as $record) {
1007             $class = get_class($record);
1008             $hash  = fActiveRecord::hash($record);
1009             if (isset($hashes[$class . '::' . $hash])) {
1010                 $new_records[]   = $record;
1011                 $classes[$class] = TRUE;
1012             }       
1013         }
1014        
1015         if ($classes) {
1016             $class = array_keys($classes);
1017         } else {
1018             $class = $this->class;
1019         }   
1020        
1021         $set = new fRecordSet($class, $new_records);
1022        
1023         if ($remember_original_count) {
1024             $set->non_limited_count    = $this->count();
1025         }
1026        
1027         return $set;
1028     }
1029    
1030    
1031     /**
1032     * Returns the primary key for the current record (used for iteration)
1033     *
1034     * @internal
1035      *
1036     * @return mixed  The primay key of the current record
1037     */
1038     public function key()
1039     {
1040         return $this->pointer;
1041     }
1042    
1043    
1044     /**
1045     * Performs an [http://php.net/array_map array_map()] on the record in the set
1046     *
1047     * The record will be passed to the callback as the first parameter unless
1048     * it's position is specified by the placeholder string `'{record}'`.
1049     *
1050     * Additional parameters can be passed to the callback in one of two
1051     * different ways:
1052     *
1053     *  - Passing a non-array value will cause it to be passed to the callback
1054     *  - Passing an array value will cause the array values to be passed to the callback with their corresponding record
1055    
1056     * If an array parameter is too long (more items than records in the set)
1057     * it will be truncated. If an array parameter is too short (less items
1058     * than records in the set) it will be padded with `NULL` values.
1059     *
1060     * To allow passing the record as a specific parameter to the callback, a
1061     * placeholder string `'{record}'` will be replaced with a the record. It
1062     * is also possible to specify `'{record}::methodName'` to cause the output
1063     * of a method from the record to be passed instead of the whole record.
1064     *
1065     * It is also possible to pass the zero-based record index to the callback
1066     * by passing a parameter that contains `'{index}'`.
1067     *
1068     * @param  callback $callback   The callback to pass the values to
1069     * @param  mixed    $parameter  The parameter to pass to the callback - see method description for details
1070     * @param  mixed    ...
1071     * @return array  An array of the results from the callback
1072     */
1073     public function map($callback)
1074     {
1075         $parameters = array_slice(func_get_args(), 1);
1076        
1077         if (!$this->records) {
1078             return array();
1079         }
1080        
1081         $parameters_array = array();
1082         $found_record     = FALSE;
1083         $total_records    = sizeof($this->records);
1084        
1085         foreach ($parameters as $parameter) {
1086             if (!is_array($parameter)) {
1087                 if (preg_match('#^\{record\}::([a-z0-9_\-]+)$#iD', $parameter, $matches)) {
1088                     $parameters_array[] = $this->call($matches[1]);
1089                     $found_record = TRUE;
1090                 } elseif ($parameter === '{record}') {
1091                     $parameters_array[] = $this->records;
1092                     $found_record = TRUE;
1093                 } elseif ($parameter === '{index}') {
1094                     $parameters_array[] = array_keys($this->records);
1095                 } else {
1096                     $parameters_array[] = array_pad(array(), $total_records, $parameter);
1097                 }
1098                
1099             } elseif (sizeof($parameter) > $total_records) {
1100                 $parameters_array[] = array_slice($parameter, 0, $total_records);
1101             } elseif (sizeof($parameter) < $total_records) {
1102                 $parameters_array[] = array_pad($parameter, $total_records, NULL);
1103             } else {
1104                 $parameters_array[] = $parameter;
1105             }
1106         }
1107        
1108         if (!$found_record) {
1109             array_unshift($parameters_array, $this->records);
1110         }
1111        
1112         if (is_string($callback) && strpos($callback, '::') !== FALSE) {
1113             $callback = explode('::', $callback);
1114         }
1115        
1116         array_unshift($parameters_array, $callback);
1117        
1118         return call_user_func_array('array_map', $parameters_array);
1119     }
1120    
1121    
1122     /**
1123     * Merges the record set with more records
1124     *
1125     * @param  fRecordSet|array|fActiveRecord $records  The record set, array of records, or record to merge with the current record set, duplicates will **not** be removed
1126     * @return fRecordSet  The merged record sets
1127     */
1128     public function merge($records)
1129     {
1130         $classes = array_flip((array) $this->class);
1131        
1132         if ($records instanceof fRecordSet) {
1133             $new_records = $records->records;
1134             $classes    += array_flip((array) $records->class);   
1135        
1136         } elseif (is_array($records)) {
1137             $new_records = array();
1138             foreach ($records as $record) {
1139                 if (!$record instanceof fActiveRecord) {
1140                     throw new fProgrammerException(
1141                         'One of the records specified is not an instance of %s',
1142                         'fActiveRecord'
1143                     );   
1144                 }
1145                 $new_records[] = $record;
1146                 $classes[get_class($record)] = TRUE;
1147             }   
1148        
1149         } elseif ($records instanceof fActiveRecord) {
1150             $new_records = array($records);
1151             $classes[get_class($records)] = TRUE;
1152            
1153         } else {
1154             throw new fProgrammerException(
1155                 'The records specified, %1$s, are invalid. Must be an %2$s, %3$s or an array of %4$s.',
1156                 $records,
1157                 'fRecordSet',
1158                 'fActiveRecord',
1159                 'fActiveRecords'
1160             );   
1161         }
1162        
1163         if (!$new_records) {
1164             return $this;   
1165         }
1166        
1167         return new fRecordSet(
1168             array_keys($classes),
1169             array_merge(
1170                 $this->records,
1171                 $new_records
1172             )
1173         );
1174     }
1175    
1176    
1177     /**
1178     * Moves to the next record in the set (used for iteration)
1179     *
1180     * @internal
1181      *
1182     * @return void
1183     */
1184     public function next()
1185     {
1186         $this->pointer++;
1187     }
1188    
1189    
1190     /**
1191     * Builds the related records for all records in this set in one DB query
1192    
1193     * @param  string $related_class  This should be the name of a related class
1194     * @param  string $route          This should be a column name or a join table name and is only required when there are multiple routes to a related table. If there are multiple routes and this is not specified, an fProgrammerException will be thrown.
1195     * @return fRecordSet  The record set object, to allow for method chaining
1196     */
1197     private function prebuild($related_class, $route=NULL)
1198     {
1199         if (!$this->records) {
1200             return $this;
1201         }
1202        
1203         $this->validateSingleClass('prebuild');
1204        
1205         // If there are no primary keys we can just exit
1206         if (!array_merge($this->getPrimaryKeys())) {
1207             return $this;
1208         }
1209        
1210         $related_table = fORM::tablize($related_class);
1211         $table         = fORM::tablize($this->class);
1212          
1213         $route        = fORMSchema::getRouteName($table, $related_table, $route, '*-to-many');
1214         $relationship = fORMSchema::getRoute($table, $related_table, $route, '*-to-many');
1215        
1216         $table_with_route = ($route) ? $table . '{' . $route . '}' : $table;
1217        
1218         // Build the query out
1219         $where_sql    = $this->constructWhereClause($route);
1220        
1221         $order_by_sql = $this->constructOrderByClause($route);
1222         if ($related_order_bys = fORMRelated::getOrderBys($this->class, $related_class, $route)) {
1223             $order_by_sql .= ', ' . fORMDatabase::createOrderByClause($related_table, $related_order_bys);
1224         }
1225        
1226         $new_sql  = 'SELECT ' . $related_table . '.*';
1227        
1228         // If we are going through a join table we need the related primary key for matching
1229         if (isset($relationship['join_table'])) {
1230             $new_sql .= ", " . $table_with_route . '.' . $relationship['column'];
1231         }
1232        
1233         $new_sql .= ' FROM :from_clause ';
1234         $new_sql .= ' WHERE ' . $where_sql;
1235         $new_sql .= ' :group_by_clause ';
1236         $new_sql .= ' ORDER BY ' . $order_by_sql;
1237          
1238         $new_sql = fORMDatabase::insertFromAndGroupByClauses($related_table, $new_sql);
1239        
1240         // Add the joining column to the group by
1241         if (strpos($new_sql, 'GROUP BY') !== FALSE) {
1242             $new_sql = str_replace(' ORDER BY', ', ' . $table . '.' . $relationship['column'] . ' ORDER BY', $new_sql);
1243         }
1244          
1245          
1246         // Run the query and inject the results into the records
1247         $result = fORMDatabase::retrieve()->translatedQuery($new_sql);
1248          
1249         $total_records = sizeof($this->records);
1250         for ($i=0; $i < $total_records; $i++) {
1251              
1252            
1253             // Get the record we are injecting into
1254             $record = $this->records[$i];
1255             $keys   = array();
1256            
1257              
1258             // If we are going through a join table, keep track of the record by the value in the join table
1259             if (isset($relationship['join_table'])) {
1260                 try {
1261                     $current_row = $result->current();
1262                     $keys[$relationship['column']] = $current_row[$relationship['column']];
1263                 } catch (fExpectedException $e) { }
1264            
1265             // If it is a straight join, keep track of the value by the related column value
1266             } else {
1267                 $method = 'get' . fGrammar::camelize($relationship['related_column'], TRUE);
1268                 $keys[$relationship['related_column']] = $record->$method();
1269             }
1270              
1271            
1272             // Loop through and find each row for the current record
1273             $rows = array();
1274                          
1275             try {
1276                 while (!array_diff_assoc($keys, $result->current())) {
1277                     $row = $result->fetchRow();
1278                      
1279                     // If we are going through a join table we need to remove the related primary key that was used for matching
1280                     if (isset($relationship['join_table'])) {
1281                         unset($row[$relationship['column']]);
1282                     }
1283                      
1284                     $rows[] = $row;
1285                 }
1286             } catch (fExpectedException $e) { }
1287              
1288            
1289             // Set up the result object for the new record set
1290             $set = new fRecordSet($related_class, new ArrayIterator($rows));
1291              
1292             // Inject the new record set into the record
1293             $method = 'inject' . fGrammar::pluralize($related_class);
1294             $record->$method($set, $route);
1295         }
1296        
1297         return $this;
1298     }
1299    
1300    
1301     /**
1302     * Counts the related records for all records in this set in one DB query
1303    
1304     * @param  string $related_class  This should be the name of a related class
1305     * @param  string $route          This should be a column name or a join table name and is only required when there are multiple routes to a related table. If there are multiple routes and this is not specified, an fProgrammerException will be thrown.
1306     * @return fRecordSet  The record set object, to allow for method chaining
1307     */
1308     private function precount($related_class, $route=NULL)
1309     {
1310         if (!$this->records) {
1311             return $this;
1312         }
1313        
1314         $this->validateSingleClass('precount');
1315        
1316         // If there are no primary keys we can just exit
1317         if (!array_merge($this->getPrimaryKeys())) {
1318             return $this;
1319         }
1320        
1321         $related_table = fORM::tablize($related_class);
1322         $table         = fORM::tablize($this->class);
1323          
1324         $route        = fORMSchema::getRouteName($table, $related_table, $route, '*-to-many');
1325         $relationship = fORMSchema::getRoute($table, $related_table, $route, '*-to-many');
1326        
1327         $table_with_route = ($route) ? $table . '{' . $route . '}' : $table;
1328        
1329         // Build the query out
1330         $where_sql    = $this->constructWhereClause($route);
1331         $order_by_sql = $this->constructOrderByClause($route);
1332        
1333         $related_table_keys = fORMSchema::retrieve()->getKeys($related_table, 'primary');
1334         $related_table_keys = fORMDatabase::addTableToValues($related_table, $related_table_keys);
1335         $related_table_keys = join(', ', $related_table_keys);
1336        
1337         $column = $table_with_route . '.' . $relationship['column'];
1338        
1339         $new_sql  = 'SELECT count(' . $related_table_keys . ') AS __flourish_count, ' . $column . ' AS __flourish_column ';
1340         $new_sql .= ' FROM :from_clause ';
1341         $new_sql .= ' WHERE ' . $where_sql;
1342         $new_sql .= ' GROUP BY ' . $column;
1343         $new_sql .= ' ORDER BY ' . $column . ' ASC';
1344          
1345         $new_sql = fORMDatabase::insertFromAndGroupByClauses($related_table, $new_sql);
1346          
1347         // Run the query and inject the results into the records
1348         $result = fORMDatabase::retrieve()->translatedQuery($new_sql);
1349        
1350         $counts = array();
1351         foreach ($result as $row) {
1352             $counts[$row['__flourish_column']] = (int) $row['__flourish_count'];
1353         }
1354        
1355         unset($result);
1356          
1357         $total_records = sizeof($this->records);
1358         $get_method   = 'get' . fGrammar::camelize($relationship['column'], TRUE);
1359         $tally_method = 'tally' . fGrammar::pluralize($related_class);
1360        
1361         for ($i=0; $i < $total_records; $i++) {
1362             $record = $this->records[$i];
1363             $count  = (isset($counts[$record->$get_method()])) ? $counts[$record->$get_method()] : 0;
1364             $record->$tally_method($count, $route);
1365         }
1366        
1367         return $this;
1368     }
1369    
1370    
1371     /**
1372     * Creates the objects for related records that are in a one-to-one or many-to-one relationship with the current class in a single DB query
1373    
1374     * @param  string $related_class  This should be the name of a related class
1375     * @param  string $route          This should be the column name of the foreign key and is only required when there are multiple routes to a related table. If there are multiple routes and this is not specified, an fProgrammerException will be thrown.
1376     * @return fRecordSet  The record set object, to allow for method chaining
1377     */
1378     private function precreate($related_class, $route=NULL)
1379     {
1380         if (!$this->records) {
1381             return $this;
1382         }
1383        
1384         $this->validateSingleClass('precreate');
1385        
1386         // If there are no primary keys we can just exit
1387         if (!array_merge($this->getPrimaryKeys())) {
1388             return $this;
1389         }
1390        
1391         $relationship = fORMSchema::getRoute(
1392             fORM::tablize($this->class),
1393             fORM::tablize($related_class),
1394             $route,
1395             '*-to-one'
1396         );
1397        
1398         $values = $this->call('get' . fGrammar::camelize($relationship['column'], TRUE));
1399         $values = array_unique($values);
1400        
1401         self::build(
1402             $related_class,
1403             array(
1404                 $relationship['related_column'] . '=' => $values
1405             )
1406         );
1407        
1408         return $this;
1409     }
1410    
1411    
1412     /**
1413     * Reduces the record set to a single value via a callback
1414     *
1415     * The callback should take two parameters and return a single value:
1416     *
1417     *  - The initial value and the first record for the first call
1418     *  - The result of the last call plus the next record for the second and subsequent calls
1419     *
1420     * @param  callback $callback      The callback to pass the records to - see method description for details
1421     * @param  mixed    $inital_value  The initial value to seed reduce with
1422     * @return mixed  The result of the reduce operation
1423     */
1424     public function reduce($callback, $inital_value=NULL)
1425     {
1426         if (!$this->records) {
1427             return $initial_value;
1428         }
1429        
1430         $result = $inital_value;
1431        
1432         if (is_string($callback) && strpos($callback, '::') !== FALSE) {
1433             $callback = explode('::', $callback);   
1434         }
1435        
1436         foreach($this->records as $record) {
1437             $result = call_user_func($callback, $result, $record);
1438         }
1439        
1440         return $result;
1441     }
1442    
1443    
1444     /**
1445     * Rewinds the set to the first record (used for iteration)
1446     *
1447     * @internal
1448      *
1449     * @return void
1450     */
1451     public function rewind()
1452     {
1453         $this->pointer = 0;
1454     }
1455    
1456    
1457     /**
1458     * Slices a section of records from the set and returns a new set containing those
1459     *
1460     * @param  integer $offset                   The index to start at, negative indexes will slice that many records from the end
1461     * @param  integer $length                   The number of records to return, negative values will stop that many records before the end, `NULL` will return all records to the end of the set - if there are not enough records, less than `$length` will be returned
1462     * @param  boolean $remember_original_count  If the number of records in the current set should be saved as the non-limited count for the new set
1463     * @return fRecordSet  The new slice of records
1464     */
1465     public function slice($offset, $length=NULL, $remember_original_count=FALSE)
1466     {
1467         if ($length === NULL) {
1468             if ($offset >= 0) {
1469                 $length = sizeof($this->records) - $offset;   
1470             } else {
1471                 $length = abs($offset);   
1472             }
1473         }
1474        
1475         $set = new fRecordSet($this->class, array_slice($this->records, $offset, $length));
1476        
1477         if ($remember_original_count) {
1478             $set->non_limited_count    = $this->count();
1479         }
1480        
1481         return $set;
1482     }
1483    
1484    
1485     /**
1486     * Sorts the set by the return value of a method from the class created and rewind the interator
1487     *
1488     * This methods uses fUTF8::inatcmp() to perform comparisons.
1489     *
1490     * @param  string $method     The method to call on each object to get the value to sort by
1491     * @param  string $direction  Either `'asc'` or `'desc'`
1492     * @return fRecordSet  The record set object, to allow for method chaining
1493     */
1494     public function sort($method, $direction)
1495     {
1496         if (!in_array($direction, array('asc', 'desc'))) {
1497             throw new fProgrammerException(
1498                 'The sort direction specified, %1$s, is invalid. Must be one of: %2$s or %3$s.',
1499                 $direction,
1500                 'asc',
1501                 'desc'
1502             );
1503         }
1504        
1505         // We will create an anonymous function here to handle the sort
1506         $lambda_params = '$a,$b';
1507         $lambda_funcs  = array(
1508             'asc'  => 'return fUTF8::inatcmp($a->' . $method . '(), $b->' . $method . '());',
1509             'desc' => 'return fUTF8::inatcmp($b->' . $method . '(), $a->' . $method . '());'
1510         );
1511        
1512         $this->sortByCallback(create_function($lambda_params, $lambda_funcs[$direction]));
1513        
1514         return $this;
1515     }
1516    
1517    
1518     /**
1519     * Sorts the set by passing the callback to [http://php.net/usort `usort()`] and rewinds the interator
1520     *
1521     * @param  mixed $callback  The function/method to pass to `usort()`
1522     * @return fRecordSet  The record set object, to allow for method chaining
1523     */
1524     public function sortByCallback($callback)
1525     {
1526         usort($this->records, $callback);
1527         $this->rewind();
1528        
1529         return $this;
1530     }
1531    
1532    
1533     /**
1534     * Throws an fEmptySetException if the record set is empty
1535     *
1536     * @throws fEmptySetException  When there are no record in the set
1537     *
1538     * @param  string $message  The message to use for the exception if there are no records in this set
1539     * @return fRecordSet  The record set object, to allow for method chaining
1540     */
1541     public function tossIfEmpty($message=NULL)
1542     {
1543         if ($this->records) {
1544             return $this;   
1545         }
1546        
1547         if ($message === NULL) {
1548             if (is_array($this->class)) {
1549                 $names = array_map(array('fORM', 'getRecordName'), $this->class);
1550                 $names = array_map(array('fGrammar', 'pluralize'), $names);
1551                 $name  = join(', ', $names);   
1552             } else {
1553                 $name = fGrammar::pluralize(fORM::getRecordName($this->class));
1554             }
1555            
1556             $message = self::compose(
1557                 'No %s could be found',
1558                 $name
1559             );   
1560         }
1561        
1562         throw new fEmptySetException($message);
1563     }
1564    
1565    
1566     /**
1567     * Returns a new fRecordSet containing only unique records in the record set
1568     *
1569     * @param  boolean $remember_original_count  If the number of records in the current set should be saved as the non-limited count for the new set
1570     * @return fRecordSet  The new record set with only unique records
1571     */
1572     public function unique($remember_original_count=FALSE)
1573     {
1574         $records = array();
1575        
1576         foreach ($this->records as $record) {
1577             $class = get_class($record);
1578             $hash  = fActiveRecord::hash($record);
1579             if (isset($records[$class . '::' . $hash])) {
1580                 continue;
1581             }   
1582             $records[$class . '::' . $hash] = $record;
1583         }
1584        
1585         $set = new fRecordSet(
1586             $this->class,
1587             array_values($records)
1588         );
1589        
1590         if ($remember_original_count) {
1591             $set->non_limited_count    = $this->count();
1592         }
1593        
1594         return $set;
1595     }
1596    
1597    
1598     /**
1599     * Returns if the set has any records left (used for iteration)
1600     *
1601     * @internal
1602      *
1603     * @return boolean  If the iterator is still valid
1604     */
1605     public function valid()
1606     {
1607         return $this->pointer < $this->count();
1608     }
1609    
1610    
1611     /**
1612     * Ensures the record set only contains a single kind of record to prevent issues with certain operations
1613     *
1614     * @param  string $operation  The operation being performed - used in the exception thrown
1615     * @return void
1616     */
1617     private function validateSingleClass($operation)
1618     {
1619         if (!is_array($this->class) && $this->class != 'fActiveRecord') {
1620             return;
1621         }           
1622        
1623         throw new fProgrammerException(
1624             'The %1$s operation can not be performed on a record set with multiple types (%2$s) of records',
1625             $operation,
1626             join(', ', $this->class)   
1627         );
1628     }
1629 }
1630  
1631  
1632  
1633 /**
1634  * Copyright (c) 2007-2009 Will Bond <will@flourishlib.com>
1635  *
1636  * Permission is hereby granted, free of charge, to any person obtaining a copy
1637  * of this software and associated documentation files (the "Software"), to deal
1638  * in the Software without restriction, including without limitation the rights
1639  * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
1640  * copies of the Software, and to permit persons to whom the Software is
1641  * furnished to do so, subject to the following conditions:
1642  *
1643  * The above copyright notice and this permission notice shall be included in
1644  * all copies or substantial portions of the Software.
1645  *
1646  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
1647  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
1648  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
1649  * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
1650  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
1651  * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
1652  * THE SOFTWARE.
1653  */