Difference between #2 and #3 of Creating and updating model and its related models in one form, inc. image

unchanged
Title
Creating and updating model and its related models in one form, inc. image
unchanged
Category
How-tos
unchanged
Tags
multiple models, one to many, Image upload
changed
Content
General
------------------
- There are more than a few forum threads and several wiki articles on how to
create more than 1 model record in a single form but I didn't find one complete
as I wish there was, covering all that I wanted. So, after going through the
process during a project I'm making, I wrote this article.
- This article covers creating a main model and a limited number of 'related'
models in a single form. It shown relevant code segments from the models,
controllers, and view files, for a full coverage of the subject (well, almost
full. See 'design notes' section below).
- The code segments contains useful comments that document lots of important
notes, decisions taken etc. I recommend not overlooking them.

How to use this article?
------------------
- I recommend reading it from top to bottom. The code samples are presented in a
logical order (IMHO...).
- As already noted, I recommend reading the code segments with attention to the
comments. Lots of decision making, design notes, etc is noted within those
comments.

Features / Use case
------------------
- Say hello to your 'music' feature. Lets make it a 'MusicModule' - a Yii module
(as usually I like to do, for the most natural packaging for bundles that
contain elements from many kinds (models, controllers, widgets, translation
files, etc)).
- For the purpose of this article the module will have two models: Artist and
Song. An artist model contains some textual fields (like title, name, 'about',
etc) and an image file. The Song model contains textual fields like name,
lyrics, etc.
- There should be one to many relationship between Artist and Song so an artist
can have multiple songs.
- The main model, Artist, shall have a single **image file** attribute. Only
Jpeg files should be allowed (for simplicity). We'd like to name the file to be
unrelated to the filename it was submitted with but rather as a
"{PK}_artist.jpg". This naming convention has the benefit that when
viewing your artists files you can immediately tell that each file is an artist
main image, and associate the image to the artist by its PK. It also has its
drawbacks and those are laid out in the plenty comments in the code samples.
- We'd like to have a limit on the max possible songs an artist can have. This
is good to prevent abusing of the system and for saving resources. Having a
limit is generally a good practice if you'd like to protect your system from
abusing.

Design notes
------------------
- This section is best served while reading the code segments as it refers to
actual design implemented in the code.
- Having said that (above), here are some design notes:
  - Song model: 'artist_id', the model-attribute/db-column that links songs
table to the artists table, on the face of it, should be a 'required field' in
the model. I recommend also making it a foreign key constraint in the DB level.
Yet, we validate both artist and songs objects before saving them so when we
validate a new song object, there will not be an artist record yet in the DB,
therefore we will not have a primary key for the artist, to be noted in the
artist_id attribute of the song... (the chicken and the egg problem...).
Therefore, we cannot make the 'artist_id' field in the Song model a required
field and it should not be required. Still, the code needs to make sure
carefully that before an attempt to save a Song object its 'artist_id' attribute
will be populated. If not, assuming that DB foreign key constraint has been
implemented, an exception originating in the CDbConnection will be thrown. As
you'll see in the code samples in the article, this is handled just as
suggested.
  - View files: Well, I'm not a front end guy. At the time of writing this I
don't have a fully working front end code simply due to resource allocation
preferences. It also results in basic view files used here and one missing thing
on the front end arena - some JS section that will bind to click events on some
'add more songs' button. Upon clicking, it needs to copy the div with id of
"extra-song" and create a blank 'new song' div from it, assigning
correct 'counter' value and assign correct value to the 'name' attribute of each
of the song's form elements (that includes the correct counter value).
  - The error messages emitted are i18n-able. See usage of Yii:t() in the code
samples. Note however - this isn't strictly so in the view files.
  - Yii's logging facility is used (calls to Yii::log()). If you use the code as
your basis I recommend configuring the log application component to your
preferences in main.php.

Comments? 
------------------
- Always true: _Practice (and feedback) makes perfect_.
- Feel free to PM me or comment on this page. 
- TIA!

## Model files

We start slowly and simple...

### Artist model
~~~
[php]
class Artist extends PcBaseArModel {
	// more code...
	/**
	 * @return array relational rules.
	 */
	public function relations() {
		return array(
			'songs' => array(self::HAS_MANY, 'Song', 'artist_id'),
		);
	}
	// more code...
}
~~~

### Song model
~~~
[php]
class Song extends PcBaseArModel {
	// more code...
	/**
	 * @return array relational rules.
	 */
	public function relations() {
		return array(
			'artist' => array(self::BELONGS_TO, 'Artist', 'artist_id'),
		);
	}
	// more code...
}
~~~

## Controller class

The first thing we'll do is to _create_ an artist. Artist creation form will
enable creating song records as well so we need to implement
ArtistController.actionCreate()

