Location: PHPKode > projects > SolarPHP > solar-system-1.1.1/solar/source/solar/Solar/Sql/Model/Record.php
<?php
/**
 * 
 * Represents a single record returned from a Solar_Sql_Model.
 * 
 * @category Solar
 * 
 * @package Solar_Sql_Model
 * 
 * @author Paul M. Jones <hide@address.com>
 * 
 * @author Jeff Moore <hide@address.com>
 * 
 * @license http://opensource.org/licenses/bsd-license.php BSD
 * 
 * @version $Id: Record.php 4416 2010-02-23 19:52:43Z pmjones $
 * 
 */
class Solar_Sql_Model_Record extends Solar_Struct
{
    const SQL_STATUS_DELETED    = 'deleted';
    const SQL_STATUS_INSERTED   = 'inserted';
    const SQL_STATUS_REFRESHED  = 'refreshed';
    const SQL_STATUS_ROLLBACK   = 'rollback';
    const SQL_STATUS_UNCHANGED  = 'unchanged';
    const SQL_STATUS_UPDATED    = 'updated';
    
    /**
     * 
     * A list of all accessor methods for all record classes.
     * 
     * @var array
     * 
     */
    static protected $_access_methods_list = array();
    
    /**
     * 
     * The "parent" model for this record.
     * 
     * @var Solar_Sql_Model
     * 
     */
    protected $_model;
    
    /**
     * 
     * The list of accessor methods for individual column properties.
     * 
     * For example, a method called __getFooBar() will be registered for
     * ['get']['foo_bar'] => '__getFooBar'.
     * 
     * @var array
     * 
     */
    protected $_access_methods = array();
    
    /**
     * 
     * Tracks the the status *of this record* at the database.
     * 
     * Status values are:
     * 
     * `deleted`
     * : This record has been deleted; load(), etc. will not work.
     * 
     * `inserted`
     * : The record was inserted successfully.
     * 
     * `updated`
     * : The record was updated successfully.
     * 
     * @var string
     * 
     */
    protected $_sql_status = null;
    
    /**
     * 
     * Tracks if *this record* is new (i.e., not in the database yet).
     * 
     * @var bool
     * 
     */
    protected $_is_new = false;
    
    /**
     * 
     * Notes which values *on this record* are not valid.
     * 
     * Keyed on property name => failure message.
     * 
     * @var array
     * 
     */
    protected $_invalid = array();
    
    /**
     * 
     * If you call save() and an exception gets thrown, this stores that
     * exception.
     * 
     * @var Solar_Exception
     * 
     */
    protected $_save_exception;
    
    /**
     * 
     * Filters added for this one record object.
     * 
     * @var array
     * 
     */
    protected $_filters = array();
    
    /**
     * 
     * An array of the initial (clean) data for the record.
     * 
     * This tracks only table-column data, not calculate-cols or related-cols.
     * 
     * @var array
     * 
     * @see setStatus()
     * 
     */
    protected $_initial = array();
    
    /**
     * 
     * Magic getter for record properties; automatically calls __getColName()
     * methods when they exist.
     * 
     * @param string $key The property name.
     * 
     * @return mixed The property value.
     * 
     */
    public function __get($key)
    {
        $found = array_key_exists($key, $this->_data);
        if (! $found && ! empty($this->_model->related[$key])) {
            // the key is for a related that has no data yet.
            // get the relationship object and get the related object
            $related = $this->_model->getRelated($key);
            $this->_data[$key] = $related->fetch($this);
        }
        
        // if an accessor method exists, use it
        if (! empty($this->_access_methods[$key]['get'])) {
            // use accessor method
            $method = $this->_access_methods[$key]['get'];
            return $this->$method();
        } else {
            // no accessor method; use parent method.
            return parent::__get($key);
        }
    }
    
    /**
     * 
     * Magic setter for record properties; automatically calls __setColName()
     * methods when they exist.
     * 
     * @param string $key The property name.
     * 
     * @param mixed $val The value to set.
     * 
     * @return void
     * 
     */
    public function __set($key, $val)
    {
        // if an accessor method exists, use it
        if (! empty($this->_access_methods[$key]['set'])) {
            // use accessor method
            $method = $this->_access_methods[$key]['set'];
            $this->$method($val);
            $this->_setIsDirty();
        } else {
            // no accessor method; use parent method
            parent::__set($key, $val);
        }
    }
    
    /**
     * 
     * Sets a key in the data to null.
     * 
     * @param string $key The requested data key.
     * 
     * @return void
     * 
     */
    public function __unset($key)
    {
        // if an accessor method exists, use it
        if (! empty($this->_access_methods[$key]['unset'])) {
            // use accessor method
            $method = $this->_access_methods[$key]['unset'];
            $this->$method();
            $this->_setIsDirty();
        } else {
            // no accessor method; use parent method
            parent::__unset($key);
        }
    }
    
    /**
     * 
     * Checks if a data key is set.
     * 
     * @param string $key The requested data key.
     * 
     * @return void
     * 
     */
    public function __isset($key)
    {
        // if an accessor method exists, use it
        if (! empty($this->_access_methods[$key]['isset'])) {
            // use accessor method
            $method = $this->_access_methods[$key]['isset'];
            $result = $this->$method();
        } else {
            // no accessor method; use parent method
            $result = parent::__isset($key);
        }
        
        // done
        return $result;
    }
    
