Skip to content
Newer
Older
100644 433 lines (359 sloc) 19.4 KB
1d891f0 @willdurand Added documentation to Propel2
willdurand authored Nov 10, 2011
1 ---
2 layout: documentation
3 title: How to Write A Behavior
4 ---
5
6 # How to Write A Behavior #
7
8 Behaviors are a good way to reuse code across models without requiring inheritance (a.k.a. horizontal reuse). This step-by-step tutorial explains how to port model code to a behavior, focusing on a simple example.
9
10 In the tutorial "[Keeping an Aggregate Column up-to-date](http://propel.posterous.com/getting-to-know-propel-15-keeping-an-aggregat)", posted in the [Propel blog](http://propel.posterous.com/), the `TotalNbVotes` property of a `PollQuestion` object was updated each time a related `PollAnswer` object was saved, edited, or deleted. This "aggregate column" behavior was implemented by hand using hooks in the model classes. To make it truly reusable, the custom model code needs to be refactored and moved to a Behavior class.
11
12 ## Boostrapping A Behavior ##
13
14 A behavior is a class that can alter the generated classes for a table of your model. It must only extend the `Behavior` class and implement special "hook" methods. Here is the class skeleton to start with for the `aggregate_column` behavior:
15
16 {% highlight php %}
17 <?php
18 class AggregateColumnBehavior extends Behavior
19 {
20 // default parameters value
21 protected $parameters = array(
22 'name' => null,
23 );
24 }
25 {% endhighlight %}
26
27 Save this class in a file called `AggregateColumnBehavior.php`, and set the path for the class file in the project `build.properties` (just replace directory separators with dots). Remember that the `build.properties` paths are relative to the include path:
28
29 {% highlight ini %}
30 propel.behavior.aggregate_column.class = path.to.AggregateColumnBehavior
31 {% endhighlight %}
32
33 Test the behavior by adding it to a table of your model, for instance to a `poll_question` table:
34
35 {% highlight xml %}
36 <database name="poll" defaultIdMethod="native">
37 <table name="poll_question" phpName="PollQuestion">
38 <column name="id" required="true" primaryKey="true" autoIncrement="true" type="INTEGER" />
39 <column name="body" type="VARCHAR" size="100" />
40 <behavior name="aggregate_column">
41 <parameter name="name" value="total_nb_votes" />
42 </behavior>
43 </table>
44 </database>
45 {% endhighlight %}
46
47 Rebuild your model, and check the generated `PollQuestionTableMap` class under the `map` subdirectory of your build class directory. This class carries the structure metadata for the `PollQuestion` ActiveRecord class at runtime. The class should feature a `getBehaviors()` method as follows, proving that the behavior was correctly applied:
48
49 {% highlight php %}
50 <?php
51 class PollQuestionTableMap extends TableMap
52 {
53 // ...
54
55 public function getBehaviors()
56 {
57 return array(
58 'aggregate_column' => array('name' => 'total_nb_votes', ),
59 );
60 } // getBehaviors()
61 }
62 {% endhighlight %}
63
64 ## Adding A Column ##
65
66 The behavior works, but it still does nothing at all. Let's make it useful by allowing it to add a column. In the `AggregateColumnBehavior` class, just implement the `modifyTable()` method with the following code:
67
68 {% highlight php %}
69 <?php
70 class AggregateColumnBehavior extends Behavior
71 {
72 // ...
73
74 public function modifyTable()
75 {
76 $table = $this->getTable();
77 if (!$columnName = $this->getParameter('name')) {
78 throw new InvalidArgumentException(sprintf(
79 'You must define a \'name\' parameter for the \'aggregate_column\' behavior in the \'%s\' table',
80 $table->getName()
81 ));
82 }
83 // add the aggregate column if not present
84 if(!$table->containsColumn($columnName)) {
85 $table->addColumn(array(
86 'name' => $columnName,
87 'type' => 'INTEGER',
88 ));
89 }
90 }
91 }
92 {% endhighlight %}
93
94 This method shows that a behavior class has access to the `<parameters>` defined for it in the `schema.xml` through the `getParameter()` command. Behaviors can also always access the `Table` object attached to them, by calling `getTable()`. A `Table` can check if a column exists and add a new one easily. The `Table` class is one of the numerous generator classes that serve to describe the object model at buildtime, together with `Column`, `ForeignKey`, `Index`, and a lot more classes. You can find all the buildtime model classes under the `generator/lib/model` directory.
95
3071b95 @cristianoc72 Update documentation, removing references to previous validators
cristianoc72 authored Apr 11, 2012
96 _Tip_: Don't mix up the _runtime_ database model (`DatabaseMap`, `TableMap`, `ColumnMap`, `RelationMap`) with the _buildtime_ database model (`Database`, `Table`, `Column`, etc.). The buildtime model is very detailed, in order to ease the work of the builders that write the ActiveRecord and Query classes. On the other hand, the runtime model is optimized for speed, and carries minimal information to allow correct hydration and binding at runtime. Behaviors use the buildtime object model, because they are run at buildtime, so they have access to the most powerful model.
1d891f0 @willdurand Added documentation to Propel2
willdurand authored Nov 10, 2011
97
98 Now rebuild the model and the SQL, and sure enough, the new column is there. `BasePollQuestion` offers a `getTotalNbVotes()` and a `setTotalNbVotes()` method, and the table creation SQL now includes the additional `total_nb_votes` column:
99
100 {% highlight sql %}
101 DROP TABLE IF EXISTS poll_question;
102 CREATE TABLE poll_question
103 (
104 id INTEGER NOT NULL AUTO_INCREMENT,
105 title VARCHAR(100),
106 total_nb_votes INTEGER,
107 PRIMARY KEY (id)
108 )Type=InnoDB;
109 {% endhighlight %}
110
111 _Tip_: The behavior only adds the column if it's not present (`!$table->containsColumn($columnName)`). So if a user needs to customize the column type, or any other attribute, he can include a `<column>` tag in the table with the same name as defined in the behavior, and the `modifyTable()` will then skip the column addition.
112
113 ## Adding A Method To The ActiveRecord Class ##
114
115 In the previous post, a method of the ActiveRecord class was in charge of updating the `total_nb_votes` column. A behavior can easily add such methods by implementing the `objectMethods()` method:
116
117 {% highlight php %}
118 <?php
119 class AggregateColumnBehavior extends Behavior
120 {
121 // ...
122
123 public function objectMethods()
124 {
125 $script = _;
126 $script .= $this->addUpdateAggregateColumn();
127 return $script;
128 }
129
130 protected function addUpdateAggregateColumn()
131 {
132 $sql = sprintf('SELECT %s FROM %s WHERE %s = ?',
133 $this->getParameter('expression'),
134 $this->getParameter('foreign_table'),
135 $this->getParameter('foreign_column')
136 );
137 $table = $this->getTable();
138 $aggregateColumn = $table->getColumn($this->getParameter('name'));
139 $columnPhpName = $aggregateColumn->getPhpName();
140 $localColumn = $table->getColumn($this->getParameter('local_column'));
141 return "
142 /**
143 * Updates the aggregate column {$aggregateColumn->getName()}
144 *
145 * @param PropelPDO \$con A connection object
146 */
147 public function update{$columnPhpName}(PropelPDO \$con)
148 {
149 \$sql = '{$sql}';
150 \$stmt = \$con->prepare(\$sql);
151 \$stmt->execute(array(\$this->get{$localColumn->getPhpName()}()));
152 \$this->set{$columnPhpName}(\$stmt->fetchColumn());
153 \$this->save(\$con);
154 }
155 ";
156 }
157 }
158 {% endhighlight %}
159
160 The ActiveRecord class builder expects a string in return to the call to `Behavior::objectMethods()`, and appends this string to the generated code of the ActiveRecord class. Don't bother about indentation: builder classes know how to properly indent a string returned by a behavior. A good rule of thumb is to create one behavior method for each added method, to provide better readability.
161
162 Of course, the schema must be modified to supply the necessary parameters to the behavior:
163
164 {% highlight xml %}
165 <database name="poll" defaultIdMethod="native">
166 <table name="poll_question" phpName="PollQuestion">
167 <column name="id" required="true" primaryKey="true" autoIncrement="true" type="INTEGER" />
168 <column name="body" type="VARCHAR" size="100" />
169 <behavior name="aggregate_column">
170 <parameter name="name" value="total_nb_votes" />
171 <parameter name="expression" value="count(nb_votes)" />
172 <parameter name="foreign_table" value="poll_answer" />
173 <parameter name="foreign_column" value="question_id" />
174 <parameter name="local_column" value="id" />
175 </behavior>
176 </table>
177 <table name="poll_answer" phpName="PollAnswer">
178 <column name="id" required="true" primaryKey="true" autoIncrement="true" type="INTEGER" />
179 <column name="question_id" required="true" type="INTEGER" />
180 <column name="body" type="VARCHAR" size="100" />
181 <column name="nb_votes" type="INTEGER" />
182 <foreign-key foreignTable="poll_question" onDelete="cascade">
183 <reference local="question_id" foreign="id" />
184 </foreign-key>
185 </table>
186 </database>
187 {% endhighlight %}
188
189 Now if you rebuild the model, you will see the new `updateTotalNbVotes()` method in the generated `BasePollQuestion` class:
190
191 {% highlight php %}
192 <?php
193 class BasePollQuestion extends BaseObject
194 {
195 // ...
196
197 /**
198 * Updates the aggregate column total_nb_votes
199 *
200 * @param PropelPDO $con A connection object
201 */
202 public function updateTotalNbVotes(PropelPDO $con)
203 {
204 $sql = 'SELECT count(nb_votes) FROM poll_answer WHERE question_id = ?';
205 $stmt = $con->prepare($sql);
206 $stmt->execute(array($this->getId()));
207 $this->setTotalNbVotes($stmt->fetchColumn());
208 $this->save($con);
209 }
210 }
211 {% endhighlight %}
212
213 Behaviors offer similar hook methods to allow the addition of methods to the query classes (`queryMethods()`) and to the peer classes (`peerMethods()`). And if you need to add attributes, just implement one of the `objectAttributes()`, `queryAttributes()`, or `peerAttributes()` methods.
214
215 ## Using a Template For Generated Code ##
216
217 The behavior's `addUpdateAggregateColumn()` method is somehow hard to read, because of the large string containing the PHP code canvas for the added method. Propel behaviors can take advantage of Propel's simple templating system to use an external file as template for the code to insert.
218
219 Let's refactor the `addUpdateAggregateColumn()` method to take advantage of this feature:
220
221 {% highlight php %}
222 <?php
223 class AggregateColumnBehavior extends Behavior
224 {
225 // ...
226
227 protected function addUpdateAggregateColumn()
228 {
229 $sql = sprintf('SELECT %s FROM %s WHERE %s = ?',
230 $this->getParameter('expression'),
231 $this->getParameter('foreign_table'),
232 $this->getParameter('foreign_column')
233 );
234 $table = $this->getTable();
235 $aggregateColumn = $table->getColumn($this->getParameter('name'));
236 return $this->renderTemplate('objectUpdateAggregate', array(
237 'aggregateColumn' => $aggregateColumn,
238 'columnPhpName' => $aggregateColumn->getPhpName(),
239 'localColumn' => $table->getColumn($this->getParameter('local_column')),
240 'sql' => $sql,
241 ));
242 }
243 }
244 {% endhighlight %}
245
246 The method no longer returns a string created by hand, but a _rendered template_. Propel templates are simple PHP files executed in a sandbox - they have only access to the variables declared as second argument of the `renderTemplate()` call.
247
248 Now create a `templates/` directory in the same directory as the `AggregateColumnBehavior` class file, and add in a `objectUpdateAggregate.php` file with the following code:
249
250 {% highlight php %}
251 /**
252 * Updates the aggregate column <?php echo $aggregateColumn->getName() ?>
253 *
254 * @param PropelPDO $con A connection object
255 */
256 public function update<?php echo $columnPhpName ?>(PropelPDO $con)
257 {
258 $sql = '<?php echo $sql ?>';
259 $stmt = $con->prepare($sql);
260 $stmt->execute(array($this->get<?php echo $localColumn->getPhpName() ?>()));
261 $this->set<?php echo $columnPhpName ?>($stmt->fetchColumn());
262 $this->save($con);
263 }
264 {% endhighlight %}
265
266 No need to escape dollar signs anymore: this syntax allows for a cleaner separation, and is very convenient for large behaviors.
267
268 ## Adding Another Behavior From A Behavior ##
269
270 This is where it's getting tricky. In the [blog post](http://propel.posterous.com/getting-to-know-propel-15-keeping-an-aggregat) describing the column aggregation technique, the calls to the `updateTotalNbVotes()` method come from the `postSave()` and `postDelete()` hooks of the `PollAnswer` class. But the current behavior is applied to the `poll_question` table, how can it modify the code of a class based on another table?
271
272 The short answer is: it can't. To modify the classes built for the `poll_answer` table, a behavior must be registered on the `poll_answer` table. But a behavior is just like a column or a foreign key: it has an object counterpart in the buildtime database model. So the trick here is to modify the `AggregateColumnBehavior::modifyTable()` method to _add a new behavior_ to the foreign table. This second behavior will be in charge of implementing the `postSave()` and `postDelete()` hooks of the `PollAnswer` class.
273
274 {% highlight php %}
275 <?php
276 class AggregateColumnBehavior extends Behavior
277 {
278 // ...
279
280 public function modifyTable()
281 {
282 // ...
283
284 // add a behavior to the foreign table to autoupdate the aggregate column
285 $foreignTable = $table->getDatabase()->getTable($this->getParameter('foreign_table'));
286 if (!$foreignTable->hasBehavior('concrete_inheritance_parent')) {
287 require_once 'AggregateColumnRelationBehavior.php';
288 $relationBehavior = new AggregateColumnRelationBehavior();
289 $relationBehavior->setName('aggregate_column_relation');
290 $relationBehavior->addParameter(array(
291 'name' => 'foreign_table',
292 'value' => $table->getName()
293 ));
294 $relationBehavior->addParameter(array(
295 'name' => 'foreign_column',
296 'value' => $this->getParameter('name')
297 ));
298 $foreignTable->addBehavior($relationBehavior);
299 }
300 }
301 }
302 {% endhighlight %}
303
304 In practice, everything now happens as if the `poll_answer` had its own behavior:
305
306 {% highlight xml %}
307 <database name="poll" defaultIdMethod="native">
308 <!-- ... -->
309 <table name="poll_answer" phpName="PollAnswer">
310 <!-- ... -->
311 <behavior name="aggregate_column_relation">
312 <parameter name="foreign_table" value="poll_question" />
313 <parameter name="foreign_column" value="total_nb_votes" />
314 </behavior>
315 </table>
316 </database>
317 {% endhighlight %}
318
319 Adding a behavior to a `Table` instance, as well as adding a `Parameter` to a `Behavior` instance, is quite straightforward. And since the second behavior class file is required in the `modifyTable()` method, there is no need to add a path for it in the `build.properties`.
320
321 ## Adding Code For Model Hooks ##
322
323 The new `AggregateColumnRelationBehavior` is yet to write. It must implement a call to `PollQuestion::updateTotalNbVotes()` in the `postSave()` and `postDelete()` hooks.
324
325 Adding code to hooks from a behavior is just like adding methods: add a method with the right hook name returning a code string, and the code will get appended at the right place. Unsurprisingly, the behavior hook methods for `postSave()` and `postDelete()` are called `postSave()` and `postDelete()`:
326
327 {% highlight php %}
328 <?php
329 class AggregateColumnBehavior extends Behavior
330 {
331 // default parameters value
332 protected $parameters = array(
333 'foreign_table' => null,
334 'foreignColumn' => null,
335 );
336
337 public function postSave()
338 {
339 $table = $this->getTable();
340 $foreignTable = $table->getDatabase()->getTable($this->getParameter('foreign_table'));
341 $foreignColumn = $foreignTable->getColumn($this->getParameter('foreign_column'));
342 $foreignColumnPhpName = $foreignColumn->getPhpName();
343 return "\$this->updateRelated{$foreignColumnPhpName}(\$con)";
344 }
345
346 public function postDelete()
347 {
348 return $this->postSave();
349 }
350
351 public function objectMethods()
352 {
353 $script = _;
354 $script .= $this->addUpdateRelatedAggregateColumn();
355 return $script;
356 }
357
358 protected function addUpdateRelatedAggregateColumn()
359 {
360 $table = $this->getTable();
361 $foreignTable = $table->getDatabase()->getTable($this->getParameter('foreign_table'));
362 $foreignTablePhpName = foreignTable->getPhpName();
363 $foreignColumn = $foreignTable->getColumn($this->getParameter('foreign_column'));
364 $foreignColumnPhpName = $foreignColumn->getPhpName();
365 return "
366 /**
367 * Updates an aggregate column in the foreign {$foreignTable->getName()} table
368 *
369 * @param PropelPDO \$con A connection object
370 */
371 protected function updateRelated{$foreignColumnPhpName}(PropelPDO \$con)
372 {
373 if (\$parent{$foreignTablePhpName} = \$this->get{$foreignTablePhpName}()) {
374 \$parent{$foreignTablePhpName}->update{$foreignColumnPhpName}(\$con);
375 }
376 }
377 ";
378 }
379 }
380 {% endhighlight %}
381
382 The `postSave()` and `postDelete()` behavior hooks will not add code to the ActiveRecord `postSave()` and `postDelete()` methods - to allow users to further implement these methods - but instead it adds code directly to the `save()` and `delete()` methods, inside a transaction. Check the generated `BasePollAnswer` class for the added code in these methods:
383
384 {% highlight php %}
385 <?php
386 // aggregate_column_relation behavior
387 $this->updateRelatedTotalNbVotes($con);
388 {% endhighlight %}
389
390 You will also see the new `updateRelatedTotalNbVotes()` method added by `AggregateColumnBehavior::objectMethods()`:
391
392 {% highlight php %}
393 <?php
394 /**
395 * Updates an aggregate column in the foreign poll_question table
396 *
397 * @param PropelPDO $con A connection object
398 */
399 protected function updateRelatedTotalNbVotes(PropelPDO $con)
400 {
401 if ($parentPollQuestion = $this->getPollQuestion()) {
402 $parentPollQuestion->updateTotalNbVotes($con);
403 }
404 }
405 {% endhighlight %}
406
407 ## Specifying a Priority For Behavior Execution ##
408
409 Since behaviors can modify tables, and even add tables, you may encounter cases where two behaviors conflict with each other. The usual way to solve these conflicts is to force a particular execution order, i.e. behavior A must be executed before behavior B, no matter in what order they were specified in the schema.
410
411 Propel Behavior classes support a `$tableModificationOrder` attribute just for that purpose. By default, it is set to 50; set it to a lower number to force an early execution, or to a greater number to force a late execution. For instance, in the following example, `BehaviorA` will be executed before `BehaviorB`:
412
413 {% highlight php %}
414 <?php
415 class BehaviorA extends Behavior
416 {
417 protected $tableModificationOrder = 40;
418 }
419
420 class BehaviorB extends Behavior
421 {
422 protected $tableModificationOrder = 60;
423 }
424 {% endhighlight %}
425
426 ## What's Left ##
427
428 These are the basics of behavior writing: implement one of the methods documented in the [behaviors chapter](../documentation/07-behaviors.html#writing-a-behavior) of the Propel guide, and return strings containing the code to be added to the ActiveRecord, Query, and Peer classes. In addition to the behavior code, you should always write unit tests - all the behaviors bundled with Propel have full unit test coverage. And to make your behavior usable by others, documentation is highly recommended. Once again, Propel core behaviors are fully documented, to let users understand the behavior usage without having to peek into the code.
429
430 As for the `AggregateColumnBehavior`, the job is not finished. The [blog post](http://propel.posterous.com/getting-to-know-propel-15-keeping-an-aggregat) emphasized the need for hooks in the Query class, and these are not yet implemented in the above code. Besides, the post kept quiet about one use case that left the aggregate column not up to date (when a question is detached from a poll without deleting it). Lastly, the parameters required for this behavior are currently a bit verbose, especially concerning the need to define the foreign table and the foreign key - this could be simplified thanks to the knowledge of the object model that behaviors have.
431
432 All this is left to the reader as an exercise. Fortunately, the final behavior is part of the Propel core behaviors, so the [aggregate_column documentation](../behaviors/aggregate-column) and the code are all ready to help you to further understand the power of Propel's behavior system.
Something went wrong with that request. Please try again.