<?php
/**
* Copyright (c) 2008, SARL Adaltas. All rights reserved.
* Code licensed under the BSD License:
* http://porte.adaltas.com/en/developer/license.html
*/
/**
* PorteAssociations
*
* Add "one-to-one", "one-to-many" and "many-to-many" associations.
*
* Each association has the freedom to use the storing arrays "attributes" and "associations"
* as they wish.
* many-to-many with list:
* Use both arrays.
* The "attribute" part is a string list of foreign keys (integers) and
* is only use on load. Since by nature it can only store save records, it can not be use when
* adding or setting non saved records.
* The "association" part is the one we work on. If does not exist, we initialize it from the
* "attribute" one.
* On load, "attributes" represent the list of associations, later, the "associations" represent
* those associations and is only made of a PorteIterator of records.
*
* @package Porte
* @subpackage plugin
* @author David Worms info(at)adaltas.com
* @copyright 2008 Adaltas
*/
class PorteAssociations{
/**
* Enrich a property model with associations.
* Listen to the "model_property_before" event.
*
* @return mixed Boolean true if property is an association
* @param PorteModels $models
* @param string $type
* @param string $property
*/
public static function _modelPropertyBefore(PorteModels $models,$type,$property){
$treated = false;
$config = $models->{$type}['properties'][$property];
if(!empty($config['belongs_to'])){
self::_modelPropertyBefore_belongsTo($models,$type,$property);
$treated = true;
}else if(!empty($config['has_many'])){
self::_modelPropertyBefore_HasMany($models,$type,$property);
$treated = true;
}else if(!empty($config['has_one'])){
PorteOneToOneHasOne::setProperty($models,$type,$property);
$treated = true;
}
if($treated){
/*
$args = array($properties,$property,&$config);
$action = 'associations_property_after';
$return = PorteEvents::call($properties->model->type,$action,$args);
return !is_null($return)?$return:true;
*/
return true;
}
}
/**
* Enrich a property model if a "belongs_to" keywork is defined.
*
* @return null
* @param PorteModels $models
* @param string $type
* @param string $property
*/
public static function _modelPropertyBefore_belongsTo(PorteModels $models,$type,$property){
$model = $models->{$type};
$config = $model['properties'][$property];
if(empty($config['field'])) $config['field'] = $property;
$relatedModel = null;
$config['type'] = 'int';
$config['foreign_key'] = true;
$config['lazy'] = true;
if(is_string($config['belongs_to'])){
$config['belongs_to'] = array('type'=>$config['belongs_to']);
}else if($config['belongs_to']===true){
$config['belongs_to'] = array();
}else if($config['belongs_to']===false){
return;
}else if(!is_array($config['belongs_to'])){
throw new PorteException('Key "belongs_to" is expected to be a string, an array or a boolean value');
}
// Take care of type
if(!isset($config['belongs_to']['type'])){
if(isset($config['belongs_to']['class'])&&$config['belongs_to']['class']!='PorteRecord'){
$relatedModel = $config['belongs_to']['class'];
if(!class_exists($relatedModel)) throw new PorteException('Error setting belongs_to property "'.$property.'" of type "'.$model['type'].'", class not found: "'.$relatedModel.'"');
$relatedModel = $models->get($relatedModel);
$config['belongs_to']['type'] = $relatedModel['type'];
}else{
$config['belongs_to']['type'] = PorteUtils::underscore($property);
}
}
// Deal with model
if(!$relatedModel){
$relatedModel = $models->get($config['belongs_to']['type']);
}
// Deal with class
if(!isset($config['belongs_to']['class'])){
$config['belongs_to']['class'] = $relatedModel['class'];
}
$relatedProperty = null;
if(empty($config['belongs_to']['property'])){
// has_one
$relatedProperty = $model['type'];
if(isset($relatedModel['properties'][$relatedProperty])){
$config['belongs_to']['property'] = $relatedProperty;
}else{
// has_many
$relatedProperty = PorteUtils::toPlural($relatedProperty);
if(isset($relatedModel['properties'][$relatedProperty])){
$config['belongs_to']['property'] = $relatedProperty;
}else{
$config['belongs_to']['property'] = false;
}
}
}else if(is_string($config['belongs_to']['property'])){
$relatedProperty = $config['belongs_to']['property'];
}else{
throw new PorteException('Invalid property "'.$config['belongs_to']['property'].'"');
}
//if(isset($relatedModel['properties'][$relatedProperty])){
$models->{$type}['properties'][$property] = $config;
//}
// Deals with methods
$camelizedProperty = PorteUtils::camelize($property);
if(isset($relatedModel['properties'][$relatedProperty]['has_one'])){
PorteModel::addMethod($models,$type,'get'.$camelizedProperty,$property,array('PorteOneToOneBelongsTo','getRecord'));
PorteModel::addMethod($models,$type,'set'.$camelizedProperty,$property,array('PorteOneToOneBelongsTo','setRecord'));
PorteModel::addMethod($models,$type,'delete'.$camelizedProperty,$property,array('PorteOneToOneBelongsTo','deleteRecord'));
}else{
PorteModel::addMethod($models,$type,'get'.$camelizedProperty,$property,array('PorteOneToManyBelongsTo','getRecord'));
PorteModel::addMethod($models,$type,'set'.$camelizedProperty,$property,array('PorteOneToManyBelongsTo','setRecord'));
}
}
/**
* Enrich a property model if a "has_many" keywork is defined.
*
* @return null
* @param PorteModels $models
* @param string $type
* @param string $property
*/
public static function _modelPropertyBefore_HasMany(PorteModels $models,$type,$property){
$model = $models->{$type};
$config = $model['properties'][$property];
$relatedModel = null;
$config['lazy'] = true;
// Take care of has_many (key or value)
if(is_string($config['has_many']))
$config['has_many'] = array('type'=>PorteUtils::toSingular($config['has_many']));
if(!is_array($config['has_many']))
$config['has_many'] = array();
// Deal with type
if(empty($config['has_many']['type'])){
if(!empty($config['has_many']['class'])&&$config['has_many']['class']!='PorteRecord'){
$relatedModel = $config['has_many']['class'];
if(!class_exists($relatedModel)){
throw new PorteException('Attempt to set an association to an inexistant class \''.$relatedModel->class.'\'.');
}
$relatedModel = $models->get(new $relatedModel());
$config['has_many']['type'] = $relatedModel['type'];
}else if(!empty($config['has_many']['table'])){
$config['has_many']['type'] = PorteUtils::underscore(PorteUtils::toSingular($config['has_many']['table']));
}else{
$config['has_many']['type'] = PorteUtils::underscore(PorteUtils::toSingular($property));
}
}
// Deal with model
if(!$relatedModel){
if($config['has_many']['type']==$model['type']){
$relatedModel = &$model;
}else{
$relatedModel = $models->get($config['has_many']['type']);
}
}
// Deal with class
if(!isset($config['has_many']['class'])){
$config['has_many']['class'] = $relatedModel['class'];
}
if(empty($config['has_many']['property'])){
if(isset($relatedModel['properties'][$model['type']])){
$config['has_many']['property'] = $model['type'];
}else if(isset($relatedModel['properties'][PorteUtils::toPlural($model['type'])])){
$config['has_many']['property'] = PorteUtils::toPlural($model['type']);
}else{
throw new PorteException('Could not determine associated property: "'.$model['type'].'" or "'.PorteUtils::toPlural($model['type']).'" on type "'.$relatedModel['type'].'"');
}
}
$relatedProperty = $config['has_many']['property'];
// Association apply between records of same type
if($relatedModel['type']==$model['type']){
$relatedConfig = $config;
// Association apply between different types of records
}else{
if(!isset($relatedModel['properties'][$relatedProperty])){
throw new PorteException('Property "'.$relatedProperty.'" not defined in model of type "'.$relatedModel['type'].'"');
}
$relatedConfig = $relatedModel['properties'][$relatedProperty];
}
// Deal with the transient side of a one-to-many association
if(!empty($relatedConfig['belongs_to'])){
$config['transient'] = true;
$models->{$type}['properties'][$property] = $config;
/*
if(!isset($relatedModel['properties'][$relatedProperty])){
$relatedModel['properties']->get($relatedProperty);
}
*/
$camelizedProperty = PorteUtils::camelize($property);
PorteModel::addMethod($models,$type,'add'.PorteUtils::camelize(PorteUtils::toSingular($property)),$property,array('PorteOneToManyHasMany','addRecord'));
PorteModel::addMethod($models,$type,'add'.$camelizedProperty,$property,array('PorteOneToManyHasMany','addRecords'));
PorteModel::addMethod($models,$type,'get'.$camelizedProperty,$property,array('PorteOneToManyHasMany','getRecords'));
PorteModel::addMethod($models,$type,'delete'.$camelizedProperty,$property,array('PorteOneToManyHasMany','deleteRecords'));
PorteModel::addMethod($models,$type,'count'.$camelizedProperty,$property,array('PorteOneToManyHasMany','countRecords'));
// Deal with many-to-many associations
}else if(!empty($relatedConfig['has_many'])){
if(!empty($config['transient'])&&!empty($relatedConfig['transient'])){
//if both side define a transient property, throw exception
throw new PorteException('Invalid Model: two related properties can not both be set as transient');
}else if((!empty($config['transient'])||!empty($relatedConfig['transient']))&&!isset($relatedConfig['has_many']['join'])){
// if one side define a transient property, list strategy
if(empty($config['transient'])){
if(empty($config['type'])) $config['type'] = 'text';
if(empty($config['field'])) $config['field'] = $property;
}
$models->{$type}['properties'][$property] = $config;
//$model['properties']->$property = $config;
/*
if(!isset($relatedModel['properties']->$relatedProperty)){
$relatedModel['properties']->get($relatedProperty);
}
*/
$camelizedProperty = PorteUtils::camelize($property);
$singularCamelizedProperty = PorteUtils::toSingular($camelizedProperty);
PorteModel::addMethod($models,$type,'add'.$singularCamelizedProperty,$property,array('PorteManyToManyList'.(empty($config['transient'])?'Persisted':'Transient'),'addRecord'));
PorteModel::addMethod($models,$type,'add'.$singularCamelizedProperty,$property,array('PorteManyToManyList'.(empty($config['transient'])?'Persisted':'Transient'),'addRecord'));
PorteModel::addMethod($models,$type,'add'.$camelizedProperty,$property,array('PorteManyToManyList'.(empty($config['transient'])?'Persisted':'Transient'),'addRecords'));
PorteModel::addMethod($models,$type,'get'.$camelizedProperty,$property,array('PorteManyToManyList'.(empty($config['transient'])?'Persisted':'Transient'),'getRecords'));
PorteModel::addMethod($models,$type,'set'.$camelizedProperty,$property,array('PorteManyToManyList'.(empty($config['transient'])?'Persisted':'Transient'),'setRecords'));
PorteModel::addMethod($models,$type,'delete'.$singularCamelizedProperty,$property,array('PorteManyToManyList'.(empty($config['transient'])?'Persisted':'Transient'),'deleteRecord'));
PorteModel::addMethod($models,$type,'delete'.$camelizedProperty,$property,array('PorteManyToManyList'.(empty($config['transient'])?'Persisted':'Transient'),'deleteRecords'));
PorteModel::addMethod($models,$type,'count'.$camelizedProperty,$property,array('PorteManyToManyList'.(empty($config['transient'])?'Persisted':'Transient'),'countRecords'));
}else{
// if none side define a transient, join strategy
$config['transient'] = true;
if(!isset($config['has_many']['join'])||!is_array($config['has_many']['join'])){
$config['has_many']['join'] = array();
}
if(empty($config['has_many']['join']['database'])){
$config['has_many']['join']['database'] = ($model['database']<$relatedModel['database'])?$model['database']:$relatedModel['database'];
}
if(empty($config['has_many']['join']['table'])){
$config['has_many']['join']['table'] = ($model['table']<$relatedModel['table'])?$model['table'].'_'.$relatedModel['table']:$relatedModel['table'].'_'.$model['table'];
}
if(empty($config['has_many']['join']['field'])){
if($relatedModel['type']==$model['type']){
/*
if(!isset($relatedModel['properties'][$relatedProperty])){
$config['has_many']['join']['field'] = array($model['type'].'_left',$model['type'].'_right');
}
*/
$config['has_many']['join']['field'] = array($model['type'].'_left',$model['type'].'_right');
}else{
$config['has_many']['join']['field'] = $model['type'];
}
}
$pkConfig = $model['properties'][$model['primary_key']];
$config['has_many']['join']['type'] = $pkConfig['type'];
$config['has_many']['join']['length'] = $pkConfig['length'];
if(empty($config['has_many']['join']['engine'])) $config['has_many']['join']['engine'] = $model['engine'];
if(empty($config['has_many']['join']['encoding'])) $config['has_many']['join']['encoding'] = $model['encoding'];
//print_r($config);
$models->{$type}['properties'][$property] = $config;
//$properties->$property = $config;
/*
if(!isset($relatedModel->properties->$relatedProperty)){
$relatedModel->properties->get($relatedProperty);
}
*/
// Deals with methods
$camelizedProperty = PorteUtils::camelize($property);
$singularCamelizedProperty = PorteUtils::toSingular($camelizedProperty);
PorteModel::addMethod($models,$type,'set'.$singularCamelizedProperty,$property,array('PorteManyToManyJoin','setRecord'));
PorteModel::addMethod($models,$type,'set'.$camelizedProperty,$property,array('PorteManyToManyJoin','setRecords'));
PorteModel::addMethod($models,$type,'add'.$singularCamelizedProperty,$property,array('PorteManyToManyJoin','addRecord'));
PorteModel::addMethod($models,$type,'add'.$camelizedProperty,$property,array('PorteManyToManyJoin','addRecords'));
PorteModel::addMethod($models,$type,'get'.$camelizedProperty,$property,array('PorteManyToManyJoin','getRecords'));
PorteModel::addMethod($models,$type,'delete'.$singularCamelizedProperty,$property,array('PorteManyToManyJoin','deleteRecord'));
PorteModel::addMethod($models,$type,'delete'.$camelizedProperty,$property,array('PorteManyToManyJoin','deleteRecords'));
PorteModel::addMethod($models,$type,'count'.$camelizedProperty,$property,array('PorteManyToManyJoin','countRecords'));
}
}
}
/**
* Create the join table in many-to-many associations
* Listen to the "table_update_after" event.
*
* @return null
* @param PorteTable $table
* @param array $existingField
*/
public static function _tableUpdateAfter(PorteTable $table,array $existingFields){
$properties = $table->porte->models->{$table->type}['properties'];
foreach($properties as $property=>$config){
if(!isset($config['has_many'])) continue;
// Create join table in used in many to many association
if(isset($config['has_many']['join'])){
$joinDb = $config['has_many']['join']['database'];
$joinTable = $config['has_many']['join']['table'];
$primaryKey = 'id';
if(!$table->porte->connection->tableExists($joinDb,$joinTable)){
$table->porte->connection->tableCreate(array('database'=>$joinDb,'table'=>$joinTable,'primary_key'=>$primaryKey,'encoding'=>$config['has_many']['join']['encoding'],'engine'=>$config['has_many']['join']['engine']));
}
// Clean the join table with unused fields
$query = 'SHOW COLUMNS FROM `'.$joinDb.'`.`'.$joinTable.'`;';
$existingFields = array();
$statement = $table->porte->query($query);
if(is_array($config['has_many']['join']['field'])){
$assocFields = $config['has_many']['join']['field'];
}else{
$assocConfig = $table->porte->models->{$config['has_many']['type']}['properties'][$config['has_many']['property']];
$assocFields = array($config['has_many']['join']['field'],$assocConfig['has_many']['join']['field']);
}
while($row = $statement->fetch()){
$field = $row['Field'];
if(!array_key_exists($field,$assocFields)&&$field!=$primaryKey){
// if a field is not in our object, drop it
$query = 'ALTER TABLE `'.$joinDb.'`.`'.$joinTable.'` DROP `'.$field.'`;';
$table->porte->exec($query);
//throw new PorteException('Error while atering table: '.$query);
}else{
$existingFields[] = $field;
}
}
$statement->closeCursor();
foreach($assocFields as $field){
if(!in_array($field,$existingFields)){
$query = array();
$query['Base'] = 'ALTER TABLE `'.$joinDb.'`.`'.$joinTable.'` ADD `'.$field.'` ';
$query['Type'] = 'int';
$query['Null'] = 'NOT NULL';
$query = implode(' ',array_values($query));
$table->porte->exec($query);
}
}
}
}
}
/**
* Update record associations before the record is saved.
* Listen to the "record_save_before" event.
*
* @return null
* @param PorteRecord $record
* @param array $circular
*/
public static function _recordSaveBefore(PorteRecord $record,array $circular){
$model = $record->porte->models->{$record->type};
foreach($record->associations as $property=>$assocRecords){
if(isset($model['properties'][$property])){
$config = $model['properties'][$property];
if(!empty($config['has_many'])){
if(isset($config['has_many']['join'])){
// nothing to do, we deal with the join table later
}else if(!empty($config['transient'])){
// nothing to do, we deal with transient type later
}else{
// many-to-many list persisted or one-to-many belongs_to
$existingFks = $assocRecords->save($circular)->getIdentifier();
$record->attributes[$property] = implode(',',$existingFks);
}
}else if(!empty($config['belongs_to'])){
if(is_null($assocRecords)){
$record->attributes[$property] = null;
}else{
$assocRecords->save($circular);
$record->attributes[$property] = $assocRecords->getIdentifier();
}
}
}
}
}
/**
* Update record associations after the record is saved.
* Listen to the "record_save_after" event.
* It loops through each associations, and then through each values in the associations. Those
* values may be int primary keys or instances of PorteRecord.
*
* @return null
* @param PorteRecord $record
* @param array $circular
*/
public static function _recordSaveAfter(PorteRecord $record,array $circular){
$model = $record->porte->models->{$record->type};
$properties = array_keys($record->associations);
while(list(,$property) = each($properties)){
if(isset($model['properties'][$property])){
$config = $model['properties'][$property];
if(isset($config['has_many'])){
$assocConfig = $record->porte->models->{$config['has_many']['type']}['properties'][$config['has_many']['property']];
if(isset($config['has_many']['join'])){
PorteManyToManyJoin::save($property,$record,$circular);
}else if(isset($assocConfig['belongs_to'])){
// nothing yet
PorteManyToManyListTransient::save($property,$record,$circular);
}else if(!empty($config['transient'])){
PorteManyToManyListTransient::save($property,$record,$circular);
}
}else if(isset($config['has_one'])){
if(isset($record->associations[$property]))
$record->associations[$property]->save($circular);
//}else if(array_key_exists('belongs_to',$config)){
}else if(isset($config['belongs_to'])){
$assocModel = $record->porte->models->{$config['belongs_to']['type']};
// Condition because an association can be defined from only one side
if(isset($assocModel['properties'][$config['belongs_to']['property']])){
$assocConfig = $assocModel['properties'][$config['belongs_to']['property']];
if(array_key_exists('has_one',$assocConfig)){
if(!is_null($record->associations[$property])) $record->associations[$property]->save($circular);
}
}
}
}
}
}
/**
* Update record associations before the record is deleted.
* Listen to the "record_delete_before" event.
*
* @return null
* @param PorteRecord $record
*/
public static function _recordDeleteBefore(PorteRecord $record){
$model = $record->porte->models->{$record->type};
while(list($property,$config) = each($model['properties'])){
//foreach($model['properties'] as $property=>$config){
if(isset($config['has_many'])){
if(!empty($config['has_many']['join'])){
$primaryKeyValue = $record->getIdentifier();
$joinDb = $config['has_many']['join']['database'];
$joinTable = $config['has_many']['join']['table'];
$joinField = $config['has_many']['join']['field'];
if(is_array($config['has_many']['join']['field'])){
$query = 'DELETE `'.$joinDb.'`.`'.$joinTable.'`.* FROM `'.$joinDb.'`.`'.$joinTable.'` WHERE `'.$joinDb.'`.`'.$joinTable.'`.`'.$joinField[0].'` = \''.$primaryKeyValue.'\' OR `'.$joinDb.'`.`'.$joinTable.'`.`'.$joinField[1].'` = \''.$primaryKeyValue.'\'';
}else{
$query = 'DELETE `'.$joinDb.'`.`'.$joinTable.'`.* FROM `'.$joinDb.'`.`'.$joinTable.'` WHERE `'.$joinDb.'`.`'.$joinTable.'`.`'.$joinField.'` = \''.$primaryKeyValue.'\'';
}
$record->porte->exec($query);
//throw new PorteException('Error while deleting associated references: '.$query);
}else if(!empty($config['transient'])){
// many-to-many with list strategy
$primaryKeyValue = $record->getIdentifier();
$assocModel = $record->porte->models->{$config['has_many']['type']};
$assocConfig = $assocModel['properties'][$config['has_many']['property']];
$assocRecords = $record->porte->tables->{$assocModel['type']}->find('FIND_IN_SET( :pk ,`'.$assocConfig['field'].'`)',array('pk'=>$primaryKeyValue));
foreach($assocRecords as $assocRecord){
$assocRecord->attributes[$config['has_many']['property']] = implode(',',array_diff(explode(',',$assocRecord->attributes[$config['has_many']['property']]),array($primaryKeyValue)));
$assocRecord->save();
}
$assocRecords->rewind();
}
}else if(isset($config['belongs_to'])){
// one-to-many on one side & one-to-one
if(isset($record->associations[$property])){
$assocRecord = $record->associations[$property];
$assocModel = $record->porte->models->{$config['belongs_to']['type']};
$assocConfig = $assocModel['properties'][$config['belongs_to']['property']];
//if(array_key_exists('has_one',$assocConfig)){
$assocRecord->associations[$config['belongs_to']['property']] = null;
//}
}
}else if(isset($config['has_one'])){
// one-to-one on transient side
if(isset($record->associations[$property])){
$assocRecord = $record->associations[$property];
//$assocModel = $record->porte->models->get($config['has_one']['type']);
//$assocConfig = $assocModel->properties->$config['has_one']['property'];
//if(array_key_exists('has_one',$assocConfig)){
$assocRecord->associations[$config['has_one']['property']] = null;
//}
}
}
}
//reset($model['properties']);
}
/**
* Create a new PorteRecord instance.
*
* @return
* @param PorteRecord $record
* @param array $config
* @param string $type
* @param mixed $arg
*/
public static function makeRecord(PorteRecord $record,array $config,$type,$arg){
// if argument is null and if the field accept null value, ok
if(is_null($arg)){
if(!empty($config['not_null']))
throw new PorteException('Invalid Arguments: argument is null');
$arg = null;
// if argument is null and if the field does not accept null value, not ok
}else if(is_object($arg)){
if(!$config[$type]['class']) throw new Exception();
if(!$arg instanceof $config[$type]['class'])
throw new InvalidArgumentException('Provided argument not an instance of "'.$config[$type]['class'].'", "'.get_class($arg).'" instead');
if(!isset($arg->porte)) $arg->setPorte($record->porte);
}else if((int)$arg>0){
$arg = $record->porte->tables->get($config[$type]['type'])->load($arg);
}else if(is_array($arg)){
if(empty($arg)){
$arg = null;
}else{
$arg = $record->porte->tables->get($config[$type]['type'])->load($arg);
}
}else{
$arg = null;
}
return $arg;
}
}
PorteEvents::connect('model_property_before',array('PorteAssociations','_modelPropertyBefore'));
PorteEvents::connect('table_update_after',array('PorteAssociations','_tableUpdateAfter'));
PorteEvents::connect('record_save_before',array('PorteAssociations','_recordSaveBefore'));
PorteEvents::connect('record_save_after',array('PorteAssociations','_recordSaveAfter'));
PorteEvents::connect('record_delete_before',array('PorteAssociations','_recordDeleteBefore'));
?>