    /**
     * 
     * Overrides normal locale() to use the **model** locale strings.
     * 
     * @param string $key The key to get a locale string for.
     * 
     * @param string $num If 1, returns a singular string; otherwise, returns
     * a plural string (if one exists).
     * 
     * @param array $replace An array of replacement values for the string.
     * 
     * @return string The locale string, or the original $key if no
     * string found.
     * 
     */
    public function locale($key, $num = 1, $replace = null)
    {
        return $this->_model->locale($key, $num, $replace);
    }
    
    /**
     * 
     * Loads the struct with data from an array or another struct.
     * 
     * Also unserializes columns per the "serialize_cols" model property.
     * 
     * This is a complete override from the parent load() method.
     * 
     * @param array|Solar_Struct $spec The data to load into the object.
     * 
     * @param array $cols Load only these columns.
     * 
     * @return void
     * 
     */
    public function load($spec, $cols = null)
    {
        // force to array
        if ($spec instanceof Solar_Struct) {
            // we can do this because $spec is of the same class
            $load = $spec->_data;
        } elseif (is_array($spec)) {
            $load = $spec;
        } else {
            $load = array();
        }
        
        // remove any load columns not in the whitelist
        if (! empty($cols)) {
            $cols = (array) $cols;
            foreach ($load as $key => $val) {
                if (! in_array($key, $cols)) {
                    unset($load[$key]);
                }
            }
        }
        
        // Set values, respecting accessor methods
        foreach ($load as $key => $value) {
            $this->$key = $value;
        }
        
        // fix relateds
        $this->_fixRelatedData();
    }
    
    /**
     * 
     * Sets the access method lists for this instance.
     * 
     * @return void
     * 
     */
    protected function _setAccessMethods()
    {
        $class = get_class($this);
        if (! array_key_exists($class, self::$_access_methods_list)) {
            $this->_loadAccessMethodsList($class);
        }
        $this->_access_methods = self::$_access_methods_list[$class];
    }
    
    /**
     * 
     * Loads the access method list for a given class.
     * 
     * @param string $class The class to load methods for.
     * 
     * @return void
     * 
     * @see $_access_methods_list
     * 
     */
    protected function _loadAccessMethodsList($class)
    {
        $list = array();
        $methods = get_class_methods($this);
        foreach ($methods as $method) {
            
            // if not a "__" method, or if a native magic method, skip it
            $skip = strncmp($method, '__', 2) !== 0
                 || $method == '__set'
                 || $method == '__get'
                 || $method == '__isset'
                 || $method == '__unset';
                 
            if ($skip) {
                continue;
            }
            
            // get
            if (strncmp($method, '__get', 5) == 0) {
                $col = strtolower(preg_replace(
                    '/([a-z])([A-Z])/',
                    '$1_$2',
                    substr($method, 5)
                ));
                $list[$col]['get'] = $method;
                continue;
            }
            
            // set
            if (strncmp($method, '__set', 5) == 0) {
                $col = strtolower(preg_replace(
                    '/([a-z])([A-Z])/',
                    '$1_$2',
                    substr($method, 5)
                ));
                $list[$col]['set'] = $method;
                continue;
            }
            
            // isset
            if (strncmp($method, '__isset', 7) == 0) {
                $col = strtolower(preg_replace(
                    '/([a-z])([A-Z])/',
                    '$1_$2',
                    substr($method, 7)
                ));
                $list[$col]['isset'] = $method;
                continue;
            }
            
            // unset
            if (strncmp($method, '__unset', 7) == 0) {
                $col = strtolower(preg_replace(
                    '/([a-z])([A-Z])/',
                    '$1_$2',
                    substr($method, 7)
                ));
                $list[$col]['unset'] = $method;
                continue;
            }
        }
        
        // retain the list of methods
        self::$_access_methods_list[$class] = $list;
    }
    
    // -----------------------------------------------------------------
    //
    // Model
    //
    // -----------------------------------------------------------------
    
    /**
     * 
     * Returns the model from which the data originates.
     * 
     * @return Solar_Sql_Model $model The origin model object.
     * 
     */
    public function getModel()
    {
        return $this->_model;
    }
    
    /**
     * 
     * Gets the name of the primary-key column.
     * 
     * @return string
     * 
     */
    public function getPrimaryCol()
    {
        return $this->_model->primary_col;
    }
    
    /**
     * 
     * Gets the value of the primary-key column.
     * 
     * @return mixed
     * 
     */
    public function getPrimaryVal()
    {
        $col = $this->_model->primary_col;
        return $this->$col;
    }
    
    
    // -----------------------------------------------------------------
    //
    // Record data
    //
    // -----------------------------------------------------------------
    
    /**
     * 
     * Converts the properties of this model Record or Collection to an array,
     * including related models stored in properties and calculated columns.
     * 
     * @return array
     * 
     */
    public function toArray()
    {
        $data = array();
        
        // snag a full list of available values
        // unloaded related values are not included            
        $keys = array_merge(
            array_keys($this->_data), 
            array_keys($this->_model->calculate_cols)
        );
        
        foreach ($keys as $key) {
            
            // not an empty-related. get the existing value.
            $val = $this->$key;
            
            // get the sub-value if any
            if ($val instanceof Solar_Struct) {
                $val = $val->toArray();
            }
            
            // keep the sub-value
            $data[$key] = $val;
        }
        
        // done!
        return $data;
    }
    
    // -----------------------------------------------------------------
    //
    // Persistence: save, insert, update, delete, refresh.
    //
    // -----------------------------------------------------------------
    