### ArtistController, create action
~~~
[php]
class ArtistController extends Controller {
	public function actionCreate() {
		/*
		 * first, do some permission's check. I use Yii's RBAC facility. YMMV, and it
doesn't matter. Just do a
		 * permissions check here.
		 */
		if (!Yii::app()->user->checkAccess('create artist')) {
			// not allowed... .
			throw new CHttpException(401);
		}

		$model = new Artist('insert');
		$song = new Song('insert');
		// by default, if we already submitted the form and if we didn't, we allow
adding more songs
		// this will be updated down below if needed to
		$enable_add_more_songs = true;

		if (isset($_POST['Artist'])) {
			// start optimistically by settings a few variables that we'll need
			$all_songs_valid = true;
			$songs_to_save = array();
			$success_saving_all = true;

			$model->attributes = $_POST['Artist'];

			// use aux variable for manipulating the image file. 'icon_filename' is the
attribute name in the Artist model
			$image = CUploadedFile::getInstance($model, 'icon_filename');
			// we need to put something into the icon_filename of the model since
otherwise validation will fail if its
			// a 'required' field (this is 'create' scenario).
			if ($image) {
				// if its not required field and image wasn't supplied this block will not
be run so
				// this is safe for both 'required field' and 'non required field' use
cases.
				$model->icon_filename = "(TEMP) " . $image->name;
			}

			// lets start handle related models that were submitted, if any
			if (isset($_POST['Song'])) {
				if (count($_POST['Song']) > Song::MAX_SONGS_PER_ARTIST) {
					/*
					 * server side protection against attempt to submit more than
MAX_SONGS_PER_ARTIST
					 * this should be accompanied with a client side (JS) protection.
					 * If its accompanied with client side protection then going into this code
block means our system
					 * is being abused/"tested". No need to give a polite error
message.
					 */
					throw new CHttpException(500, Yii::t("MusicModule.forms",
"The max amount of allowed songs is {max_songs_num}",
array('{max_songs_num}' => Song::MAX_SONGS_PER_ARTIST)));
				}
				// now handle each submitted song:
				foreach ($_POST['Song'] as $index => $submitted_song) {
					// We could have empty songs which means empty submitted forms in POST.
Ignore those:
					if ($submitted_song['title'] == '') {
						// empty one - skip it, if you please.
						continue;
					}

					// validate each submitted song instance
					$song = new Song();
					$song->attributes = $submitted_song;
					if (!$song->validate()) {
						// at least one invalid song. We'll need to remember this fact in order to
render the create
						// form back to the user, with the error message:
						$all_songs_valid = false;
						// we do not 'break' here since we want validation on all songs at the
same shot
					}
					else {
						// put aside the new *and valid* Song to be saved
						$songs_to_save[] = $song;
					}
				}

				// while we know that songs were submitted, determine if the number of songs
has exceeded its limit.
				// this will be goof when rendering back the 'create' form so no more
songs-forms will be available.
				if (count($_POST['Song']) == Song::MAX_SONGS_PER_ARTIST) {
					$enable_add_more_songs = false;
				}
			}

			// Done validation. Summarize all valid/invalid information thus far and act
accordingly
			if ($all_songs_valid && $model->validate()) {
				/*
				 * all songs (if any) were valid and artist model is valid too. Save it all
in one transaction. Save first the
				 * artist as we need its 'id' attribute for its songs records
(Song.artist_id is NOT NULL in the DB level
				 * and a 'required' attribute in our model).
				 */
				$trans = Yii::app()->db->beginTransaction();

				try {
					// save the artist
					$model->save(false);
					// handle image of artist, if supplied:
					if ($image) {
						// Song.getImageFsFilename() encapsulates the image filename setting
details.
						$image->saveAs($model->getImageFsFilename($image));
						/**
						 * now update the model itself again with the full filename of the image
file.
						 * this is the disadvantage of using filenames that include the PK of the
entry - we need it first saved
						 * in the DB before we know what it is... . */
						// icon_filename is a VARCHAR(512) in the DB (for long filenames) and
'string' in the Artist model
						$model->icon_filename = $model->getImageFsFilename($image);
						$model->save(false);
					}

					// save the songs
					foreach ($songs_to_save as $song) {
						$song->artist_id = $model->id;
						$song->save(false);
					}

					// here, it means no exception was thrown during saving of artist and its
songs (from the DB, for example).
					// good - now commit it all...:
					$trans->commit();
				}
				catch (Exception $e) {
					// oops, saving artist or its songs failed. rollback, report us, and show
the user an error.
					$trans->rollback();
					Yii::log("Error occurred while saving artist or its 'songs'. Rolling
back... . Failure reason as reported in exception: " . $e->getMessage(),
CLogger::LEVEL_ERROR, __METHOD__);
					Yii::app()->user->setFlash('error',
Yii::t("MusicModule.forms", "Error occurred"));
					$success_saving_all = false;
				}

				if ($success_saving_all) {
					// everything's done. Would you believe it?! Go to 'view' page :)
					$success_msg = (count($songs_to_save) > 0) ? "Artist and song
records has been created successfully!" : "Artist record has been
created successfully!";
					Yii::app()->user->setFlash('success',
Yii::t("MusicModule.forms", $success_msg));
					$this->redirect(array("view", "id" =>
$model->id));
				}
			}
		}

		$this->render('create', array(
			'artist' => $model,
			'songs' => (isset($songs_to_save)) ? $songs_to_save : array(new
Song('insert')),
			'enable_add_more_songs' => $enable_add_more_songs,
		));
	}
}
~~~

