root/fActiveRecord.php

Revision 881, 92.8 kB (checked in by wbond, 4 weeks ago)

InternalBackwardsCompatibilityBreak - changed fORM::parseMethod() to not underscorize the subject of the method, which also completes ticket #483. Updated fGrammar::singularize() and fGrammar::pluralize() to be able to handle underscore_CamelCase.

LineHide Line Numbers
1 <?php
2 /**
3  * An [http://en.wikipedia.org/wiki/Active_record_pattern active record pattern] base class
4  *
5  * This class uses fORMSchema to inspect your database and provides an
6  * OO interface to a single database table. The class dynamically handles
7  * method calls for getting, setting and other operations on columns. It also
8  * dynamically handles retrieving and storing related records.
9  *
10  * @copyright  Copyright (c) 2007-2010 Will Bond, others
11  * @author     Will Bond [wb] <will@flourishlib.com>
12  * @author     Will Bond, iMarc LLC [wb-imarc] <will@imarc.net>
13  * @license    http://flourishlib.com/license
14  *
15  * @package    Flourish
16  * @link       http://flourishlib.com/fActiveRecord
17  *
18  * @version    1.0.0b67
19  * @changes    1.0.0b67  Updated code to work with the new fORM API [wb, 2010-08-06]
20  * @changes    1.0.0b66  Fixed a bug with ::store() and non-primary key auto-incrementing columns [wb, 2010-07-05]
21  * @changes    1.0.0b65  Fixed bugs with ::inspect() making some `min_value` and `max_value` elements available for non-numeric types, fixed ::reflect() to list the `min_value` and `max_value` elements [wb, 2010-06-08]
22  * @changes    1.0.0b64  BackwardsCompatibilityBreak - changed ::validate()'s returned messages array to have field name keys - added the option to ::validate() to remove field names from messages [wb, 2010-05-26]
23  * @changes    1.0.0b63  Changed how is_subclass_of() is used to work around a bug in 5.2.x [wb, 2010-04-06]
24  * @changes    1.0.0b62  Fixed a bug that could cause infinite recursion starting in v1.0.0b60 [wb, 2010-04-02]
25  * @changes    1.0.0b61  Fixed issues with handling `populate` actions when working with mapped classes [wb, 2010-03-31]
26  * @changes    1.0.0b60  Fixed issues with handling `associate` and `has` actions when working with mapped classes, added ::validateClass() [wb, 2010-03-30]
27  * @changes    1.0.0b59  Changed an extended UTF-8 arrow character into the correct `->` [wb, 2010-03-29]
28  * @changes    1.0.0b58  Fixed ::reflect() to specify the value returned from `set` methods [wb, 2010-03-15]
29  * @changes    1.0.0b57  Added the `post::loadFromIdentityMap()` hook and fixed ::__construct() to always call the `post::__construct()` hook [wb, 2010-03-14]
30  * @changes    1.0.0b56  Fixed `$force_cascade` in ::delete() to work even when the restricted relationship is once-removed through an unrestricted relationship [wb, 2010-03-09]
31  * @changes    1.0.0b55  Fixed ::load() to that related records are cleared, requiring them to be loaded from the database [wb, 2010-03-04]
32  * @changes    1.0.0b54  Fixed detection of route name for one-to-one relationships in ::delete() [wb, 2010-03-03]
33  * @changes    1.0.0b53  Fixed a bug where related records with a primary key that contained a foreign key with an on update cascade clause would be deleted when changing the value of the column referenced by the foreign key [wb, 2009-12-17]
34  * @changes    1.0.0b52  Backwards Compatibility Break - Added the $force_cascade parameter to ::delete() and ::store() - enabled calling ::prepare() and ::encode() for non-column get methods, added `::has{RelatedRecords}()` methods [wb, 2009-12-16]
35  * @changes    1.0.0b51  Made ::changed() properly recognize that a blank string and NULL are equivalent due to the way that ::set() casts values [wb, 2009-11-14]
36  * @changes    1.0.0b50  Fixed a bug with trying to load by a multi-column primary key where one of the columns was not specified [wb, 2009-11-13]
37  * @changes    1.0.0b49  Fixed a bug affecting where conditions with columns that are not null but have a default value [wb, 2009-11-03]
38  * @changes    1.0.0b48  Updated code for the new fORMDatabase and fORMSchema APIs [wb, 2009-10-28]
39  * @changes    1.0.0b47  Changed `::associate{RelatedRecords}()`, `::link{RelatedRecords}()` and `::populate{RelatedRecords}()` to allow for method chaining [wb, 2009-10-22]
40  * @changes    1.0.0b46  Changed SQL statements to use value placeholders and identifier escaping [wb, 2009-10-22]
41  * @changes    1.0.0b45  Added support for `!~`, `&~`, `><` and OR comparisons to ::checkConditions(), made object handling in ::checkConditions() more robust [wb, 2009-09-21]
42  * @changes    1.0.0b44  Updated code for new fValidationException API [wb, 2009-09-18]
43  * @changes    1.0.0b43  Updated code for new fRecordSet API [wb, 2009-09-16]
44  * @changes    1.0.0b42  Corrected a grammar bug in ::hash() [wb, 2009-09-09]
45  * @changes    1.0.0b41  Fixed a bug in the last version that would cause issues with classes containing a custom class to table mapping [wb, 2009-09-01]
46  * @changes    1.0.0b40  Added a check to the configuration part of ::__construct() to ensure modelled tables have primary keys [wb, 2009-08-26]
47  * @changes    1.0.0b39  Changed `set{ColumnName}()` methods to return the record for method chaining, fixed a bug with loading by multi-column unique constraints, fixed a bug with ::load() [wb, 2009-08-26]
48  * @changes    1.0.0b38  Updated ::changed() to do a strict comparison when at least one value is NULL [wb, 2009-08-17]
49  * @changes    1.0.0b37  Changed ::__construct() to allow any Iterator object instead of just fResult [wb, 2009-08-12]
50  * @changes    1.0.0b36  Fixed a bug with setting NULL values from v1.0.0b33 [wb, 2009-08-10]
51  * @changes    1.0.0b35  Fixed a bug with unescaping data in ::loadFromResult() from v1.0.0b33 [wb, 2009-08-10]
52  * @changes    1.0.0b34  Added the ability to compare fActiveRecord objects in ::checkConditions() [wb, 2009-08-07]
53  * @changes    1.0.0b33  Performance enhancements to ::__call() and ::__construct() [wb, 2009-08-07]
54  * @changes    1.0.0b32  Changed ::delete() to remove auto-incrementing primary keys after the post::delete() hook [wb, 2009-07-29]
55  * @changes    1.0.0b31  Fixed a bug with loading a record by a multi-column primary key, fixed one-to-one relationship API [wb, 2009-07-21]
56  * @changes    1.0.0b30  Updated ::reflect() for new fORM::callReflectCallbacks() API [wb, 2009-07-13]
57  * @changes    1.0.0b29  Updated to use new fORM::callInspectCallbacks() method [wb, 2009-07-13]
58  * @changes    1.0.0b28  Fixed a bug where records would break the identity map at the end of ::store() [wb, 2009-07-09]
59  * @changes    1.0.0b27  Changed ::hash() from a protected method to a static public/internal method that requires the class name for non-fActiveRecord values [wb, 2009-07-09]
60  * @changes    1.0.0b26  Added ::checkConditions() from fRecordSet [wb, 2009-07-08]
61  * @changes    1.0.0b25  Updated ::validate() to use new fORMValidation API, including new message search/replace functionality [wb, 2009-07-01]
62  * @changes    1.0.0b24  Changed ::validate() to remove duplicate validation messages [wb, 2009-06-30]
63  * @changes    1.0.0b23  Updated code for new fORMValidation::validateRelated() API [wb, 2009-06-26]
64  * @changes    1.0.0b22  Added support for the $formatting parameter to encode methods on char, text and varchar columns [wb, 2009-06-19]
65  * @changes    1.0.0b21  Performance tweaks and updates for fORM and fORMRelated API changes [wb, 2009-06-15]
66  * @changes    1.0.0b20  Changed replacement values in preg_replace() calls to be properly escaped [wb, 2009-06-11]
67  * @changes    1.0.0b19  Added `list{RelatedRecords}()` methods, updated code for new fORMRelated API [wb, 2009-06-02]
68  * @changes    1.0.0b18  Changed ::store() to use new fORMRelated::store() method [wb, 2009-06-02]
69  * @changes    1.0.0b17  Added some missing parameter information to ::reflect() [wb, 2009-06-01]
70  * @changes    1.0.0b16  Fixed bugs in ::__clone() and ::replicate() related to recursive relationships [wb-imarc, 2009-05-20]
71  * @changes    1.0.0b15  Fixed an incorrect variable reference in ::store() [wb, 2009-05-06]
72  * @changes    1.0.0b14  ::store() no longer tries to get an auto-incrementing ID from the database if a value was set [wb, 2009-05-02]
73  * @changes    1.0.0b13  ::delete(), ::load(), ::populate() and ::store() now return the record to allow for method chaining [wb, 2009-03-23]
74  * @changes    1.0.0b12  ::set() now removes commas from integers and floats to prevent validation issues [wb, 2009-03-22]
75  * @changes    1.0.0b11  ::encode() no longer adds commas to floats [wb, 2009-03-22]
76  * @changes    1.0.0b10  ::__wakeup() no longer registers the record as the definitive copy in the identity map [wb, 2009-03-22]
77  * @changes    1.0.0b9   Changed ::__construct() to populate database default values when a non-existing record is instantiated [wb, 2009-01-12]
78  * @changes    1.0.0b8   Fixed ::exists() to properly detect cases when an existing record has one or more NULL values in the primary key [wb, 2009-01-11]
79  * @changes    1.0.0b7   Fixed ::__construct() to not trigger the post::__construct() hook when force-configured [wb, 2008-12-30]
80  * @changes    1.0.0b6   ::__construct() now accepts an associative array matching any unique key or primary key, fixed the post::__construct() hook to be called once for each record [wb, 2008-12-26]
81  * @changes    1.0.0b5   Fixed ::replicate() to use plural record names for related records [wb, 2008-12-12]
82  * @changes    1.0.0b4   Added ::replicate() to allow cloning along with related records [wb, 2008-12-12]
83  * @changes    1.0.0b3   Changed ::__clone() to clone objects contains in the values and cache arrays [wb, 2008-12-11]
84  * @changes    1.0.0b2   Added the ::__clone() method to properly duplicate a record [wb, 2008-12-04]
85  * @changes    1.0.0b    The initial implementation [wb, 2007-08-04]
86  */
87 abstract class fActiveRecord
88 {
89     // The following constants allow for nice looking callbacks to static methods
90     const assign          = 'fActiveRecord::assign';
91     const changed         = 'fActiveRecord::changed';
92     const checkConditions = 'fActiveRecord::checkConditions';
93     const forceConfigure  = 'fActiveRecord::forceConfigure';
94     const hasOld          = 'fActiveRecord::hasOld';
95     const reset           = 'fActiveRecord::reset';
96     const retrieveOld     = 'fActiveRecord::retrieveOld';
97     const validateClass   = 'fActiveRecord::validateClass';
98    
99    
100     /**
101     * Caches callbacks for methods
102     *
103     * @var array
104     */
105     static protected $callback_cache = array();
106    
107     /**
108     * An array of flags indicating a class has been configured
109     *
110     * @var array
111     */
112     static protected $configured = array();
113    
114    
115     /**
116     * Maps objects via their primary key
117     *
118     * @var array
119     */
120     static protected $identity_map = array();
121    
122    
123     /**
124     * Caches method name parsings
125     *
126     * @var array
127     */
128     static protected $method_name_cache = array();
129    
130    
131     /**
132     * Keeps track of the recursive call level of replication so we can clear the map
133     *
134     * @var integer
135     */
136     static protected $replicate_level = 0;
137    
138    
139     /**
140     * Keeps a list of records that have been replicated
141     *
142     * @var array
143     */
144     static protected $replicate_map = array();
145    
146     /**
147     * Contains a list of what columns in each class need to be unescaped and what data type they are
148     *
149     * @var array
150     */
151     static protected $unescape_map = array();
152    
153    
154     /**
155     * Sets a value to the `$values` array, preserving the old value in `$old_values`
156     *
157     * @internal
158      *
159     * @param  array  &$values      The current values
160     * @param  array  &$old_values  The old values
161     * @param  string $column       The column to set
162     * @param  mixed  $value        The value to set
163     * @return void
164     */
165     static public function assign(&$values, &$old_values, $column, $value)
166     {
167         if (!isset($old_values[$column])) {
168             $old_values[$column] = array();
169         }
170        
171         $old_values[$column][] = $values[$column];
172         $values[$column]       = $value;   
173     }
174    
175    
176     /**
177     * Checks to see if a value has changed
178     *
179     * @internal
180      *
181     * @param  array  &$values      The current values
182     * @param  array  &$old_values  The old values
183     * @param  string $column       The column to check
184     * @return boolean  If the value for the column specified has changed
185     */
186     static public function changed(&$values, &$old_values, $column)
187     {
188         if (!isset($old_values[$column])) {
189             return FALSE;
190         }
191        
192         $oldest_value = $old_values[$column][0];
193         $new_value    = $values[$column];
194        
195         // We do a strict comparison when one of the values is NULL since
196         // NULL is almost always meant to be distinct from 0, FALSE, etc.
197         // However, since we cast blank strings to NULL in ::set() but a blank
198         // string could come out of the database, we consider them to be
199         // equivalent, so we don't do a strict comparison
200         if (($oldest_value === NULL && $new_value !== '') || ($new_value === NULL && $oldest_value !== '')) {
201             return $oldest_value !== $new_value;   
202         }
203        
204         return $oldest_value != $new_value;   
205     }
206    
207    
208     /**
209     * Ensures a class extends fActiveRecord
210     *
211     * @internal
212      *
213     * @param  string $class  The class to check
214     * @return boolean  If the class is an fActiveRecord descendant
215     */
216     static public function checkClass($class)
217     {
218         if (isset(self::$configured[$class])) {
219             return TRUE;
220         }
221        
222         if (!is_string($class) || !$class || !class_exists($class) || !($class == 'fActiveRecord' || is_subclass_of($class, 'fActiveRecord'))) {
223             return FALSE;
224         }
225         return TRUE;
226     }
227    
228    
229     /**
230     * Checks to see if a record matches a condition
231     *
232     * @internal
233      *
234     * @param  string $operator  The record to check
235     * @param  mixed  $value     The value to compare to
236     * @param  mixed $result     The result of the method call(s)
237     * @return boolean  If the comparison was successful
238     */
239     static private function checkCondition($operator, $value, $result)
240     {
241         $was_array = is_array($value);
242         if (!$was_array) { $value = array($value); }
243         foreach ($value as $i => $_value) {
244             if (is_object($_value)) {
245                 if ($_value instanceof fActiveRecord) {
246                     continue;
247                 }
248                 if (method_exists($_value, '__toString')) {
249                     $value[$i] = $_value->__toString();
250                 }   
251             }   
252         }
253         if (!$was_array) { $value = $value[0]; }
254        
255         $was_array = is_array($result);
256         if (!$was_array) { $result = array($result); }
257         foreach ($result as $i => $_result) {
258             if (is_object($_result)) {
259                 if ($_result instanceof fActiveRecord) {
260                     continue;
261                 }
262                 if (method_exists($_result, '__toString')) {
263                     $result[$i] = $_result->__toString();
264                 }   
265             }   
266         }
267         if (!$was_array) { $result = $result[0]; }
268        
269         $match_all   = $operator == '&~';
270         $negate_like = $operator == '!~';
271        
272         switch ($operator) {
273             case '&~':
274             case '!~':
275             case '~':
276                 if (!$match_all && !$negate_like && !is_array($value) && is_array($result)) {
277                     $value = fORMDatabase::parseSearchTerms($value, TRUE);
278                 }   
279                    
280                 settype($value, 'array');
281                 settype($result, 'array');
282                
283                 if (count($result) > 1) {
284                     foreach ($value as $_value) {
285                         $found = FALSE;
286                         foreach ($result as $_result) {
287                             if (fUTF8::ipos($_result, $_value) !== FALSE) {
288                                 $found = TRUE;
289                             }
290                         }
291                         if (!$found) {
292                             return FALSE;
293                         }   
294                     }
295                 } else {
296                     $found = FALSE;
297                     foreach ($value as $_value) {
298                         if (fUTF8::ipos($result[0], $_value) !== FALSE) {
299                             $found = TRUE;
300                         } elseif ($match_all) {
301                             return FALSE;
302                         }
303                     }
304                     if ((!$negate_like && !$found) || ($negate_like && $found)) {
305                         return FALSE;
306                     }
307                 }   
308                 break;
309            
310             case '=':
311                 if ($value instanceof fActiveRecord && $result instanceof fActiveRecord) {
312                     if (get_class($value) != get_class($result) || !$value->exists() || !$result->exists() || self::hash($value) != self::hash($result)) {
313                         return FALSE;
314                     }
315                    
316                 } elseif (is_array($value) && !in_array($result, $value)) {
317                     return FALSE;
318                        
319                 } elseif (!is_array($value) && $result != $value) {
320                     return FALSE;   
321                 }
322                 break;
323                
324             case '!':
325                 if ($value instanceof fActiveRecord && $result instanceof fActiveRecord) {
326                     if (get_class($value) == get_class($result) && $value->exists() && $result->exists() && self::hash($value) == self::hash($result)) {
327                         return FALSE;
328                     }
329                    
330                 } elseif (is_array($value) && in_array($result, $value)) {
331                     return FALSE;   
332                    
333                 } elseif (!is_array($value) && $result == $value) {
334                     return FALSE;   
335                 }
336                 break;
337            
338             case '<':
339                 if ($result >= $value) {
340                     return FALSE;   
341                 }
342                 break;
343            
344             case '<=':
345                 if ($result > $value) {
346                     return FALSE;   
347                 }
348                 break;
349            
350             case '>':
351                 if ($result <= $value) {
352                     return FALSE;   
353                 }
354                 break;
355            
356             case '>=':
357                 if ($result < $value) {
358                     return FALSE;   
359                 }
360                 break;
361         }
362        
363         return TRUE;       
364     }
365    
366    
367     /**
368     * Checks to see if a record matches all of the conditions
369     *
370     * @internal
371      *
372     * @param  fActiveRecord $record      The record to check
373     * @param  array         $conditions  The conditions to check - see fRecordSet::filter() for format details
374     * @return boolean  If the record meets all conditions
375     */
376     static public function checkConditions($record, $conditions)
377     {
378         foreach ($conditions as $method => $value) {
379            
380             // Split the operator off of the end of the method name
381             if (in_array(substr($method, -2), array('<=', '>=', '!=', '<>', '!~', '&~', '><'))) {
382                 $operator = strtr(
383                     substr($method, -2),
384                     array(
385                         '<>' => '!',
386                         '!=' => '!'
387                     )
388                 );
389                 $method   = substr($method, 0, -2);
390             } else {
391                 $operator = substr($method, -1);
392                 $method   = substr($method, 0, -1);
393             }
394            
395             if (preg_match('#(?<!\|)\|(?!\|)#', $method)) {
396                
397                 $methods   = explode('|', $method);
398                 $values    = $value;
399                 $operators = array();
400                
401                 foreach ($methods as &$_method) {
402                     if (in_array(substr($_method, -2), array('<=', '>=', '!=', '<>', '!~', '&~', '><'))) {
403                         $operators[] = strtr(
404                             substr($_method, -2),
405                             array(
406                                 '<>' => '!',
407                                 '!=' => '!'
408                             )
409                         );
410                         $_method     = substr($_method, 0, -2);
411                     } elseif (!ctype_alnum(substr($_method, -1))) {
412                         $operators[] = substr($_method, -1);
413                         $_method     = substr($_method, 0, -1);
414                     }
415                 }
416                 $operators[] = $operator;
417                
418                
419                 if (sizeof($operators) == 1) {
420                
421                     // Handle fuzzy searches
422                     if ($operator == '~') {
423                    
424                         $results = array();
425                         foreach ($methods as $method) {
426                             $results[] = $record->$method();   
427                         }
428                         if (!self::checkCondition($operator, $value, $results)) {
429                             return FALSE;   
430                         }
431                    
432                     // Handle intersection
433                     } elseif ($operator == '><') {
434                        
435                         if (sizeof($methods) != 2 || sizeof($values) != 2) {
436                             throw new fProgrammerException(
437                                 'The intersection operator, %s, requires exactly two methods and two values',
438                                 $operator
439                             );   
440                         }
441                                    
442                         $results    = array();
443                         $results[0] = $record->{$methods[0]}();
444                         $results[1] = $record->{$methods[1]}();
445                        
446                         if ($results[1] === NULL && $values[1] === NULL) {
447                             if (!self::checkCondition('=', $values[0], $results[0])) {
448                                 return FALSE;
449                             }
450                            
451                            
452                         } else {
453                            
454                             $starts_between_values = FALSE;
455                             $overlaps_value_1      = FALSE;
456                            
457                             if ($values[1] !== NULL) {
458                                 $start_lt_value_1      = self::checkCondition('<', $values[0], $results[0]);
459                                 $start_gt_value_2      = self::checkCondition('>', $values[1], $results[0]);
460                                 $starts_between_values = !$start_lt_value_1 && !$start_gt_value_2;
461                             }
462                             if ($results[1] !== NULL) {
463                                 $start_gt_value_1 = self::checkCondition('>', $values[0], $results[0]);
464                                 $end_lt_value_1   = self::checkCondition('<', $values[0], $results[1]);
465                                 $overlaps_value_1 = !$start_gt_value_1 && !$end_lt_value_1;
466                             }
467                            
468                             if (!$starts_between_values && !$overlaps_value_1) {
469                                 return FALSE;
470                             }
471                         }
472                    
473                     } else {
474                         throw new fProgrammerException(
475                             'An invalid comparison operator, %s, was specified for multiple columns',
476                             $operator
477                         );
478                     }
479                    
480                 // Handle OR conditions
481                 } else {
482                    
483                     if (sizeof($methods) != sizeof($values)) {
484                         throw new fProgrammerException(
485                             'When performing an %1$s comparison there must be an equal number of methods and values, however there are not',
486                             'OR',
487                             sizeof($methods),
488                             sizeof($values)
489                         );
490                     }
491                    
492                     if (sizeof($methods) != sizeof($operators)) {
493                         throw new fProgrammerException(
494                             'When performing an %s comparison there must be a comparison operator for each column, however one or more is missing',
495                             'OR'
496                         );
497                     }
498                    
499                     $results    = array();
500                     $iterations = sizeof($methods);
501                     for ($i=0; $i<$iterations; $i++) {
502                         $results[] = self::checkCondition($operators[$i], $values[$i], $record->{$methods[$i]}());
503                     }
504                    
505                     if (!array_filter($results)) {
506                         return FALSE;   
507                     }
508                    
509                 }
510                
511             // Single method comparisons   
512             } else {
513                 $result = $record->$method();
514                 if (!self::checkCondition($operator, $value, $result)) {
515                     return FALSE;   
516                 }
517             }   
518         }
519        
520         return TRUE;
521     }
522    
523    
524     /**
525     * Composes text using fText if loaded
526     *
527     * @param  string  $message    The message to compose
528     * @param  mixed   $component  A string or number to insert into the message
529     * @param  mixed   ...
530     * @return string  The composed and possible translated message
531     */
532     static protected function compose($message)
533     {
534         $args = array_slice(func_get_args(), 1);
535        
536         if (class_exists('fText', FALSE)) {
537             return call_user_func_array(
538                 array('fText', 'compose'),
539                 array($message, $args)
540             );
541         } else {
542             return vsprintf($message, $args);
543         }
544     }
545    
546    
547     /**
548     * Takes information from a method call and determines the subject, route and if subject was plural
549     *
550     * @param string $class    The class the method was called on
551     * @param string $subject  An underscore_notation subject - either a singular or plural class name
552     * @param string $route    The route to the subject
553     * @return array  An array with the structure: array(0 => $subject, 1 => $route, 2 => $plural)
554     */
555     static private function determineSubject($class, $subject, $route)
556     {
557         $schema  = fORMSchema::retrieve($class);
558         $table   = fORM::tablize($class);
559         $type    = '*-to-many';
560         $plural  = FALSE;
561        
562         // one-to-many relationships need to use plural forms
563         $singular_form = fGrammar::singularize($subject, TRUE);
564         if ($singular_form && fORM::isClassMappedToTable($singular_form)) {
565             $subject = $singular_form;
566             $plural  = TRUE;
567            
568         } elseif (!fORM::isClassMappedToTable($subject) && in_array(fGrammar::underscorize($subject), $schema->getTables())) {
569             $subject = fGrammar::singularize($subject);
570             $plural  = TRUE;
571         }
572        
573         $related_table = fORM::tablize($subject);
574         $one_to_one    = fORMSchema::isOneToOne($schema, $table, $related_table, $route);
575         if ($one_to_one) {
576             $type = 'one-to-one';
577         }
578         if (($one_to_one && $plural) || (!$plural && !$one_to_one)) {
579             throw new fProgrammerException(
580                 'The table %1$s is not in a %2$srelationship with the table %3$s',
581                 $table,
582                 $type,
583                 $related_table
584             );
585         }
586        
587         $route = fORMSchema::getRouteName($schema, $table, $related_table, $route, $type);
588        
589         return array($subject, $route, $plural);
590     }
591    
592    
593     /**
594     * Ensures that ::configure() has been called for the class
595     *
596     * @internal
597      *
598     * @param  string $class  The class to configure
599     * @return void
600     */
601     static public function forceConfigure($class)
602     {
603         if (isset(self::$configured[$class])) {
604             return;   
605         }
606         new $class();
607     }
608    
609    
610     /**
611     * Takes a row of data or a primary key and makes a hash from the primary key
612     *
613     * @internal
614      *
615     * @param  fActiveRecord|array|string|int $record   An fActiveRecord object, an array of the records data, an array of primary key data or a scalar primary key value
616     * @param  string                         $class    The class name, if $record isn't an fActiveRecord
617     * @return string|NULL  A hash of the record's primary key value or NULL if the record doesn't exist yet
618     */
619     static public function hash($record, $class=NULL)
620     {
621         if ($record instanceof fActiveRecord && !$record->exists()) {
622             return NULL;   
623         }
624        
625         if ($class === NULL) {
626             if (!$record instanceof fActiveRecord) {
627                 throw new fProgrammerException(
628                     'The class of the record must be provided if the record specified is not an instance of fActiveRecord'
629                 );
630             }
631             $class = get_class($record);   
632         }
633        
634         $schema     = fORMSchema::retrieve($class);
635         $table      = fORM::tablize($class);
636         $pk_columns = $schema->getKeys($table, 'primary');
637        
638         // Build an array of just the primary key data
639         $pk_data = array();
640         foreach ($pk_columns as $pk_column) {
641             if ($record instanceof fActiveRecord) {
642                 $value = (self::hasOld($record->old_values, $pk_column)) ? self::retrieveOld($record->old_values, $pk_column) : $record->values[$pk_column];
643            
644             } elseif (is_array($record)) {
645                 $value = $record[$pk_column];
646            
647             } else {
648                 $value = $record;   
649             }
650            
651             $pk_data[$pk_column] = fORM::scalarize(
652                 $class,
653                 $pk_column,
654                 $value
655             );
656            
657             if (is_numeric($pk_data[$pk_column]) || is_object($pk_data[$pk_column])) {
658                 $pk_data[$pk_column] = (string) $pk_data[$pk_column];   
659             }
660         }
661        
662         return md5(serialize($pk_data));
663     }
664    
665    
666     /**
667     * Checks to see if an old value exists for a column
668     *
669     * @internal
670      *
671     * @param  array  &$old_values  The old values
672     * @param  string $column       The column to set
673     * @return boolean  If an old value for that column exists
674     */
675     static public function hasOld(&$old_values, $column)
676     {
677         return array_key_exists($column, $old_values);
678     }
679    
680    
681     /**
682     * Resets the configuration of the class
683     *
684     * @internal
685      *
686     * @return void
687     */
688     static public function reset()
689     {
690         self::$callback_cache    = array();
691         self::$configured        = array();
692         self::$identity_map      = array();
693         self::$method_name_cache = array();
694         self::$unescape_map      = array();
695     }
696    
697    
698     /**
699     * Retrieves the oldest value for a column or all old values
700     *
701     * @internal
702      *
703     * @param  array   &$old_values  The old values
704     * @param  string  $column       The column to get
705     * @param  mixed   $default      The default value to return if no value exists
706     * @param  boolean $return_all   Return the array of all old values for this column instead of just the oldest
707     * @return mixed  The old value for the column
708     */
709     static public function retrieveOld(&$old_values, $column, $default=NULL, $return_all=FALSE)
710     {
711         if (!isset($old_values[$column])) {
712             return $default;   
713         }
714        
715         if ($return_all === TRUE) {
716             return $old_values[$column];   
717         }
718        
719         return $old_values[$column][0];
720     }
721    
722    
723     /**
724     * Ensures a class extends fActiveRecord
725     *
726     * @internal
727      *
728     * @param  string $class  The class to verify
729     * @return void
730     */
731     static public function validateClass($class)
732     {
733         if (isset(self::$configured[$class])) {
734             return TRUE;
735         }
736        
737         if (!self::checkClass($class)) {
738             throw new fProgrammerException(
739                 'The class specified, %1$s, does not appear to be a valid %2$s class',
740                 $class,
741                 'fActiveRecord'
742             );
743         }
744     }
745    
746    
747     /**
748     * A data store for caching data related to a record, the structure of this is completely up to the developer using it
749     *
750     * @var array
751     */
752     protected $cache = array();
753    
754     /**
755     * The old values for this record
756     *
757     * Column names are the keys, but a column key will only be present if a
758     * value has changed. The value associated with each key is an array of
759     * old values with the first entry being the oldest value. The static
760     * methods ::assign(), ::changed(), ::hasOld() and ::retrieveOld() are the
761     * best way to interact with this array.
762     *
763     * @var array
764     */
765     protected $old_values = array();
766    
767     /**
768     * Records that are related to the current record via some relationship
769     *
770     * This array is used to cache related records so that a database query
771     * is not required each time related records are accessed. The fORMRelated
772     * class handles most of the interaction with this array.
773     *
774     * @var array
775     */
776     protected $related_records = array();
777    
778     /**
779     * The values for this record
780     *
781     * This array always contains every column in the database table as a key
782     * with the value being the current value.
783     *
784     * @var array
785     */
786     protected $values = array();
787    
788    
789     /**
790     * Handles all method calls for columns, related records and hook callbacks
791     *
792     * Dynamically handles `get`, `set`, `prepare`, `encode` and `inspect`
793     * methods for each column in this record. Method names are in the form
794     * `verbColumName()`.
795     *
796     * This method also handles `associate`, `build`, `count`, `has`, and `link`
797     * verbs for records in many-to-many relationships; `build`, `count`, `has`
798     * and `populate` verbs for all related records in one-to-many relationships
799     * and `create`, `has` and `populate` verbs for all related records in
800     * one-to-one relationships, and the `create` verb for all related records
801     * in many-to-one relationships.
802     *
803     * Method callbacks registered through fORM::registerActiveRecordMethod()
804     * will be delegated via this method.
805     *
806     * @param  string $method_name  The name of the method called
807     * @param  array  $parameters   The parameters passed
808     * @return mixed  The value returned by the method called
809     */
810     public function __call($method_name, $parameters)
811     {
812         $class = get_class($this);
813        
814         if (!isset(self::$callback_cache[$class][$method_name])) {
815             if (!isset(self::$callback_cache[$class])) {
816                 self::$callback_cache[$class] = array();
817             }   
818             $callback = fORM::getActiveRecordMethod($class, $method_name);
819             self::$callback_cache[$class][$method_name] = $callback ? $callback : FALSE;
820         }
821        
822         if ($callback = self::$callback_cache[$class][$method_name]) {
823             return call_user_func_array(
824                 $callback,
825                 array(
826                     $this,
827                     &$this->values,
828                     &$this->old_values,
829                     &$this->related_records,
830                     &$this->cache,
831                     $method_name,
832                     $parameters
833                 )
834             );
835         }
836        
837         if (!isset(self::$method_name_cache[$method_name])) {
838             list ($action, $subject) = fORM::parseMethod($method_name);
839             if (in_array($action, array('get', 'encode', 'prepare', 'inspect', 'set'))) {
840                 $subject = fGrammar::underscorize($subject);
841             } elseif (in_array($action, array('build', 'count', 'inject', 'link', 'list', 'tally'))) {
842                 $subject = fGrammar::singularize($subject);
843             }
844             self::$method_name_cache[$method_name] = array(
845                 'action'  => $action,
846                 'subject' => $subject
847             );   
848         } else {
849             $action  = self::$method_name_cache[$method_name]['action'];
850             $subject = self::$method_name_cache[$method_name]['subject'];   
851         }
852        
853         switch ($action) {
854            
855             // Value methods
856             case 'get':
857                 return $this->get($subject);
858                
859             case 'encode':
860                 if (isset($parameters[0])) {
861                     return $this->encode($subject, $parameters[0]);
862                 }
863                 return $this->encode($subject);
864            
865             case 'prepare':
866                 if (isset($parameters[0])) {
867                     return $this->prepare($subject, $parameters[0]);
868                 }
869                 return $this->prepare($subject);
870            
871             case 'inspect':
872                 if (isset($parameters[0])) {
873                     return $this->inspect($subject, $parameters[0]);
874                 }
875                 return $this->inspect($subject);
876            
877             case 'set':
878                 if (sizeof($parameters) < 1) {
879                     throw new fProgrammerException(
880                         'The method, %s(), requires at least one parameter',
881                         $method_name
882                     );
883                 }
884                 return $this->set($subject, $parameters[0]);
885            
886             // Related data methods
887             case 'associate':
888                 if (sizeof($parameters) < 1) {
889                     throw new fProgrammerException(
890                         'The method, %s(), requires at least one parameter',
891                         $method_name
892                     );
893                 }
894                
895                 $records = $parameters[0];
896                 $route   = isset($parameters[1]) ? $parameters[1] : NULL;
897                
898                 list ($subject, $route, $plural) = self::determineSubject($class, $subject, $route);
899                
900                 if ($plural) {
901                     fORMRelated::associateRecords($class, $this->related_records, $subject, $records, $route);
902                 } else {
903                     fORMRelated::associateRecord($class, $this->related_records, $subject, $records, $route);
904                 }
905                 return $this;
906            
907             case 'build':
908                 if (isset($parameters[0])) {
909                     return fORMRelated::buildRecords($class, $this->values, $this->related_records, $subject, $parameters[0]);
910                 }
911                 return fORMRelated::buildRecords($class, $this->values, $this->related_records, $subject);
912            
913             case 'count':
914                 if (isset($parameters[0])) {
915                     return fORMRelated::countRecords($class, $this->values, $this->related_records, $subject, $parameters[0]);
916                 }
917                 return fORMRelated::countRecords($class, $this->values, $this->related_records, $subject);
918            
919             case 'create':
920                 if (isset($parameters[0])) {
921                     return fORMRelated::createRecord($class, $this->values, $this->related_records, $subject, $parameters[0]);
922                 }
923                 return fORMRelated::createRecord($class, $this->values, $this->related_records, $subject);
924                
925             case 'has':
926                 $route = isset($parameters[0]) ? $parameters[0] : NULL;
927                
928                 list ($subject, $route, ) = self::determineSubject($class, $subject, $route);
929                
930                 return fORMRelated::hasRecords($class, $this->values, $this->related_records, $subject, $route);
931              
932             case 'inject':
933                 if (sizeof($parameters) < 1) {
934                     throw new fProgrammerException(
935                         'The method, %s(), requires at least one parameter',
936                         $method_name
937                     );
938                 }
939                
940                 if (isset($parameters[1])) {
941                     return fORMRelated::setRecordSet($class, $this->related_records, $subject, $parameters[0], $parameters[1]);
942                 }
943                 return fORMRelated::setRecordSet($class, $this->related_records, $subject, $parameters[0]);
944  
945             case 'link':
946                 if (isset($parameters[0])) {
947                     fORMRelated::linkRecords($class, $this->related_records, $subject, $parameters[0]);
948                 } else {
949                     fORMRelated::linkRecords($class, $this->related_records, $subject);
950                 }
951                 return $this;
952            
953             case 'list':
954                 if (isset($parameters[0])) {
955                     return fORMRelated::getPrimaryKeys($class, $this->values, $this->related_records, $subject, $parameters[0]);
956                 }
957                 return fORMRelated::getPrimaryKeys($class, $this->values, $this->related_records, $subject);
958            
959             case 'populate':
960                 $route = isset($parameters[0]) ? $parameters[0] : NULL;
961                
962                 list ($subject, $route, ) = self::determineSubject($class, $subject, $route);
963                
964                 fORMRelated::populateRecords($class, $this->related_records, $subject, $route);
965                 return $this;
966            
967             case 'tally':
968                 if (sizeof($parameters) < 1) {
969                     throw new fProgrammerException(
970                         'The method, %s(), requires at least one parameter',
971                         $method_name
972                     );
973                 }
974                
975                 if (isset($parameters[1])) {
976                     return fORMRelated::setCount($class, $this->related_records, $subject, $parameters[0], $parameters[1]);
977                 }
978                 return fORMRelated::setCount($class, $this->related_records, $subject, $parameters[0]);
979            
980             // Error handler
981             default:
982                 throw new fProgrammerException(
983                     'Unknown method, %s(), called',
984                     $method_name
985                 );
986         }
987     }
988    
989    
990     /**
991     * Creates a clone of a record
992     *
993     * If the record has an auto incrementing primary key, the primary key will
994     * be erased in the clone. If the primary key is not auto incrementing,
995     * the primary key will be left as-is in the clone. In either situation the
996     * clone will return `FALSE` from the ::exists() method until ::store() is
997     * called.
998     *
999     * @internal
1000      *
1001     * @return fActiveRecord
1002     */
1003     public function __clone()
1004     {
1005         $class = get_class($this);
1006        
1007         // Copy values and cache, making sure objects are cloned to prevent reference issues
1008         $temp_values  = $this->values;
1009         $new_values   = array();
1010         $this->values =& $new_values;
1011         foreach ($temp_values as $column => $value) {
1012             $this->values[$column] = fORM::replicate($class, $column, $value);
1013         }
1014        
1015         $temp_cache  = $this->cache;
1016         $new_cache   = array();
1017         $this->cache =& $new_cache;
1018         foreach ($temp_cache as $key => $value) {
1019             if (is_object($value)) {
1020                 $this->cache[$key] = clone $value;
1021             } else {
1022                 $this->cache[$key] = $value;
1023             }       
1024         }
1025        
1026         // Related records are purged
1027         $new_related_records   = array();
1028         $this->related_records =& $new_related_records;
1029        
1030         // Old values are changed to look like the record is non-existant
1031         $new_old_values   = array();
1032         $this->old_values =& $new_old_values;
1033        
1034         foreach (array_keys($this->values) as $key) {
1035             $this->old_values[$key] = array(NULL);
1036         }
1037        
1038         // If we have a single auto incrementing primary key, remove the value
1039         $schema     = fORMSchema::retrieve($class);
1040         $table      = fORM::tablize($class);
1041         $pk_columns = $schema->getKeys($table, 'primary');
1042        
1043         if (sizeof($pk_columns) == 1 && $schema->getColumnInfo($table, $pk_columns[0], 'auto_increment')) {
1044             $this->values[$pk_columns[0]] = NULL;
1045             unset($this->old_values[$pk_columns[0]]);
1046         }       
1047     }
1048    
1049    
1050     /**
1051     * Creates a new record or loads one from the database - if a primary key or unique key is provided the record will be loaded
1052     *
1053     * @throws fNotFoundException  When the record specified by `$key` can not be found in the database
1054     *
1055     * @param  mixed $key  The primary key or unique key value(s) - single column primary keys will accept a scalar value, all others must be an associative array of `(string) {column} => (mixed) {value}`
1056     * @return fActiveRecord
1057     */
1058     public function __construct($key=NULL)
1059     {
1060         $class  = get_class($this);
1061         $schema = fORMSchema::retrieve($class);
1062        
1063         // If the features of this class haven't been set yet, do it
1064         if (!isset(self::$configured[$class])) {
1065             self::$configured[$class] = TRUE;
1066             $this->configure();
1067            
1068             $table = fORM::tablize($class);
1069             if (!$schema->getKeys($table, 'primary')) {
1070                 throw new fProgrammerException(
1071                     'The database table %1$s (being modelled by the class %2$s) does not appear to have a primary key defined. %3$s and %4$s will not work properly without a primary key.',
1072                     $table,
1073                     $class,
1074                     'fActiveRecord',
1075                     'fRecordSet'
1076                 );   
1077             }
1078            
1079             // If the configuration was forced, prevent the post::__construct() hook from
1080             // being triggered since it is not really a real record instantiation
1081             $trace = array_slice(debug_backtrace(), 0, 2);
1082            
1083             $is_forced = sizeof($trace) == 2;
1084             $is_forced = $is_forced && $trace[1]['function'] == 'forceConfigure';
1085             $is_forced = $is_forced && isset($trace[1]['class']);
1086             $is_forced = $is_forced && $trace[1]['type'] == '::';
1087             $is_forced = $is_forced && in_array($trace[1]['class'], array('fActiveRecord', $class));
1088            
1089             if ($is_forced) {
1090                 return;   
1091             }
1092         }
1093        
1094         if (!isset(self::$callback_cache[$class]['__construct'])) {
1095             if (!isset(self::$callback_cache[$class])) {
1096                 self::$callback_cache[$class] = array();
1097             }
1098             $callback = fORM::getActiveRecordMethod($class, '__construct');
1099             self::$callback_cache[$class]['__construct'] = $callback ? $callback : FALSE;   
1100         }
1101         if ($callback = self::$callback_cache[$class]['__construct']) {
1102             return $this->__call($callback);
1103         }
1104        
1105         // Handle loading by a result object passed via the fRecordSet class
1106         if ($key instanceof Iterator) {
1107            
1108             $this->loadFromResult($key);
1109        
1110         // Handle loading an object from the database
1111         } elseif ($key !== NULL) {
1112            
1113             $table      = fORM::tablize($class);
1114             $pk_columns = $schema->getKeys($table, 'primary');
1115            
1116             // If the primary key does not look properly formatted, check to see if it is a UNIQUE key
1117             $is_unique_key = FALSE;
1118             if (is_array($key) && (sizeof($pk_columns) == 1 || array_diff(array_keys($key), $pk_columns))) {
1119                 $unique_keys = $schema->getKeys($table, 'unique');
1120                 $key_keys    = array_keys($key);
1121                 foreach ($unique_keys as $unique_key) {
1122                     if (!array_diff($key_keys, $unique_key)) {
1123                         $is_unique_key = TRUE;
1124                     }
1125                 }   
1126             }
1127            
1128             $wrong_keys = is_array($key) && (count($key) != count($pk_columns) || array_diff(array_keys($key), $pk_columns));
1129             $wrong_type = !is_array($key) && (sizeof($pk_columns) != 1 || !is_scalar($key));
1130            
1131             // If we didn't find a UNIQUE key and primary key doesn't look right we fail
1132             if (!$is_unique_key && ($wrong_keys || $wrong_type)) {
1133                 throw new fProgrammerException(
1134                     'An invalidly formatted primary or unique key was passed to this %s object',
1135                     fORM::getRecordName($class)
1136                 );
1137             }
1138            
1139             if ($is_unique_key) {
1140                
1141                 $result = $this->fetchResultFromUniqueKey($key);
1142                 $this->loadFromResult($result);
1143                
1144             } else {
1145                
1146                 $hash = self::hash($key, $class);
1147                 if (!$this->loadFromIdentityMap($key, $hash)) {
1148                     // Assign the primary key values for loading
1149                     if (is_array($key)) {
1150                         foreach ($pk_columns as $pk_column) {
1151                             $this->values[$pk_column] = $key[$pk_column];
1152                         }
1153                     } else {
1154                         $this->values[$pk_columns[0]] = $key;
1155                     }
1156                    
1157                     $this->load();
1158                 }
1159             }
1160            
1161         // Create an empty array for new objects
1162         } else {
1163             $column_info = $schema->getColumnInfo(fORM::tablize($class));
1164             foreach ($column_info as $column => $info) {
1165                 $this->values[$column] = NULL;
1166                 if ($info['default'] !== NULL) {
1167                     self::assign(
1168                         $this->values,
1169                         $this->old_values,
1170                         $column,
1171                         fORM::objectify($class, $column, $info['default'])
1172                     );   
1173                 }
1174             }
1175         }
1176        
1177         fORM::callHookCallbacks(
1178             $this,
1179             'post::__construct()',
1180             $this->values,
1181             $this->old_values,
1182             $this->related_records,
1183             $this->cache
1184         );
1185     }
1186    
1187    
1188     /**
1189     * All requests that hit this method should be requests for callbacks
1190     *
1191     * @internal
1192      *
1193     * @param  string $method  The method to create a callback for
1194     * @return callback  The callback for the method requested
1195     */
1196     public function __get($method)
1197     {
1198         return array($this, $method);       
1199     }
1200    
1201    
1202     /**
1203     * Configure itself when coming out of the session. Records from the session are NOT hooked into the identity map.
1204     *
1205     * @internal
1206      *
1207     * @return void
1208     */
1209     public function __wakeup()
1210     {
1211         $class = get_class($this);
1212        
1213         if (!isset(self::$configured[$class])) {
1214             $this->configure();
1215             self::$configured[$class] = TRUE;
1216         }       
1217     }
1218    
1219    
1220     /**
1221     * Allows the programmer to set features for the class
1222     *
1223     * This method is only called once per page load for each class.
1224     *
1225     * @return void
1226     */
1227     protected function configure()
1228     {
1229     }
1230    
1231    
1232     /**
1233     * Creates the fDatabase::translatedQuery() insert statement params
1234     *
1235     * @return array  The parameters for an fDatabase::translatedQuery() SQL insert statement
1236     */
1237     protected function constructInsertParams()
1238     {
1239         $columns = array();
1240         $values  = array();
1241        
1242         $column_placeholders = array();
1243         $value_placeholders  = array();
1244        
1245         $class       = get_class($this);
1246         $schema      = fORMSchema::retrieve($class);
1247         $table       = fORM::tablize($class);
1248         $column_info = $schema->getColumnInfo($table);
1249         foreach ($column_info as $column => $info) {
1250             if ($schema->getColumnInfo($table, $column, 'auto_increment') && $schema->getColumnInfo($table, $column, 'not_null') && $this->values[$column] === NULL) {
1251                 continue;
1252             }
1253            
1254             $value = fORM::scalarize($class, $column, $this->values[$column]);
1255             if ($value === NULL && $info['not_null'] && $info['default'] !== NULL) {
1256                 $value = $info['default'];   
1257             }
1258            
1259             $columns[] = $column;
1260             $values[]  = $value;
1261            
1262             $column_placeholders[] = '%r';
1263             $value_placeholders[]  = $info['placeholder'];
1264         }
1265        
1266         $sql    = 'INSERT INTO %r (' . join(', ', $column_placeholders) . ') VALUES (' . join(', ', $value_placeholders) . ')';
1267         $params = array($sql, $table);
1268         $params = array_merge($params, $columns);
1269         $params = array_merge($params, $values);
1270        
1271         return $params;   
1272     }
1273    
1274    
1275     /**
1276     * Creates the fDatabase::translatedQuery() update statement params
1277     *
1278     * @return array  The parameters for an fDatabase::translatedQuery() SQL update statement
1279     */
1280     protected function constructUpdateParams()
1281     {
1282         $class       = get_class($this);
1283         $schema      = fORMSchema::retrieve($class);
1284        
1285         $table       = fORM::tablize($class);
1286         $column_info = $schema->getColumnInfo($table);
1287        
1288         $assignments = array();
1289         $params      = array($table);
1290            
1291         foreach ($column_info as $column => $info) {
1292             if ($info['auto_increment'] && !fActiveRecord::changed($this->values, $this->old_values, $column)) {
1293                 continue;
1294             }
1295            
1296             $assignments[] = '%r = ' . $info['placeholder'];
1297            
1298             $value = fORM::scalarize($class, $column, $this->values[$column]);
1299             if ($value === NULL && $info['not_null'] && $info['default'] !== NULL) {
1300                 $value = $info['default'];   
1301             }
1302            
1303             $params[] = $column;
1304             $params[] = $value;
1305         }
1306        
1307         $sql = 'UPDATE %r SET ' . join(', ', $assignments) . ' WHERE ';
1308         array_unshift($params, $sql);
1309        
1310         return fORMDatabase::addPrimaryKeyWhereParams($schema, $params, $table, $table, $this->values, $this->old_values);
1311     }
1312    
1313    
1314     /**
1315     * Deletes a record from the database, but does not destroy the object
1316     *
1317     * This method will start a database transaction if one is not already active.
1318     *
1319     * @param  boolean $force_cascade  When TRUE, this will cause all child objects to be deleted, even if the ON DELETE clause is RESTRICT or NO ACTION
1320     * @return fActiveRecord  The record object, to allow for method chaining
1321     */
1322     public function delete($force_cascade=FALSE)
1323     {
1324         // This flag prevents recursive relationships, such as one-to-one
1325         // relationships, from creating infinite loops
1326         if (!empty($this->cache['fActiveRecord::delete()::being_deleted'])) {
1327             return;   
1328         }
1329        
1330         $class = get_class($this);
1331        
1332         if (fORM::getActiveRecordMethod($class, 'delete')) {
1333             return $this->__call('delete', array());
1334         }
1335        
1336         if (!$this->exists()) {
1337             throw new fProgrammerException(
1338                 'This %s object does not yet exist in the database, and thus can not be deleted',
1339                 fORM::getRecordName($class)
1340             );
1341         }
1342        
1343         $db     = fORMDatabase::retrieve($class, 'write');
1344         $schema = fORMSchema::retrieve($class);
1345        
1346         fORM::callHookCallbacks(
1347             $this,
1348             'pre::delete()',
1349             $this->values,
1350             $this->old_values,
1351             $this->related_records,
1352             $this->cache
1353         );
1354        
1355         $table = fORM::tablize($class);
1356        
1357         $inside_db_transaction = $db->isInsideTransaction();
1358        
1359         try {
1360            
1361             if (!$inside_db_transaction) {
1362                 $db->translatedQuery('BEGIN');
1363             }
1364            
1365             fORM::callHookCallbacks(
1366                 $this,
1367                 'post-begin::delete()',
1368                 $this->values,
1369                 $this->old_values,
1370                 $this->related_records,
1371                 $this->cache
1372             );
1373            
1374             // Check to ensure no foreign dependencies prevent deletion
1375             $one_to_one_relationships   = $schema->getRelationships($table, 'one-to-one');
1376             $one_to_many_relationships  = $schema->getRelationships($table, 'one-to-many');
1377             $many_to_many_relationships = $schema->getRelationships($table, 'many-to-many');
1378            
1379             $relationships = array_merge($one_to_one_relationships, $one_to_many_relationships, $many_to_many_relationships);
1380             $records_sets_to_delete = array();
1381            
1382             $restriction_messages = array();
1383            
1384             $this->cache['fActiveRecord::delete()::being_deleted'] = TRUE;
1385            
1386             foreach ($relationships as $relationship) {
1387                
1388                 // Figure out how to check for related records
1389                 if (isset($relationship['join_table'])) {
1390                     $type = 'many-to-many';
1391                 } else {
1392                     $type = in_array($relationship, $one_to_one_relationships) ? 'one-to-one' : 'one-to-many';
1393                 }
1394                 $route = fORMSchema::getRouteNameFromRelationship($type, $relationship);
1395                
1396                 $related_class = fORM::classize($relationship['related_table']);
1397                
1398                 if ($type == 'one-to-one') {
1399                     $method         = 'create' . $related_class;
1400                     $related_record = $this->$method($route);
1401                     if (!$related_record->exists()) {
1402                         continue;
1403                     }
1404                    
1405                 } else {
1406                     $method     = 'build' . fGrammar::pluralize($related_class);
1407                     $record_set = $this->$method($route);
1408                     if (!$record_set->count()) {
1409                         continue;
1410                     }
1411                    
1412                     if ($type == 'one-to-many' && $relationship['on_delete'] == 'cascade') {
1413                         $records_sets_to_delete[] = $record_set;
1414                     }
1415                 }
1416                
1417                 // If we are focing the cascade we have to delete child records and join table entries before this record
1418                 if ($force_cascade) {
1419                    
1420                     if ($type == 'one-to-one') {
1421                         $related_record->delete($force_cascade);
1422                        
1423                     // For one-to-many we explicitly delete all of the records
1424                     } elseif ($type == 'one-to-many') {
1425                         foreach ($record_set as $record) {
1426                             if ($record->exists()) {
1427                                 $record->delete($force_cascade);
1428                             }
1429                         }
1430                    
1431                     // For many-to-many relationships we explicitly delete the join table entries
1432                     } elseif ($type == 'many-to-many') {
1433                         $join_column_placeholder = $schema->getColumnInfo($relationship['join_table'], $relationship['join_column'], 'placeholder');
1434                         $column_get_method       = 'get' . fGrammar::camelize($relationship['column'], TRUE);
1435                        
1436                         $db->translatedQuery(
1437                             $db->escape(
1438                                 'DELETE FROM %r WHERE %r = ',
1439                                 $relationship['join_table'],
1440                                 $relationship['join_column']
1441                             ) . $join_column_placeholder,
1442                             $this->$column_get_method()
1443                         );       
1444                     }
1445                
1446                 // Otherwise we have a restriction and we can to create a nice error message for the user
1447                 } elseif ($relationship['on_delete'] == 'restrict' || $relationship['on_delete'] == 'no_action') {
1448                    
1449                     $related_class_name  = fORM::classize($relationship['related_table']);
1450                     $related_record_name = fORM::getRecordName($related_class_name);
1451                    
1452                     if ($type == 'one-to-one') {
1453                         $restriction_messages[] = self::compose("A %s references it", $related_record_name);
1454                     } else {
1455                         $related_record_name = fGrammar::pluralize($related_record_name);
1456                         $restriction_messages[] = self::compose("One or more %s references it", $related_record_name);
1457                     }
1458                 }
1459             }
1460            
1461             if ($restriction_messages) {
1462                 throw new fValidationException(
1463                     self::compose('This %s can not be deleted because:', fORM::getRecordName($class)),
1464                     $restriction_messages
1465                 );
1466             }
1467            
1468            
1469             // Delete this record
1470             $params = array('DELETE FROM %r WHERE ', $table);
1471             $params = fORMDatabase::addPrimaryKeyWhereParams($schema, $params, $table, $table, $this->values, $this->old_values);
1472            
1473             $result = call_user_func_array($db->translatedQuery, $params);
1474            
1475            
1476             // Delete related records to ensure any PHP-level cleanup is done
1477             foreach ($records_sets_to_delete as $record_set) {
1478                 foreach ($record_set as $record) {
1479                     if ($record->exists()) {
1480                         $record->delete($force_cascade);
1481                     }
1482                 }
1483             }
1484            
1485             unset($this->cache['fActiveRecord::delete()::being_deleted']);
1486            
1487             fORM::callHookCallbacks(
1488                 $this,
1489                 'pre-commit::delete()',
1490                 $this->values,
1491                 $this->old_values,
1492                 $this->related_records,
1493                 $this->cache
1494             );
1495            
1496             if (!$inside_db_transaction) {
1497                 $db->translatedQuery('COMMIT');
1498             }
1499            
1500             fORM::callHookCallbacks(
1501                 $this,
1502                 'post-commit::delete()',
1503                 $this->values,
1504                 $this->old_values,
1505                 $this->related_records,
1506                 $this->cache
1507             );
1508            
1509         } catch (fException $e) {
1510            
1511             if (!$inside_db_transaction) {
1512                 $db->translatedQuery('ROLLBACK');
1513             }
1514            
1515             fORM::callHookCallbacks(
1516                 $this,
1517                 'post-rollback::delete()',
1518                 $this->values,
1519                 $this->old_values,
1520                 $this->related_records,
1521                 $this->cache
1522             );
1523            
1524             // Check to see if the validation exception came from a related record, and fix the message
1525             if ($e instanceof fValidationException) {
1526                 $message = $e->getMessage();
1527                 $search  = self::compose('This %s can not be deleted because:', fORM::getRecordName($class));
1528                 if (stripos($message, $search) === FALSE) {
1529                     $regex       = self::compose('This %s can not be deleted because:', '__');
1530                     $regex_parts = explode('__', $regex);
1531                     $regex       = '#(' . preg_quote($regex_parts[0], '#') . ').*?(' . preg_quote($regex_parts[0], '#') . ')#';
1532                    
1533                     $message = preg_replace($regex, '\1' . strtr(fORM::getRecordName($class), array('\\' => '\\\\', '$' => '\\$')) . '\2', $message);
1534                    
1535                     $find          = self::compose("One or more %s references it", '__');
1536                     $find_parts    = explode('__', $find);
1537                     $find_regex    = '#' . preg_quote($find_parts[0], '#') . '(.*?)' . preg_quote($find_parts[1], '#') . '#';
1538                    
1539                     $replace       = self::compose("One or more %s indirectly references it", '__');
1540                     $replace_parts = explode('__', $replace);
1541                     $replace_regex = strtr($replace_parts[0], array('\\' => '\\\\', '$' => '\\$')) . '\1' . strtr($replace_parts[1], array('\\' => '\\\\', '$' => '\\$'));
1542                    
1543                     $message = preg_replace($find_regex, $replace_regex, $regex);
1544                     throw new fValidationException($message);
1545                 }
1546             }
1547            
1548             throw $e;
1549         }
1550        
1551         fORM::callHookCallbacks(
1552             $this,
1553             'post::delete()',
1554             $this->values,
1555             $this->old_values,
1556             $this->related_records,
1557             $this->cache
1558         );
1559        
1560         // If we just deleted an object that has an auto-incrementing primary key,
1561         // lets delete that value from the object since it is no longer valid
1562         $pk_columns  = $schema->getKeys($table, 'primary');
1563         if (sizeof($pk_columns) == 1 && $schema->getColumnInfo($table, $pk_columns[0], 'auto_increment')) {
1564             $this->values[$pk_columns[0]] = NULL;
1565             unset($this->old_values[$pk_columns[0]]);
1566         }
1567        
1568         return $this;
1569     }
1570    
1571    
1572     /**
1573     * Retrieves a value from the record and prepares it for output into an HTML form element.
1574     *
1575     * Below are the transformations performed:
1576    
1577     *  - **varchar, char, text**: will run through fHTML::encode(), if `TRUE` is passed the text will be run through fHTML::convertNewLinks() and fHTML::makeLinks()
1578     *  - **float**: takes 1 parameter to specify the number of decimal places
1579     *  - **date, time, timestamp**: `format()` will be called on the fDate/fTime/fTimestamp object with the 1 parameter specified
1580     *  - **objects**: the object will be converted to a string by `__toString()` or a `(string)` cast and then will be run through fHTML::encode()
1581     *  - **all other data types**: the value will be run through fHTML::encode()
1582     *
1583     * @param  string $column      The name of the column to retrieve
1584     * @param  string $formatting  The formatting string
1585     * @return string  The encoded value for the column specified
1586     */
1587     protected function encode($column, $formatting=NULL)
1588     {
1589         $column_exists = array_key_exists($column, $this->values);
1590         $method_name   = 'get' . fGrammar::camelize($column, TRUE);
1591         $method_exists = method_exists($this, $method_name);
1592        
1593         if (!$column_exists && !$method_exists) {
1594             throw new fProgrammerException(
1595                 'The column specified, %s, does not exist',
1596                 $column
1597             );
1598         }
1599        
1600         if ($column_exists) {
1601             $class       = get_class($this);
1602             $schema      = fORMSchema::retrieve($class);
1603             $table       = fORM::tablize($class);
1604             $column_type = $schema->getColumnInfo($table, $column, 'type');
1605            
1606             // Ensure the programmer is calling the function properly
1607             if ($column_type == 'blob') {
1608                 throw new fProgrammerException(
1609                     'The column specified, %s, does not support forming because it is a blob column',
1610                     $column
1611                 );
1612             }
1613            
1614             if ($formatting !== NULL && in_array($column_type, array('boolean', 'integer'))) {
1615                 throw new fProgrammerException(
1616                     'The column specified, %s, does not support any formatting options',
1617                     $column
1618                 );
1619             }
1620            
1621         // If the column doesn't exist, we are just pulling the
1622         // value from a get method, so treat it as text
1623         } else {
1624             $column_type = 'text';   
1625         }
1626        
1627         // Grab the value for empty value checking
1628         $value = $this->$method_name();
1629        
1630         // Date/time objects
1631         if (is_object($value) && in_array($column_type, array('date', 'time', 'timestamp'))) {
1632             if ($formatting === NULL) {
1633                 throw new fProgrammerException(
1634                     'The column specified, %s, requires one formatting parameter, a valid date() formatting string',
1635                     $column
1636                 );
1637             }
1638             $value = $value->format($formatting);
1639         }
1640        
1641         // Other objects
1642         if (is_object($value) && is_callable(array($value, '__toString'))) {
1643             $value = $value->__toString();
1644         } elseif (is_object($value)) {
1645             $value = (string) $value;   
1646         }
1647        
1648         // Make sure we don't mangle a non-float value
1649         if ($column_type == 'float' && is_numeric($value)) {
1650             $column_decimal_places = $schema->getColumnInfo($table, $column, 'decimal_places');
1651            
1652             // If the user passed in a formatting value, use it
1653             if ($formatting !== NULL && is_numeric($formatting)) {
1654                 $decimal_places = (int) $formatting;
1655                
1656             // If the column has a pre-defined number of decimal places, use that
1657             } elseif ($column_decimal_places !== NULL) {
1658                 $decimal_places = $column_decimal_places;
1659            
1660             // This figures out how many decimal places are part of the current value
1661             } else {
1662                 $value_parts    = explode('.', $value);
1663                 $decimal_places = (!isset($value_parts[1])) ? 0 : strlen($value_parts[1]);
1664             }
1665            
1666             return number_format($value, $decimal_places, '.', '');
1667         }
1668        
1669         // Turn line-breaks into breaks for text fields and add links
1670         if ($formatting === TRUE && in_array($column_type, array('varchar', 'char', 'text'))) {
1671             return fHTML::makeLinks(fHTML::convertNewlines(fHTML::encode($value)));
1672         }
1673        
1674         // Anything that has gotten to here is a string value or is not the proper data type for the column that contains it
1675         return fHTML::encode($value);
1676     }
1677    
1678    
1679     /**
1680     * Checks to see if the record exists in the database
1681     *
1682     * @return boolean  If the record exists in the database
1683     */
1684     public function exists()
1685     {
1686         $class = get_class($this);
1687        
1688         if (fORM::getActiveRecordMethod($class, 'exists')) {
1689             return $this->__call('exists', array());
1690         }
1691        
1692         $schema     = fORMSchema::retrieve($class);
1693         $table      = fORM::tablize($class);
1694         $pk_columns = $schema->getKeys($table, 'primary');
1695         $exists     = FALSE;
1696        
1697         foreach ($pk_columns as $pk_column) {
1698             $has_old = self::hasOld($this->old_values, $pk_column);
1699             if (($has_old && self::retrieveOld($this->old_values, $pk_column) !== NULL) || (!$has_old && $this->values[$pk_column] !== NULL)) {
1700                 $exists = TRUE;
1701             }
1702         }
1703        
1704         return $exists;
1705     }
1706    
1707    
1708     /**
1709     * Loads a record from the database based on a UNIQUE key
1710     *
1711     * @throws fNotFoundException
1712     *
1713     * @param  array $values  The UNIQUE key values to try and load with
1714     * @return void
1715     */
1716     protected function fetchResultFromUniqueKey($values)
1717     {       
1718         $class = get_class($this);
1719        
1720         $db     = fORMDatabase::retrieve($class, 'read');
1721         $schema = fORMSchema::retrieve($class);
1722        
1723         try {
1724             if ($values === array_combine(array_keys($values), array_fill(0, sizeof($values), NULL))) {
1725                 throw new fExpectedException('The values specified for the unique key are all NULL');   
1726             }
1727            
1728             $table  = fORM::tablize($class);
1729             $params = array('SELECT * FROM %r WHERE ', $table);
1730            
1731             $column_info = $schema->getColumnInfo($table);
1732            
1733             $conditions = array();
1734             foreach ($values as $column => $value) {
1735                
1736                 // This makes sure the query performs the way an insert will
1737                 if ($value === NULL && $column_info[$column]['not_null'] && $column_info[$column]['default'] !== NULL) {
1738                     $value = $column_info[$column]['default'];
1739                 }
1740                
1741                 $conditions[] = fORMDatabase::makeCondition($schema, $table, $column, '=', $value);
1742                 $params[] = $column;
1743                 $params[] = $value;   
1744             }
1745            
1746             $params[0] .= join(' AND ', $conditions);
1747        
1748             $result = call_user_func_array($db->translatedQuery, $params);
1749             $result->tossIfNoRows();
1750            
1751         } catch (fExpectedException $e) {
1752             throw new fNotFoundException(
1753                 'The %s requested could not be found',
1754                 fORM::getRecordName($class)
1755             );
1756         }
1757        
1758         return $result;
1759     }
1760    
1761    
1762     /**
1763     * Retrieves a value from the record
1764     *
1765     * @param  string $column  The name of the column to retrieve
1766     * @return mixed  The value for the column specified
1767     */
1768     protected function get($column)
1769     {
1770         if (!isset($this->values[$column]) && !array_key_exists($column, $this->values)) {
1771             throw new fProgrammerException(
1772                 'The column specified, %s, does not exist',
1773                 $column
1774             );
1775         }
1776         return $this->values[$column];
1777     }
1778    
1779    
1780     /**
1781     * Retrieves information about a column
1782     *
1783     * @param  string $column   The name of the column to inspect
1784     * @param  string $element  The metadata element to retrieve
1785     * @return mixed  The metadata array for the column, or the metadata element specified
1786     */
1787     protected function inspect($column, $element=NULL)
1788     {
1789         if (!array_key_exists($column, $this->values)) {
1790             throw new fProgrammerException(
1791                 'The column specified, %s, does not exist',
1792                 $column
1793             );
1794         }
1795        
1796         $class  = get_class($this);
1797         $table  = fORM::tablize($class);
1798         $schema = fORMSchema::retrieve($class);
1799         $info   = $schema->getColumnInfo($table, $column);
1800        
1801         if (!in_array($info['type'], array('varchar', 'char', 'text'))) {
1802             unset($info['valid_values']);
1803             unset($info['max_length']);
1804         }
1805        
1806         if ($info['type'] != 'float') {
1807             unset($info['decimal_places']);
1808         }
1809        
1810         if ($info['type'] != 'integer') {
1811             unset($info['auto_increment']);
1812         }
1813        
1814         if (!in_array($info['type'], array('integer', 'float'))) {
1815             unset($info['min_value']);
1816             unset($info['max_value']);
1817         }
1818        
1819         $info['feature'] = NULL;
1820        
1821         fORM::callInspectCallbacks(get_class($this), $column, $info);
1822        
1823         if ($element) {
1824             if (!isset($info[$element])) {
1825                 throw new fProgrammerException(
1826                     'The element specified, %1$s, is invalid. Must be one of: %2$s.',
1827                     $element,
1828                     join(', ', array_keys($info))
1829                 );
1830             }
1831             return $info[$element];
1832         }
1833        
1834         return $info;
1835     }
1836    
1837    
1838     /**
1839     * Loads a record from the database
1840     *
1841     * @throws fNotFoundException  When the record could not be found in the database
1842     *
1843     * @return fActiveRecord  The record object, to allow for method chaining
1844     */
1845     public function load()
1846     {
1847         $class  = get_class($this);
1848         $db     = fORMDatabase::retrieve($class, 'read');
1849         $schema = fORMSchema::retrieve($class);
1850        
1851         if (fORM::getActiveRecordMethod($class, 'load')) {
1852             return $this->__call('load', array());
1853         }
1854        
1855         try {
1856             $table = fORM::tablize($class);
1857             $params = array('SELECT * FROM %r WHERE ', $table);
1858             $params = fORMDatabase::addPrimaryKeyWhereParams($schema, $params, $table, $table, $this->values, $this->old_values);
1859        
1860             $result = call_user_func_array($db->translatedQuery, $params);
1861             $result->tossIfNoRows();
1862            
1863         } catch (fExpectedException $e) {
1864             throw new fNotFoundException(
1865                 'The %s requested could not be found',
1866                 fORM::getRecordName($class)
1867             );
1868         }
1869        
1870         $this->loadFromResult($result, TRUE);
1871        
1872         // Clears the cached related records so they get pulled from the database
1873         $this->related_records = array();
1874        
1875         return $this;
1876     }
1877    
1878    
1879     /**
1880     * Loads a record from the database directly from a result object
1881     *
1882     * @param  Iterator $result               The result object to use for loading the current object
1883     * @param  boolean  $ignore_identity_map  If the identity map should be ignored and the values loaded no matter what
1884     * @return boolean  If the record was loaded from the identity map
1885     */
1886     protected function loadFromResult($result, $ignore_identity_map=FALSE)
1887     {
1888         $class  = get_class($this);
1889         $table  = fORM::tablize($class);
1890         $row    = $result->current();
1891        
1892         $db     = fORMDatabase::retrieve($class, 'read');
1893         $schema = fORMSchema::retrieve($class);
1894        
1895         if (!isset(self::$unescape_map[$class])) {
1896             self::$unescape_map[$class] = array();
1897             $column_info                = $schema->getColumnInfo($table);
1898            
1899             foreach ($column_info as $column => $info) {
1900                 if (in_array($info['type'], array('blob', 'boolean', 'date', 'time', 'timestamp'))) {
1901                     self::$unescape_map[$class][$column] = $info['type'];
1902                 }
1903             }   
1904         }
1905        
1906         $pk_columns = $schema->getKeys($table, 'primary');
1907         foreach ($pk_columns as $column) {
1908             $value = $row[$column];
1909             if ($value !== NULL && isset(self::$unescape_map[$class][$column])) {
1910                 $value = $db->unescape(self::$unescape_map[$class][$column], $value);
1911             }   
1912            
1913             $this->values[$column] = fORM::objectify($class, $column, $value);
1914             unset($row[$column]);
1915         }
1916        
1917         $hash = self::hash($this->values, $class);
1918         if (!$ignore_identity_map && $this->loadFromIdentityMap($this->values, $hash)) {
1919             return TRUE;
1920         }
1921        
1922         foreach ($row as $column => $value) {
1923             if ($value !== NULL && isset(self::$unescape_map[$class][$column])) {
1924                 $value = $db->unescape(self::$unescape_map[$class][$column], $value);
1925             }
1926            
1927             $this->values[$column] = fORM::objectify($class, $column, $value);
1928         }
1929        
1930         // Save this object to the identity map
1931         if (!isset(self::$identity_map[$class])) {
1932             self::$identity_map[$class] = array();         
1933         }
1934         self::$identity_map[$class][$hash] = $this;
1935        
1936         fORM::callHookCallbacks(
1937             $this,
1938             'post::loadFromResult()',
1939             $this->values,
1940             $this->old_values,
1941             $this->related_records,
1942             $this->cache
1943         );
1944        
1945         return FALSE;
1946     }
1947    
1948    
1949     /**
1950     * Tries to load the object (via references to class vars) from the fORM identity map
1951     *
1952     * @param  array  $row   The data source for the primary key values
1953     * @param  string $hash  The unique hash for this record
1954     * @return boolean  If the load was successful
1955     */
1956     protected function loadFromIdentityMap($row, $hash)
1957     {
1958         $class = get_class($this);
1959        
1960         if (!isset(self::$identity_map[$class])) {
1961             return FALSE;
1962         }
1963        
1964         if (!isset(self::$identity_map[$class][$hash])) {
1965             return FALSE;
1966         }
1967        
1968         $object = self::$identity_map[$class][$hash];
1969        
1970         // If we got a result back, it is the object we are creating
1971         $this->cache           = &$object->cache;
1972         $this->values          = &$object->values;
1973         $this->old_values      = &$object->old_values;
1974         $this->related_records = &$object->related_records;
1975        
1976         fORM::callHookCallbacks(
1977             $this,
1978             'post::loadFromIdentityMap()',
1979             $this->values,
1980             $this->old_values,
1981             $this->related_records,
1982             $this->cache
1983         );
1984        
1985         return TRUE;
1986     }
1987    
1988    
1989     /**
1990     * Sets the values for this record by getting values from the request through the fRequest class
1991     *
1992     * @return fActiveRecord  The record object, to allow for method chaining
1993     */
1994     public function populate()
1995     {
1996         $class = get_class($this);
1997        
1998         if (fORM::getActiveRecordMethod($class, 'populate')) {
1999             return $this->__call('populate', array());
2000         }
2001        
2002         fORM::callHookCallbacks(
2003             $this,
2004             'pre::populate()',
2005             $this->values,
2006             $this->old_values,
2007             $this->related_records,
2008             $this->cache
2009         );
2010        
2011         $schema = fORMSchema::retrieve($class);
2012         $table  = fORM::tablize($class);
2013        
2014         $column_info = $schema->getColumnInfo($table);
2015         foreach ($column_info as $column => $info) {
2016             if (fRequest::check($column)) {
2017                 $method = 'set' . fGrammar::camelize($column, TRUE);
2018                 $this->$method(fRequest::get($column));
2019             }
2020         }
2021        
2022         fORM::callHookCallbacks(
2023             $this,
2024             'post::populate()',
2025             $this->values,
2026             $this->old_values,
2027             $this->related_records,
2028             $this->cache
2029         );
2030        
2031         return $this;
2032     }
2033    
2034    
2035     /**
2036     * Retrieves a value from the record and prepares it for output into html.
2037     *
2038     * Below are the transformations performed:
2039     *
2040     *  - **varchar, char, text**: will run through fHTML::prepare(), if `TRUE` is passed the text will be run through fHTML::convertNewLinks() and fHTML::makeLinks()
2041     *  - **boolean**: will return `'Yes'` or `'No'`
2042     *  - **integer**: will add thousands/millions/etc. separators
2043     *  - **float**: will add thousands/millions/etc. separators and takes 1 parameter to specify the number of decimal places
2044     *  - **date, time, timestamp**: `format()` will be called on the fDate/fTime/fTimestamp object with the 1 parameter specified
2045     *  - **objects**: the object will be converted to a string by `__toString()` or a `(string)` cast and then will be run through fHTML::prepare()
2046     *
2047     * @param  string $column      The name of the column to retrieve
2048     * @param  mixed  $formatting  The formatting parameter, if applicable
2049     * @return string  The formatted value for the column specified
2050     */
2051     protected function prepare($column, $formatting=NULL)
2052     {
2053         $column_exists = array_key_exists($column, $this->values);
2054         $method_name   = 'get' . fGrammar::camelize($column, TRUE);
2055         $method_exists = method_exists($this, $method_name);
2056        
2057         if (!$column_exists && !$method_exists) {
2058             throw new fProgrammerException(
2059                 'The column specified, %s, does not exist',
2060                 $column
2061             );
2062         }
2063        
2064         if ($column_exists) {
2065             $class  = get_class($this);
2066             $table  = fORM::tablize($class);
2067             $schema = fORMSchema::retrieve($class);
2068            
2069             $column_info = $schema->getColumnInfo($table, $column);
2070             $column_type = $column_info['type'];
2071            
2072             // Ensure the programmer is calling the function properly
2073             if ($column_type == 'blob') {
2074                 throw new fProgrammerException(
2075                     'The column specified, %s, can not be prepared because it is a blob column',
2076                     $column
2077                 );
2078             }
2079            
2080             if ($formatting !== NULL && in_array($column_type, array('integer', 'boolean'))) {
2081                 throw new fProgrammerException(
2082                     'The column specified, %s, does not support any formatting options',
2083                     $column
2084                 );
2085             }
2086        
2087         // If the column doesn't exist, we are just pulling the
2088         // value from a get method, so treat it as text
2089         } else {
2090             $column_type = 'text';   
2091         }
2092        
2093         // Grab the value for empty value checking
2094         $value = $this->$method_name();
2095        
2096         // Date/time objects
2097         if (is_object($value) && in_array($column_type, array('date', 'time', 'timestamp'))) {
2098             if ($formatting === NULL) {
2099                 throw new fProgrammerException(
2100                     'The column specified, %s, requires one formatting parameter, a valid date() formatting string',
2101                     $column
2102                 );
2103             }
2104             return $value->format($formatting);
2105         }
2106        
2107         // Other objects
2108         if (is_object($value) && is_callable(array($value, '__toString'))) {
2109             $value = $value->__toString();
2110         } elseif (is_object($value)) {
2111             $value = (string) $value;   
2112         }
2113        
2114         // Ensure the value matches the data type specified to prevent mangling
2115         if ($column_type == 'boolean' && is_bool($value)) {
2116             return ($value) ? 'Yes' : 'No';
2117         }
2118        
2119         if ($column_type == 'integer' && is_numeric($value)) {
2120             return number_format($value, 0, '', ',');
2121         }
2122        
2123         if ($column_type == 'float' && is_numeric($value)) {
2124             // If the user passed in a formatting value, use it
2125             if ($formatting !== NULL && is_numeric($formatting)) {
2126                 $decimal_places = (int) $formatting;
2127                
2128             // If the column has a pre-defined number of decimal places, use that
2129             } elseif ($column_info['decimal_places'] !== NULL) {
2130                 $decimal_places = $column_info['decimal_places'];
2131            
2132             // This figures out how many decimal places are part of the current value
2133             } else {
2134                 $value_parts    = explode('.', $value);
2135                 $decimal_places = (!isset($value_parts[1])) ? 0 : strlen($value_parts[1]);
2136             }
2137            
2138             return number_format($value, $decimal_places, '.', ',');
2139         }
2140        
2141         // Turn line-breaks into breaks for text fields and add links
2142         if ($formatting === TRUE && in_array($column_type, array('varchar', 'char', 'text'))) {
2143             return fHTML::makeLinks(fHTML::convertNewlines(fHTML::prepare($value)));
2144         }
2145        
2146         // Anything that has gotten to here is a string value, or is not the
2147         // proper data type for the column, so we just make sure it is marked
2148         // up properly for display in HTML
2149         return fHTML::prepare($value);
2150     }
2151    
2152    
2153     /**
2154     * Generates a pre-formatted block of text containing the method signatures for all methods (including dynamic ones)
2155     *
2156     * @param  boolean $include_doc_comments  If the doc block comments for each method should be included
2157     * @return string  A preformatted block of text with the method signatures and optionally the doc comment
2158     */
2159     public function reflect($include_doc_comments=FALSE)
2160     {
2161         $signatures = array();
2162        
2163         $class        = get_class($this);
2164         $table        = fORM::tablize($class);
2165         $schema       = fORMSchema::retrieve($class);
2166         $columns_info = $schema->getColumnInfo($table);
2167         foreach ($columns_info as $column => $column_info) {
2168             $camelized_column = fGrammar::camelize($column, TRUE);
2169            
2170             // Get and set methods
2171             $signature = '';
2172             if ($include_doc_comments) {
2173                 $fixed_type = $column_info['type'];
2174                 if ($fixed_type == 'blob') {
2175                     $fixed_type = 'string';
2176                 }
2177                 if ($fixed_type == 'date') {
2178                     $fixed_type = 'fDate';
2179                 }
2180                 if ($fixed_type == 'timestamp') {
2181                     $fixed_type = 'fTimestamp';
2182                 }
2183                 if ($fixed_type == 'time') {
2184                     $fixed_type = 'fTime';
2185                 }
2186                
2187                 $signature .= "/**\n";
2188                 $signature .= " * Gets the current value of " . $column . "\n";
2189                 $signature .= " * \n";
2190                 $signature .= " * @return " . $fixed_type . "  The current value\n";
2191                 $signature .= " */\n";
2192             }
2193             $get_method = 'get' . $camelized_column;
2194             $signature .= 'public function ' . $get_method . '()';
2195            
2196             $signatures[$get_method] = $signature;
2197            
2198            
2199             $signature = '';
2200             if ($include_doc_comments) {
2201                 $fixed_type = $column_info['type'];
2202                 if ($fixed_type == 'blob') {
2203                     $fixed_type = 'string';
2204                 }
2205                 if ($fixed_type == 'date') {
2206                     $fixed_type = 'fDate|string';
2207                 }
2208                 if ($fixed_type == 'timestamp') {
2209                     $fixed_type = 'fTimestamp|string';
2210                 }
2211                 if ($fixed_type == 'time') {
2212                     $fixed_type = 'fTime|string';
2213                 }
2214                
2215                 $signature .= "/**\n";
2216                 $signature .= " * Sets the value for " . $column . "\n";
2217                 $signature .= " * \n";
2218                 $signature .= " * @param  " . $fixed_type . " \$" . $column . "  The new value\n";
2219                 $signature .= " * @return fActiveRecord  The record object, to allow for method chaining\n";
2220                 $signature .= " */\n";
2221             }
2222             $set_method = 'set' . $camelized_column;
2223             $signature .= 'public function ' . $set_method . '($' . $column . ')';
2224            
2225             $signatures[$set_method] = $signature;
2226            
2227            
2228             // The encode method
2229             $signature = '';
2230             if ($include_doc_comments) {
2231                 $signature .= "/**\n";
2232                 $signature .= " * Encodes the value of " . $column . " for output into an HTML form\n";
2233                 $signature .= " * \n";
2234                
2235                 if (in_array($column_info['type'], array('time', 'timestamp', 'date'))) {
2236                     $signature .= " * @param  string \$date_formatting_string  A date() compatible formatting string\n";
2237                 }
2238                 if (in_array($column_info['type'], array('float'))) {
2239                     $signature .= " * @param  integer \$decimal_places  The number of decimal places to include - if not specified will default to the precision of the column or the current value\n";
2240                 }
2241                
2242                 $signature .= " * @return string  The HTML form-ready value\n";
2243                 $signature .= " */\n";
2244             }
2245             $encode_method = 'encode' . $camelized_column;
2246             $signature .= 'public function ' . $encode_method . '(';
2247             if (in_array($column_info['type'], array('time', 'timestamp', 'date'))) {
2248                 $signature .= '$date_formatting_string';
2249             }
2250             if (in_array($column_info['type'], array('float'))) {
2251                 $signature .= '$decimal_places=NULL';
2252             }
2253             $signature .= ')';
2254            
2255             $signatures[$encode_method] = $signature;
2256            
2257            
2258             // The prepare method
2259             $signature = '';
2260             if ($include_doc_comments) {
2261                 $signature .= "/**\n";
2262                 $signature .= " * Prepares the value of " . $column . " for output into HTML\n";
2263                 $signature .= " * \n";
2264                
2265                 if (in_array($column_info['type'], array('time', 'timestamp', 'date'))) {
2266                     $signature .= " * @param  string \$date_formatting_string  A date() compatible formatting string\n";
2267                 }
2268                 if (in_array($column_info['type'], array('float'))) {
2269                     $signature .= " * @param  integer \$decimal_places  The number of decimal places to include - if not specified will default to the precision of the column or the current value\n";
2270                 }
2271                 if (in_array($column_info['type'], array('varchar', 'char', 'text'))) {
2272                     $signature .= " * @param  boolean \$create_links_and_line_breaks  Will cause links to be automatically converted into [a] tags and line breaks into [br] tags \n";
2273                 }
2274                
2275                 $signature .= " * @return string  The HTML-ready value\n";
2276                 $signature .= " */\n";
2277             }
2278             $prepare_method = 'prepare' . $camelized_column;
2279             $signature .= 'public function ' . $prepare_method . '(';
2280             if (in_array($column_info['type'], array('time', 'timestamp', 'date'))) {
2281                 $signature .= '$date_formatting_string';
2282             }
2283             if (in_array($column_info['type'], array('float'))) {
2284                 $signature .= '$decimal_places=NULL';
2285             }
2286             if (in_array($column_info['type'], array('varchar', 'char', 'text'))) {
2287                 $signature .= '$create_links_and_line_breaks=FALSE';
2288             }
2289             $signature .= ')';
2290            
2291             $signatures[$prepare_method] = $signature;
2292            
2293            
2294             // The inspect method
2295             $signature = '';
2296             if ($include_doc_comments) {
2297                 $signature .= "/**\n";
2298                 $signature .= " * Returns metadata about " . $column . "\n";
2299                 $signature .= " * \n";
2300                 $elements = array('type', 'not_null', 'default');
2301                 if (in_array($column_info['type'], array('varchar', 'char', 'text'))) {
2302                     $elements[] = 'valid_values';
2303                     $elements[] = 'max_length';
2304                 }
2305                 if ($column_info['type'] == 'float') {
2306                     $elements[] = 'decimal_places';
2307                 }
2308                 if ($column_info['type'] == 'integer') {
2309                     $elements[] = 'auto_increment';
2310                     $elements[] = 'min_value';
2311                     $elements[] = 'max_value';
2312                 }
2313                 $signature .= " * @param  string \$element  The element to return. Must be one of: '" . join("', '", $elements) . "'.\n";
2314                 $signature .= " * @return mixed  The metadata array or a single element\n";
2315                 $signature .= " */\n";
2316             }
2317             $inspect_method = 'inspect' . $camelized_column;
2318             $signature .= 'public function ' . $inspect_method . '($element=NULL)';
2319            
2320             $signatures[$inspect_method] = $signature;
2321         }
2322        
2323         fORMRelated::reflect($class, $signatures, $include_doc_comments);
2324        
2325         fORM::callReflectCallbacks($class, $signatures, $include_doc_comments);
2326        
2327         $reflection = new ReflectionClass($class);
2328         $methods    = $reflection->getMethods();
2329        
2330         foreach ($methods as $method) {
2331             $signature = '';
2332            
2333             if (!$method->isPublic() || $method->getName() == '__call') {
2334                 continue;
2335             }
2336            
2337             if ($method->isFinal()) {
2338                 $signature .= 'final ';
2339             }
2340            
2341             if ($method->isAbstract()) {
2342                 $signature .= 'abstract ';
2343             }
2344            
2345             if ($method->isStatic()) {
2346                 $signature .= 'static ';
2347             }
2348            
2349             $signature .= 'public function ';
2350            
2351             if ($method->returnsReference()) {
2352                 $signature .= '&';
2353             }
2354            
2355             $signature .= $method->getName();
2356             $signature .= '(';
2357            
2358             $parameters = $method->getParameters();
2359             foreach ($parameters as $parameter) {
2360                 if (substr($signature, -1) == '(') {
2361                     $signature .= '';
2362                 } else {
2363                     $signature .= ', ';
2364                 }
2365                
2366                 if ($parameter->isArray()) {
2367                     $signature .= 'array ';   
2368                 }
2369                 if ($parameter->getClass()) {
2370                     $signature .= $parameter->getClass()->getName() . ' ';   
2371                 }
2372                 if ($parameter->isPassedByReference()) {
2373                     $signature .= '&';   
2374                 }
2375                 $signature .= '$' . $parameter->getName();
2376                
2377                 if ($parameter->isDefaultValueAvailable()) {
2378                     $val = var_export($parameter->getDefaultValue(), TRUE);
2379                     if ($val == 'true') {
2380                         $val = 'TRUE';
2381                     }
2382                     if ($val == 'false') {
2383                         $val = 'FALSE';
2384                     }
2385                     if (is_array($parameter->getDefaultValue())) {
2386                         $val = preg_replace('#array\s+\(\s+#', 'array(', $val);
2387                         $val = preg_replace('#,(\r)?\n  #', ', ', $val);
2388                         $val = preg_replace('#,(\r)?\n\)#', ')', $val);
2389                     }
2390                     $signature .= '=' . $val;
2391                 }
2392             }
2393            
2394             $signature .= ')';
2395            
2396             if ($include_doc_comments) {
2397                 $comment = $method->getDocComment();
2398                 $comment = preg_replace('#^\t+#m', '', $comment);
2399                 $signature = $comment . "\n" . $signature;
2400             }
2401             $signatures[$method->getName()] = $signature;
2402         }
2403        
2404         ksort($signatures);
2405        
2406         return join("\n\n", $signatures);
2407     }
2408    
2409    
2410     /**
2411     * Generates a clone of the current record, removing any auto incremented primary key value and allowing for replicating related records
2412     *
2413     * This method will accept three different sets of parameters:
2414     *
2415     *  - No parameters: this object will be cloned
2416     *  - A single `TRUE` value: this object plus all many-to-many associations and all child records (recursively) will be cloned
2417     *  - Any number of plural related record class names: the many-to-many associations or child records that correspond to the classes specified will be cloned
2418     *
2419     * The class names specified can be a simple class name if there is only a
2420     * single route between the two corresponding database tables. If there is
2421     * more than one route between the two tables, the class name should be
2422     * substituted with a string in the format `'RelatedClass{route}'`.
2423     *
2424     * @param  string $related_class  The plural related class to replicate - see method description for details
2425     * @param  string ...
2426     * @return fActiveRecord  The cloned record
2427     */
2428     public function replicate($related_class=NULL)
2429     {
2430         fActiveRecord::$replicate_level++;
2431        
2432         $class  = get_class($this);
2433         $hash   = self::hash($this->values, $class);
2434         $schema = fORMSchema::retrieve($class);
2435         $table  = fORM::tablize($class);
2436            
2437         // If the object has not been replicated yet, do it now
2438         if (!isset(fActiveRecord::$replicate_map[$class])) {
2439             fActiveRecord::$replicate_map[$class] = array();
2440         }
2441         if (!isset(fActiveRecord::$replicate_map[$class][$hash])) {
2442             fActiveRecord::$replicate_map[$class][$hash] = clone $this;
2443            
2444             // We need the primary key to get a hash, otherwise certain recursive relationships end up losing members
2445             $pk_columns = $schema->getKeys($table, 'primary');
2446             if (sizeof($pk_columns) == 1 && $schema->getColumnInfo($table, $pk_columns[0], 'auto_increment')) {
2447                 fActiveRecord::$replicate_map[$class][$hash]->values[$pk_columns[0]] = $this->values[$pk_columns[0]];
2448             }
2449            
2450         }
2451         $clone = fActiveRecord::$replicate_map[$class][$hash];
2452        
2453         $parameters = func_get_args();
2454        
2455         $recursive                  = FALSE;
2456         $many_to_many_relationships = $schema->getRelationships($table, 'many-to-many');
2457         $one_to_many_relationships  = $schema->getRelationships($table, 'one-to-many');
2458        
2459        
2460         // When just TRUE is passed we recursively replicate all related records
2461         if (sizeof($parameters) == 1 && $parameters[0] === TRUE) {
2462             $parameters = array();
2463             $recursive  = TRUE;
2464            
2465             foreach ($many_to_many_relationships as $relationship) {
2466                 $parameters[] = fGrammar::pluralize(fORM::classize($relationship['related_table'])) . '{' . $relationship['join_table'] . '}';         
2467             }
2468             foreach ($one_to_many_relationships as $relationship) {
2469                 $parameters[] = fGrammar::pluralize(fORM::classize($relationship['related_table'])) . '{' . $relationship['related_column'] . '}';         
2470             }           
2471         }
2472        
2473         $record_sets = array();
2474        
2475         foreach ($parameters as $parameter) {
2476            
2477             // Parse the Class{route} strings
2478             if (strpos($parameter, '{') !== FALSE) {
2479                 $brace         = strpos($parameter, '{');
2480                 $related_class = fGrammar::singularize(substr($parameter, 0, $brace));
2481                 $related_table = fORM::tablize($related_class);
2482                 $route         = substr($parameter, $brace+1, -1);
2483             } else {
2484                 $related_class = fGrammar::singularize($parameter);
2485                 $related_table = fORM::tablize($related_class);
2486                 $route         = fORMSchema::getRouteName($schema, $table, $related_table);
2487             }
2488            
2489             // Determine the kind of relationship
2490             $many_to_many = FALSE;
2491             $one_to_many  = FALSE;
2492            
2493             foreach ($many_to_many_relationships as $relationship) {
2494                 if ($relationship['related_table'] == $related_table && $relationship['join_table'] == $route) {
2495                     $many_to_many = TRUE;   
2496                     break;
2497                 }
2498             }
2499            
2500             foreach ($one_to_many_relationships as $relationship) {
2501                 if ($relationship['related_table'] == $related_table && $relationship['related_column'] == $route) {
2502                     $one_to_many = TRUE;
2503                     break;
2504                 }   
2505             }
2506            
2507             if (!$many_to_many && !$one_to_many) {
2508                 throw new fProgrammerException(
2509                     'The related class specified, %1$s, does not appear to be in a many-to-many or one-to-many relationship with %$2s',
2510                     $parameter,
2511                     get_class($this)
2512                 );   
2513             }
2514            
2515             // Get the related records
2516             $record_set = fORMRelated::buildRecords($class, $this->values, $this->related_records, $related_class, $route);
2517            
2518             // One-to-many records need to be replicated, possibly recursively
2519             if ($one_to_many) {
2520                 if ($recursive) {
2521                     $records = $record_set->call('replicate', TRUE);
2522                 } else {
2523                     $records = $record_set->call('replicate');
2524                 }
2525                 $record_set = fRecordSet::buildFromArray($related_class, $records);
2526                 $record_set->call(
2527                     'set' . fGrammar::camelize($route, TRUE),
2528                     NULL
2529                 );   
2530             }
2531            
2532             // Cause the related records to be associated with the new clone
2533             fORMRelated::associateRecords($class, $clone->related_records, $related_class, $record_set, $route);
2534         }
2535        
2536         fActiveRecord::$replicate_level--;
2537         if (!fActiveRecord::$replicate_level) {
2538             // This removes the primary keys we had added back in for proper duplicate detection
2539             foreach (fActiveRecord::$replicate_map as $class => $records) {
2540                 $table      = fORM::tablize($class);
2541                 $pk_columns = $schema->getKeys($table, 'primary');
2542                 if (sizeof($pk_columns) != 1 || !$schema->getColumnInfo($table, $pk_columns[0], 'auto_increment')) {
2543                     continue;
2544                 }
2545                 foreach ($records as $hash => $record) {
2546                     $record->values[$pk_columns[0]] = NULL;       
2547                 }   
2548             }
2549             fActiveRecord::$replicate_map = array();   
2550         }
2551        
2552         return $clone;
2553     }
2554    
2555    
2556     /**
2557     * Sets a value to the record
2558     *
2559     * @param  string $column  The column to set the value to
2560     * @param  mixed  $value   The value to set
2561     * @return fActiveRecord  This record, to allow for method chaining
2562     */
2563     protected function set($column, $value)
2564     {
2565         if (!array_key_exists($column, $this->values)) {
2566             throw new fProgrammerException(
2567                 'The column specified, %s, does not exist',
2568                 $column
2569             );
2570         }
2571        
2572         // We consider an empty string to be equivalent to NULL
2573         if ($value === '') {
2574             $value = NULL;
2575         }
2576        
2577         $class = get_class($this);
2578         $value = fORM::objectify($class, $column, $value);
2579        
2580         // Float and int columns that look like numbers with commas will have the commas removed
2581         if (is_string($value)) {
2582             $table  = fORM::tablize($class);
2583             $schema = fORMSchema::retrieve($class);
2584             $type   = $schema->getColumnInfo($table, $column, 'type');
2585             if (in_array($type, array('integer', 'float')) && preg_match('#^(\d+,)+\d+(\.\d+)?$#', $value)) {
2586                 $value = str_replace(',', '', $value);
2587             }
2588         }
2589        
2590         self::assign($this->values, $this->old_values, $column, $value);
2591        
2592         return $this;
2593     }
2594    
2595    
2596     /**
2597     * Stores a record in the database, whether existing or new
2598     *
2599     * This method will start database and filesystem transactions if they have
2600     * not already been started.
2601     *
2602     * @throws fValidationException  When ::validate() throws an exception
2603     *
2604     * @param  boolean $force_cascade  When storing related records, this will force deleting child records even if they have their own children in a relationship with an RESTRICT or NO ACTION for the ON DELETE clause
2605     * @return fActiveRecord  The record object, to allow for method chaining
2606     */
2607     public function store($force_cascade=FALSE)
2608     {
2609         $class = get_class($this);
2610        
2611         if (fORM::getActiveRecordMethod($class, 'store')) {
2612             return $this->__call('store', array());
2613         }
2614        
2615         fORM::callHookCallbacks(
2616             $this,
2617             'pre::store()',
2618             $this->values,
2619             $this->old_values,
2620             $this->related_records,
2621             $this->cache
2622         );
2623        
2624         $db     = fORMDatabase::retrieve($class, 'write');
2625         $schema = fORMSchema::retrieve($class);
2626        
2627         try {
2628             $table = fORM::tablize($class);
2629            
2630             // New auto-incrementing records require lots of special stuff, so we'll detect them here
2631             $new_autoincrementing_record = FALSE;
2632             if (!$this->exists()) {
2633                 $pk_columns           = $schema->getKeys($table, 'primary');
2634                 $pk_column            = $pk_columns[0];
2635                 $pk_auto_incrementing = $schema->getColumnInfo($table, $pk_column, 'auto_increment');
2636                
2637                 if (sizeof($pk_columns) == 1 && $pk_auto_incrementing && !$this->values[$pk_column]) {
2638                     $new_autoincrementing_record = TRUE;
2639                 }
2640             }
2641            
2642             $inside_db_transaction = $db->isInsideTransaction();
2643            
2644             if (!$inside_db_transaction) {
2645                 $db->translatedQuery('BEGIN');
2646             }
2647            
2648             fORM::callHookCallbacks(
2649                 $this,
2650                 'post-begin::store()',
2651                 $this->values,
2652                 $this->old_values,
2653                 $this->related_records,
2654                 $this->cache
2655             );
2656            
2657             $this->validate();
2658            
2659             fORM::callHookCallbacks(
2660                 $this,
2661                 'post-validate::store()',
2662                 $this->values,
2663                 $this->old_values,
2664                 $this->related_records,
2665                 $this->cache
2666             );
2667            
2668             // Storing main table
2669            
2670             if (!$this->exists()) {
2671                 $params = $this->constructInsertParams();
2672             } else {
2673                 $params = $this->constructUpdateParams();
2674             }
2675             $result = call_user_func_array($db->translatedQuery, $params);
2676            
2677            
2678             // If there is an auto-incrementing primary key, grab the value from the database
2679             if ($new_autoincrementing_record) {
2680                 $this->set($pk_column, $result->getAutoIncrementedValue());
2681             }
2682            
2683            
2684             // Fix cascade updated columns for in-memory objects to prevent issues when saving
2685             $one_to_one_relationships  = $schema->getRelationships($table, 'one-to-one');
2686             $one_to_many_relationships = $schema->getRelationships($table, 'one-to-many');
2687            
2688             $relationships = array_merge($one_to_one_relationships, $one_to_many_relationships);
2689            
2690             foreach ($relationships as $relationship) {
2691                 $type  = in_array($relationship, $one_to_one_relationships) ? 'one-to-one' : 'one-to-many';
2692                 $route = fORMSchema::getRouteNameFromRelationship($type, $relationship);
2693                
2694                 $related_table = $relationship['related_table'];
2695                 $related_class = fORM::classize($related_table);
2696                
2697                 if ($relationship['on_update'] != 'cascade') {
2698                     continue;
2699                 }
2700                
2701                 $column = $relationship['column'];
2702                 if (!fActiveRecord::changed($this->values, $this->old_values, $column)) {
2703                     continue;
2704                 }
2705                
2706                 if (!isset($this->related_records[$related_table][$route]['record_set'])) {
2707                     continue;
2708                 }
2709                
2710                 $record_set     = $this->related_records[$related_table][$route]['record_set'];
2711                 $related_column = $relationship['related_column'];
2712                
2713                 $old_value      = fActiveRecord::retrieveOld($this->old_values, $column);
2714                 $value          = $this->values[$column];
2715                
2716                 foreach ($record_set as $record) {
2717                     if (isset($record->old_values[$related_column])) {
2718                         foreach (array_keys($record->old_values[$related_column]) as $key) {
2719                             if ($record->old_values[$related_column][$key] === $old_value) {
2720                                 $record->old_values[$related_column][$key] = $value;
2721                             }
2722                         }
2723                     }
2724                     if ($record->values[$related_column] === $old_value) {
2725                         $record->values[$related_column] = $value;
2726                     }
2727                 }
2728             }
2729            
2730             // Storing *-to-many and one-to-one relationships
2731             fORMRelated::store($class, $this->values, $this->related_records, $force_cascade);
2732            
2733            
2734             fORM::callHookCallbacks(
2735                 $this,
2736                 'pre-commit::store()',
2737                 $this->values,
2738                 $this->old_values,
2739                 $this->related_records,
2740                 $this->cache
2741             );
2742            
2743             if (!$inside_db_transaction) {
2744                 $db->translatedQuery('COMMIT');
2745             }
2746            
2747             fORM::callHookCallbacks(
2748                 $this,
2749                 'post-commit::store()',
2750                 $this->values,
2751                 $this->old_values,
2752                 $this->related_records,
2753                 $this->cache
2754             );
2755            
2756         } catch (fException $e) {
2757            
2758             if (!$inside_db_transaction) {
2759                 $db->translatedQuery('ROLLBACK');
2760             }
2761            
2762             fORM::callHookCallbacks(
2763                 $this,
2764                 'post-rollback::store()',
2765                 $this->values,
2766                 $this->old_values,
2767                 $this->related_records,
2768                 $this->cache
2769             );
2770            
2771             if ($new_autoincrementing_record && self::hasOld($this->old_values, $pk_column)) {
2772                 $this->values[$pk_column] = self::retrieveOld($this->old_values, $pk_column);
2773                 unset($this->old_values[$pk_column]);
2774             }
2775            
2776             throw $e;
2777         }
2778        
2779         fORM::callHookCallbacks(
2780             $this,
2781             'post::store()',
2782             $this->values,
2783             $this->old_values,
2784             $this->related_records,
2785             $this->cache
2786         );
2787        
2788         $was_new = !$this->exists();
2789        
2790         // If we got here we succefully stored, so update old values to make exists() work
2791         foreach ($this->values as $column => $value) {
2792             $this->old_values[$column] = array($value);
2793         }
2794        
2795         // If the object was just inserted into the database, save it to the identity map
2796         if ($was_new) {
2797             $hash = self::hash($this->values, $class);
2798            
2799             if (!isset(self::$identity_map[$class])) {
2800                 self::$identity_map[$class] = array();         
2801             }
2802             self::$identity_map[$class][$hash] = $this;       
2803         }
2804        
2805         return $this;
2806     }
2807    
2808    
2809     /**
2810     * Validates the values of the record against the database and any additional validation rules
2811     *
2812     * @throws fValidationException  When the record, or one of the associated records, violates one of the validation rules for the class or can not be properly stored in the database
2813     *
2814     * @param  boolean $return_messages      If an array of validation messages should be returned instead of an exception being thrown
2815     * @param  boolean $remove_column_names  If column names should be removed from the returned messages, leaving just the message itself
2816     * @return void|array  If $return_messages is TRUE, an array of validation messages will be returned
2817     */
2818     public function validate($return_messages=FALSE, $remove_column_names=FALSE)
2819     {
2820         $class = get_class($this);
2821        
2822         if (fORM::getActiveRecordMethod($class, 'validate')) {
2823             return $this->__call('validate', array($return_messages));
2824         }
2825        
2826         $validation_messages = array();
2827        
2828         fORM::callHookCallbacks(
2829             $this,
2830             'pre::validate()',
2831             $this->values,
2832             $this->old_values,
2833             $this->related_records,
2834             $this->cache,
2835             $validation_messages
2836         );
2837        
2838         // Validate the local values
2839         $local_validation_messages = fORMValidation::validate($this, $this->values, $this->old_values);
2840        
2841         // Validate related records
2842         $related_validation_messages = fORMValidation::validateRelated($this, $this->values, $this->related_records);
2843        
2844         $validation_messages = array_merge($validation_messages, $local_validation_messages, $related_validation_messages);
2845        
2846         fORM::callHookCallbacks(
2847             $this,
2848             'post::validate()',
2849             $this->values,
2850             $this->old_values,
2851             $this->related_records,
2852             $this->cache,
2853             $validation_messages
2854         );
2855        
2856         $validation_messages = array_unique($validation_messages);
2857        
2858         $validation_messages = fORMValidation::replaceMessages($class, $validation_messages);
2859         $validation_messages = fORMValidation::reorderMessages($class, $validation_messages);
2860        
2861         if ($return_messages) {
2862             if ($remove_column_names) {
2863                 $validation_messages = fValidationException::removeFieldNames($validation_messages);
2864             }
2865             return $validation_messages;
2866         }
2867        
2868         if (!empty($validation_messages)) {
2869             throw new fValidationException(
2870                 'The following problems were found:',
2871                 $validation_messages
2872             );
2873         }
2874     }
2875 }
2876  
2877  
2878  
2879 /**
2880  * Copyright (c) 2007-2010 Will Bond <will@flourishlib.com>, others
2881  *
2882  * Permission is hereby granted, free of charge, to any person obtaining a copy
2883  * of this software and associated documentation files (the "Software"), to deal
2884  * in the Software without restriction, including without limitation the rights
2885  * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
2886  * copies of the Software, and to permit persons to whom the Software is
2887  * furnished to do so, subject to the following conditions:
2888  *
2889  * The above copyright notice and this permission notice shall be included in
2890  * all copies or substantial portions of the Software.
2891  *
2892  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
2893  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
2894  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
2895  * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
2896  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
2897  * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
2898  * THE SOFTWARE.
2899  */