summaryrefslogtreecommitdiff
path: root/framework/Data/ActiveRecord/Relations/TActiveRecordHasManyAssociation.php
blob: d845d372693e4a680e9bb35972f55f26e31bc8a6 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
<?php
/**
 * TActiveRecordHasManyAssociation class file.
 *
 * @author Wei Zhuo <weizhuo[at]gmail[dot]com>
 * @link http://www.pradosoft.com/
 * @copyright Copyright &copy; 2005-2013 PradoSoft
 * @license http://www.pradosoft.com/license/
 * @version $Id$
 * @package System.Data.ActiveRecord.Relations
 */

/**
 * Loads base active record relations class.
 */
Prado::using('System.Data.ActiveRecord.Relations.TActiveRecordRelation');

/**
 * Implements the M-N (many to many) relationship via association table.
 * Consider the <b>entity</b> relationship between Articles and Categories
 * via the association table <tt>Article_Category</tt>.
 * <code>
 * +---------+            +------------------+            +----------+
 * | Article | * -----> * | Article_Category | * <----- * | Category |
 * +---------+            +------------------+            +----------+
 * </code>
 * Where one article may have 0 or more categories and each category may have 0
 * or more articles. We may model Article-Category <b>object</b> relationship
 * as active record as follows.
 * <code>
 * class ArticleRecord
 * {
 *     const TABLE='Article';
 *     public $article_id;
 *
 *     public $Categories=array(); //foreign object collection.
 *
 *     public static $RELATIONS = array
 *     (
 *         'Categories' => array(self::MANY_TO_MANY, 'CategoryRecord', 'Article_Category')
 *     );
 *
 *     public static function finder($className=__CLASS__)
 *     {
 *         return parent::finder($className);
 *     }
 * }
 * class CategoryRecord
 * {
 *     const TABLE='Category';
 *     public $category_id;
 *
 *     public $Articles=array();
 *
 *     public static $RELATIONS = array
 *     (
 *         'Articles' => array(self::MANY_TO_MANY, 'ArticleRecord', 'Article_Category')
 *     );
 *
 *     public static function finder($className=__CLASS__)
 *     {
 *         return parent::finder($className);
 *     }
 * }
 * </code>
 *
 * The static <tt>$RELATIONS</tt> property of ArticleRecord defines that the
 * property <tt>$Categories</tt> has many <tt>CategoryRecord</tt>s. Similar, the
 * static <tt>$RELATIONS</tt> property of CategoryRecord defines many ArticleRecords.
 *
 * The articles with categories list may be fetched as follows.
 * <code>
 * $articles = TeamRecord::finder()->withCategories()->findAll();
 * </code>
 * The method <tt>with_xxx()</tt> (where <tt>xxx</tt> is the relationship property
 * name, in this case, <tt>Categories</tt>) fetchs the corresponding CategoryRecords using
 * a second query (not by using a join). The <tt>with_xxx()</tt> accepts the same
 * arguments as other finder methods of TActiveRecord.
 *
 * @author Wei Zhuo <weizho[at]gmail[dot]com>
 * @version $Id$
 * @package System.Data.ActiveRecord.Relations
 * @since 3.1
 */
class TActiveRecordHasManyAssociation extends TActiveRecordRelation
{
	private $_association;
	private $_sourceTable;
	private $_foreignTable;
	private $_association_columns=array();

	/**
	* Get the foreign key index values from the results and make calls to the
	* database to find the corresponding foreign objects using association table.
	* @param array original results.
	*/
	protected function collectForeignObjects(&$results)
	{
		list($sourceKeys, $foreignKeys) = $this->getRelationForeignKeys();
		$properties = array_values($sourceKeys);
		$indexValues = $this->getIndexValues($properties, $results);
		$this->fetchForeignObjects($results, $foreignKeys,$indexValues,$sourceKeys);
	}

	/**
	 * @return array 2 arrays of source keys and foreign keys from the association table.
	 */
	public function getRelationForeignKeys()
	{
		$association = $this->getAssociationTable();
		$sourceKeys = $this->findForeignKeys($association, $this->getSourceRecord(), true);
		$fkObject = $this->getContext()->getForeignRecordFinder();
		$foreignKeys = $this->findForeignKeys($association, $fkObject);
		return array($sourceKeys, $foreignKeys);
	}