    /**
     * 
     * Saves this record and all related records to the database, inserting or
     * updating as needed.
     * 
     * Hook methods:
     * 
     * 1. `_preSave()` runs before all save operations.
     * 
     * 2. `_preInsert()` and `_preUpdate()` run before the insert or update.
     * 
     * 3. As part of the model insert()/update() logic, `filter()` gets called,
     *    which itself has `_preFilter()` and `_postFilter()` hooks.
     *    
     * 4. `_postInsert()` and `_postUpdate()` run after the insert or update.
     * 
     * 5. `_postSave()` runs after all save operations, but before related
     *    records are saved.
     * 
     * 6. `_preSaveRelated()` runs before saving related records.
     * 
     * 7. Each related record is saved, invoking the save() routine with all
     *    its hooks on each related record.
     * 
     * 8. `_postSaveRelated()` runs after all related records are saved.
     * 
     * @param array $data An associative array of data to merge with existing
     * record data.
     * 
     * @return bool True on success, false on failure.
     * 
     */
    public function save($data = null)
    {
        if ($this->isDeleted()) {
            throw $this->_exception('ERR_DELETED', array(
                'class' => get_class($this),
            ));
        }
        
        $this->_save_exception = null;
        
        // load data at save-time?
        if ($data) {
            $this->load($data);
            $this->_setIsDirty();
        }
        
        try {
            $this->_save();
            $this->_saveRelated();
            if ($this->isInvalid()) {
                return false;
            } else {
                return true;
            }
        } catch (Solar_Sql_Model_Record_Exception_Invalid $e) {
            // filtering should already have set the invalid messages
            $this->_save_exception = $e;
            return false;
        }
    }
    
    /**
     * 
     * Perform a save() within a transaction, with automatic commit and
     * rollback.
     * 
     * @param array $data An associative array of data to merge with existing
     * record data.
     * 
     * @return bool True on success, false on failure.
     * 
     * @todo Make this the default save() behavior? That means renamaing and
     * refactoring the record/collection save() methods.
     * 
     */
    public function saveInTransaction($data = null)
    {
        // convenient reference to the SQL connection
        $sql = $this->_model->sql;
        
        // start the transaction
        $sql->begin();
        
        try {
            
            // attempt the save
            if ($this->save($data)) {
                // entire save was valid, keep it
                $sql->commit();
                return true;
            } else {
                // at least one part of the save was *not* valid.
                // throw it all away.
                $sql->rollback();
                
                // note that we're not invalid, exactly, but that we
                // rolled back.
                $this->_setSqlStatus(self::SQL_STATUS_ROLLBACK);
                return false;
            }
            
        } catch (Exception $e) {
            
            // some sort of exception came up **besides** invalid data (which
            // is handled inside save() already).  get its message.
            if ($e->getCode() == 'ERR_QUERY_FAILED') {
                // special treatment for failed queries.
                $info = $e->getInfo();
                $text = $info['pdo_text'] . ". "
                      . "Please call getSaveException() for more information.";
            } else {
                // normal treatment.
                $text = $e->getCode() . ': ' . $e->getMessage();
            }
            
            // roll back and retain the exception
            $sql->rollback();
            $this->_save_exception = $e;
            
            // set as invalid and force the record status afterwards
            $this->setInvalid('*', $text);
            $this->_setSqlStatus(self::SQL_STATUS_ROLLBACK);
            
            // done
            return false;
        }
    }
    
    /**
     * 
     * Saves the current record, but only if the record is "dirty".
     * 
     * On saving, invokes the pre-save, pre- and post- insert/update,
     * and post-save hooks.
     * 
     * @return void
     * 
     */
    protected function _save()
    {
        // only save if need to
        if ($this->isDirty() || $this->isNew()) {
            
            // pre-save routine
            $this->_preSave();
            
            // perform pre-save for any relateds that need to modify the 
            // native record, but only if instantiated
            $list = array_keys($this->_model->related);
            foreach ($list as $name) {
                if (! empty($this->_data[$name])) {
                    $this->_model->getRelated($name)->preSave($this);
                }
            }
            
            // insert or update based on newness
            if ($this->isNew()) {
                $this->_insert();
            } else {
                $this->_update();
            }
            
            // post-save routine
            $this->_postSave();
        }
    }
    
    /**
     * 
     * User-defined pre-save logic.
     * 
     * @return void
     * 
     */
    protected function _preSave()
    {
    }
    
    /**
     * 
     * User-defined post-save logic.
     * 
     * @return void
     * 
     */
    protected function _postSave()
    {
    }
    
    /**
     * 
     * Inserts the current record into the database, making calls to pre- and
     * post-insert logic.
     * 
     * @return void
     * 
     */
    protected function _insert()
    {
        // pre-insert logic
        $this->_preInsert();
        
        // modify special columns for insert
        $this->_modInsert();
        
        // apply record filters
        $this->filter();
        
        // get the data for insert
        $data = $this->_getInsertData();
        
        // try the insert
        try {
            // retain the inserted ID, if any
            $id = $this->_model->insert($data);
        } catch (Solar_Sql_Adapter_Exception_QueryFailed $e) {
            // failed at at the database for some reason
            $this->setInvalid('*', $e->getInfo('pdo_text'));
            throw $e;
        }
        
        // if there is an autoinc column, set its value
        foreach ($this->_model->table_cols as $col => $info) {
            if ($info['autoinc'] && empty($this->_data[$col])) {
                // set the value ...
                $this->_data[$col] = $id;
                // ... and skip all other cols
                break;
            }
        }
        
        // record was successfully inserted
        $this->_setSqlStatus(self::SQL_STATUS_INSERTED);
        
        // post-insert logic
        $this->_postInsert();
    }
    
