Creating and updating model and its related models in one form, inc. image

  1. General
  2. How to use this article?
  3. Features / Use case
  4. Design notes
  5. Comments?
  6. Model files
  7. Controller class
  8. View files

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
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
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
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
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
/* @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
/* @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>
6 1
21 followers
Viewed: 169 658 times
Version: 1.1
Category: How-tos
Written by: Boaz
Last updated by: Boaz
Created on: Sep 9, 2012
Last updated: 11 years ago
Update Article

Revisions

View all history

Related Articles