	/**
	 * @return TDbTableInfo association table information.
	 */
	protected function getAssociationTable()
	{
		if($this->_association===null)
		{
			$gateway = $this->getSourceRecord()->getRecordGateway();
			$conn = $this->getSourceRecord()->getDbConnection();
			//table name may include the fk column name separated with a dot.
			$table = explode('.', $this->getContext()->getAssociationTable());
			if(count($table)>1)
			{
				$columns = preg_replace('/^\((.*)\)/', '\1', $table[1]);
				$this->_association_columns = preg_split('/\s*[, ]\*/',$columns);
			}
			$this->_association = $gateway->getTableInfo($conn, $table[0]);
		}
		return $this->_association;
	}

	/**
	 * @return TDbTableInfo source table information.
	 */
	protected function getSourceTable()
	{
		if($this->_sourceTable===null)
		{
			$gateway = $this->getSourceRecord()->getRecordGateway();
			$this->_sourceTable = $gateway->getRecordTableInfo($this->getSourceRecord());
		}
		return $this->_sourceTable;
	}

	/**
	 * @return TDbTableInfo foreign table information.
	 */
	protected function getForeignTable()
	{
		if($this->_foreignTable===null)
		{
			$gateway = $this->getSourceRecord()->getRecordGateway();
			$fkObject = $this->getContext()->getForeignRecordFinder();
			$this->_foreignTable = $gateway->getRecordTableInfo($fkObject);
		}
		return $this->_foreignTable;
	}

	/**
	 * @return TDataGatewayCommand
	 */
	protected function getCommandBuilder()
	{
		return $this->getSourceRecord()->getRecordGateway()->getCommand($this->getSourceRecord());
	}

	/**
	 * @return TDataGatewayCommand
	 */
	protected function getForeignCommandBuilder()
	{
		$obj = $this->getContext()->getForeignRecordFinder();
		return $this->getSourceRecord()->getRecordGateway()->getCommand($obj);
	}


	/**
	 * Fetches the foreign objects using TActiveRecord::findAllByIndex()
	 * @param array field names
	 * @param array foreign key index values.
	 */
	protected function fetchForeignObjects(&$results,$foreignKeys,$indexValues,$sourceKeys)
	{
		$criteria = $this->getCriteria();
		$finder = $this->getContext()->getForeignRecordFinder();
		$type = get_class($finder);
		$command = $this->createCommand($criteria, $foreignKeys,$indexValues,$sourceKeys);
		$srcProps = array_keys($sourceKeys);
		$collections=array();
		foreach($this->getCommandBuilder()->onExecuteCommand($command, $command->query()) as $row)
		{
			$hash = $this->getObjectHash($row, $srcProps);
			foreach($srcProps as $column)
				unset($row[$column]);
			$obj = $this->createFkObject($type,$row,$foreignKeys);
			$collections[$hash][] = $obj;
		}
		$this->setResultCollection($results, $collections, array_values($sourceKeys));
	}

	/**
	 * @param string active record class name.
	 * @param array row data
	 * @param array foreign key column names
	 * @return TActiveRecord
	 */
	protected function createFkObject($type,$row,$foreignKeys)
	{
		$obj = TActiveRecord::createRecord($type, $row);
		if(count($this->_association_columns) > 0)
		{
			$i=0;
			foreach($foreignKeys as $ref=>$fk)
				$obj->setColumnValue($ref, $row[$this->_association_columns[$i++]]);
		}
		return $obj;
	}

	/**
	 * @param TSqlCriteria
	 * @param TTableInfo association table info
	 * @param array field names
	 * @param array field values
	 */
	public function createCommand($criteria, $foreignKeys,$indexValues,$sourceKeys)
	{
		$innerJoin = $this->getAssociationJoin($foreignKeys,$indexValues,$sourceKeys);
		$fkTable = $this->getForeignTable()->getTableFullName();
		$srcColumns = $this->getSourceColumns($sourceKeys);
		if(($where=$criteria->getCondition())===null)
			$where='1=1';
		$sql = "SELECT {$fkTable}.*, {$srcColumns} FROM {$fkTable} {$innerJoin} WHERE {$where}";

		$parameters = $criteria->getParameters()->toArray();
		$ordering = $criteria->getOrdersBy();
		$limit = $criteria->getLimit();
		$offset = $criteria->getOffset();

		$builder = $this->getForeignCommandBuilder()->getBuilder();
		$command = $builder->applyCriterias($sql,$parameters,$ordering,$limit,$offset);
		$this->getCommandBuilder()->onCreateCommand($command, $criteria);
		return $command;
	}