    /**
     * 
     * Modify the current record before it is inserted into the DB.
     * 
     * @return void
     * 
     */
    protected function _modInsert()
    {
        // time right now for created/updated
        $now = date('Y-m-d H:i:s');
        
        // force the 'created' value if there is a 'created' column
        $col = $this->_model->created_col;
        if ($col) {
            $this->$col = $now;
        }
        
        // force the 'updated' value if there is an 'updated' column
        $col = $this->_model->updated_col;
        if ($col) {
            $this->$col = $now;
        }
        
        // if inheritance is turned on, auto-set the inheritance value
        if ($this->_model->isInherit()) {
            $col = $this->_model->inherit_col;
            $this->$col = $this->_model->inherit_name;
        }
        
        // auto-set sequence values if needed
        foreach ($this->_model->sequence_cols as $col => $val) {
            if (empty($this->$col)) {
                // no value given for the key. add a new sequence value.
                $this->$col = $this->_model->sql->nextSequence($val);
            }
        }
    }
    
    /**
     * 
     * Gather values to insert into the DB for a new record.
     * 
     * @return array The values to be inserted.
     * 
     */
    protected function _getInsertData()
    {
        // get only table columns
        $data = array();
        $cols = array_keys($this->_model->table_cols);
        foreach ($this->_data as $col => $val) {
            if (in_array($col, $cols)) {
                $data[$col] = $val;
            }
        }
        
        // serialize columns for insert
        $this->_model->serializeCols($data);
        
        // done
        return $data;
    }
    
    /**
     * 
     * User-defined pre-insert logic.
     * 
     * @return void
     * 
     */
    protected function _preInsert()
    {
    }
    
    /**
     * 
     * User-defined post-insert logic.
     * 
     * @return void
     * 
     */
    protected function _postInsert()
    {
    }
    
    /**
     * 
     * Updates the current record at the database, making calls to pre- and
     * post-update logic.
     * 
     * @return void
     * 
     */
    protected function _update()
    {
        // pre-update logic
        $this->_preUpdate();
        
        // modify special columns for update
        $this->_modUpdate();
        
        // apply record filters
        $this->filter();
        
        // get the data for update
        $data = $this->_getUpdateData();
        
        // it's possible we have no data to update, even after all that
        if (! $data) {
            $this->_setSqlStatus(self::SQL_STATUS_UNCHANGED);
            return;
        }
        
        // build the where clause
        $primary = $this->getPrimaryCol();
        $where = array("$primary = ?" => $this->getPrimaryVal());
        
        // try the update
        try {
            $this->_model->update($data, $where);
        } catch (Solar_Sql_Adapter_Exception_QueryFailed $e) {
            // failed at at the database for some reason
            $this->setInvalid('*', $e->getInfo('pdo_text'));
            throw $e;
        }
        
        // record was successfully updated
        $this->_setSqlStatus(self::SQL_STATUS_UPDATED);
        
        // post-update logic
        $this->_postUpdate();
    }
    
    /**
     * 
     * Modify the current record before it is updated into the DB.
     * 
     * @return void
     * 
     */
    protected function _modUpdate()
    {
        // force the 'updated' value
        $col = $this->_model->updated_col;
        if ($col) {
            $this->$col = date('Y-m-d H:i:s');
        }
        
        // if inheritance is turned on, auto-set the inheritance value
        if ($this->_model->isInherit()) {
            $col = $this->_model->inherit_col;
            $this->$col = $this->_model->inherit_name;
        }
        
        // auto-set sequences where keys exist and values are empty
        foreach ($this->_model->sequence_cols as $col => $val) {
            if (array_key_exists($col, $this->_data) && empty($this->$col)) {
                // key is present but no value is given.
                // add a new sequence value.
                $this->$col = $this->_model->sql->nextSequence($val);
            }
        }
    }
    
    /**
     * 
     * Gather values to update into the DB.  Only values that have
     * Changed will be updated
     * 
     * @return array values that should be updated
     * 
     */
    protected function _getUpdateData()
    {
        // get only table columns that have changed
        $data = array();
        $cols = array_keys($this->_model->table_cols);
        foreach ($this->_data as $col => $val) {
            if (in_array($col, $cols) && $this->isChanged($col)) {
                $data[$col] = $val;
            }
        }
        
        // serialize columns for update
        $this->_model->serializeCols($data);
        
        // done!
        return $data;
    }
    
    /**
     * 
     * User-defined pre-update logic.
     * 
     * @return void
     * 
     */
    protected function _preUpdate()
    {
    }
    
    /**
     * 
     * User-defined post-update logic.
     * 
     * @return void
     * 
     */
    protected function _postUpdate()
    {
    }
    
    /**
     * 
     * Saves each related record.
     * 
     * Invokes the pre- and post- saveRelated methods.
     * 
     * @return void
     * 
     * @todo Keep track of invalid saves on related records and collections?
     * 
     */
    protected function _saveRelated()
    {
        // pre-hook
        $this->_preSaveRelated();
        
        // save each related
        $list = array_keys($this->_model->related);
        foreach ($list as $name) {
            
            // only save if instantiated
            if (! empty($this->_data[$name])) {
                // get the relationship object and save the related
                $related = $this->_model->getRelated($name);
                $related->save($this);
            }
        }
        
        // post-hook
        $this->_postSaveRelated();
    }
    
    /**
     * 
     * User-defined logic to execute before saving related records.
     * 
     * @return void
     * 
     */
    protected function _preSaveRelated()
    {
    }
    
