root/fORMRelated.php

Revision 735, 62.0 kB (checked in by wbond, 9 months ago)

BackwardsCompatibilityBreak - Added the $force_cascade parameter to fActiveRecord::delete() and fActiveRecord::store(), which will break classes that override those methods. This fixed ticket #306.

Fixed ticket #283 - added has{RelatedRecords}() methods to fActiveRecord which return a boolean for any related records in one-to-one, one-to-many or many-to-many relationships.

LineHide Line Numbers
1 <?php
2 /**
3  * Handles related record tasks for fActiveRecord classes
4  *
5  * The functionality of this class only works with single-field `FOREIGN KEY`
6  * constraints.
7  *
8  * @copyright  Copyright (c) 2007-2009 Will Bond
9  * @author     Will Bond [wb] <will@flourishlib.com>
10  * @license    http://flourishlib.com/license
11  *
12  * @package    Flourish
13  * @link       http://flourishlib.com/fORMRelated
14  *
15  * @version    1.0.0b21
16  * @changes    1.0.0b21  Added support for the $force_cascade parameter of fActiveRecord::store(), added ::hasRecords() and fixed a bug with creating non-existent one-to-one related records [wb, 2009-12-16]
17  * @changes    1.0.0b20  Updated code for the new fORMDatabase and fORMSchema APIs [wb, 2009-10-28]
18  * @changes    1.0.0b19  Internal Backwards Compatibility Break - Added the `$class` parameter to ::storeManyToMany() - also fixed ::countRecords() to work across all databases, changed SQL statements to use value placeholders, identifier escaping and support schemas [wb, 2009-10-22]
19  * @changes    1.0.0b18  Fixed a bug in ::countRecords() that would occur when multiple routes existed to the table being counted [wb, 2009-10-05]
20  * @changes    1.0.0b17  Updated code for new fRecordSet API [wb, 2009-09-16]
21  * @changes    1.0.0b16  Fixed a bug with ::createRecord() not creating non-existent record when the related value is NULL [wb, 2009-08-25]
22  * @changes    1.0.0b15  Fixed a bug with ::createRecord() where foreign keys with a different column and related column name would not load properly [wb, 2009-08-17]
23  * @changes    1.0.0b14  Fixed a bug with ::createRecord() when a foreign key constraint is on a column other than the primary key [wb, 2009-08-10]
24  * @changes    1.0.0b13  ::setOrderBys() now (properly) only recognizes *-to-many relationships [wb, 2009-07-31]
25  * @changes    1.0.0b12  Changed how related record values are set and how related validation messages are ignored because of recursive relationships [wb, 2009-07-29]
26  * @changes    1.0.0b11  Fixed some bugs with one-to-one relationships [wb, 2009-07-21]
27  * @changes    1.0.0b10  Fixed a couple of bugs with validating related records [wb, 2009-06-26]
28  * @changes    1.0.0b9   Fixed a bug where ::store() would not save associations with no related records [wb, 2009-06-23]
29  * @changes    1.0.0b8   Changed ::associateRecords() to work for *-to-many instead of just many-to-many relationships [wb, 2009-06-17]
30  * @changes    1.0.0b7   Updated code for new fORM API, fixed API documentation bugs [wb, 2009-06-15]
31  * @changes    1.0.0b6   Updated code to use new fValidationException::formatField() method [wb, 2009-06-04] 
32  * @changes    1.0.0b5   Added ::getPrimaryKeys() and ::setPrimaryKeys(), renamed ::setRecords() to ::setRecordSet() and ::tallyRecords() to ::setCount() [wb, 2009-06-02]
33  * @changes    1.0.0b4   Updated code to handle new association method for related records and new `$related_records` structure, added ::store() and ::validate() [wb, 2009-06-02]
34  * @changes    1.0.0b3   ::associateRecords() can now accept an array of records or primary keys instead of only an fRecordSet [wb, 2009-06-01]
35  * @changes    1.0.0b2   ::populateRecords() now accepts any input field keys instead of sequential ones starting from 0 [wb, 2009-05-03]
36  * @changes    1.0.0b    The initial implementation [wb, 2007-12-30]
37  */
38 class fORMRelated
39 {
40     // The following constants allow for nice looking callbacks to static methods
41     const associateRecords          = 'fORMRelated::associateRecords';
42     const buildRecords              = 'fORMRelated::buildRecords';
43     const countRecords              = 'fORMRelated::countRecords';
44     const createRecord              = 'fORMRelated::createRecord';
45     const determineRequestFilter    = 'fORMRelated::determineRequestFilter';
46     const flagForAssociation        = 'fORMRelated::flagForAssociation';
47     const getOrderBys               = 'fORMRelated::getOrderBys';
48     const getRelatedRecordName      = 'fORMRelated::getRelatedRecordName';
49     const hasRecords                = 'fORMRelated::hasRecords';
50     const linkRecords               = 'fORMRelated::linkRecords';
51     const overrideRelatedRecordName = 'fORMRelated::overrideRelatedRecordName';
52     const populateRecords           = 'fORMRelated::populateRecords';
53     const reflect                   = 'fORMRelated::reflect';
54     const reset                     = 'fORMRelated::reset';
55     const setOrderBys               = 'fORMRelated::setOrderBys';
56     const setCount                  = 'fORMRelated::setCount';
57     const setPrimaryKeys            = 'fORMRelated::setPrimaryKeys';
58     const setRecordSet              = 'fORMRelated::setRecordSet';
59     const store                     = 'fORMRelated::store';
60     const storeManyToMany           = 'fORMRelated::storeManyToMany';
61     const storeOneToMany            = 'fORMRelated::storeOneToMany';
62     const validate                  = 'fORMRelated::validate';
63    
64    
65     /**
66     * A generic cache for the class
67     *
68     * @var array
69     */
70     static private $cache = array();
71    
72     /**
73     * Rules that control what order related data is returned in
74     *
75     * @var array
76     */
77     static private $order_bys = array();
78    
79     /**
80     * Names for related records
81     *
82     * @var array
83     */
84     static private $related_record_names = array();
85    
86    
87     /**
88     * Creates associations for one-to-one relationships
89     *
90     * @internal
91      *
92     * @param  string                             $class             The class to get the related values for
93     * @param  array                              &$related_records  The related records existing for the fActiveRecord class
94     * @param  string                             $related_class     The class we are associating with the current record
95     * @param  fActiveRecord|array|string|integer $record            The record (or primary key of the record) to be associated
96     * @param  string                             $route             The route to use between the current class and the related class
97     * @return void
98     */
99     static public function associateRecord($class, &$related_records, $related_class, $record, $route=NULL)
100     {
101         $table         = fORM::tablize($class);
102         $related_table = fORM::tablize($related_class);
103        
104         if (!$record instanceof fActiveRecord) {
105             $record = new $related_class($record);   
106         }
107        
108         $schema  = fORMSchema::retrieve($class);
109         $records = fRecordSet::buildFromArray($related_class, array($record));   
110         $route   = fORMSchema::getRouteName($schema, $table, $related_table, $route, 'one-to-one');
111        
112         self::setRecordSet($class, $related_records, $related_class, $records, $route);
113         self::flagForAssociation($class, $related_records, $related_class, $route);
114     }
115    
116    
117     /**
118     * Creates associations for *-to-many relationships
119     *
120     * @internal
121      *
122     * @param  string            $class                 The class to get the related values for
123     * @param  array             &$related_records      The related records existing for the fActiveRecord class
124     * @param  string            $related_class         The class we are associating with the current record
125     * @param  fRecordSet|array  $records_to_associate  An fRecordSet, an array or records, or an array of primary keys of the records to be associated
126     * @param  string            $route                 The route to use between the current class and the related class
127     * @return void
128     */
129     static public function associateRecords($class, &$related_records, $related_class, $records_to_associate, $route=NULL)
130     {
131         $table         = fORM::tablize($class);
132         $related_table = fORM::tablize($related_class);
133        
134         $primary_keys = FALSE;
135        
136         if ($records_to_associate instanceof fRecordSet) {
137             $records = clone $records_to_associate;
138        
139         } elseif (!sizeof($records_to_associate)) {
140             $records = fRecordSet::buildFromArray($related_class, array());
141        
142         } elseif ($records_to_associate[0] instanceof fActiveRecord) {
143             $records = fRecordSet::buildFromArray($related_class, $records_to_associate);
144        
145         // This indicates we are working with just primary keys, so we have to call a different method
146         } else {
147             $primary_keys = TRUE;   
148         }
149        
150         $schema = fORMSchema::retrieve($class);
151         $route  = fORMSchema::getRouteName($schema, $table, $related_table, $route, '*-to-many');
152        
153         if ($primary_keys) {
154             self::setPrimaryKeys($class, $related_records, $related_class, $records_to_associate, $route);
155         } else {
156             self::setRecordSet($class, $related_records, $related_class, $records, $route);
157         }
158         self::flagForAssociation($class, $related_records, $related_class, $route);
159     }
160    
161    
162     /**
163     * Builds a set of related records along a one-to-many or many-to-many relationship
164     *
165     * @internal
166      *
167     * @param  string $class             The class to get the related values for
168     * @param  array  &$values           The values for the fActiveRecord class
169     * @param  array  &$related_records  The related records existing for the fActiveRecord class
170     * @param  string $related_class     The class that is related to the current record
171     * @param  string $route             The route to follow for the class specified
172     * @return fRecordSet  A record set of the related records
173     */
174     static public function buildRecords($class, &$values, &$related_records, $related_class, $route=NULL)
175     {
176         $table         = fORM::tablize($class);
177         $related_table = fORM::tablize($related_class);
178        
179         $schema = fORMSchema::retrieve($class);
180         $route  = fORMSchema::getRouteName($schema, $table, $related_table, $route, '*-to-many');
181        
182         // If we already have the sequence, we can stop here
183         if (isset($related_records[$related_table][$route]['record_set'])) {
184             return $related_records[$related_table][$route]['record_set'];
185         }
186        
187         $relationship = fORMSchema::getRoute($schema, $table, $related_table, $route, '*-to-many');
188        
189         // Determine how we are going to build the sequence
190         if ($values[$relationship['column']] === NULL) {
191             $record_set = fRecordSet::buildFromArray($related_class, array());
192        
193         } else {
194             // When joining to the same table, we have to use a different column
195             $same_class = $related_class == fORM::getClass($class);
196             if ($same_class && isset($relationship['join_table'])) {
197                 $column = $table . '{' . $relationship['join_table'] . '}.' . $relationship['column'];
198             } elseif ($same_class) {
199                 $column = $table . '{' . $route . '}.' . $relationship['related_column'];
200             } else {
201                 $column = $table . '{' . $route . '}.' . $relationship['column'];
202             }
203            
204             $where_conditions = array($column . '=' => $values[$relationship['column']]);
205             $order_bys        = self::getOrderBys($class, $related_class, $route);
206             $record_set       = fRecordSet::build($related_class, $where_conditions, $order_bys);
207         }
208        
209         self::setRecordSet($class, $related_records, $related_class, $record_set, $route);
210        
211         return $record_set;
212     }
213    
214    
215     /**
216     * Composes text using fText if loaded
217     *
218     * @param  string  $message    The message to compose
219     * @param  mixed   $component  A string or number to insert into the message
220     * @param  mixed   ...
221     * @return string  The composed and possible translated message
222     */
223     static private function compose($message)
224     {
225         $args = array_slice(func_get_args(), 1);
226        
227         if (class_exists('fText', FALSE)) {
228             return call_user_func_array(
229                 array('fText', 'compose'),
230                 array($message, $args)
231             );
232         } else {
233             return vsprintf($message, $args);
234         }
235     }
236    
237    
238     /**
239     * Counts the number of related one-to-many or many-to-many records
240     *
241     * @internal
242      *
243     * @param  string $class             The class to get the related values for
244     * @param  array  &$values           The values for the fActiveRecord class
245     * @param  array  &$related_records  The related records existing for the fActiveRecord class
246     * @param  string $related_class     The class that is related to the current record
247     * @param  string $route             The route to follow for the class specified
248     * @return integer  The number of related records
249     */
250     static public function countRecords($class, &$values, &$related_records, $related_class, $route=NULL)
251     {
252         $db     = fORMDatabase::retrieve($class, 'read');
253         $schema = fORMSchema::retrieve($class);
254        
255         $table         = fORM::tablize($class);
256         $related_table = fORM::tablize($related_class);
257        
258         $route = fORMSchema::getRouteName($schema, $table, $related_table, $route, '*-to-many');
259        
260         // If we already have the sequence, we can stop here
261         if (isset($related_records[$related_table][$route]['count'])) {
262             return $related_records[$related_table][$route]['count'];
263         }
264        
265         $relationship = fORMSchema::getRoute($schema, $table, $related_table, $route, '*-to-many');
266        
267         // Determine how we are going to build the sequence
268         if ($values[$relationship['column']] === NULL) {
269             $count = 0;
270         } else {
271             $column = $relationship['column'];
272             $value  = $values[$column];
273            
274             $pk_columns = $schema->getKeys($related_table, 'primary');
275            
276             // One-to-many relationships require joins
277             if (!isset($relationship['join_table'])) {
278                 $table_with_route = $table . '{' . $relationship['related_column'] . '}';
279                
280                 $params = array("SELECT count(*) AS flourish__count FROM :from_clause WHERE ");
281                
282                 $params[0] .= str_replace(
283                     '%r',
284                     $db->escape('%r', $table_with_route . '.' . $column),
285                     fORMDatabase::makeCondition($schema, $table, $column, '=', $value)
286                 );
287                 $params[] = $value;
288                
289                 $params[0] .= ' :group_by_clause';
290                
291                 $params = fORMDatabase::injectFromAndGroupByClauses($db, $schema, $params, $related_table);
292            
293             // Many-to-many relationships allow counting just from the join table
294             } else {
295                
296                 $params = array($db->escape(
297                     "SELECT count(*) FROM %r WHERE %r = ",
298                     $relationship['join_table'],
299                     $relationship['join_column']
300                 ));
301                
302                 $params[0] .= $schema->getColumnInfo($table, $column, 'placeholder');
303                 $params[] = $value;
304             }
305            
306             $result = call_user_func_array($db->translatedQuery, $params);
307            
308             $count = ($result->valid()) ? (int) $result->fetchScalar() : 0;
309         }
310        
311         self::setCount($class, $related_records, $related_class, $count, $route);
312        
313         return $count;
314     }
315    
316    
317     /**
318     * Builds the object for the related class specified
319     *
320     * @internal
321      *
322     * @param  string $class             The class to create the related record for
323     * @param  array  $values            The values existing in the fActiveRecord class
324     * @param  array  &$related_records  The related records for the record
325     * @param  string $related_class     The related class name
326     * @param  string $route             The route to the related class
327     * @return fActiveRecord  An instace of the class specified
328     */
329     static public function createRecord($class, $values, &$related_records, $related_class, $route=NULL)
330     {
331         $schema        = fORMSchema::retrieve($class);
332         $table         = fORM::tablize($class);
333         $related_table = fORM::tablize($related_class);
334        
335         $relationship = fORMSchema::getRoute($schema, $table, $related_table, $route, '*-to-one');
336         $route        = $relationship['column'];
337        
338         // Determine if the relationship is one-to-one
339         if (isset(self::$cache['one-to-one::' . $table . '::' . $related_table . '::' . $route])) {
340             $one_to_one = self::$cache['one-to-one::' . $table . '::' . $related_table . '::' . $route];   
341        
342         } else {
343             $one_to_one = FALSE;
344             $one_to_one_relationships = fORMSchema::getRoutes($schema, $table, $related_table, 'one-to-one');
345             foreach ($one_to_one_relationships as $one_to_one_relationship) {
346                 if ($relationship['column'] == $one_to_one_relationship['column']) {
347                     $one_to_one = TRUE;
348                     break;   
349                 }
350             }   
351            
352             self::$cache['one-to-one::' . $table . '::' . $related_table . '::' . $route] = $one_to_one;
353         }
354        
355         // One-to-one records are stored in the related records array to support populating
356         if ($one_to_one) {
357             if (isset($related_records[$related_table][$route]['record_set'])) {
358                 if ($related_records[$related_table][$route]['record_set']->count()) {
359                     return $related_records[$related_table][$route]['record_set']->current();
360                 }
361                 return new $related_class();
362             }
363            
364             // If the value is NULL, don't pass it to the constructor because an fNotFoundException will be thrown
365             if ($values[$relationship['column']] !== NULL) {
366                 try {
367                     $records = array(new $related_class(array($relationship['related_column'] => $values[$relationship['column']])));
368                 } catch (fNotFoundException $e) {
369                     $records = array();   
370                 }
371             } else {
372                 $records = array();
373             }   
374             $record_set = fRecordSet::buildFromArray($related_class, $records);
375             self::setRecordSet($class, $related_records, $related_class, $record_set, $route);
376            
377             if ($record_set->count()) {
378                 return $record_set->current();       
379             }
380             return new $related_class();   
381         }
382        
383         // This allows records without a related record to return a non-existent one
384         if ($values[$relationship['column']] === NULL) {
385             return new $related_class();
386         }
387        
388         return new $related_class(array($relationship['related_column'] => $values[$relationship['column']]));
389     }
390    
391    
392    
393     /**
394     * Figures out the first primary key column for a related class that is not the related column
395     *
396     * @internal
397      *
398     * @param  string $class          The class name of the main class
399     * @param  string $related_class  The related class being filtered for
400     * @param  string $route          The route to the related class
401     * @return string  The first primary key column in the related class
402     */
403     static public function determineFirstPKColumn($class, $related_class, $route)
404     {
405         $table         = fORM::tablize($class);
406         $related_table = fORM::tablize($related_class);
407        
408         $schema        = fORMSchema::retrieve($class);
409         $pk_columns    = $schema->getKeys($related_table, 'primary');
410        
411         // If there is a multi-fiend primary key we want to populate based on any field BUT the foreign key to the current class
412         if (sizeof($pk_columns) > 1) {
413        
414             $first_pk_column = NULL;
415             $relationships   = fORMSchema::getRoutes($schema, $related_table, $table, '*-to-one');
416             foreach ($pk_columns as $pk_column) {
417                 foreach ($relationships as $relationship) {
418                     if ($pk_column == $relationship['column']) {
419                         continue;
420                     }
421                     $first_pk_column = $pk_column;
422                     break 2;
423                 }   
424             }
425            
426             if (!$first_pk_column) {
427                 $first_pk_column = $pk_columns[0];
428             }
429            
430         } else {
431             $first_pk_column = $pk_columns[0];
432         }
433        
434         return $first_pk_column;   
435     }
436    
437    
438     /**
439     * Figures out what filter to pass to fRequest::filter() for the specified related class
440     *
441     * @internal
442      *
443     * @param  string $class          The class name of the main class
444     * @param  string $related_class  The related class being filtered for
445     * @param  string $route          The route to the related class
446     * @return string  The prefix to filter the request fields by
447     */
448     static public function determineRequestFilter($class, $related_class, $route)
449     {
450         $table           = fORM::tablize($class);
451         $schema          = fORMSchema::retrieve($class);
452        
453         $related_table   = fORM::tablize($related_class);
454         $relationship    = fORMSchema::getRoute($schema, $table, $related_table, $route);
455        
456         $route_name         = fORMSchema::getRouteNameFromRelationship('one-to-many', $relationship);
457        
458         $primary_keys    = $schema->getKeys($related_table, 'primary');
459         $first_pk_column = $primary_keys[0];
460        
461         $filter_table            = $related_table;
462         $filter_table_with_route = $related_table . '{' . $route_name . '}';
463        
464         $pk_field            = $filter_table . '::' . $first_pk_column;
465         $pk_field_with_route = $filter_table_with_route . '::' . $first_pk_column;
466        
467         if (!fRequest::check($pk_field) && fRequest::check($pk_field_with_route)) {
468             $filter_table = $filter_table_with_route;
469         }
470        
471         return $filter_table . '::';
472     }
473    
474    
475     /**
476     * Sets the related records for a *-to-many relationship to be associated upon fActiveRecord::store()
477     *
478     * @internal
479      *
480     * @param  string $class             The class to associate the related records to
481     * @param  array  &$related_records  The related records existing for the fActiveRecord class
482     * @param  string $related_class     The class we are associating with the current record
483     * @param  string $route             The route to use between the current class and the related class
484     * @return void
485     */
486     static public function flagForAssociation($class, &$related_records, $related_class, $route=NULL)
487     {
488         $table         = fORM::tablize($class);
489         $related_table = fORM::tablize($related_class);
490        
491         $schema = fORMSchema::retrieve($class);
492         $route  = fORMSchema::getRouteName($schema, $table, $related_table, $route, '!many-to-one');
493        
494         if (!isset($related_records[$related_table][$route]['record_set']) && !isset($related_records[$related_table][$route]['primary_keys'])) {
495             throw new fProgrammerException(
496                 '%1$s can only be called after %2$s or %3$s',
497                 __CLASS__ . '::flagForAssociation()',
498                 __CLASS__ . '::setRecordSet()',
499                 __CLASS__ . '::setPrimaryKeys()'
500             );
501         }
502        
503         $related_records[$related_table][$route]['associate'] = TRUE;
504     }
505    
506    
507     /**
508     * Gets the ordering to use when returning an fRecordSet of related objects
509     *
510     * @internal
511      *
512     * @param  string $class          The class to get the order bys for
513     * @param  string $related_class  The related class the ordering rules apply to
514     * @param  string $route          The route to the related table, should be a column name in the current table or a join table name
515     * @return array  An array of the order bys - see fRecordSet::build() for format
516     */
517     static public function getOrderBys($class, $related_class, $route)
518     {
519         $table         = fORM::tablize($class);
520         $related_table = fORM::tablize($related_class);
521        
522         $schema = fORMSchema::retrieve($class);
523         $route  = fORMSchema::getRouteName($schema, $table, $related_table, $route);
524        
525         if (!isset(self::$order_bys[$table][$related_table]) || !isset(self::$order_bys[$table][$related_table][$route])) {
526             return array();
527         }
528        
529         return self::$order_bys[$table][$related_table][$route];
530     }
531    
532    
533     /**
534     * Gets the primary keys of the related records for *-to-many relationships
535     *
536     * @internal
537      *
538     * @param  string $class             The class to get the related primary keys for
539     * @param  array  &$values           The values for the fActiveRecord class
540     * @param  array  &$related_records  The related records existing for the fActiveRecord class
541     * @param  string $related_class     The class that is related to the current record
542     * @param  string $route             The route to follow for the class specified
543     * @return array  The primary keys of the related records
544     */
545     static public function getPrimaryKeys($class, &$values, &$related_records, $related_class, $route=NULL)
546     {
547         $table         = fORM::tablize($class);
548         $related_table = fORM::tablize($related_class);
549        
550         $db     = fORMDatabase::retrieve($class, 'read');
551         $schema = fORMSchema::retrieve($class);
552        
553         $route = fORMSchema::getRouteName($schema, $table, $related_table, $route, '*-to-many');
554        
555         if (!isset($related_records[$related_table])) {
556             $related_records[$related_table] = array();
557         }
558         if (!isset($related_records[$related_table][$route])) {
559             $related_records[$related_table][$route] = array();
560         }
561        
562         $related_info =& $related_records[$related_table][$route];
563         if (!isset($related_info['primary_keys'])) {
564             if (isset($related_info['record_set'])) {
565                 $related_info['primary_keys'] = $related_info['record_set']->getPrimaryKeys();
566                
567             // If we don't have a record set yet we want to use a single SQL query to just get the primary keys
568             } else {
569                 $relationship       = fORMSchema::getRoute($schema, $table, $related_table, $route, '*-to-many');
570                 $related_pk_columns = $schema->getKeys($related_table, 'primary');
571                 $column_info        = $schema->getColumnInfo($related_table);
572                 $column             = $relationship['column'];
573                
574                 $new_related_pk_columns = array();
575                 foreach ($related_pk_columns as $related_pk_column) {
576                     $new_related_pk_columns[] = $related_table . '.' . $related_pk_column;   
577                 }
578                 $related_pk_columns = $new_related_pk_columns;
579                
580                 if (isset($relationship['join_table'])) {
581                     $table_with_route = $table . '{' . $relationship['join_table'] . '}';   
582                 } else {
583                     $table_with_route = $table . '{' . $relationship['related_column'] . '}';
584                 }
585                
586                 $column         = $relationship['column'];
587                 $related_column = $relationship['related_column'];
588                
589                 $params = array(
590                     $db->escape(
591                         "SELECT %r FROM :from_clause WHERE %r = ",
592                         $related_pk_columns,
593                         $table_with_route . '.' . $column
594                     ),
595                 );
596                 $params[0] .= $schema->getColumnInfo($table, $column, 'placeholder');
597                 $params[] = $values[$column];
598                
599                 $params[0] .= " :group_by_clause ";
600                
601                 if ($order_bys = self::getOrderBys($class, $related_class, $route)) {
602                     $params[0] .= " ORDER BY ";
603                     $params = fORMDatabase::addOrderByClause($db, $schema, $params, $related_table, $order_bys);
604                 }
605                
606                 $params = fORMDatabase::injectFromAndGroupByClauses($db, $schema, $params, $related_table);
607                    
608                 $result = call_user_func_array($db->translatedQuery, $params);
609                
610                 $primary_keys = array();
611                
612                 foreach ($result as $row) {
613                     if (sizeof($row) > 1) {
614                         $primary_key = array();
615                         foreach ($row as $column => $value) {
616                             $value = $db->unescape($column_info[$column]['type'], $value);
617                             $primary_key[$column] = $value;
618                         }   
619                         $primary_keys[] = $primary_key;
620                     } else {
621                         $column = key($row);
622                         $primary_keys[] = $db->unescape($column_info[$column]['type'], $row[$column]);
623                     }   
624                 }
625                
626                 $related_info['record_set']   = NULL;
627                 $related_info['count']        = sizeof($primary_keys);
628                 $related_info['associate']    = FALSE;
629                 $related_info['primary_keys'] = $primary_keys;
630             }   
631         }
632        
633         return $related_info['primary_keys'];
634     }
635    
636    
637     /**
638     * Returns the record name for a related class
639     *
640     * The default record name of a related class is the result of
641     * fGrammar::humanize() called on the class.
642     *
643     * @internal
644      *
645     * @param  string $class          The class to get the related class name for
646     * @param  string $related_class  The related class to get the record name of
647     * @return string  The record name for the related class specified
648     */
649     static public function getRelatedRecordName($class, $related_class, $route=NULL)
650     {
651         $table         = fORM::tablize($class);
652         $related_table = fORM::tablize($related_class);
653        
654         $schema = fORMSchema::retrieve($class);
655         $route  = fORMSchema::getRouteName($schema, $table, $related_table, $route);
656        
657         if (!isset(self::$related_record_names[$table]) ||
658               !isset(self::$related_record_names[$table][$related_class]) ||
659               !isset(self::$related_record_names[$table][$related_class][$route])) {
660             return fORM::getRecordName($related_class);
661         }
662        
663         return self::$related_record_names[$table][$related_class][$route];
664     }
665    
666    
667     /**
668     * Indicates if a record has a one-to-one or any *-to-many related records
669     *
670     * @internal
671      *
672     * @param  string $class             The class to check related records for
673     * @param  array  &$values           The values for the record we are checking
674     * @param  array  &$related_records  The related records for the record we are checking
675     * @param  string $related_class     The related class we are checking for
676     * @param  string $route             The route to the related class
677     * @return void
678     */
679     static public function hasRecords($class, &$values, &$related_records, $related_class, $route=NULL)
680     {
681         $table         = fORM::tablize($class);
682         $related_table = fORM::tablize($related_class);
683        
684         $schema = fORMSchema::retrieve($class);
685         $route  = fORMSchema::getRouteName($schema, $table, $related_table, $route, '!many-to-one');
686        
687         if (!isset($related_records[$related_table][$route]['count'])) {
688             if (fORMSchema::isOneToOne($schema, $table, $related_table, $route)) {
689                 self::createRecord($class, $values, $related_records, $related_class, $route);
690             } else {
691                 self::countRecords($class, $values, $related_records, $related_class, $route);
692             }   
693         }
694        
695         return (boolean) $related_records[$related_table][$route]['count'];   
696     }
697    
698    
699     /**
700     * Parses associations for many-to-many relationships from the page request
701     *
702     * @internal
703      *
704     * @param  string $class             The class to get link the related records to
705     * @param  array  &$related_records  The related records existing for the fActiveRecord class
706     * @param  string $related_class     The related class to populate
707     * @param  string $route             The route to the related class
708     * @return void
709     */
710     static public function linkRecords($class, &$related_records, $related_class, $route=NULL)
711     {
712         $table         = fORM::tablize($class);
713         $related_table = fORM::tablize($related_class);
714        
715         $schema       = fORMSchema::retrieve($class);
716         $route_name   = fORMSchema::getRouteName($schema, $table, $related_table, $route, 'many-to-many');
717         $relationship = fORMSchema::getRoute($schema, $table, $related_table, $route, 'many-to-many');
718        
719         $field_table      = $relationship['related_table'];
720         $field_column     = '::' . $relationship['related_column'];
721        
722         $field            = $field_table . $field_column;
723         $field_with_route = $field_table . '{' . $route_name . '}' . $field_column;
724        
725         // If there is only one route and they specified the route instead of leaving it off, use that
726         if ($route === NULL && !fRequest::check($field) && fRequest::check($field_with_route)) {
727             $field = $field_with_route;
728         }
729        
730         $record_set = fRecordSet::build(
731             $related_class,
732             array(
733                 $relationship['related_column'] . '=' => fRequest::get($field, 'array', array())
734             )
735         );
736        
737         self::associateRecords($class, $related_records, $related_class, $record_set, $route_name);
738     }
739    
740    
741     /**
742     * Does an [http://php.net/array_diff array_diff()] for two arrays that have arrays as values
743     *
744     * @param  array $array1  The array to remove items from
745     * @param  array $array2  The array of items to remove
746     * @return array  The items in `$array1` that were not also in `$array2`
747     */
748     static private function multidimensionArrayDiff($array1, $array2)
749     {
750         $output = array();
751         foreach ($array1 as $sub_array1) {
752             $remove = FALSE;
753             foreach ($array2 as $sub_array2) {
754                 if ($sub_array1 == $sub_array2) {
755                     $remove = TRUE;
756                 }
757             }
758             if (!$remove) {
759                 $output[] = $sub_array1;
760             }
761         }
762         return $output;
763     }
764    
765    
766     /**
767     * Allows overriding of default record names or related records
768     *
769     * The default record name of a related record is the result of
770     * fGrammar::humanize() called on the class name.
771     *
772     * @param  mixed  $class          The class name or instance of the class to set the related record name for
773     * @param  mixed  $related_class  The name of the related class, or an instance of it
774     * @param  string $record_name    The human version of the related record
775     * @param  string $route          The route to the related class
776     * @return void
777     */
778     static public function overrideRelatedRecordName($class, $related_class, $record_name, $route=NULL)
779     {
780         $class         = fORM::getClass($class);
781         $table         = fORM::tablize($class);
782         $related_class = fORM::getClass($related_class);
783         $related_table = fORM::tablize($related_class);
784        
785         $schema = fORMSchema::retrieve($class);
786         $route  = fORMSchema::getRouteName($schema, $table, $related_table, $route, '*-to-many');
787        
788         if (!isset(self::$related_record_names[$table])) {
789             self::$related_record_names[$table] = array();
790         }
791        
792         if (!isset(self::$related_record_names[$table][$related_class])) {
793             self::$related_record_names[$table][$related_class] = array();
794         }
795        
796         self::$related_record_names[$table][$related_class][$route] = $record_name;
797     }
798    
799    
800     /**
801     * Sets the values for records in a one-to-many relationship with this record
802     *
803     * @internal
804      *
805     * @param  string $class             The class to populate the related records of
806     * @param  array  &$related_records  The related records existing for the fActiveRecord class
807     * @param  string $related_class     The related class to populate
808     * @param  string $route             The route to the related class
809     * @return void
810     */
811     static public function populateRecords($class, &$related_records, $related_class, $route=NULL)
812     {
813         $table           = fORM::tablize($class);
814         $related_table   = fORM::tablize($related_class);
815         $schema          = fORMSchema::retrieve($class);
816         $pk_columns      = $schema->getKeys($related_table, 'primary');
817        
818         $first_pk_column = self::determineFirstPKColumn($class, $related_class, $route);
819        
820         $filter          = self::determineRequestFilter($class, $related_class, $route);
821         $pk_field        = $filter . $first_pk_column;
822        
823         $input_keys = array_keys(fRequest::get($pk_field, 'array', array()));
824         $records    = array();
825        
826         foreach ($input_keys as $input_key) {
827             fRequest::filter($filter, $input_key);
828            
829             // Try to load the value from the database first
830             try {
831                 if (sizeof($pk_columns) == 1) {
832                     $primary_key_values = fRequest::get($first_pk_column);
833                 } else {
834                     $primary_key_values = array();
835                     foreach ($pk_columns as $pk_column) {
836                         $primary_key_values[$pk_column] = fRequest::get($pk_column);
837                     }
838                 }
839                
840                 $record = new $related_class($primary_key_values);
841                
842             } catch (fNotFoundException $e) {
843                 $record = new $related_class();
844             }
845            
846             $record->populate();
847             $records[] = $record;
848            
849             fRequest::unfilter();
850         }
851        
852         $record_set = fRecordSet::buildFromArray($related_class, $records);
853         self::setRecordSet($class, $related_records, $related_class, $record_set, $route);
854         self::flagForAssociation($class, $related_records, $related_class, $route);
855     }
856    
857    
858     /**
859     * Adds information about methods provided by this class to fActiveRecord
860     *
861     * @internal
862      *
863     * @param  string  $class                 The class to reflect the related record methods for
864     * @param  array   &$signatures           The associative array of `{method_name} => {signature}`
865     * @param  boolean $include_doc_comments  If the doc block comments for each method should be included
866     * @return void
867     */
868     static public function reflect($class, &$signatures, $include_doc_comments)
869     {
870         $table  = fORM::tablize($class);
871         $schema = fORMSchema::retrieve($class);
872        
873         $one_to_one_relationships   = $schema->getRelationships($table, 'one-to-one');
874         $one_to_many_relationships  = $schema->getRelationships($table, 'one-to-many');
875         $many_to_one_relationships  = $schema->getRelationships($table, 'many-to-one');
876         $many_to_many_relationships = $schema->getRelationships($table, 'many-to-many');
877        
878         $to_one_relationships  = array_merge($one_to_one_relationships, $many_to_one_relationships);
879         $to_many_relationships = array_merge($one_to_many_relationships, $many_to_many_relationships);
880        
881         $to_one_created = array();
882        
883         foreach ($to_one_relationships as $relationship) {
884             $related_class = fORM::classize($relationship['related_table']);
885            
886             if (isset($to_one_created[$related_class])) {
887                 continue;
888             }
889            
890             $routes = fORMSchema::getRoutes($schema, $table, $relationship['related_table'], '*-to-one');
891             $route_names = array();
892            
893             foreach ($routes as $route) {
894                 $route_names[] = fORMSchema::getRouteNameFromRelationship('one-to-one', $route);
895             }
896            
897             $signature = '';
898             if ($include_doc_comments) {
899                 $signature .= "/**\n";
900                 $signature .= " * Creates the related " . $related_class . "\n";
901                 $signature .= " * \n";
902                 if (sizeof($route_names) > 1) {
903                     $signature .= " * @param  string \$route  The route to the related class. Must be one of: '" . join("', '", $route_names) . "'.\n";
904                 }
905                 $signature .= " * @return " . $related_class . "  The related object\n";
906                 $signature .= " */\n";
907             }
908             $create_method = 'create' . $related_class;
909             $signature .= 'public function ' . $create_method . '(';
910             if (sizeof($route_names) > 1) {
911                 $signature .= '$route';
912             }
913             $signature .= ')';
914            
915             $signatures[$create_method] = $signature;
916            
917             $to_one_created[$related_class] = TRUE;
918         }
919        
920         $one_to_one_created = array();
921        
922         foreach ($one_to_one_relationships as $relationship) {
923             $related_class = fORM::classize($relationship['related_table']);
924            
925             if (isset($one_to_one_created[$related_class])) {
926                 continue;
927             }
928            
929             $routes = fORMSchema::getRoutes($schema, $table, $relationship['related_table'], 'one-to-one');
930             $route_names = array();
931            
932             foreach ($routes as $route) {
933                 $route_names[] = fORMSchema::getRouteNameFromRelationship('one-to-one', $route);
934             }
935            
936             $signature = '';
937             if ($include_doc_comments) {
938                 $signature .= "/**\n";
939                 $signature .= " * Populates the related " . $related_class . "\n";
940                 $signature .= " * \n";
941                 if (sizeof($route_names) > 1) {
942                     $signature .= " * @param  string \$route  The route to the related class. Must be one of: '" . join("', '", $route_names) . "'.\n";
943                 }
944                 $signature .= " * @return void\n";
945                 $signature .= " */\n";
946             }
947             $populate_method = 'populate' . $related_class;
948             $signature .= 'public function ' . $populate_method . '(';
949             if (sizeof($route_names) > 1) {
950                 $signature .= '$route';
951             }
952             $signature .= ')';
953            
954             $signatures[$populate_method] = $signature;
955            
956             $signature = '';
957             if ($include_doc_comments) {
958                 $signature .= "/**\n";
959                 $signature .= " * Associates the related " . $related_class . " to this record\n";
960                 $signature .= " * \n";
961                 $signature .= " * @param  fActiveRecord|array|string|integer \$record  The record, or the primary key of the record, to associate\n";
962                 if (sizeof($route_names) > 1) {
963                     $signature .= " * @param  string \$route  The route to the related class. Must be one of: '" . join("', '", $route_names) . "'.\n";
964                 }
965                 $signature .= " * @return void\n";
966                 $signature .= " */\n";
967             }
968             $associate_method = 'associate' . $related_class;
969             $signature .= 'public function ' . $associate_method . '(';
970             if (sizeof($route_names) > 1) {
971                 $signature .= '$route';
972             }
973             $signature .= ')';
974            
975             $signatures[$associate_method] = $signature;
976            
977             $one_to_one_created[$related_class] = TRUE;       
978         }
979        
980         $to_many_created = array();
981        
982         foreach ($to_many_relationships as $relationship) {
983             $related_class = fORM::classize($relationship['related_table']);
984            
985             if (isset($to_many_created[$related_class])) {
986                 continue;
987             }
988            
989             $routes = fORMSchema::getRoutes($schema, $table, $relationship['related_table'], '*-to-many');
990             $route_names = array();
991            
992             $many_to_many_route_names = array();
993             $one_to_many_route_names  = array();
994            
995             foreach ($routes as $route) {
996                 if (isset($route['join_table'])) {
997                     $route_name = fORMSchema::getRouteNameFromRelationship('many-to-many', $route);
998                     $route_names[]              = $route_name;
999                     $many_to_many_route_names[] = $route_name;
1000                    
1001                 } else {
1002                     $route_name = fORMSchema::getRouteNameFromRelationship('one-to-many', $route);
1003                     $route_names[]             = $route_name;
1004                     $one_to_many_route_names[] = $route_name;
1005                 }
1006             }
1007            
1008             if ($one_to_many_route_names) {
1009                 $signature = '';
1010                 if ($include_doc_comments) {
1011                     $related_table = fORM::tablize($related_class);
1012                
1013                     $signature .= "/**\n";
1014                     $signature .= " * Calls the ::populate() method for multiple child " . $related_class . " records. Uses request value arrays in the form " . $related_table . "::{column_name}[].\n";
1015                     $signature .= " * \n";
1016                     if (sizeof($one_to_many_route_names) > 1) {
1017                         $signature .= " * @param  string \$route  The route to the related class. Must be one of: '" . join("', '", $one_to_many_route_names) . "'.\n";
1018                     }
1019                     $signature .= " * @return void\n";
1020                     $signature .= " */\n";
1021                 }
1022                 $populate_related_method = 'populate' . fGrammar::pluralize($related_class);
1023                 $signature .= 'public function ' . $populate_related_method . '(';
1024                 if (sizeof($one_to_many_route_names) > 1) {
1025                     $signature .= '$route';
1026                 }
1027                 $signature .= ')';
1028                
1029                 $signatures[$populate_related_method] = $signature;
1030             }
1031            
1032            
1033             if ($many_to_many_route_names) {
1034                 $signature = '';
1035                 if ($include_doc_comments) {
1036                     $related_table = fORM::tablize($related_class);
1037                
1038                     $signature .= "/**\n";
1039                     $signature .= " * Creates entries in the appropriate joining table to create associations with the specified " . $related_class . " records. Uses request value array(s) in the form " . $related_table . "::{primary_key_column_name(s)}[].\n";
1040                     $signature .= " * \n";
1041                     if (sizeof($many_to_many_route_names) > 1) {
1042                         $signature .= " * @param  string \$route  The route to the related class. Must be one of: '" . join("', '", $many_to_many_route_names) . "'.\n";
1043                     }
1044                     $signature .= " * @return void\n";
1045                     $signature .= " */\n";
1046                 }
1047                 $link_related_method = 'link' . fGrammar::pluralize($related_class);
1048                 $signature .= 'public function ' . $link_related_method . '(';
1049                 if (sizeof($many_to_many_route_names) > 1) {
1050                     $signature .= '$route';
1051                 }
1052                 $signature .= ')';
1053                
1054                 $signatures[$link_related_method] = $signature;
1055                
1056                
1057                 $signature = '';
1058                 if ($include_doc_comments) {
1059                     $related_table = fORM::tablize($related_class);
1060                
1061                     $signature .= "/**\n";
1062                     $signature .= " * Creates entries in the appropriate joining table to create associations with the specified " . $related_class . " records\n";
1063                     $signature .= " * \n";
1064                     $signature .= " * @param  fRecordSet|array \$records_to_associate  The records to associate - should be an fRecords, an array of records or an array of primary keys\n";
1065                     if (sizeof($many_to_many_route_names) > 1) {
1066                         $signature .= " * @param  string           \$route  The route to the related class. Must be one of: '" . join("', '", $many_to_many_route_names) . "'.\n";
1067                     }
1068                     $signature .= " * @return void\n";
1069                     $signature .= " */\n";
1070                 }
1071                 $associate_related_method = 'associate' . fGrammar::pluralize($related_class);
1072                 $signature .= 'public function ' . $associate_related_method . '(';
1073                 if (sizeof($many_to_many_route_names) > 1) {
1074                     $signature .= '$route';
1075                 }
1076                 $signature .= ')';
1077                
1078                 $signatures[$associate_related_method] = $signature;
1079             }
1080            
1081            
1082             $signature = '';
1083             if ($include_doc_comments) {
1084                 $signature .= "/**\n";
1085                 $signature .= " * Builds an fRecordSet of the related " . $related_class . " objects\n";
1086                 $signature .= " * \n";
1087                 if (sizeof($route_names) > 1) {
1088                     $signature .= " * @param  string \$route  The route to the related class. Must be one of: '" . join("', '", $route_names) . "'.\n";
1089                 }
1090                 $signature .= " * @return fRecordSet  A record set of the related " . $related_class . " objects\n";
1091                 $signature .= " */\n";
1092             }
1093             $build_method = 'build' . fGrammar::pluralize($related_class);
1094             $signature .= 'public function ' . $build_method . '(';
1095             if (sizeof($route_names) > 1) {
1096                 $signature .= '$route';
1097             }
1098             $signature .= ')';
1099            
1100             $signatures[$build_method] = $signature;
1101            
1102            
1103             $signature = '';
1104             if ($include_doc_comments) {
1105                 $signature .= "/**\n";
1106                 $signature .= " * Counts the number of related " . $related_class . " objects\n";
1107                 $signature .= " * \n";
1108                 if (sizeof($route_names) > 1) {
1109                     $signature .= " * @param  string \$route  The route to the related class. Must be one of: '" . join("', '", $route_names) . "'.\n";
1110                 }
1111                 $signature .= " * @return integer  The number related " . $related_class . " objects\n";
1112                 $signature .= " */\n";
1113             }
1114             $count_method = 'count' . fGrammar::pluralize($related_class);
1115             $signature .= 'public function ' . $count_method . '(';
1116             if (sizeof($route_names) > 1) {
1117                 $signature .= '$route';
1118             }
1119             $signature .= ')';
1120            
1121             $signatures[$count_method] = $signature;
1122            
1123            
1124             $to_many_created[$related_class] = TRUE;
1125         }
1126     }
1127    
1128    
1129     /**
1130     * Resets the configuration of the class
1131     *
1132     * @internal
1133      *
1134     * @return void
1135     */
1136     static public function reset()
1137     {
1138         self::$cache                = array();
1139         self::$order_bys            = array();
1140         self::$related_record_names = array();
1141     }
1142    
1143    
1144     /**
1145     * Sets the ordering to use when returning an fRecordSet of related objects
1146     *
1147     * @param  mixed  $class           The class name or instance of the class this ordering rule applies to
1148     * @param  string $related_class   The related class we are getting info from
1149     * @param  array  $order_bys       An array of the order bys for this table.column combination - see fRecordSet::build() for format
1150     * @param  string $route           The route to the related table, this should be a column name in the current table or a join table name
1151     * @return void
1152     */
1153     static public function setOrderBys($class, $related_class, $order_bys, $route=NULL)
1154     {
1155         $class         = fORM::getClass($class);
1156         $table         = fORM::tablize($class);
1157         $related_table = fORM::tablize($related_class);
1158        
1159         $schema = fORMSchema::retrieve($class);
1160         $route  = fORMSchema::getRouteName($schema, $table, $related_table, $route, '*-to-many');
1161        
1162         if (!isset(self::$order_bys[$table])) {
1163             self::$order_bys[$table] = array();
1164         }
1165        
1166         if (!isset(self::$order_bys[$table][$related_table])) {
1167             self::$order_bys[$table][$related_table] = array();
1168         }
1169        
1170         self::$order_bys[$table][$related_table][$route] = $order_bys;
1171     }
1172    
1173    
1174     /**
1175     * Records the number of related one-to-many or many-to-many records
1176     *
1177     * @internal
1178      *
1179     * @param  string  $class             The class to set the related records count for
1180     * @param  array   &$values           The values for the fActiveRecord class
1181     * @param  array   &$related_records  The related records existing for the fActiveRecord class
1182     * @param  string  $related_class     The class that is related to the current record
1183     * @param  integer $count             The number of records
1184     * @param  string  $route             The route to follow for the class specified
1185     * @return void
1186     */
1187     static public function setCount($class, &$related_records, $related_class, $count, $route=NULL)
1188     {
1189         $table         = fORM::tablize($class);
1190         $related_table = fORM::tablize($related_class);
1191        
1192         $schema = fORMSchema::retrieve($class);
1193         $route  = fORMSchema::getRouteName($schema, $table, $related_table, $route, '*-to-many');
1194        
1195         // Cache the results for subsequent calls
1196         if (!isset($related_records[$related_table])) {
1197             $related_records[$related_table] = array();
1198         }
1199         if (!isset($related_records[$related_table][$route])) {
1200             $related_records[$related_table][$route] = array();
1201         }
1202        
1203         if (!isset($related_records[$related_table][$route]['record_set'])) {
1204             $related_records[$related_table][$route]['record_set']   = NULL;
1205             $related_records[$related_table][$route]['associate']    = FALSE;
1206             $related_records[$related_table][$route]['primary_keys'] = NULL;
1207         }
1208        
1209         $related_records[$related_table][$route]['count'] = $count;
1210     }
1211    
1212    
1213     /**
1214     * Sets the related records for *-to-many relationships, providing only primary keys
1215     *
1216     * @internal
1217      *
1218     * @param  string $class             The class to set the related primary keys for
1219     * @param  array  &$related_records  The related records existing for the fActiveRecord class
1220     * @param  string $related_class     The class we are setting the records for
1221     * @param  array  $primary_keys      The records to set
1222     * @param  string $route             The route to use between the current class and the related class
1223     * @return void
1224     */
1225     static public function setPrimaryKeys($class, &$related_records, $related_class, $primary_keys, $route=NULL)
1226     {
1227         $table         = fORM::tablize($class);
1228         $related_table = fORM::tablize($related_class);
1229        
1230         $schema = fORMSchema::retrieve($class);
1231         $route  = fORMSchema::getRouteName($schema, $table, $related_table, $route, '*-to-many');
1232        
1233         if (!isset($related_records[$related_table])) {
1234             $related_records[$related_table] = array();
1235         }
1236         if (!isset($related_records[$related_table][$route])) {
1237             $related_records[$related_table][$route] = array();
1238         }
1239        
1240         $related_records[$related_table][$route]['record_set']   = NULL;
1241         $related_records[$related_table][$route]['count']        = sizeof($primary_keys);
1242         $related_records[$related_table][$route]['associate']    = FALSE;
1243         $related_records[$related_table][$route]['primary_keys'] = $primary_keys;
1244     }
1245    
1246    
1247     /**
1248     * Sets the related records for *-to-many relationships
1249     *
1250     * @internal
1251      *
1252     * @param  string $class             The class to set the related records for
1253     * @param  array  &$related_records  The related records existing for the fActiveRecord class
1254     * @param  string $related_class     The class we are associating with the current record
1255     * @param  fRecordSet $records       The records are associating
1256     * @param  string $route             The route to use between the current class and the related class
1257     * @return void
1258     */
1259     static public function setRecordSet($class, &$related_records, $related_class, fRecordSet $records, $route=NULL)
1260     {
1261         $table         = fORM::tablize($class);
1262         $related_table = fORM::tablize($related_class);
1263        
1264         $schema = fORMSchema::retrieve($class);
1265         $route  = fORMSchema::getRouteName($schema, $table, $related_table, $route, '!many-to-one');
1266        
1267         if (!isset($related_records[$related_table])) {
1268             $related_records[$related_table] = array();
1269         }
1270         if (!isset($related_records[$related_table][$route])) {
1271             $related_records[$related_table][$route] = array();
1272         }
1273        
1274         $related_records[$related_table][$route]['record_set']   = $records;
1275         $related_records[$related_table][$route]['count']        = $records->count();
1276         $related_records[$related_table][$route]['associate']    = FALSE;
1277         $related_records[$related_table][$route]['primary_keys'] = NULL;
1278     }
1279    
1280    
1281     /**
1282     * Stores any many-to-many associations or any one-to-many records that have been flagged for association
1283     *
1284     * @internal
1285      *
1286     * @param  string  $class             The class to store the related records for
1287     * @param  array   &$values           The current values for the main record being stored
1288     * @param  array   &$related_records  The related records array
1289     * @param  boolean $force_cascade     This flag will be passed to the fActiveRecord::delete() method on related records that are being deleted
1290     * @return void
1291     */
1292     static public function store($class, &$values, &$related_records, $force_cascade)
1293     {
1294         $table  = fORM::tablize($class);
1295         $schema = fORMSchema::retrieve($class);
1296        
1297         foreach ($related_records as $related_table => $relationships) {
1298             foreach ($relationships as $route => $related_info) {
1299                 if (!$related_info['associate']) {
1300                     continue;
1301                 }
1302                
1303                 $relationship = fORMSchema::getRoute($schema, $table, $related_table, $route);
1304                 if (isset($relationship['join_table'])) {
1305                     fORMRelated::storeManyToMany($class, $values, $relationship, $related_info);
1306                 } else {
1307                     fORMRelated::storeOneToStar($class, $values, $related_records, fORM::classize($related_table), $route, $force_cascade);
1308                 }
1309             }
1310         }
1311     }
1312    
1313    
1314     /**
1315     * Associates a set of many-to-many related records with the current record
1316     *
1317     * @internal
1318      *
1319     * @param  string $class         The class the relationship is being stored for
1320     * @param  array  &$values       The current values for the main record being stored
1321     * @param  array  $relationship  The information about the relationship between this object and the records in the record set
1322     * @param  array  $related_info  An array containing the keys `'record_set'`, `'count'`, `'primary_keys'` and `'associate'`
1323     * @return void
1324     */
1325     static public function storeManyToMany($class, &$values, $relationship, $related_info)
1326     {
1327         $db     = fORMDatabase::retrieve($class, 'write');
1328         $schema = fORMSchema::retrieve($class);
1329        
1330         $column_value      = $values[$relationship['column']];
1331        
1332         // First, we remove all existing relationships between the two tables
1333         $join_table        = $relationship['join_table'];
1334         $join_column       = $relationship['join_column'];
1335        
1336         $params = array(
1337             "DELETE FROM %r WHERE " . fORMDatabase::makeCondition($schema, $join_table, $join_column, '=', $column_value),
1338             $join_table,
1339             $join_column,
1340             $column_value
1341         );
1342         call_user_func_array($db->translatedQuery, $params);
1343        
1344         // Then we add back the ones in the record set
1345         $join_related_column = $relationship['join_related_column'];
1346        
1347         $related_pk_columns  = $schema->getKeys($relationship['related_table'], 'primary');
1348        
1349         $related_column_values = array();
1350        
1351         // If the related column is the primary key, we can just use the primary keys if we have them
1352         if ($related_pk_columns[0] == $relationship['related_column'] && $related_info['primary_keys']) {
1353             $related_column_values = $related_info['primary_keys'];
1354        
1355         // Otherwise we need to pull the related values out of the record set
1356         } else {
1357             // If there is no record set, build it from the primary keys
1358             if (!$related_info['record_set']) {
1359                 $related_class = fORM::classize($relationship['related_table']);
1360                 $related_info['record_set'] = fRecordSet::build($related_class, array($related_pk_columns[0] . '=' => $related_info['primary_keys']));
1361             }
1362            
1363             $get_related_method_name = 'get' . fGrammar::camelize($relationship['related_column'], TRUE);
1364            
1365             foreach ($related_info['record_set'] as $record) {
1366                 $related_column_values[] = $record->$get_related_method_name();
1367             }   
1368         }
1369        
1370         // Ensure we aren't storing duplicates
1371         $related_column_values = array_unique($related_column_values);
1372        
1373         $join_column_placeholder    = $schema->getColumnInfo($join_table, $join_column, 'placeholder');
1374         $related_column_placeholder = $schema->getColumnInfo($join_table, $join_related_column, 'placeholder');
1375        
1376         foreach ($related_column_values as $related_column_value) {
1377             $params = array(
1378                 "INSERT INTO %r (%r, %r) VALUES (" . $join_column_placeholder . ", " . $related_column_placeholder . ")",
1379                 $join_table,
1380                 $join_column,
1381                 $join_related_column,
1382                 $column_value,
1383                 $related_column_value
1384             );
1385             call_user_func_array($db->translatedQuery, $params);
1386         }
1387     }
1388    
1389    
1390     /**
1391     * Stores a set of one-to-many related records in the database
1392     *
1393     * @throws fValidationException  When one of the "many" records throws an exception from fActiveRecord::store()
1394     * @internal
1395      *
1396     * @param  string  $class             The class to store the related records for
1397     * @param  array   &$values           The current values for the main record being stored
1398     * @param  array   &$related_records  The related records array
1399     * @param  string  $related_class     The related class being stored
1400     * @param  string  $route             The route to the related class
1401     * @param  boolean $force_cascade     This flag will be passed to the fActiveRecord::delete() method on related records that are being deleted
1402     * @return void
1403     */
1404     static public function storeOneToStar($class, &$values, &$related_records, $related_class, $route, $force_cascade)
1405     {
1406         $table         = fORM::tablize($class);
1407         $related_table = fORM::tablize($related_class);
1408        
1409         $schema       = fORMSchema::retrieve($class);
1410         $relationship = fORMSchema::getRoute($schema, $table, $related_table, $route);
1411         $column_value = $values[$relationship['column']];
1412        
1413         if (!empty($related_records[$related_table][$route]['record_set'])) {
1414             $record_set = $related_records[$related_table][$route]['record_set'];
1415         } else {
1416             $record_set = self::buildRecords($class, $values, $related_records, $related_class, $route);   
1417         }
1418        
1419         $where_conditions = array(
1420             $relationship['related_column'] . '=' => $column_value
1421         );
1422        
1423        
1424         $existing_records = fRecordSet::build($related_class, $where_conditions);
1425        
1426         $existing_primary_keys  = $existing_records->getPrimaryKeys();
1427         $new_primary_keys       = $record_set->getPrimaryKeys();
1428        
1429         $primary_keys_to_delete = self::multidimensionArrayDiff($existing_primary_keys, $new_primary_keys);
1430        
1431         foreach ($primary_keys_to_delete as $primary_key_to_delete) {
1432             $object_to_delete = new $related_class($primary_key_to_delete);
1433             $object_to_delete->delete($force_cascade);
1434         }
1435        
1436         $set_method_name = 'set' . fGrammar::camelize($relationship['related_column'], TRUE);
1437        
1438         $first_pk_column = self::determineFirstPKColumn($class, $related_class, $route);
1439         $filter          = self::determineRequestFilter(fORM::classize($relationship['table']), $related_class, $relationship['related_column']);
1440         $pk_field        = $filter . $first_pk_column;
1441         $input_keys      = array_keys(fRequest::get($pk_field, 'array', array()));
1442        
1443         // Set all of the values first to prevent issues with recursive relationships
1444         foreach ($record_set as $i => $record) {
1445             $record->$set_method_name($column_value);   
1446         }
1447        
1448         foreach ($record_set as $i => $record) {
1449             fRequest::filter($filter, isset($input_keys[$i]) ? $input_keys[$i] : $i);
1450             $record->store();
1451             fRequest::unfilter();
1452         }
1453     }
1454    
1455    
1456     /**
1457     * Validates any many-to-many associations or any one-to-many records that have been flagged for association
1458     *
1459     * @internal
1460      *
1461     * @param  string $class             The class to validate the related records for
1462     * @param  array  &$values           The values for the object
1463     * @param  array  &$related_records  The related records for the object
1464     * @return void
1465     */
1466     static public function validate($class, &$values, &$related_records)
1467     {
1468         $table  = fORM::tablize($class);
1469         $schema = fORMSchema::retrieve($class);
1470        
1471         $validation_messages = array();
1472        
1473         // Find the record sets to validate
1474         foreach ($related_records as $related_table => $routes) {
1475             foreach ($routes as $route => $related_info) {
1476                 if (!$related_info['count'] || !$related_info['associate']) {
1477                     continue;
1478                 }
1479                
1480                 $related_class = fORM::classize($related_table);
1481                 $relationship  = fORMSchema::getRoute($schema, $table, $related_table, $route);
1482                                                                                                                
1483                 if (isset($relationship['join_table'])) {
1484                     $related_messages = self::validateManyToMany($class, $related_class, $route, $related_info);
1485                 } else {
1486                     $related_messages = self::validateOneToStar($class, $values, $related_records, $related_class, $route);
1487                 }
1488                
1489                 $validation_messages = array_merge($validation_messages, $related_messages);
1490             }
1491         }   
1492        
1493         return $validation_messages;
1494     }
1495    
1496    
1497     /**
1498     * Validates one-to-* related records
1499     *
1500     * @param  string $class             The class to validate the related records for
1501     * @param  array  &$values           The values for the object
1502     * @param  array  &$related_records  The related records for the object
1503     * @param  string $related_class     The name of the class for this record set
1504     * @param  string $route             The route between the table and related table
1505     * @return array  An array of validation messages
1506     */
1507     static private function validateOneToStar($class, &$values, &$related_records, $related_class, $route)
1508     {
1509         $schema              = fORMSchema::retrieve($class);
1510         $table               = fORM::tablize($class);
1511         $related_table       = fORM::tablize($related_class);
1512        
1513         $first_pk_column     = self::determineFirstPKColumn($class, $related_class, $route);
1514         $filter              = self::determineRequestFilter($class, $related_class, $route);
1515         $pk_field            = $filter . $first_pk_column;
1516         $input_keys          = array_keys(fRequest::get($pk_field, 'array', array()));
1517        
1518         $related_record_name = self::getRelatedRecordName($class, $related_class, $route);
1519        
1520         $messages = array();
1521        
1522         $one_to_one = fORMSchema::isOneToOne($schema, $table, $related_table, $route);
1523         if ($one_to_one) {
1524             $records = array(self::createRecord($class, $values, $related_records, $related_class, $route));
1525  
1526         } else {
1527             $records = self::buildRecords($class, $values, $related_records, $related_class, $route);
1528         }
1529        
1530         // Ignore validation messages about the primary key since it will be added
1531         $primary_key_name  = fValidationException::formatField(fORM::getColumnName($related_class, $route));
1532         $primary_key_regex = '#^' . preg_quote($primary_key_name, '#') . '.*$#D';
1533         fORMValidation::addRegexReplacement($related_class, $primary_key_regex, '');
1534        
1535         foreach ($records as $i => $record) {
1536             fRequest::filter($filter, isset($input_keys[$i]) ? $input_keys[$i] : $i);
1537             $record_messages = $record->validate(TRUE);
1538            
1539             foreach ($record_messages as $record_message) {
1540                 $token_field           = fValidationException::formatField('__TOKEN__');
1541                 $extract_message_regex = '#' . str_replace('__TOKEN__', '(.*?)', preg_quote($token_field, '#')) . '(.*)$#D';
1542                 preg_match($extract_message_regex, $record_message, $matches);
1543                
1544                 if ($one_to_one) {
1545                     $column_name = self::compose(
1546                         '%1$s %2$s',
1547                         $related_record_name,
1548                         $matches[1]
1549                     );
1550                    
1551                 } else {
1552                     $column_name = self::compose(
1553                         '%1$s #%2$s %3$s',
1554                         $related_record_name,
1555                         $i+1,
1556                         $matches[1]
1557                     );   
1558                 }
1559                
1560                 $messages[] = self::compose(
1561                     '%1$s%2$s',
1562                     fValidationException::formatField($column_name),
1563                     $matches[2]
1564                 );
1565             }
1566             fRequest::unfilter();
1567         }
1568        
1569         fORMValidation::removeRegexReplacement($related_class, $primary_key_regex, '');
1570        
1571         return $messages;
1572     }
1573    
1574    
1575     /**
1576     * Validates many-to-many related records
1577     *
1578     * @param  string $class          The class to validate the related records for
1579     * @param  string $related_class  The name of the class for this record set
1580     * @param  string $route          The route between the table and related table
1581     * @param  array  $related_info   The related info to validate
1582     * @return array  An array of validation messages
1583     */
1584     static private function validateManyToMany($class, $related_class, $route, $related_info)
1585     {
1586         $related_record_name = fORMRelated::getRelatedRecordName($class, $related_class, $route);
1587         $record_number = 1;
1588        
1589         $messages = array();
1590        
1591         $related_records = $related_info['record_set'] ? $related_info['record_set'] : $related_info['primary_keys'];
1592        
1593         foreach ($related_records as $record) {
1594             if ((is_object($record) && !$record->exists()) || !$record) {
1595                 $messages[] = self::compose(
1596                     '%sPlease select a %3$s',
1597                     fValidationException::formatField(
1598                         self::compose(
1599                             '%1$s #%2$s',
1600                             $related_record_name,
1601                             $record_number
1602                         )
1603                     ),
1604                     $related_record_name
1605                 );
1606             }
1607             $record_number++;
1608         }
1609        
1610         return $messages;
1611     }
1612    
1613    
1614     /**
1615     * Forces use as a static class
1616     *
1617     * @return fORMRelated
1618     */
1619     private function __construct() { }
1620 }
1621  
1622  
1623  
1624 /**
1625  * Copyright (c) 2007-2009 Will Bond <will@flourishlib.com>
1626  *
1627  * Permission is hereby granted, free of charge, to any person obtaining a copy
1628  * of this software and associated documentation files (the "Software"), to deal
1629  * in the Software without restriction, including without limitation the rights
1630  * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
1631  * copies of the Software, and to permit persons to whom the Software is
1632  * furnished to do so, subject to the following conditions:
1633  *
1634  * The above copyright notice and this permission notice shall be included in
1635  * all copies or substantial portions of the Software.
1636  *
1637  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
1638  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
1639  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
1640  * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
1641  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
1642  * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
1643  * THE SOFTWARE.
1644  */