	/**
	 * @param array source table column names.
	 * @return string comma separated source column names.
	 */
	protected function getSourceColumns($sourceKeys)
	{
		$columns=array();
		$table = $this->getAssociationTable();
		$tableName = $table->getTableFullName();
		$columnNames = array_merge(array_keys($sourceKeys),$this->_association_columns);
		foreach($columnNames as $name)
			$columns[] = $tableName.'.'.$table->getColumn($name)->getColumnName();
		return implode(', ', $columns);
	}

	/**
	 * SQL inner join for M-N relationship via association table.
	 * @param array foreign table column key names.
	 * @param array source table index values.
	 * @param array source table column names.
	 * @return string inner join condition for M-N relationship via association table.
	 */
	protected function getAssociationJoin($foreignKeys,$indexValues,$sourceKeys)
	{
		$refInfo= $this->getAssociationTable();
		$fkInfo = $this->getForeignTable();

		$refTable = $refInfo->getTableFullName();
		$fkTable = $fkInfo->getTableFullName();

		$joins = array();
		$hasAssociationColumns = count($this->_association_columns) > 0;
		$i=0;
		foreach($foreignKeys as $ref=>$fk)
		{
			if($hasAssociationColumns)
				$refField = $refInfo->getColumn($this->_association_columns[$i++])->getColumnName();
			else
				$refField = $refInfo->getColumn($ref)->getColumnName();
			$fkField = $fkInfo->getColumn($fk)->getColumnName();
			$joins[] = "{$fkTable}.{$fkField} = {$refTable}.{$refField}";
		}
		$joinCondition = implode(' AND ', $joins);
		$index = $this->getCommandBuilder()->getIndexKeyCondition($refInfo,array_keys($sourceKeys), $indexValues);
		return "INNER JOIN {$refTable} ON ({$joinCondition}) AND {$index}";
	}

	/**
	 * Updates the associated foreign objects.
	 * @return boolean true if all update are success (including if no update was required), false otherwise .
	 */
	public function updateAssociatedRecords()
	{
		$obj = $this->getContext()->getSourceRecord();
		$fkObjects = &$obj->{$this->getContext()->getProperty()};
		$success=true;
		if(($total = count($fkObjects))> 0)
		{
			$source = $this->getSourceRecord();
			$builder = $this->getAssociationTableCommandBuilder();
			for($i=0;$i<$total;$i++)
				$success = $fkObjects[$i]->save() && $success;
			return $this->updateAssociationTable($obj, $fkObjects, $builder) && $success;
		}
		return $success;
	}

	/**
	 * @return TDbCommandBuilder
	 */
	protected function getAssociationTableCommandBuilder()
	{
		$conn = $this->getContext()->getSourceRecord()->getDbConnection();
		return $this->getAssociationTable()->createCommandBuilder($conn);
	}

	private function hasAssociationData($builder,$data)
	{
		$condition=array();
		$table = $this->getAssociationTable();
		foreach($data as $name=>$value)
			$condition[] = $table->getColumn($name)->getColumnName().' = ?';
		$command = $builder->createCountCommand(implode(' AND ', $condition),array_values($data));
		$result = $this->getCommandBuilder()->onExecuteCommand($command, intval($command->queryScalar()));
		return intval($result) > 0;
	}

	private function addAssociationData($builder,$data)
	{
		$command = $builder->createInsertCommand($data);
		return $this->getCommandBuilder()->onExecuteCommand($command, $command->execute()) > 0;
	}

	private function updateAssociationTable($obj,$fkObjects, $builder)
	{
		$source = $this->getSourceRecordValues($obj);
		$foreignKeys = $this->findForeignKeys($this->getAssociationTable(), $fkObjects[0]);
		$success=true;
		foreach($fkObjects as $fkObject)
		{
			$data = array_merge($source, $this->getForeignObjectValues($foreignKeys,$fkObject));
			if(!$this->hasAssociationData($builder,$data))
				$success = $this->addAssociationData($builder,$data) && $success;
		}
		return $success;
	}

	private function getSourceRecordValues($obj)
	{
		$sourceKeys = $this->findForeignKeys($this->getAssociationTable(), $obj);
		$indexValues = $this->getIndexValues(array_values($sourceKeys), $obj);
		$data = array();
		$i=0;
		foreach($sourceKeys as $name=>$srcKey)
			$data[$name] = $indexValues[0][$i++];
		return $data;
	}

	private function getForeignObjectValues($foreignKeys,$fkObject)
	{
		$data=array();
		foreach($foreignKeys as $name=>$fKey)
			$data[$name] = $fkObject->getColumnValue($fKey);
		return $data;
	}
}