    /**
     * 
     * User-defined logic to execute after saving related records.
     * 
     * @return void
     * 
     */
    protected function _postSaveRelated()
    {
    }
    
    /**
     * 
     * Deletes this record from the database.
     * 
     * @return void
     * 
     */
    public function delete()
    {
        if ($this->isNew()) {
            throw $this->_exception('ERR_CANNOT_DELETE_NEW_RECORD', array(
                'class' => get_class($this),
            ));
        }
        
        if ($this->isDeleted()) {
            throw $this->_exception('ERR_DELETED', array(
                'class' => get_class($this),
            ));
        }
        
        $this->_preDelete();
        
        $primary = $this->getPrimaryCol();
        $where = array(
            "$primary = ?" => $this->getPrimaryVal(),
        );
        
        $this->_model->delete($where);
        
        $this->_setSqlStatus(self::SQL_STATUS_DELETED);
        
        $this->_postDelete();
    }
    
    /**
     * 
     * User-defined pre-delete logic.
     * 
     * @return void
     * 
     */
    protected function _preDelete()
    {
    }
    
    /**
     * 
     * User-defined post-delete logic.
     * 
     * @return void
     * 
     */
    protected function _postDelete()
    {
    }
    
    /**
     * 
     * Refreshes data for this record from the database.
     * 
     * Note that this does not refresh any related or calculated values.
     * 
     * @return void
     * 
     */
    public function refresh()
    {
        if ($this->isNew()) {
            throw $this->_exception('ERR_CANNOT_REFRESH_NEW_RECORD', array(
                'class' => get_class($this),
            ));
        }

        if ($this->isDeleted()) {
            throw $this->_exception('ERR_DELETED', array(
                'class' => get_class($this),
            ));
        }
        
        $id = $this->getPrimaryVal();
        if (! $id) {
            throw $this->_exception('ERR_CANNOT_REFRESH_BLANK_ID', array(
                'class' => get_class($this),
            ));
        }
        
        $result = $this->_model->fetch($id);
        foreach ($this->_model->table_cols as $col => $info) {
            $this->_data[$col] = $result->_data[$col];
        }
        
        // note record is refreshed
        $this->_setSqlStatus(self::SQL_STATUS_REFRESHED);
        
        // cannot be dirty or invalid at this point
        $this->_is_dirty = false;
        $this->_invalid = array();
    }
    
    /**
     * 
     * Increments the value of a column **immediately at the database** and
     * retains the incremented value in the record.
     * 
     * Incrementing by a negative value effectively decrements the value.
     * 
     * N.b.: This results in 2 SQL calls: one to update the value at the
     * database, then one to select the new value from the database.
     * 
     * N.b.: You may have trouble incrementing from a NULL starting point.
     * You should define columns to be incremented with a  "DEFAULT '0'" so
     * they never are null (although strictly speaking you do *not* need to 
     * define them as NOT NULL).
     * 
     * N.b.: This **will not** clear the cache for the model, since it uses
     * direct SQL to effefct the increment.  Thus, you will need to clear the
     * cache manually if you want to the incremented values to show up from
     * the cache.
     * 
     * @param string $col The column to increment.
     * 
     * @param int|float $amt The amount to increment by (default 1).
     * 
     * @return int|float The value after incrementing.  Note that other 
     * processes may have incremented the column as well, so this may not
     * correspond directly with adding the amount to the current value in the
     * record.
     * 
     */
    public function increment($col, $amt = 1)
    {
        if ($this->isNew()) {
            throw $this->_exception('ERR_CANNOT_INCREMENT_NEW_RECORD', array(
                'class' => get_class($this),
            ));
        }

        if ($this->isDeleted()) {
            throw $this->_exception('ERR_DELETED', array(
                'class' => get_class($this),
            ));
        }
        
        // make sure the column exists
        if (! array_key_exists($col, $this->_model->table_cols)) {
            throw $this->_exception('ERR_NO_SUCH_COLUMN', array(
                'class' => get_class($this),
                'name' => $col,
            ));
        }
        
        // the table and primary-key col name
        $table = $this->_model->table_name;
        $key = $this->getPrimaryCol();
        $sql = $this->_model->sql;
        
        // we need to have a primary value
        $val = $this->getPrimaryVal();
        if (! $val) {
            throw $this->_exception('ERR_NO_PRIMARY_VAL', array(
                'primary_col' => $col
            ));
        };
        
        // change column by $amt
        $cmd = "UPDATE $table SET $col = $col + :amt WHERE $key = :$key";
        $sql->query($cmd, array(
            $key  => $val,
            'amt' => $amt,
        ));
        
        // get the most-current value
        $cmd = "SELECT $col FROM $table WHERE $key = :$key";
        $new = $sql->fetchValue($cmd, array($key => $val));
        
        // set the data directly, **without** passing through
        // __set(), so as not to dirty the record.
        $this->_data[$col] = $new;
        
        // fake the initial value so that isChanged() won't trigger
        $this->_initial[$col] = $new;
        
        // done!
        return $new;
    }
    
    // -----------------------------------------------------------------
    // 
    // Filtering and data invalidation.
    // 
    // -----------------------------------------------------------------
    
