root/fRecordSet.php

Revision 267, 33.8 kB (checked in by wbond, 2 years ago)

Added instance callback handlers

LineHide Line Numbers
1 <?php
2 /**
3  * A lightweight, iterable set of {@link fActiveRecord}-based objects
4  *
5  * @copyright  Copyright (c) 2007-2008 William Bond
6  * @author     William 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.0b
13  * @changes    1.0.0b  The initial implementation [wb, 2007-08-04]
14  */
15 class fRecordSet implements Iterator
16 {
17     const build            = 'fRecordSet::build';
18     const buildFromRecords = 'fRecordSet::buildFromRecords';
19     const buildFromSQL     = 'fRecordSet::buildFromSQL';
20     const configure        = 'fRecordSet::configure';
21    
22    
23     /**
24     * Callbacks registered for the __call() handler
25     *
26     * @var array
27     */
28     static private $method_callbacks = array();
29    
30    
31     /**
32     * Creates an {@link fRecordSet} by specifying the class to create plus the where conditions and order by rules
33     *
34     * The where conditions array can contain key => value entries in any of the following formats (where VALUE/VALUE2 can be of any data type):
35     * <pre>
36     *  - '%column%='                       => VALUE,                        // column = VALUE
37     *  - '%column%!'                       => VALUE,                        // column <> VALUE
38     *  - '%column%~'                       => VALUE,                        // column LIKE '%VALUE%'
39     *  - '%column%<'                       => VALUE,                        // column < VALUE
40     *  - '%column%<='                      => VALUE,                        // column <= VALUE
41     *  - '%column%>'                       => VALUE,                        // column > VALUE
42     *  - '%column%>='                      => VALUE,                        // column >= VALUE
43     *  - '%column%='                       => array(VALUE, VALUE2,...),     // column IN (VALUE, VALUE2, ...)
44     *  - '%column%!'                       => array(VALUE, VALUE2,...),     // column NOT IN (VALUE, VALUE2, ...)
45     *  - '%column%~'                       => array(VALUE, VALUE2,...),     // (column LIKE '%VALUE%' OR column LIKE '%VALUE2%' OR column ...)
46     *  - '%column%!|%column2%<|%column3%=' => array(VALUE, VALUE2, VALUE3), // (column <> '%VALUE%' OR column2 < '%VALUE2%' OR column3 = '%VALUE3%')
47     *  - '%column%|%column2%|%column3%~'   => VALUE,                        // (column LIKE '%VALUE%' OR column2 LIKE '%VALUE2%' OR column3 LIKE '%VALUE%')
48     *  - '%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 ...)
49     * </pre>
50     *
51     * The order bys array can contain key => value entries in any of the following formats:
52     * <pre>
53     *  - '%column%'     => 'asc'      // 'first_name' => 'asc'
54     *  - '%column%'     => 'desc'     // 'last_name'  => 'desc'
55     *  - '%expression%' => 'asc'      // "CASE first_name WHEN 'smith' THEN 1 ELSE 2 END" => 'asc'
56     *  - '%expression%' => 'desc'     // "CASE first_name WHEN 'smith' THEN 1 ELSE 2 END" => 'desc'
57     * </pre>
58     *
59     * The %column% in both the where conditions and order bys can be in any of the formats:
60     * <pre>
61     *  - '%column%'                                                                 // e.g. 'first_name'
62     *  - '%current_table%.%column%'                                                 // e.g. 'users.first_name'
63     *  - '%related_table%.%column%'                                                 // e.g. 'user_groups.name'
64     *  - '%related_table%{%route%}.%column%'                                        // e.g. 'user_groups{user_group_id}.name'
65     *  - '%related_table%=>%once_removed_related_table%.%column%'                   // e.g. 'user_groups=>permissions.level'
66     *  - '%related_table%{%route%}=>%once_removed_related_table%.%column%'          // e.g. 'user_groups{user_group_id}=>permissions.level'
67     *  - '%related_table%=>%once_removed_related_table%{%route%}.%column%'          // e.g. 'user_groups=>permissions{read}.level'
68     *  - '%related_table%{%route%}=>%once_removed_related_table%{%route%}.%column%' // e.g. 'user_groups{user_group_id}=>permissions{read}.level'
69     * </pre>
70     *
71     * @param  string  $class             The class to create the {@link fRecordSet} of
72     * @param  array   $where_conditions  The column => value comparisons for the where clause
73     * @param  array   $order_bys         The column => direction values to use for sorting
74     * @param  integer $limit             The number of records to fetch
75     * @param  integer $page              The page offset to use when limiting records
76     * @return fRecordSet  A set of {@link fActiveRecord} objects
77     */
78     static public function build($class, $where_conditions=array(), $order_bys=array(), $limit=NULL, $page=NULL)
79     {
80         self::configure($class);
81        
82         $table = fORM::tablize($class);
83        
84         $sql = "SELECT " . $table . ".* FROM :from_clause";
85        
86         if ($where_conditions) {
87             $sql .= ' WHERE ' . fORMDatabase::createWhereClause($table, $where_conditions);
88         }
89        
90         $sql .= ' :group_by_clause ';
91        
92         if ($order_bys) {
93             $sql .= 'ORDER BY ' . fORMDatabase::createOrderByClause($table, $order_bys);
94        
95         // If no ordering is specified, order by the primary key
96         } else {
97             $primary_keys = fORMSchema::getInstance()->getKeys($table, 'primary');
98             $expressions = array();
99             foreach ($primary_keys as $primary_key) {
100                 $expressions[] = $table . '.' . $primary_key . ' ASC';
101             }
102             $sql .= 'ORDER BY ' . join(', ', $expressions);
103         }
104        
105         $sql = fORMDatabase::insertFromAndGroupByClauses($table, $sql);
106        
107         // Add the limit clause and create a query to get the non-limited total
108         $non_limited_count_sql = NULL;
109         if ($limit !== NULL) {
110             $primary_key_fields = fORMSchema::getInstance()->getKeys($table, 'primary');
111             $primary_key_fields = fORMDatabase::addTableToValues($table, $primary_key_fields);
112            
113             $non_limited_count_sql = str_replace('SELECT ' . $table . '.*', 'SELECT ' . join(', ', $primary_key_fields), $sql);
114             $non_limited_count_sql = 'SELECT count(*) FROM (' . $non_limited_count_sql . ') AS sq';
115            
116             $sql .= ' LIMIT ' . $limit;
117            
118             if ($page !== NULL) {
119                
120                 if (!is_numeric($page) || $page < 1) {
121                     fCore::toss(
122                         'fProgrammerException',
123                         fGrammar::compose(
124                             'The page specified, %s, is not a number or less than one',
125                             fCore::dump($page)
126                         )
127                     );
128                 }
129                
130                 $sql .= ' OFFSET ' . (($page-1) * $limit);
131             }
132         }
133        
134         return new fRecordSet($class, fORMDatabase::getInstance()->translatedQuery($sql), $non_limited_count_sql);
135     }
136    
137    
138     /**
139     * Creates an {@link fRecordSet} from an array of records
140     *
141     * @throws fValidationException
142     * @internal
143      *
144     * @param  string $class    The type of object to create
145     * @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.
146     * @return fRecordSet  A set of {@link fActiveRecord} objects
147     */
148     static public function buildFromRecords($class, $records)
149     {
150         $record_set = new fRecordSet($class);
151         $record_set->records = $records;
152         return $record_set;
153     }
154    
155    
156     /**
157     * Creates an {@link fRecordSet} from an SQL statement
158     *
159     * @param  string $class                  The type of object to create
160     * @param  string $sql                    The SQL to create the set from
161     * @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.
162     * @return fRecordSet  A set of {@link fActiveRecord} objects
163     */
164     static public function buildFromSQL($class, $sql, $non_limited_count_sql=NULL)
165     {
166         self::configure($class);
167        
168         $result = fORMDatabase::getInstance()->translatedQuery($sql);
169         return new fRecordSet($class, $result, $non_limited_count_sql);
170     }
171    
172    
173     /**
174     * Ensures that an {@link fActiveRecord} class has been configured, allowing custom mapping options to be set in {@link fActiveRecord::configure()}
175    
176     * @param  string  $class  The class to ensure the configuration of
177     * @return void
178     */
179     static public function configure($class)
180     {
181         if (!fORM::isConfigured($class)) {
182             new $class();
183         }
184     }
185    
186    
187     /**
188     * Registers a callback to be called when a specific method is handled by __call()
189    
190     * The callback should accept the following parameters:
191     *   - $record_set:  The actual record set
192     *   - $class:       The class of each record
193     *   - &$records:    The ordered array of fActiveRecords
194     *   - &$pointer:    The current array pointer for the records array
195     *   - &$associate:  If the record should be associated with an fActiveRecord holding it
196     *
197     * @param  string   $method    The method to hook for
198     * @param  callback $callback  The callback to execute - see method description for parameter list
199     * @return void
200     */
201     static public function registerMethodCallback($method, $callback)
202     {
203         self::$method_callbacks[$method] = $callback;
204     }
205    
206    
207     /**
208     * A flag to indicate this should record set should be associated to the parent {@link fActiveRecord} object
209     *
210     * @var boolean
211     */
212     private $associate = FALSE;
213    
214     /**
215     * The type of class to create from the primary keys provided
216     *
217     * @var string
218     */
219     private $class = NULL;
220    
221     /**
222     * The number of rows that would have been returned if a LIMIT clause had not been used
223     *
224     * @var integer
225     */
226     private $non_limited_count = NULL;
227    
228     /**
229     * The SQL to get the total number of rows that would have been returned if a LIMIT clause had not been used
230     *
231     * @var string
232     */
233     private $non_limited_count_sql = NULL;
234    
235     /**
236     * The index of the current record
237     *
238     * @var integer
239     */
240     private $pointer = 0;
241    
242     /**
243     * An array of the records in the set, initially empty
244     *
245     * @var array
246     */
247     private $records = array();
248    
249    
250     /**
251     * Allows for preloading of related records by dynamically creating preload{related plural class}() methods
252    
253     * @throws fValidationException
254     *
255     * @param  string $method_name  The name of the method called
256     * @param  string $parameters   The parameters passed
257     * @return void
258     */
259     public function __call($method_name, $parameters)
260     {
261         list($action, $element) = fORM::parseMethod($method_name);
262        
263         if (isset(self::$method_callbacks[$method_name])) {
264             return call_user_func_array(
265                 self::$method_callbacks[$method_name],
266                 array(
267                     $this,
268                     $this->class,
269                     &$this->records,
270                     &$this->pointer,
271                     &$this->associate
272                 )
273             );   
274         }
275          
276         switch ($action) {
277             case 'build':
278                 $element = fGrammar::camelize($element, TRUE);
279                 $element = fGrammar::singularize($element);
280                 return $this->preloadRecords($element, ($parameters != array()) ? $parameters[0] : NULL);
281            
282             case 'count':
283                 $element = fGrammar::camelize($element, TRUE);
284                 $element = fGrammar::singularize($element);
285                 return $this->preloadCounts($element, ($parameters != array()) ? $parameters[0] : NULL);
286         }
287          
288         fCore::toss('fProgrammerException', 'Unknown method, ' . $method_name . '(), called');
289     }
290      
291      
292     /**
293     * Sets the contents of the set
294     *
295     * @param  string  $class                  The type of records to create
296     * @param  fResult $result_object          The {@link fResult} object of the records to create
297     * @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.
298     * @return fRecordSet
299     */
300     protected function __construct($class, fResult $result_object=NULL, $non_limited_count_sql=NULL)
301     {
302         if (!class_exists($class)) {
303             fCore::toss(
304                 'fProgrammerException',
305                 fGrammar::compose(
306                     'The class specified, %s, could not be loaded',
307                     fCore::dump($class)
308                 )
309             );
310         }
311        
312         if (!is_subclass_of($class, 'fActiveRecord')) {
313             fCore::toss(
314                 'fProgrammerException',
315                 fGrammar::compose(
316                     'The class specified, %1$s, does not extend %2$s. All classes used with %3$s must extend %4$s.',
317                     fCore::dump($class),
318                     'fActiveRecord',
319                     'fRecordSet',
320                     'fActiveRecord'
321                 )
322             );
323         }
324        
325         $this->class                 = $class;
326         $this->non_limited_count_sql = $non_limited_count_sql;
327        
328         while ($result_object && $result_object->valid()) {
329             $this->records[] = new $class($result_object);
330             $result_object->next();
331         }
332     }
333    
334    
335     /**
336     * All requests that hit this method should be requests for callbacks
337     *
338     * @param  string $method  The method to create a callback for
339     * @return callback  The callback for the method requested
340     */
341     public function __get($method)
342     {
343         return array($this, $method);       
344     }
345    
346    
347     /**
348     * Calls a specific method on each object, returning an array of the results
349     *
350     * @return array  An array the size of the record set with one result from each record/method
351     */
352     public function call($method)
353     {
354         $output = array();
355         foreach ($this->records as $record) {
356             $output[] = $record->$method();
357         }
358         return $output;
359     }
360    
361    
362     /**
363     * Creates an order by clause for the primary keys of this record set
364     *
365     * @param  string $route  The route to this table from another table
366     * @return string  The order by clause
367     */
368     private function constructOrderByClause($route=NULL)
369     {
370         $table = fORM::tablize($this->class);
371         $table_with_route = ($route) ? $table . '{' . $route . '}' : $table;
372        
373         $pk_columns      = fORMSchema::getInstance()->getKeys($table, 'primary');
374         $first_pk_column = $pk_columns[0];
375        
376         $sql = '';
377        
378         $number = 0;
379         foreach ($this->getPrimaryKeys() as $primary_key) {
380             $sql .= 'WHEN ';
381              
382             if (is_array($primary_key)) {
383                 $conditions = array();
384                 foreach ($pk_columns as $pk_column) {
385                     $conditions[] = $table_with_route . '.' . $pk_column . fORMDatabase::escapeBySchema($table, $pk_column, $primary_key[$pk_column], '=');
386                 }
387                 $sql .= join(' AND ', $conditions);
388             } else {
389                 $sql .= $table_with_route . '.' . $first_pk_column . fORMDatabase::escapeBySchema($table, $first_pk_column, $primary_key, '=');
390             }
391              
392             $sql .= ' THEN ' . $number . ' ';
393              
394             $number++;
395         }
396        
397         return 'CASE ' . $sql . 'END ASC';
398     }
399    
400    
401     /**
402     * Creates a where clause for the primary keys of this record set
403     *
404     * @param  string $route  The route to this table from another table
405     * @return string  The where clause
406     */
407     private function constructWhereClause($route=NULL)
408     {
409         $table = fORM::tablize($this->class);
410         $table_with_route = ($route) ? $table . '{' . $route . '}' : $table;
411        
412         $pk_columns = fORMSchema::getInstance()->getKeys($table, 'primary');
413        
414         $sql = '';
415        
416         // We have a multi-field primary key, making things kinda ugly
417         if (sizeof($pk_columns) > 1) {
418            
419             $conditions = array();
420              
421             foreach ($this->getPrimaryKeys() as $primary_key) {
422                 $sub_conditions = array();
423                 foreach ($pk_columns as $pk_column) {
424                     $sub_conditions[] = $table_with_route . '.' . $pk_column . fORMDatabase::escapeBySchema($table, $pk_column, $primary_key[$pk_column], '=');
425                 }
426                 $conditions[] = join(' AND ', $sub_conditions);
427             }
428             $sql .= '(' . join(') OR (', $conditions) . ')';
429          
430         // We have a single primary key field, making things nice and easy
431         } else {
432             $first_pk_column = $pk_columns[0];
433          
434             $values = array();
435             foreach ($this->getPrimaryKeys() as $primary_key) {
436                 $values[] = fORMDatabase::escapeBySchema($table, $first_pk_column, $primary_key);
437             }
438             $sql .= $table_with_route . '.' . $first_pk_column . ' IN (' . join(', ', $values) . ')';
439         }
440        
441         return $sql;
442     }
443    
444    
445     /**
446     * Returns the number of records in the set
447     *
448     * @return integer  The number of records in the set
449     */
450     public function count()
451     {
452         return sizeof($this->records);
453     }
454    
455    
456     /**
457     * Returns the number of records that would have been returned if the SQL statement had not used a LIMIT clause.
458     *
459     * @return integer  The number of records that would have been returned if there was no LIMIT clause, or the number of records in the set if there was no LIMIT clause.
460     */
461     public function countWithoutLimit()
462     {
463         // A query that does not use a LIMIT clause just returns the number of returned rows
464         if ($this->non_limited_count_sql === NULL) {
465             return $this->count();
466         }
467        
468         if ($this->non_limited_count === NULL) {
469             try {
470                 $this->non_limited_count = fORMDatabase::getInstance()->translatedQuery($this->non_limited_count_sql)->fetchScalar();
471             } catch (fExpectedException $e) {
472                 $this->non_limited_count = $this->count();
473             }
474         }
475         return $this->non_limited_count;
476     }
477    
478    
479     /**
480     * Returns the current record in the set (used for iteration)
481     *
482     * @throws fValidationException
483     * @internal
484      *
485     * @return object  The current record
486     */
487     public function current()
488     {
489         if (!$this->valid()) {
490             fCore::toss(
491                 'fProgrammerException',
492                 fGrammar::compose('There are no remaining records')
493             );
494         }
495        
496         return $this->records[$this->pointer];
497     }
498    
499    
500     /**
501     * Filters the record set via a callback
502     *
503     * @param  callback $callback  The callback can be either a callback that accepts a single parameter and returns a boolean, or a string like '{record}::methodName' to filter based on the output of $record->methodName()
504     * @return fRecordSet  A new fRecordSet with the filtered records
505     */
506     public function filter($callback)
507     {
508         if (!$this->records) {
509             return clone $this;
510         }
511        
512         $call_filter = FALSE;
513         if (preg_match('#^\{record\}::([a-z0-9_\-]+)$#i', $callback, $matches)) {
514             $call_filter = TRUE;
515             $method      = $matches[1];
516         }
517            
518         $new_records = array();
519         foreach ($this->records as $record) {
520             if ($call_filter) {
521                 $value = $record->$method();
522             } else {
523                 $value = call_user_func($callback, $record);
524             }
525             if ($value) {
526                 $new_records[] = $record;
527             }
528         }
529        
530         return self::buildFromRecords($this->class, $new_records);
531     }
532    
533    
534     /**
535     * Flags this record set for association with the {@link fActiveRecord} object that references it
536     *
537     * @internal
538      *
539     * @return void
540     */
541     public function flagAssociate()
542     {
543         $this->associate = TRUE;
544     }
545    
546    
547     /**
548     * Returns the current record in the set and moves the pointer to the next
549     *
550     * @throws fValidationException
551     *
552     * @return object|false  The current record or FALSE if no remaining records
553     */
554     public function fetchRecord()
555     {
556         try {
557             $record = $this->current();
558             $this->next();
559             return $record;
560         } catch (fValidationException $e) {
561             throw $e;
562         } catch (fExpectedException $e) {
563             fCore::toss(
564                 'fNoRemainingException',
565                 fGrammar::compose('There are no remaining records')
566             );
567         }
568     }
569    
570    
571     /**
572     * Returns the class name of the record being stored
573     *
574     * @return string  The class name of the records in the set
575     */
576     public function getClass()
577     {
578         return $this->class;
579     }
580    
581    
582     /**
583     * Returns all of the records in the set
584     *
585     * @throws fValidationException
586     *
587     * @return array  The records in the set
588     */
589     public function getRecords()
590     {
591         return $this->records;
592     }
593    
594    
595     /**
596     * Returns the primary keys for all of the records in the set
597     *
598     * @throws fValidationException
599     *
600     * @return array  The primary keys of all the records in the set
601     */
602     public function getPrimaryKeys()
603     {
604         $table           = fORM::tablize($this->class);
605         $pk_columns      = fORMSchema::getInstance()->getKeys($table, 'primary');
606         $first_pk_column = $pk_columns[0];
607        
608         $primary_keys = array();
609        
610         foreach ($this->records as $number => $record) {
611             $keys = array();
612            
613             foreach ($pk_columns as $pk_column) {
614                 $method = 'get' . fGrammar::camelize($pk_column, TRUE);
615                 $keys[$pk_column] = $record->$method();
616             }
617            
618             $primary_keys[$number] = (sizeof($pk_columns) == 1) ? $keys[$first_pk_column] : $keys;
619         }
620        
621         return $primary_keys;
622     }
623    
624    
625     /**
626     * Returns if this record set is flagged for association with the {@link fActiveRecord} object that references it
627     *
628     * @internal
629      *
630     * @return boolean  If this record set is flagged for association
631     */
632     public function isFlaggedForAssociation()
633     {
634         return $this->associate;
635     }
636    
637    
638     /**
639     * Returns the primary key for the current record (used for iteration)
640     *
641     * @internal
642      *
643     * @return mixed  The primay key of the current record
644     */
645     public function key()
646     {
647         return $this->pointer;
648     }
649    
650    
651     /**
652     * Performs an array_map on the record in the set
653     *
654     * The record will be passed to the callback as the first parameter unless
655     * it's position is specified by the placeholder string '{record}'. More
656     * details further down.
657     *
658     * Additional parameters can be passed to the callback in one of two
659     * different ways:
660     *  - Passing a non-array value will cause it to be passed to the callback
661     *  - Passing an array value will cause the array values to be passed to the callback with their corresponding record
662    
663     * If an array parameter is too long (more items than records in the set)
664     * it will be truncated. If an array parameter is too short (less items
665     * than records in the set) it will be padded with NULL values.
666     *
667     * To allow passing the record as a specific parameter to the callback, a
668     * placeholder string '{record}' will be replaced with a the record. You
669     * can also specify '{record}::methodName' to cause the output of a method
670     * from the record to be passed instead of the whole record.
671     *
672     * @param  callback $callback       The callback to pass the values to
673     * @param  mixed    $parameter,...  The parameter to pass to the callback - see method description for details
674     * @return array  An array of the results from the callback
675     */
676     public function map($callback)
677     {
678         $parameters = array_slice(func_get_args(), 1);
679        
680         if (!$this->records) {
681             return array();
682         }
683        
684         $parameters_array = array();
685         $found_record     = FALSE;
686         $total_records    = sizeof($this->records);
687        
688         foreach ($parameters as $parameter) {
689             if (!is_array($parameter)) {
690                 if (preg_match('#^\{record\}::([a-z0-9_\-]+)$#i', $parameter, $matches)) {
691                     $parameters_array[] = $this->call($matches[1]);
692                     $found_record = TRUE;
693                 } elseif ($parameter == '{record}') {
694                     $parameters_array[] = $this->records;
695                     $found_record = TRUE;
696                 } else {
697                     $parameters_array[] = array_pad(array(), $total_records, $parameter);
698                 }
699                
700             } elseif (sizeof($parameter) > $total_records) {
701                 $parameters_array[] = array_slice($parameter, 0, $total_records);
702             } elseif (sizeof($parameter) < $total_records) {
703                 $parameters_array[] = array_pad($parameter, $total_records, NULL);
704             } else {
705                 $parameters_array[] = $parameter;
706             }
707         }
708        
709         if (!$found_record) {
710             array_unshift($parameters_array, $this->records);
711         }
712        
713         array_unshift($parameters_array, $callback);
714        
715         return call_user_func_array('array_map', $parameters_array);
716     }
717    
718    
719     /**
720     * Moves to the next record in the set (used for iteration)
721     *
722     * @internal
723      *
724     * @return void
725     */
726     public function next()
727     {
728         $this->pointer++;
729     }
730    
731    
732     /**
733     * Counts the related records for all records in this set in one DB query
734    
735     * @throws fValidationException
736    
737     * @param  string $related_class  This should be the name of a related class
738     * @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.
739     * @return void
740     */
741     private function preloadCounts($related_class, $route=NULL)
742     {
743         // If there are no primary keys we can just exit
744         if (!array_merge($this->getPrimaryKeys())) {
745             return;
746         }
747        
748         $related_table = fORM::tablize($related_class);
749         $table         = fORM::tablize($this->class);
750          
751         $route        = fORMSchema::getRouteName($table, $related_table, $route, '*-to-many');
752         $relationship = fORMSchema::getRoute($table, $related_table, $route, '*-to-many');
753        
754         $table_with_route = ($route) ? $table . '{' . $route . '}' : $table;
755        
756         // Build the query out
757         $where_sql    = $this->constructWhereClause($route);
758         $order_by_sql = $this->constructOrderByClause($route);
759        
760         $related_table_keys = fORMSchema::getInstance()->getKeys($related_table, 'primary');
761         $related_table_keys = fORMDatabase::addTableToValues($related_table, $related_table_keys);
762         $related_table_keys = join(', ', $related_table_keys);
763        
764         $column = $table_with_route . '.' . $relationship['column'];
765        
766         $new_sql  = 'SELECT count(' . $related_table_keys . ') AS __flourish_count, ' . $column . ' AS __flourish_column ';
767         $new_sql .= ' FROM :from_clause ';
768         $new_sql .= ' WHERE ' . $where_sql;
769         $new_sql .= ' GROUP BY ' . $column;
770         $new_sql .= ' ORDER BY ' . $column . ' ASC';
771          
772         $new_sql = fORMDatabase::insertFromAndGroupByClauses($related_table, $new_sql);
773          
774         // Run the query and inject the results into the records
775         $result = fORMDatabase::getInstance()->translatedQuery($new_sql);
776        
777         $counts = array();
778         foreach ($result as $row) {
779             $counts[$row['__flourish_column']] = (int) $row['__flourish_count'];
780         }
781        
782         unset($result);
783          
784         $total_records = sizeof($this->records);
785         $get_method   = 'get' . fGrammar::camelize($relationship['column'], TRUE);
786         $tally_method = 'tally' . fGrammar::pluralize($related_class);
787        
788         for ($i=0; $i < $total_records; $i++) {
789             $record = $this->records[$i];
790             $count  = (isset($counts[$record->$get_method()])) ? $counts[$record->$get_method()] : 0;
791             $record->$tally_method($count, $route);
792         }
793     }
794    
795    
796     /**
797     * Builds the related records for all records in this set in one DB query
798    
799     * @throws fValidationException
800    
801     * @param  string $related_class  This should be the name of a related class
802     * @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.
803     * @return void
804     */
805     private function preloadRecords($related_class, $route=NULL)
806     {
807         // If there are no primary keys we can just exit
808         if (!array_merge($this->getPrimaryKeys())) {
809             return;
810         }
811        
812         $related_table = fORM::tablize($related_class);
813         $table         = fORM::tablize($this->class);
814          
815         $route        = fORMSchema::getRouteName($table, $related_table, $route, '*-to-many');
816         $relationship = fORMSchema::getRoute($table, $related_table, $route, '*-to-many');
817        
818         $table_with_route = ($route) ? $table . '{' . $route . '}' : $table;
819        
820         // Build the query out
821         $where_sql    = $this->constructWhereClause($route);
822        
823         $order_by_sql = $this->constructOrderByClause($route);
824         if ($related_order_bys = fORMRelated::getOrderBys($this->class, $related_class, $route)) {
825             $order_by_sql .= ', ' . fORMDatabase::createOrderByClause($related_table, $related_order_bys);
826         }
827        
828         $new_sql  = 'SELECT ' . $related_table . '.*';
829        
830         // If we are going through a join table we need the related primary key for matching
831         if (isset($relationship['join_table'])) {
832             $new_sql .= ", " . $table_with_route . '.' . $relationship['column'];
833         }
834        
835         $new_sql .= ' FROM :from_clause ';
836         $new_sql .= ' WHERE ' . $where_sql;
837         $new_sql .= ' :group_by_clause ';
838         $new_sql .= ' ORDER BY ' . $order_by_sql;
839          
840         $new_sql = fORMDatabase::insertFromAndGroupByClauses($related_table, $new_sql);
841        
842         // Add the joining column to the group by
843         if (strpos($new_sql, 'GROUP BY') !== FALSE) {
844             $new_sql = str_replace(' ORDER BY', ', ' . $table . '.' . $relationship['column'] . ' ORDER BY', $new_sql);
845         }
846          
847          
848         // Run the query and inject the results into the records
849         $result = fORMDatabase::getInstance()->translatedQuery($new_sql);
850          
851         $total_records = sizeof($this->records);
852         for ($i=0; $i < $total_records; $i++) {
853              
854            
855             // Get the record we are injecting into
856             $record = $this->records[$i];
857             $keys   = array();
858            
859              
860             // If we are going through a join table, keep track of the record by the value in the join table
861             if (isset($relationship['join_table'])) {
862                 try {
863                     $current_row = $result->current();
864                     $keys[$relationship['column']] = $current_row[$relationship['column']];
865                 } catch (fExpectedException $e) { }
866            
867             // If it is a straight join, keep track of the value by the related column value
868             } else {
869                 $method = 'get' . fGrammar::camelize($relationship['related_column'], TRUE);
870                 $keys[$relationship['related_column']] = $record->$method();
871             }
872              
873            
874             // Loop through and find each row for the current record
875             $rows = array();
876                          
877             try {
878                 while (!array_diff_assoc($keys, $result->current())) {
879                     $row = $result->fetchRow();
880                      
881                     // If we are going through a join table we need to remove the related primary key that was used for matching
882                     if (isset($relationship['join_table'])) {
883                         unset($row[$relationship['column']]);
884                     }
885                      
886                     $rows[] = $row;
887                 }
888             } catch (fExpectedException $e) { }
889              
890              
891             // Build the SQL for the record set we are injecting
892             $method = 'get' . fGrammar::camelize($relationship['column'], TRUE);
893              
894             $sql  = "SELECT " . $related_table . ".* FROM :from_clause";
895              
896             $where_conditions = array(
897                 $table_with_route . '.' . $relationship['column'] . '=' => $record->$method()
898             );
899             $sql .= ' WHERE ' . fORMDatabase::createWhereClause($related_table, $where_conditions);
900            
901             $sql .= ' :group_by_clause ';
902              
903             if ($order_bys = fORMRelated::getOrderBys($this->class, $related_class, $route)) {
904                 $sql .= ' ORDER BY ' . fORMDatabase::createOrderByClause($related_table, $order_bys);
905             }
906              
907             $sql = fORMDatabase::insertFromAndGroupByClauses($related_table, $sql);
908              
909            
910             // Set up the result object for the new record set
911             $injected_result = new fResult(fORMDatabase::getInstance()->getType(), 'array');
912             $injected_result->setSQL($sql);
913             $injected_result->setResult($rows);
914             $injected_result->setReturnedRows(sizeof($rows));
915             $injected_result->setAffectedRows(0);
916             $injected_result->setAutoIncrementedValue(NULL);
917              
918             $set = new fRecordSet($related_class, $injected_result);
919              
920              
921             // Inject the new record set into the record
922             $method = 'inject' . fGrammar::pluralize($related_class);
923             $record->$method($set, $route);
924         }
925     }
926    
927    
928     /**
929     * Reduces the record set to a single value via a callback
930     *
931     * The callback should take two parameters:
932     *  - The first two records on the first call if no $inital_value is specified
933     *  - The initial value and the first record for the first call if an $initial_value is specified
934     *  - The result of the last call plus the next record for the second and subsequent calls
935     *
936     * @param  callback $callback      The callback to pass the records to - see method description for details
937     * @param  mixed    $inital_value  The initial value to seed reduce with
938     * @return mixed  The result of the reduce operation
939     */
940     public function reduce($callback, $inital_value=NULL)
941     {
942         if (!$this->records) {
943             return $initial_value;
944         }
945        
946         $values = $this->records;
947         if ($inital_value === NULL) {
948             $result = $values[0];
949             $values = array_slice($values, 1);
950         } else {
951             $result = $inital_value;
952         }
953        
954         foreach($values as $value) {
955             $result = call_user_func($callback, $result, $value);
956         }
957        
958         return $result;
959     }
960    
961    
962     /**
963     * Rewinds the set to the first record (used for iteration)
964     *
965     * @internal
966      *
967     * @return void
968     */
969     public function rewind()
970     {
971         $this->pointer = 0;
972     }
973    
974    
975     /**
976     * Sorts the set by the return value of a method from the class created and rewind the interator
977     *
978     * This methods uses {@link fUTF8::inatcmp()} to perform comparisons.
979     *
980     * @throws fValidationException
981     *
982     * @param  string $method     The method to call on each object to get the value to sort by
983     * @param  string $direction  Either 'asc' or 'desc'
984     * @return void
985     */
986     public function sort($method, $direction)
987     {
988         if (!in_array($direction, array('asc', 'desc'))) {
989             fCore::toss(
990                 'fProgrammerException',
991                 fGrammar::compose(
992                     'The sort direction specified, %1$s, is invalid. Must be one of: %2$s or %3$s.',
993                     fCore::dump($direction),
994                     'asc',
995                     'desc'
996                 )
997             );
998         }
999        
1000         // We will create an anonymous function here to handle the sort
1001         $lambda_params = '$a,$b';
1002         $lambda_funcs  = array(
1003             'asc'  => 'return fUTF8::inatcmp($a->' . $method . '(), $b->' . $method . '());',
1004             'desc' => 'return fUTF8::inatcmp($b->' . $method . '(), $a->' . $method . '());'
1005         );
1006        
1007         $this->sortByCallback(create_function($lambda_params, $lambda_funcs[$direction]));
1008     }
1009    
1010    
1011     /**
1012     * Sorts the set by passing the callback to {@link http://php.net/usort usort()} and rewinds the interator
1013     *
1014     * @throws fValidationException
1015     *
1016     * @param  mixed $callback  The function/method to pass to usort()
1017     * @return void
1018     */
1019     public function sortByCallback($callback)
1020     {
1021         usort($this->records, $callback);
1022         $this->rewind();
1023     }
1024    
1025    
1026     /**
1027     * Throws a {@link fEmptySetException} if the {@link fRecordSet} is empty
1028     *
1029     * @throws fEmptySetException
1030     *
1031     * @return void
1032     */
1033     public function tossIfEmpty()
1034     {
1035         if (!$this->count()) {
1036             fCore::toss(
1037                 'fEmptySetException',
1038                 fGrammar::compose(
1039                     'No %s could be found',
1040                     fGrammar::pluralize(fORM::getRecordName($this->class))
1041                 )
1042             );
1043         }
1044     }
1045    
1046    
1047     /**
1048     * Returns if the set has any records left (used for iteration)
1049     *
1050     * @internal
1051      *
1052     * @return boolean  If the iterator is still valid
1053     */
1054     public function valid()
1055     {
1056         return $this->pointer < $this->count();
1057     }
1058 }
1059  
1060  
1061  
1062 /**
1063  * Copyright (c) 2007-2008 William Bond <will@flourishlib.com>
1064  *
1065  * Permission is hereby granted, free of charge, to any person obtaining a copy
1066  * of this software and associated documentation files (the "Software"), to deal
1067  * in the Software without restriction, including without limitation the rights
1068  * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
1069  * copies of the Software, and to permit persons to whom the Software is
1070  * furnished to do so, subject to the following conditions:
1071  *
1072  * The above copyright notice and this permission notice shall be included in
1073  * all copies or substantial portions of the Software.
1074  *
1075  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
1076  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
1077  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
1078  * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
1079  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
1080  * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
1081  * THE SOFTWARE.
1082  */