Permalink
Find file
Fetching contributors…
Cannot retrieve contributors at this time
715 lines (660 sloc) 27.7 KB
<?php
/**
* GxActiveRecord class file.
*
* @author Rodrigo Coelho <rodrigo@giix.org>
* @link http://giix.org/
* @copyright Copyright &copy; 2010-2011 Rodrigo Coelho
* @license http://giix.org/license/ New BSD License
*/
/**
* GxActiveRecord is the base class for the generated AR (base) models.
*
* @author Rodrigo Coelho <rodrigo@giix.org>
* @package giix.components
*/
abstract class GxActiveRecord extends CActiveRecord {
/**
* @var string The separator (delimiter) used to separate the primary keys values in a
* string representation of the pks of a composite pk record. Usually a character.
*/
public $pkSeparator = '-';
/**
* @var string The separator (delimiter) used to separate the {@link representingColumn}
* values when there are multiple representing columns while building the
* string representation of the record in {@link __toString}.
*/
public $repColumnsSeparator = '-';
public static function model($className=__CLASS__) {
return parent::model($className);
}
/**
* This method should be overridden to declare related pivot models for each MANY_MANY relationship.
* The pivot model is used by {@link saveWithRelated} and by {@link saveMultiple}.
* @return array List of pivot models for each MANY_MANY relationship. Defaults to empty array.
* @see saveRelated
*/
public function pivotModels() {
return array();
}
/**
* The active record label.
* The active record label is the user friendly name displayed in the views.
* Each active record class should override this method and explicitly specify the label.
* See the documentation when overriding: http://www.yiiframework.com/doc/guide/1.1/en/topics.i18n#plural-forms-format
* @param integer $n The number value. This is used to support plurals. Defaults to 1 (means singular).
* Notice that this number doesn't necessarily corresponds to the number (count) of items.
* @return string The label.
* @throws CException If the method wasn't overriden.
* @see getRelationLabel
*/
public static function label($n = 1) {
throw new CException(Yii::t('giix.messages', 'This method should be overriden by the Active Record class.'));
}
/**
* Returns the text label for the specified active record relation, attribute or class property.
* The labels are the user friendly names displayed in the views.
* If defined in the model, the label for its attribute, property or relation is returned.
* If not defined in the model (in {@link CModel::attributeLabels}),
* the label is generated using the related active record class label (via {@link GxActiveRecord::label}) (for FK attributes and relations)
* or using {@link CModel::generateAttributeLabel} (for other attributes and class properties).
* @param string $relationName The relation, attribute or class property name.
* This method supports chained relations in the form of "post.author.name".
* @param integer $n The number value. This is used to support plurals.
* In the default implementation, when this argument is null, if the relation is BELONGS_TO or HAS_ONE, the singular form is returned.
* If the relation is HAS_MANY or MANY_MANY, the plural form is returned.
* If this argument is null and the relation is not one of the types listed above, the singular form is returned.
* For most languages, 1 means singular and all other values mean plural.
* Defaults to null.
* Note: It is not supported when returning labels for attributes or class properties.
* @param boolean $useRelationLabel Whether to use the relation label for the FK attribute.
* When true, if the specified attribute name is a FK, the corresponding related AR label will be used.
* Defaults to true.
* Note: this will only work when there is no label defined in {@link CModel::attributeLabels} for this attribute.
* @return string The label.
* @throws InvalidArgumentException If an attribute name is found and is not the last item in the relationName parameter.
* @uses label
*/
public function getRelationLabel($relationName, $n = null, $useRelationLabel = true) {
// Exploding the chained relation names.
$relNames = explode('.', $relationName);
// Everything starts with this object.
$relClassName = get_class($this);
// The item index.
$relIndex = 0;
// Get the count of relation names;
$countRelNames = count($relNames);
// Walk through the chained relations.
foreach ($relNames as $relName) {
// Increments the item index.
$relIndex++;
// Get the related static class.
$relStaticClass = self::model($relClassName);
// If is is the last name and the label is explicitly defined, return it.
if ($relIndex === $countRelNames) {
$labels = $relStaticClass->attributeLabels();
if (isset($labels[$relName]))
return $labels[$relName];
}
// Get the relations for the current class.
$relations = $relStaticClass->relations();
// Check if there is (not) a relation with the current name.
if (!isset($relations[$relName])) {
// There is no relation with the current name. It is an attribute or a property.
// It must be the last name.
if ($relIndex === $countRelNames) {
// Check if it is an attribute.
$attributeNames = $relStaticClass->attributeNames();
$isAttribute = in_array($relName, $attributeNames);
// If it is an attribute and the attribute is a FK and $useRelationLabel is true, return the related AR label.
if ($isAttribute && $useRelationLabel && (($relData = self::findRelation($relStaticClass, $relName)) !== null)) {
// This will always be a BELONGS_TO, then singular.
return self::model($relData[3])->label(1);
} else {
// There's no label for this attribute or property, generate one.
return $relStaticClass->generateAttributeLabel($relName);
}
} else {
// It is not the last item.
throw new InvalidArgumentException(Yii::t('giix.messages', 'The attribute "{attribute}" should be the last name.', array('{attribute}' => $relName)));
}
}
// Change the current class name: walk to the next relation.
$relClassName = $relations[$relName][1];
}
// Automatically apply the correct number if requested.
if ($n === null) {
// Get the type of the last relation from the last but one class.
$relType = $relations[end($relNames)][0];
switch ($relType) {
case self::HAS_MANY:
case self::MANY_MANY:
$n = 2;
break;
case self::BELONGS_TO:
case self::HAS_ONE:
default :
$n = 1;
}
}
// Get and return the label from the related AR.
return self::model($relClassName)->label($n);
}
/**
* Returns the text label for the specified attribute.
* Also supported: relations and chained relations in the form of "post.author.name".
* This method just calls {@link getRelationLabel}.
* @param string $attribute The attribute name.
* @return string The attribute label.
* @see CActiveRecord::getAttributeLabel
* @see getRelationLabel
*/
public function getAttributeLabel($attribute) {
return $this->getRelationLabel($attribute);
}
/**
* The specified column(s) is(are) the responsible for the
* string representation of the model instance.
* The column is used in the {@link __toString} default implementation.
* Every model must specify the attributes used to build their
* string representation by overriding this method.
* This method must be overriden in each model class
* that extends this class.
* @return string|array The name of the representing column for the table (string) or
* the names of the representing columns (array).
* @see __toString
*/
public static function representingColumn() {
return null;
}
/**
* Returns a string representation of the model instance, based on
* {@link representingColumn}.
* When you override this method, all model attributes used to build
* the string representation of the model must be specified in
* {@link representingColumn}.
* @return string The string representation for the model instance.
* @throws CException If {@link representingColumn} is not defined.
* @uses representingColumn
* @uses repColumnsSeparator
*/
public function __toString() {
$representingColumn = $this->representingColumn();
if (empty($representingColumn)) {
throw new CException(Yii::t('giix.messages', 'The representing column for the active record "{model}" is not set.', array(
'{model}' => get_class($this),
)));
}
if (is_array($representingColumn)) {
$repValues = array();
foreach ($representingColumn as $repColumn_item) {
$repValues[] = ((($repColumn_item_value = $this->$repColumn_item) === null) ? '' : (string) $repColumn_item_value);
}
return implode($this->repColumnsSeparator, $repValues);
} else {
return ((($repColumn_value = $this->$representingColumn) === null) ? '' : (string) $repColumn_value);
}
}
/**
* Finds all active records satisfying the specified condition, selecting only the requested
* attributes and, if specified, the primary keys.
* See {@link CActiveRecord::find} for detailed explanation about $condition and $params.
* #MethodTracker
* This method is based on {@link CActiveRecord::findAll}, from version 1.1.7 (r3135). Changes:
* <ul>
* <li>Selects only the specified attributes.</li>
* <li>Detects and selects the representing column.</li>
* <li>Detects and selects the PK attribute.</li>
* </ul>
* @param string|array $attributes The names of the attributes to be selected.
* Optional. If not specified, the {@link representingColumn} will be used.
* @param boolean $withPk Specifies if the primary keys will be selected.
* @param mixed $condition Query condition or criteria.
* @param array $params Parameters to be bound to an SQL statement.
* @return array List of active records satisfying the specified condition. An empty array is returned if none is found.
* @uses representingColumn
*/
public function findAllAttributes($attributes = null, $withPk = false, $condition='', $params=array()) {
Yii::trace(get_class($this) . '.findAllAttributes()', 'giix.components.GxActiveRecord');
$criteria = $this->getCommandBuilder()->createCriteria($condition, $params);
if ($attributes === null)
$attributes = $this->representingColumn();
if ($withPk) {
$pks = self::model(get_class($this))->getTableSchema()->primaryKey;
if (!is_array($pks))
$pks = array($pks);
if (!is_array($attributes))
$attributes = array($attributes);
$attributes = array_merge($pks, $attributes);
}
$criteria->select = $attributes;
return parent::findAll($criteria);
}
/**
* Extracts and returns only the primary keys values from each model.
* @param GxActiveRecord|array $model A model or an array of models.
* @param boolean $forceString Whether pk values on composite pk tables
* should be compressed into a string. The values on the string will by
* separated by {@link pkSeparator}.
* @return string|array The pk value as a string (for single pk tables) or
* array (for composite pk tables) if one model was specified or
* an array of strings or arrays if multiple models were specified.
* @uses pkSeparator
*/
public static function extractPkValue($model, $forceString = false) {
if ($model === null)
return null;
if (!is_array($model)) {
$pk = $model->getPrimaryKey();
if ($forceString && is_array($pk))
$pk = implode($model->pkSeparator, $pk);
return $pk;
} else {
$pks = array();
foreach ($model as $model_item) {
$pks[] = self::extractPkValue($model_item, $forceString);
}
return $pks;
}
}
/**
* Fills the provided array of PK values with the composite PK column names.
* Warning: the order of the values in the array must match the order of
* the columns in the composite PK.
* The returned array has the format required by {@link CActiveRecord::findByPk}
* for composite keys.
* The method supports single PK also.
* @param mixed $pk The PK value or array of PK values.
* @return array The array of PK values, indexed by column name.
* @see CActiveRecord::findByPk
* @throws InvalidArgumentException If the count of values doesn't match the
* count of columns in the composite PK.
*/
public function fillPkColumnNames($pk) {
// Get the table PK column names.
$columnNames = $this->getTableSchema()->primaryKey;
// Check if the count of values and columns match.
$columnCount = count($columnNames);
if (count($pk) !== $columnCount)
throw new InvalidArgumentException(Yii::t('giix.messages', 'The count of values in the argument "pk" ({countPk}) does not match the count of columns in the composite PK ({countColumns}).'), array(
'{countPk}' => count($pk),
'{countColumns}' => $columnCount,
));
// Build the array indexed by the column names.
if ($columnCount === 1) {
if (is_array($pk))
$pk = $pk[0];
return array($columnNames => $pk);
} else {
$result = array();
for ($columnIndex = 0; $columnIndex < $columnCount; $columnIndex++) {
$result[$columnNames[$columnIndex]] = $pk[$columnIndex];
}
return $result;
}
}
/**
* Saves the current record and its MANY_MANY relations.
* This method will save the active record and update
* the necessary pivot tables for the MANY_MANY relations.
* The pivot table is the table that maps the relationship between two
* other tables in a MANY_MANY relation.
* This method won't save data on other active record models.
* @param array $relatedData The relation data in the format returned by {@link GxController::getRelatedData}.
* @param boolean $runValidation Whether to perform validation before saving the record.
* If the validation fails, the record will not be saved to database. This applies to all (including related) models.
* This does not apply for related models when in batch mode. This does not apply for deletes.
* Defaults to true.
* @param array $attributes List of attributes that need to be saved. Defaults to null,
* meaning all attributes that are loaded from DB will be saved. This applies only to the main model.
* @param array $options Additional options. Valid options are:
* <ul>
* <li>'withTransaction', boolean: Whether to use a transaction. Defaults to true.</li>
* <li>'batch', boolean: Whether to try to do the deletes and inserts in batch.
* While batches may be faster, using active record instances provides better control, validation, event support etc.
* Batch is currently supported only for deletes. Defaults to true.</li>
* </ul>
* @return boolean Whether the saving succeeds.
* @see pivotModels
*/
public function saveWithRelated($relatedData, $runValidation = true, $attributes = null, $options = array()) {
Yii::trace(get_class($this) . '.saveWithRelated()', 'giix.components.GxActiveRecord');
// Merge the specified options with the default options.
$options = array_merge(
// The default options.
array(
'withTransaction' => true,
'batch' => true,
)
,
// The specified options.
$options
);
try {
// Start the transaction if required.
if ($options['withTransaction'] && ($this->getDbConnection()->getCurrentTransaction() === null)) {
$transacted = true;
$transaction = $this->getDbConnection()->beginTransaction();
} else
$transacted = false;
// Save the main model.
if (!$this->save($runValidation, $attributes)) {
if ($transacted)
$transaction->rollback();
return false;
}
// If there is related data, call saveRelated.
if (!empty($relatedData)) {
if (!$this->saveRelated($relatedData, $runValidation, $options['batch'])) {
if ($transacted)
$transaction->rollback();
return false;
}
}
// If transacted, commit the transaction.
if ($transacted)
$transaction->commit();
} catch (Exception $ex) {
// If there is an exception, roll back the transaction...
if ($transacted)
$transaction->rollback();
// ... and rethrow the exception.
throw $ex;
}
return true;
}
/**
* Saves the MANY_MANY relations of this record.
* Internally used by {@link saveWithRelated} and {@link saveMultiple}.
* See {@link saveWithRelated} and {@link saveMultiple} for details.
* @param array $relatedData The relation data in the format returned by {@link GxController::getRelatedData}.
* @param boolean $runValidation Whether to perform validation before saving the record.
* @param boolean $batch Whether to try to do the deletes and inserts in batch.
* While batches may be faster, using active record instances provides better control, validation, event support etc.
* Batch is only supported for deletes.
* @return boolean Whether the saving succeeds.
* @see saveWithRelated
* @see saveMultiple
* @throws CException If this record is new.
* @throws CException If this active record has composite PK.
* @uses pivotModels
*/
protected function saveRelated($relatedData, $runValidation = true, $batch = true) {
if (empty($relatedData))
return true;
// This active record can't be new for the method to work correctly.
if ($this->getIsNewRecord())
throw new CException(Yii::t('giix.messages', 'Cannot save the related records to the database because the main record is new.'));
// Save each related data.
foreach ($relatedData as $relationName => $relationData) {
// The pivot model class name.
$pivotClassNames = $this->pivotModels();
$pivotClassName = $pivotClassNames[$relationName];
$pivotModelStatic = GxActiveRecord::model($pivotClassName);
// Get the foreign key names for the models.
$activeRelation = $this->getActiveRelation($relationName);
$relatedClassName = $activeRelation->className;
if (preg_match('/(.+)\((.+),\s*(.+)\)/', $activeRelation->foreignKey, $matches)) {
// By convention, the first fk is for this model, the second is for the related model.
$thisFkName = $matches[2];
$relatedFkName = $matches[3];
}
// Get the primary key value of the main model.
$thisPkValue = $this->getPrimaryKey();
if (is_array($thisPkValue))
throw new CException(Yii::t('giix.messages', 'Composite primary keys are not supported.'));
// Get the current related models of this relation and map the current related primary keys.
$currentRelation = $pivotModelStatic->findAll(new CDbCriteria(array(
'select' => $relatedFkName,
'condition' => "$thisFkName = :thisfkvalue",
'params' => array(':thisfkvalue' => $thisPkValue),
)));
$currentMap = array();
foreach ($currentRelation as $currentRelModel) {
$currentMap[] = $currentRelModel->$relatedFkName;
}
// Compare the current map to the new data and identify what is to be kept, deleted or inserted.
$newMap = $relationData;
$deleteMap = array();
$insertMap = array();
if ($newMap !== null) {
// Identify the relations to be deleted.
foreach ($currentMap as $currentItem) {
if (!in_array($currentItem, $newMap))
$deleteMap[] = $currentItem;
}
// Identify the relations to be inserted.
foreach ($newMap as $newItem) {
if (!in_array($newItem, $currentMap))
$insertMap[] = $newItem;
}
} else // If the new data is empty, everything must be deleted.
$deleteMap = $currentMap;
// If nothing changed, we simply continue the loop.
if (empty($deleteMap) && empty($insertMap))
continue;
// Now act inserting and deleting the related data: first prepare the data.
// Inject the foreign key names of both models and the primary key value of the main model in the maps.
foreach ($deleteMap as &$deleteMapPkValue)
$deleteMapPkValue = array_merge(array($relatedFkName => $deleteMapPkValue), array($thisFkName => $thisPkValue));
unset($deleteMapPkValue); // Clear reference;
foreach ($insertMap as &$insertMapPkValue)
$insertMapPkValue = array_merge(array($relatedFkName => $insertMapPkValue), array($thisFkName => $thisPkValue));
unset($insertMapPkValue); // Clear reference;
// Now act inserting and deleting the related data: then execute the changes.
// Delete the data.
if (!empty($deleteMap)) {
if ($batch) {
// Delete in batch mode.
if ($pivotModelStatic->deleteByPk($deleteMap) !== count($deleteMap)) {
return false;
}
} else {
// Delete one active record at a time.
foreach ($deleteMap as $value) {
$pivotModel = GxActiveRecord::model($pivotClassName)->findByPk($value);
if (!$pivotModel->delete()) {
return false;
}
}
}
}
// Insert the new data.
foreach ($insertMap as $value) {
$pivotModel = new $pivotClassName();
$pivotModel->setAttributes($value);
if (!$pivotModel->save($runValidation)) {
return false;
}
}
} // This is the end of the loop "save each related data".
return true;
}
/**
* Saves multiple active records.
* This method can detect automatically all new active records
* having a BELONGS_TO relation (to HAS_ONE or to HAS_MANY) and
* fill in the data for their FK if it is null.
* The order of the active records in the $models array parameter is
* important to make it work. The models that need to be saved first
* should come first in the array.
* @param GxActiveRecord|array $models A model or an array of models.
* The array should follow the format:
* <pre>
* array(
* array(
* 'model' => $theModelInstance,
* 'modelOptions' => array( ... ),
* ),
* array(
* 'model' => $anotherModelInstance,
* 'modelOptions' => array( ... ),
* ),
* )
* </pre>
* The following modelOptions are available:
* <ul>
* <li>'runValidation', boolean: see {@link CActiveRecord::save} for details. Defauls to true.</li>
* <li>'attributes', array: see {@link CActiveRecord::save} for details. Defauls to null.</li>
* <li>'relatedData', array: see {@link saveWithRelated} for details. Defauls to null.</li>
* <li>'batch', boolean: see {@link saveWithRelated} for details. Applies only to the related data. Defauls to true.</li>
* <li></li>
* </ul>
* @param boolean $runValidation Whether to perform validation before saving all the records.
* If the validation fails, the record will not be saved to database.
* Optional. If true, forces the validation on all records. If false,
* disables the validation on all records. If null, the options for
* each record will be followed. Defaults to true.
* @param array $options Additional options. Valid options are:
* <ul>
* <li>'withTransaction', boolean: Whether to use a transaction.
* Defaults to true.</li>
* <li>'detectRelations', boolean: detect automatically all new active records
* having a BELONGS_TO relation (to HAS_ONE or to HAS_MANY) and
* fill in the data for its FK if it is null.
* Defaults to false.</li>
* </ul>
* @return boolean Whether the saving succeeds.
* @throws CException If "detectRelations" is true and the related model is not found.
* @see CActiveRecord::save
* @see saveWithRelated
*/
public static function saveMultiple($models, $runValidation = true, $options = array()) {
Yii::trace('GxActiveRecord::saveMultiple()', 'giix.components.GxActiveRecord');
// Merge the specified options with the default options.
$options = array_merge(
// The default options.
array(
'withTransaction' => true,
'detectRelations' => false,
)
,
// The specified options.
$options
);
// Define the default model options.
$defaultModelOptions = array(
'runValidation' => true,
'attributes' => null,
'relatedData' => null,
'batch' => true,
);
// If $models is a single record, make it an array.
if (!is_array($models))
$models = array($models);
// The saved models array.
$savedModels = array();
try {
// Start the transaction if required.
if ($options['withTransaction'] && ($this->getDbConnection()->getCurrentTransaction() === null)) {
$transacted = true;
$transaction = $this->getDbConnection()->beginTransaction();
} else
$transacted = false;
foreach ($models as $modelItem) {
// Get the model instance.
$model = $modelItem['model'];
// Merge the options.
if (isset($modelItem['modelOptions']) && ($modelItem['modelOptions'] !== array()))
$modelOptions = array_merge($defaultModelOptions, $modelItem['modelOptions']);
else
$modelOptions = $defaultModelOptions;
// If set, the global "runValidation" value overrides the model setting.
if ($runValidation !== null)
$modelOptions['runValidation'] = $runValidation;
// Detect automatically the new active record and fill in the data for its FK.
if ($options['detectRelations']) {
// Find if the model is new...
if ($model->getIsNewRecord()) {
// ... if the model has a BELONGS_TO relation...
foreach ($model->relations() as $relationName => $relationData) {
if ($relationData[0] === GxActiveRecord::BELONGS_TO) {
// ...and if its FK is null.
$fkName = $relationData[2];
if ($model->$fkName === null) {
// The FK is null. We need to fill it in.
// We take the related model class name.
$relatedClassName = $relationData[1];
// And look for it in the array of the already saved models.
if (isset($savedModels[$relatedClassName])) {
// We assume that this is the related model and
// we assume that the relation is to the PK.
$model->$fkName = $savedModels[$relatedClassName]->getPrimaryKey();
} else {
// Related model not found.
// We can't continue without filling up the FK!
throw new CException(Yii::t('giix.messages', 'Related model not found. Cannot continue without filling up the FK.'));
}
}
}
}
}
} // This is the end of 'detectRelations' loop.
// Save the model
if (!$this->save($modelOptions['runValidation'], $modelOptions['attributes'])) {
if ($transacted)
$transaction->rollback();
return false;
}
// If there is related data, use saveRelated.
if (!empty($modelOptions['relatedData'])) {
if (!$model->saveRelated($modelOptions['relatedData'], $modelOptions['runValidation'], $modelOptions['batch'])) {
if ($transacted)
$transaction->rollback();
return false;
}
}
// Add the model to the saved models array.
// Only the last model of each class is recorded.
if ($options['detectRelations'])
$savedModels[get_class($model)] = $model;
}
// If transacted, commit the transaction.
if ($transacted)
$transaction->commit();
} catch (Exception $ex) {
// If there is an exception, roll back the transaction...
if ($transacted)
$transaction->rollback();
// ... and rethrow the exception.
throw $ex;
}
return true;
}
/**
* Finds the relation of the specified column.
* @param string|GxActiveRecord $modelClass The model class name or a model instance.
* @param string|CDbColumnSchema $column The column.
* @return array The relation. The array will have 3 values:
* 0: the relation name,
* 1: the relation type (will always be GxActiveRecord::BELONGS_TO),
* 2: the foreign key (will always be the specified column),
* 3: the related active record class name.
* Or null if no matching relation was found.
*/
public static function findRelation($modelClass, $column) {
if (is_string($modelClass))
$staticModelClass = self::model($modelClass);
else
$staticModelClass = self::model(get_class($modelClass));
if (is_string($column))
$column = $staticModelClass->getTableSchema()->getColumn($column);
if (!$column->isForeignKey)
return null;
$relations = $staticModelClass->relations();
// Find the relation for this attribute.
foreach ($relations as $relationName => $relation) {
// For attributes on this model, relation must be BELONGS_TO.
if (($relation[0] === GxActiveRecord::BELONGS_TO) && ($relation[2] === $column->name)) {
return array(
$relationName, // the relation name
$relation[0], // the relation type
$relation[2], // the foreign key
$relation[1] // the related active record class name
);
}
}
// None found.
return null;
}
}