    /**
     * 
     * Filter the data.
     * 
     * @param Solar_Filter $filter Use this filter instead of the default one.
     * When empty (the default), uses the default filter for the record.
     * 
     * @return void
     * 
     */
    public function filter($filter = null)
    {
        // pre-filter hook
        $this->_preFilter();
        
        // filter object
        if ($filter) {
            // do not free external filter
            $free = false;
        } else {
            // use default filter, free when done
            $filter = $this->newFilter();
            $free   = true;
        }
        
        // apply filters
        $valid = $filter->applyChain($this);
        
        // retain invalids
        $invalid = $filter->getChainInvalid();
        
        // free the filter?
        if ($free) {
            $filter->free();
        }
        
        // reclaim memory
        unset($filter);
        
        // was it valid?
        if (! $valid) {
            
            // use custom validation messages per column when available
            foreach ($invalid as $key => $old) {
                $locale_key = "INVALID_" . strtoupper($key);
                $new = $this->_model->locale($locale_key);
                if ($new != $locale_key) {
                    $invalid[$key] = $new;
                }
            }
            
            $this->_invalid = $invalid;
            throw $this->_exception('ERR_INVALID', $this->_invalid);
        }
        
        // post-logic, and done
        $this->_postFilter();
    }
    
    /**
     * 
     * Returns a new filter object with the filters from the record model.
     * 
     * @return Solar_Filter
     * 
     */
    public function newFilter()
    {
        // create a filter object based on the model's filter class
        $filter = Solar::factory($this->_model->filter_class);
        
        // note which table cols are not part of the fetch cols
        $skip = array_diff(
            array_keys($this->_model->table_cols),
            $this->_model->fetch_cols
        );
        
        // set filters as specified by the model
        foreach ($this->_model->filters as $key => $list) {
            // skip table cols that are not part of the fetch cols
            if (in_array($key, $skip)) {
                continue;
            }
            $filter->addChainFilters($key, $list);
        }
        
        // set filters added to this record
        foreach ($this->_filters as $key => $list) {
            $filter->addChainFilters($key, $list);
        }
        
        // set which elements are required by the table itself
        foreach ($this->_model->table_cols as $key => $info) {
            if ($info['autoinc']) {
                // autoinc are not required
                $flag = false;
            } elseif (in_array($key, $this->_model->sequence_cols)) {
                // auto-sequence are not required
                $flag = false;
            } else {
                // go with the col info
                $flag = $info['require'];
            }
            
            // set the requirement flag
            $filter->setChainRequire($key, $flag);
        }
        
        // tell the filter to use the model for locale strings
        $filter->setChainLocaleObject($this->_model);
        
        // done!
        return $filter;
    }
    
    /**
     * 
     * User-defined logic executed before filters are applied to the record
     * data.
     * 
     * @return void
     * 
     */
    protected function _preFilter()
    {
    }
    
    /**
     * 
     * User-defined logic executed after filters are applied to the record
     * data.
     * 
     * @return void
     * 
     */
    protected function _postFilter()
    {
    }
    
    /**
     * 
     * Forces one property to be "invalid" and sets a validation failure message
     * for it.
     * 
     * @param string $key The property name.
     * 
     * @param string $message The validation failure message.
     * 
     * @return void
     * 
     */
    public function setInvalid($key, $message)
    {
        $this->_invalid[$key][] = $message;
    }
    
    /**
     * 
     * Forces multiple properties to be "invalid" and sets validation failure
     * message for them.
     * 
     * @param array $list An associative array where the key is the property
     * name, and the value is a string (or array of strings) of invalidation
     * messages.
     * 
     * @return void
     * 
     */
    public function setInvalids($list)
    {
        foreach ($list as $key => $messages) {
            foreach ((array) $messages as $message) {
                $this->_invalid[$key][] = $message;
            }
        }
    }
    
    /**
     * 
     * Returns the validation failure message for one or more properties,
     * including the messages on related records and collections.
     * 
     * @param string $key Return the message for this property; if empty,
     * returns messages for all invalid properties.
     * 
     * @return string|array
     * 
     */
    public function getInvalid($key = null)
    {
        $invalid = $this->_getInvalid();
        if ($key) {
            return $invalid[$key];
        } else {
            return $invalid;
        }
    }
    
    /**
     * 
     * Support method to collect all validation failure messages for all
     * properties and relateds.
     * 
     * @return array
     * 
     */
    protected function _getInvalid()
    {
        // Start with the invalids we've collected for this record
        $list = $this->_invalid;
        
        $relateds = array_keys($this->_model->related);
        foreach ($relateds as $name) {
            // Skip relateds that are not instantiated
            // or that are NULL or array()
            if (!isset($this->_data[$name])) {
                continue;
            }
            
            // Prevent infinite recursion
            $related = $this->_model->getRelated($name);
            if ($related instanceof Solar_Sql_Model_Related_BelongsTo) {
                continue;
            }
            
            $val = $this->_data[$name];

            // Only check actual related objects
            if (!is_object($val)) {
                continue;
            }

            // Copy any invalid data from related items            
            if ($val->isInvalid()) {
                $list[$name] = $val->getInvalid();
            }
        }
        // done!
        return $list;
    }
    
    // -----------------------------------------------------------------
    //
    // Record status
    //
    // -----------------------------------------------------------------

    /**
     * 
     * Is the record new?
     * 
     * @return bool
     * 
     */
    public function isNew()
    {
        return (bool) $this->_is_new;
    }

    /**
     * 
     * Returns the SQL status of this record at the database.
     * 
     * @return string The status value.
     * 
     */
    public function getSqlStatus()
    {
        return $this->_sql_status;
    }
    