Now we need to implement an _update_ action. Updating will also update both the
artist and its existing songs, and will allow to add more songs (if limit not
exceeded).

### ArtistController, update action
~~~
[php]
class ArtistController extends Controller {
	public function actionUpdate($id) {
		/* @var Artist $model */
		$model = $this->loadModel($id);

		// check access
		if (!Yii::app()->user->checkAccess('edit artist')) {
			throw new CHttpException(401);
		}

		// does this artists exists in our DB?
		if ($model === null) {
			Yii::log("Artist update requested with id $id but no such artist
found!", CLogger::LEVEL_INFO, __METHOD__);
			throw new CHttpException(404, Yii::t("MusicModule.general", 'The
requested page does not exist.'));
		}

		// enable adding songs by default (will be changed below if needed to)
		$enable_add_more_songs = true;

		if (isset($_POST['Artist'])) {
			// start optimistically
			$all_songs_valid = true;
			$songs_to_save = array();
			$success_saving_all = true;

			$model->attributes = $_POST['Artist'];
			if (isset($_POST['Song'])) {
				if (count($_POST['Song']) > Song::MAX_SONGS_PER_ARTIST) {
					/*
					 * server side protection against attempt to submit more than
MAX_SONGS_PER_ARTIST
					 * this should be accompanied with a client side (JS) protection.
					 * If its accompanied with client side protection then going into this code
block means our system
					 * is being abused/"tested". No need to give a polite error
message.
					 */
					throw new CHttpException(500, Yii::t("MusicModule.forms",
"The max amount of allowed songs is {max_songs_num}",
array('{max_songs_num}' => Song::MAX_SONGS_PER_ARTIST)));
				}
				foreach ($_POST['Song'] as $index => $submitted_song) {
					// We could have empty songs which means empty submitted forms in POST.
Ignore those:
					if ($submitted_song['title'] == '') {
						// empty one - skip it, if you please.
						continue;
					}

					// next, validate each submitted song instance
					if ((int)$submitted_song['id'] > 0) {
						/* Validate that the submitted song belong to this artist */
						$song = Song::model()->findByPk($submitted_song['id']);
						if ($song->artist->id != $model->id) {
							Yii::log("Attempts to update Song with an id of {$song->id} but
it belongs to an Artist with an id of {$song->model->id}" .
									" and not 'this' artist with id = {$model->id}",
CLogger::LEVEL_ERROR, __METHOD__);
							throw new CHttpException(500, "Error occurred");
						}
					}
					else {
						// this submitted song object is a new model. instantiate one:
						$song = new Song();
					}

					$song->attributes = $submitted_song;
					if (!$song->validate()) {
						// at least one invalid song:
						$all_songs_valid = false;
						// we do not 'break' here since we want validation on all song at the same
shot
					}
					else {
						// put aside the valid song to be saved
						$songs_to_save[] = $song;
					}
				}

				// while we know that songs were submitted, determine if to show 'adding
songs' or no.
				// a check whether the max songs per artist was exceeded was performed
already above.
				if (count($_POST['Song']) == Song::MAX_SONGS_PER_ARTIST) {
					$enable_add_more_songs = false;
				}
			}

			if ($all_songs_valid && $model->validate()) {
				/* all songs (if any) were valid and artist object is valid too. Save it all
in one transaction. Save first the
				 * artist as we need its id for its songs records
				 */
				$trans = Yii::app()->db->beginTransaction();

				try {
					// use aux variable for manipulating the image file.
					$image = CUploadedFile::getInstance($model, 'icon_filename');
					// check if a new image was submitted or not:
					if ($image) {
						/* the only thing that might have changed in the update is the extension
name of the image (if you support more than 'only jpeg').
						 * therefore, if something was submitted, and since we already know the ID
of the artist (this is an update scenario), we can
						 * determine the full updated icon_filename attribute of the model prior
to its save() (unlike in create action - see there...).
						 */
						$model->icon_filename = $model->getImageFsFilename($image);
					}

					$model->save(false);
					// save the updated image, if any
					if ($image) {
						$image->saveAs($model->getImageFsFilename($image));
					}

					// save songs
					foreach ($songs_to_save as $song) {
						$song->save(false);
					}
					$trans->commit();
				}
				catch (Exception $e) {
					// oops, saving artist or its songs failed. rollback, report us, and show
the user an error.
					$trans->rollback();
					Yii::log("Error occurred while saving (update scenario) artist or its
'songs'. Rolling back... . Failure reason as reported in exception: " .
$e->getMessage(), CLogger::LEVEL_ERROR, __METHOD__);
					Yii::app()->user->setFlash('error',
Yii::t("MusicModule.forms", "Error occurred"));
					$success_saving_all = false;
				}

				if ($success_saving_all) {
					$success_msg = (count($songs_to_save) > 0) ? "Artist and song
records have been updated" : "Artist record have been updated";
					Yii::app()->user->setFlash('success',
Yii::t("MusicModule.forms", $success_msg));
					$this->redirect(array("view", "id" =>
$model->id));
				}
			}
		}
		else {
			// initial rendering of update form. prepare songs for printing.
			// we put it in the same variable as used for saving (that's the reason for
the awkward variable naming).
			$songs_to_save = $model->songs;
		}

		$this->render('update', array(
			'artist' => $model,
			'songs' => (isset($songs_to_save)) ? $songs_to_save : array(new
Song('insert')),
			'enable_add_more_songs' => $enable_add_more_songs,
		));
	}
}
~~~

## View files

Ok, now to the last member of the party - the view files:

### views/artist/_form.php
~~~
[php]
<?php
/* @var $this ArtistController */
/* @var $artist Artist */
/* @var $songs array */
/* @var $form CActiveForm */
/* @var $enable_add_more_songs bool */
?>

<div class="form">
	<?php $form = $this->beginWidget('CActiveForm', array(
	'id' => 'artist-form',
	'enableAjaxValidation' => false,
	// we need the next one for transmission of files in the form.
	'htmlOptions' => array('enctype' => 'multipart/form-data'),
)); ?>

	<p class="note">Fields with <span
class="required">*</span> are required.</p>

	<?php echo $form->errorSummary($artist); ?>

	<?php // All 'regular' artist form fields were omitted...  ?>

	<fieldset>
		<legend><?php echo Yii::t("MusicModule.forms",
"Songs"); ?></legend>
		<div id="songs-multiple">
			<?php
			$i = 0;
			foreach ($songs as $song) {
				/* @var Song $song */
				$this->renderPartial('/songs/_form', array('model' => $song, 'counter'
=> $i));
				$i++;
			}
			?>
			<?php
			// add button to 'add' a song if adding more songs is enabled
			if ($enable_add_more_songs) {
				// print the button here, which replicates the form but advances its
counters
				// add the blank, extra 'song' form, with display=none... :
				echo '<div id="extra-song" style="display:
none;">';
				$this->renderPartial('/song/_form', array('model' => new Song(),
'counter' => $i));
				echo "</div>";
			}
			?>
		</div>
	</fieldset>

	<div class="row">
		<?php echo $form->labelEx($artist, 'icon_filename'); ?>
		<?php if (isset($update) && ($update === true)) { ?>
		<div><?php echo 'Choose new file or leave empty to keep current
image.';?></div>
		<?php } ?>
		<?php echo $form->fileField($artist, 'icon_filename', array('size' =>
20, 'maxlength' => 512)); ?>
		<?php echo $form->error($artist, 'icon_filename'); ?>
	</div>

	<div class="row buttons">
		<?php echo CHtml::submitButton($artist->isNewRecord ? 'Create' :
'Save'); ?>
	</div>

	<?php $this->endWidget(); ?>
</div>
~~~

### views/song/_form.php
~~~
[php]
<?php
/* @var Song $song */
/* @var int $counter */

/*
 * Design note: in order to prevent nested forms that will possibly confuse yii
we render this form using simple CHtml methods
 *  (like beginForm()) and not using CActiveForm
 */
?>
<div class="form">
	<?php
	echo CHtml::beginForm();
	?>
	<div class="song-<?php echo $counter ?>">
		<div class="row">
			<?php
			// if this is an 'update' use case (form re-use), render also the id of the
song itself (so upon submission we'll
			// know which song to update. Check if $song->id exists...
			?>
		</div>

		<?php
		/*
		 * Rest of the form fields should be rendered here, using CHtml::...
		 */
		?>
	</div>

	<?php CHtml::endForm(); ?>
</div>
~~~