    /**
     * 
     * Sets the SQL status of this record, resetting dirty/new/invalid as
     * needed.
     * 
     * @param string $sql_status The new status to set on this record.
     * 
     * @return void
     * 
     */
    protected function _setSqlStatus($sql_status)
    {
        // is this a change in status?
        if ($sql_status == $this->_sql_status) {
            // no change, we're done
            return;
        }
        
        // set the new status
        $this->_sql_status = $sql_status;
        
        // should we reset other information?
        $reset = in_array($this->_sql_status, array(
            self::SQL_STATUS_INSERTED,
            self::SQL_STATUS_REFRESHED,
            self::SQL_STATUS_UNCHANGED,
            self::SQL_STATUS_UPDATED,
        ));
        
        if ($reset) {
            
            // reset the initial data for table columns
            $this->_initial = array_intersect_key(
                $this->_data,
                $this->_model->table_cols
            );
            
            // no longer invalid, dirty, or new
            $this->_invalid = array();
            $this->_is_dirty = false;
            $this->_is_new = false;
        }
    }
    
    /**
     * 
     * Tells if the record, or a particular table-column in the record, has
     * changed from its initial value.
     * 
     * This is slightly complicated.  Changes to or from a null are reported
     * as "changed".  If both the initial value and new value are numeric
     * (that is, whether they are string/float/int), they are compared using
     * normal inequality (!=).  Otherwise, the initial value and new value
     * are compared using strict inequality (!==).
     * 
     * This complexity results from converting string and numeric values in
     * and out of the database.  Coming from the database, a string numeric
     * '1' might be filtered to an integer 1 at some point, making it look
     * like the value was changed when in practice it has not.
     * 
     * Similarly, we need to make allowances for nulls, because a non-numeric
     * null is loosely equal to zero or an empty string.
     * 
     * @param string $col The table-column name; if null, 
     * 
     * @return void|bool Returns null if the table-column name does not exist,
     * boolean true if the data is changed, boolean false if not changed.
     * 
     * @todo How to handle changes to array values?
     * 
     */
    public function isChanged($col = null)
    {
        // if no column specified, check if the record as a whole has changed
        if ($col === null) {
            foreach ($this->_initial as $col => $val) {
                if ($this->isChanged($col)) {
                    return true;
                }
            }
            return false;
        }
        
        // col needs to exist in the initial array
        if (! array_key_exists($col, $this->_initial)) {
            return null;
        }
        
        // track changes on structs
        $dirty = $this->_data[$col] instanceof Solar_Struct
              && $this->_data[$col]->isDirty();
        if ($dirty) {
            return true;
        }
        
        // track changes to or from null
        $from_null = $this->_initial[$col] === null &&
                     $this->_data[$col] !== null;
        
        $to_null   = $this->_initial[$col] !== null &&
                     $this->_data[$col] === null;
        
        if ($from_null || $to_null) {
            return true;
        }
        
        // track numeric changes
        $both_numeric = is_numeric($this->_initial[$col]) &&
                        is_numeric($this->_data[$col]);
        if ($both_numeric) {
            // use normal inequality
            return $this->_initial[$col] != (string) $this->_data[$col];
        }
        
        // use strict inequality
        return $this->_initial[$col] !== $this->_data[$col];
    }
    
    /**
     * 
     * Is the record or one of its relateds invalid?
     * 
     * @return bool
     * 
     */
    public function isInvalid()
    {
        if ($this->_invalid) {
            // one or more properties on this record is invalid.
            // although we could use _getInvalid() here, this is
            // a quick shortcut for common cases.
            return true;
        } elseif ($this->_sql_status == self::SQL_STATUS_ROLLBACK) {
            // we had a rollback, so *something* is invalid
            return true;
        } elseif ($this->_getInvalid()) {
            // one or more related records is invalid
            return true;
        } else {
            // looks like nothing is invalid
            return false;
        }
    }
    
    /**
     * 
     * Gets a list of all changed table columns.
     * 
     * @return array
     * 
     */
    public function getChanged()
    {
        $list = array();
        foreach ($this->_initial as $col => $val) {
            if ($this->isChanged($col)) {
                $list[] = $col;
            }
        }
        return $list;
    }
    
    /**
     * 
     * Returns the exception (if any) generated by the most-recent call to the
     * save() method.
     * 
     * @return Exception
     * 
     * @see save()
     * 
     */
    public function getSaveException()
    {
        return $this->_save_exception;
    }
    
    // -----------------------------------------------------------------
    // 
    // Automated forms.
    // 
    // -----------------------------------------------------------------
    
    /**
     * 
     * Returns a new Solar_Form object pre-populated with column properties,
     * values, and filters ready for processing (all based on the model for
     * this record).
     * 
     * @param array $cols An array of column property names to include in
     * the form.  If empty, uses all fetch columns and all calculate columns.
     * 
     * @return Solar_Form
     * 
     */
    public function newForm($cols = null)
    {
        // put into this array in the form
        $array_name = $this->_model->array_name;
        
        // build the form
        $form = Solar::factory('Solar_Form');
        $form->load('Solar_Form_Load_Model', $this->_model, $cols, $array_name);
        $form->setValues($this, $array_name);
        $form->addInvalids($this->_invalid, $array_name);
        
        // set the form status. if the record is invalid, always set the
        // form to failure.  if the record is valid, only set the form to
        // success when the form has not already been set to success.
        if ($this->isInvalid()) {
            
            // set the form to "failure"
            $form->setStatus(Solar_Form::STATUS_FAILURE);
            
        } elseif ($form->getStatus() !== Solar_Form::STATUS_FAILURE) {
            
            // set the form to "success" on these SQL statuses
            $success = array(
                self::SQL_STATUS_INSERTED,
                self::SQL_STATUS_UPDATED,
                self::SQL_STATUS_UNCHANGED,
            );
            
            if (in_array($this->getSqlStatus(), $success)) {
                $form->setStatus(Solar_Form::STATUS_SUCCESS);
            }
            
        }
        
        return $form;
    }
    
    /**
     * 
     * Adds a column filter to this record instance.
     * 
     * @param string $col The column name to filter.
     * 
     * @param string $method The filter method name, e.g. 'validateUnique'.
     * 
     * @args Remaining arguments are passed to the filter method.
     * 
     * @return void
     * 
     */
    public function addFilter($col, $method)
    {
        $args = func_get_args();
        array_shift($args); // the first param is $col
        $this->_filters[$col][] = $args;
    }
    
    /**
     * 
     * Initialize the record object.  This is effectively a "first load"
     * method.
     * 
     * @param Solar_Sql_Model $model The originating model object instance (a
     * dependency injection).
     * 
     * @param array $spec The data with which to initialize this record.
     * 
     * @return void
     * 
     */
    public function init(Solar_Sql_Model $model, $spec)
    {
        if ($this->_model) {
            throw $this->_exception('ERR_CANNOT_REINIT', array(
                'class' => get_class($this),
            ));
        }
        
        // inject the model
        $this->_model = $model;
        
        // sets access methods
        $this->_setAccessMethods();
        
        // force spec to array
        if ($spec instanceof Solar_Struct) {
            // we can do this because $spec is of the same class
            $load = $spec->_data;
        } elseif (is_array($spec)) {
            $load = $spec;
        } else {
            $load = array();
        }
        
        // unserialize any serialize_cols in the load
        $this->_model->unserializeCols($load);

        // Make sure changes to xml struct records cause us to be dirty
        foreach ($this->_model->xmlstruct_cols as $col) {
            if (!empty($load[$col])) {
                $load[$col]->setParent($this);
            }
        }
        
        // use parent load to push values directly into $_data array
        parent::load($load);
        
        // Record the inital values but only for columns that have physical backing
        $this->_initial = array_intersect_key($load, $model->table_cols);
        
        // placeholders for nonexistent calculate_cols, bypassing __set
        foreach ($this->_model->calculate_cols as $name => $info) {
            if (! array_key_exists($name, $this->_data)) {
                $this->_data[$name] = null;
            }
        }
        
        // fix up related data elements
        $this->_fixRelatedData();
        
        // new?
        $this->_is_new = false;
        
        // can't be invalid
        $this->_invalid = array();
        
        // can't be dirty
        $this->_is_dirty = false;
        
        // no last sql status
        $this->_sql_status = null;
    }
    
    /**
     * 
     * Initialize the record object as a "new" record; as with init(), this is
     * effectively a "first load" method.
     * 
     * @param Solar_Sql_Model $model The originating model object instance (a
     * dependency injection).
     * 
     * @param array $spec The data with which to initialize this record.
     * 
     * @return void
     * 
     * @see init()
     * 
     */
    public function initNew(Solar_Sql_Model $model, $spec)
    {
        $this->init($model, $spec);
        $this->_is_new = true;
    }
    
    /**
     * 
     * Has this record been deleted?
     * 
     * @return bool
     * 
     */
    public function isDeleted()
    {
        return $this->_sql_status == self::SQL_STATUS_DELETED;
    }
    
    /**
     * 
     * Make sure our related data values are the right value and type.
     * 
     * Make sure our related objects are the right type or will be loaded when
     * necessary
     * 
     * @return void
     * 
     */
    protected function _fixRelatedData()
    {
        $list = array_keys($this->_model->related);
        foreach ($list as $name) {
            
            // convert related values to correct object type
            $convert = array_key_exists($name, $this->_data)
                    && ! is_object($this->_data[$name]);
            
            if (! $convert) {
                continue;
            }
            
            $related = $this->_model->getRelated($name);
            if (empty($this->_data[$name])) {
                $this->_data[$name] = $related->fetchEmpty();
            } else {
                $this->_data[$name] = $related->newObject($this->_data[$name]);
            }
        }
    }
    
    /**
     * 
     * Create a new record/collection related to this one and returns it.
     * 
     * @param string $name The relation name.
     * 
     * @param array $data Initial data.
     * 
     * @return Solar_Sql_Model_Record|Solar_Sql_Model_Collection
     * 
     */
    public function newRelated($name, $data = null)
    {
        $related = $this->_model->getRelated($name);
        $new = $related->fetchNew($data);
        return $new;
    }
    
    /**
     * 
     * Sets the related to be a new record/collection, but only if the
     * related is empty.
     * 
     * @param string $name The relation name.
     * 
     * @param array $data Initial data.
     * 
     * @return Solar_Sql_Model_Record|Solar_Sql_Model_Collection
     * 
     */
    public function setNewRelated($name, $data = null)
    {
        if ($this->$name) {
            throw $this->_exception('ERR_RELATED_ALREADY_SET', array(
                'class' => get_class($this),
                'name'  => $name,
            ));
        }
        $this->$name = $this->newRelated($name, $data);
        return $this->$name;
    }
    
    /**
     * 
     * Convenience method for getting a dump of the record, or one of its
     * properties, or an external variable.
     * 
     * @param mixed $var If null, dump $this; if a string, dump $this->$var;
     * otherwise, dump $var.
     * 
     * @param string $label Label the dump output with this string.
     * 
     * @return void
     * 
     */
    public function dump($var = null, $label = null)
    {
        if ($var) {
            return parent::dump($var, $label);
        }
        
        $clone = clone($this);
        unset($clone->_model);
        parent::dump($clone, $label);
    }
}
Return current item: SolarPHP