Kohana Nested Sets Module — Работаем с деревьями в Kohana

Kohana Nested Sets

Вот и добрался я до того, что бы представить сознательной общественности еще одну свою наработку — модуль Kohana Nested Sets. Который, как вы наверное уже догадались, позволяет работать с деревьями в Kohana.

И так приступим … Я собственно говоря не буду ходить вокруг да около и приступлю сразу к его использованию, для этого рассмотрим использование модуля Kohana Nested Sets на простом и обыденном (как эта жизнь) примере который встречается весьма часто, а именно в процессе знакомства с модулем я вместе с вами создам простое приложение на Kohana 3.2 которое будет позволять управлять структурой категорий чего-либо.

1 — Установка и настройка Kohana

Сразу оговорюсь на данном шаге я для вас не открою новую вселенную поэтому я его пропущу …, если вы все таки не знаете как установить и настроить Kohana, то вы можете узнать это тут я же просто воспользуюсь настроенным фреймворком из моей предыдущей статьи о Kohana.

2 — Установка модуля Kohana Nested Sets

Для начала нужно скопировать файлы модуля в директорию модулей Kohana для этого вы можете скачать файлы модуля Kohana Nested Sets с github или воспользоваться git для его установки:

git clone https://github.com/snovichkov/kohana-nested-sets.git

Далее нам нужно зарегистрировать модуль в Kohana для этого в файле application/bootstrap.php настройте параметры вызова метода Kohana::modules примерно так:

Kohana::modules(array(
	// 'auth'       => MODPATH.'auth',       // Basic authentication
	// 'cache'      => MODPATH.'cache',      // Caching with multiple backends
	// 'codebench'  => MODPATH.'codebench',  // Benchmarking tool
	'database'      => MODPATH.'database',   // Database access
	// 'image'      => MODPATH.'image',      // Image manipulation
	'orm'           => MODPATH.'orm',        // Object Relationship Mapping
	// 'unittest'   => MODPATH.'unittest',   // Unit testing
	// 'userguide'  => MODPATH.'userguide',  // User guide and API documentation
	'nested-sets'   => MODPATH.'kohana-nested-sets', // Kohana Nested Sets Module
));

3 — Создание таблицы в БД

Предположим что наши категории будут иметь 2 параметра для редактирования это имя и описание категории, на основании чего создадим  для хранения наших категорий таблицу в БД. SQL скрипт создания таблицы представлен ниже:

CREATE TABLE IF NOT EXISTS `categories` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `lft` int(11) NOT NULL,
  `rgt` int(11) NOT NULL,
  `level` int(4) NOT NULL,
  `name` varchar(200) NOT NULL,
  `description` text,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB  DEFAULT CHARSET=utf8 ;

4 — Создание модели

Так как для работы с БД мы будем использовать Kohana ORM, то для начала работы нам нужно описать модель категории которая будет наследовать класс ORM_Nested_Sets, то что получилось у меня представлено ниже:

<?php defined('SYSPATH') or die('No direct script access.');

/**
 * Category entity class
 *
 * @author     Novichkov Sergey(Radik) <novichkovsergey@yandex.ru>
 * @copyright  Copyrights (c) 2012 Novichkov Sergey
 *
 * @property  integer $id
 * @property  integer $lft
 * @property  integer $rgt
 * @property  integer $level
 * @property  string  $name
 * @property  string  $description
 */
class Model_Category extends ORM_Nested_Sets{

	/**
	 * Use or not scope for multi root's tree
	 *
	 * @var bool
	 */
	protected $use_scope = FALSE;

	/**
	 * Table columns
	 *
	 * Field name => Label
	 *
	 * @var array
	 */
	protected  $_table_columns = array(
		'id'            => 'id',
		'lft'           => 'lft',
		'rgt'           => 'rgt',
		'level'         => 'level',
		'name'          => 'name',
		'description'   => 'description',
	);

} // End Model_Category

5 — Создание маршрутов

Для того что бы приступить к созданию контроллера нам нужно подготовить маршрут (файл application/bootstrap.php) который будет использовать наш контроллер, я за основу взял базовый маршрут изменив параметры по умолчанию для главной страницы приложения:

Route::set('default', '(<controller>(/<action>(/<id>)))')
	->defaults(array(
		'controller' => 'category',
		'action'     => 'view',
	));

6 — Создание контроллера

Что бы не раздувать статью я определил следующие действия которые будет реализовывать наш контроллер:

  1. Вывод дерева списком
  2. Добавление / Изменение узла дерева
  3. Удаление узла из дерева

Вот то, что у меня получилось:

<?php defined('SYSPATH') or die('No direct script access.');

/**
 * Category controller class
 *
 * @author     Novichkov Sergey(Radik) <novichkovsergey@yandex.ru>
 * @copyright  Copyrights (c) 2012 Novichkov Sergey
 */
class Controller_Category extends Controller_Template {

	/**
	 * @var View
	 */
	public $template = 'category/view';

	/**
	 * View all tree action
	 */
	public function action_view()
	{
		/** @var Model_Category $root **/ // check root category
		$root = ORM::factory('Category', array('lft' => 1, ));

		// if root not exist, create new root
		if (! $root->loaded())
		{
			// create new
			$root->name = 'Root';
			$root->description = 'Root node of categories tree';
			$root->save();

			// refresh root
			$root->reload();
		}

		// set template variables
		$this->template->set(array(
			// all root node child's
			'root' => $root,
			'categories' => $root->get_descendants(),
			'message' => Session::instance()->get_once('message'),
		));
	}

	/**
	 * Create node action
	 */
	public function action_new()
	{
		$this->action_modify();
	}

	/**
	 * Edit node action
	 */
	public function action_edit()
	{
		$this->action_modify();
	}

	/**
	 * Create or edit view action
	 */
	private  function action_modify()
	{
		// change template
		$this->template = View::factory('category/modify');

		/** @var Model_Category $root **/ // check root category
		$root = ORM::factory('Category', array('lft' => 1, ));

		// check root
		if ( ! $root->loaded())
		{
			throw new HTTP_Exception_502('Root node of categories tree not founded');
		}

		/** @var Model_Category $node **/ // node
		$node = ORM::factory('Category', $this->request->param('id'));

		// set template variables
		$this->template->set(array(
			// all root node child's
			'root' => $root,
			'node' => $node,
			'categories' => $root->get_descendants(),
			'message' => Session::instance()->get_once('message'),
		));
	}

	/**
	 * Create, Update tree node action
	 */
	public function action_save()
	{
		/** @var Model_Category $root **/ // root node
		$root = ORM::factory('Category', Arr::get($this->request->post(), 'parent'));

		// check root
		if ( ! $root->loaded())
		{
			throw new HTTP_Exception_502('Root node of categories tree not founded');
		}

		/** @var Model_Category $node **/ // create new node object
		$node = ORM::factory('Category', Arr::get($this->request->post(), 'id'));

		// bind data
		$node->values($this->request->post(), array('name', 'description'));

		// insert node as last child of root
		try {
			if (! $node->loaded())
			{
				// insert
				$node->insert_as_last_child_of($root);
			}
			else
			{
				// save
				$node->save();

				// change parent if needed
				if (! $root->is_equal_to($node->get_parent()))
				{
					$node->move_as_last_child_of($root);
				}
			}
		} catch (Exception $e) {
			throw $e;
			// process error check
		}

		// setup success message
		Session::instance()->set('message', 'Operation was successfully completed');

		// redirect to modify page
		$this->request->redirect(Route::url('default', array('controller' => 'category', 'action' => 'edit', 'id' => $node->id)));
	}

	/**
	 * Delete node action
	 */
	public function action_delete()
	{
		/** @var Model_Category $node **/ // node to delete
		$node = ORM::factory('Category', $this->request->param('id'));

		// check node
		if (! $node->loaded())
		{
			// setup error message
			Session::instance()->set('message', 'Operation was failed');

			// redirect to view tree page
			$this->request->redirect('/');
		}

		// remove node
		$node->delete();

		// setup success message
		Session::instance()->set('message', 'Operation was successfully completed');

		// redirect to view tree page
		$this->request->redirect('/');
	}

} // End Controller Category

P.S: Сразу хотел бы оговориться, что при создании контроллера я не удилил достаточно внимания проверке ошибок, а так же валидации т.к. это не является целью данной статьи и я намеренно упустил все это, что бы не заграмождать код.

7 — Создание шаблонов

Как уже видно из кода контроллера я использовал два шаблона в проекте.

category/view.php — вывод дерева в виде таблицы и отображение ссылок для основных операций над деревом

<?php defined('SYSPATH') or die('No direct script access.');
/**
 * @var Model_Category $root
 * @var Boolean|Database_Result $categories
 * @var string $message
 *
 * @author     Novichkov Sergey(Radik) <novichkovsergey@yandex.ru>
 * @copyright  Copyrights (c) 2012 Novichkov Sergey
 */
?><!DOCTYPE html>
<html>
	<head>
		<title>Categories</title>
		<style type="text/css">
			*{ margin: 0; padding: 0; }
			html, body{ width: 100%; height: 100%; }
			a:hover{ text-decoration: none; }
			#wrap{ margin: 0 auto; width: 960px; }
			#wrap h2{ margin: 20px 0; }
			#wrap h2 a{ float: right; }
			#wrap .message{ padding: 5px; border: 3px solid #00f; color: #00f; margin-bottom: 20px; }
			#wrap table{ width: 100%; border-collapse: collapse; }
			#wrap table th, td{ border: 1px solid #000; padding: 5px; vertical-align: top; }
			#wrap table th{ background: #000; color: #fff; }
			#wrap .actions{ white-space: nowrap; width: 1%; }
		</style>
	</head>

	<body>
		<div id="wrap">
			<h2>Categories <a href="<?php echo Route::url('default', array('controller' => 'category', 'action' => 'new')) ?>" title="New">New</a></h2>
			<?php if ($message) : ?>
			    <div class="message"><?php echo HTML::chars($message) ?></div>
			<?php endif; ?>
			<table>
				<tr>
					<th>Name</th>
					<th>Description</th>
					<th class="actions">Actions</th>
				</tr>
				<?php if ($categories AND $categories->count() > 0) : ?>
					<?php foreach($categories as $category) : /** @var Model_Category $category **/ ?>
						<tr>
							<td style="padding-left: <?php echo 5 + ($category->level - 1) * 10 ?>px"><?php echo HTML::chars($category->name) ?></td>
							<td><?php echo HTML::chars($category->description) ?></td>
							<td class="actions">
								<a title="Edit" href="<?php echo Route::url('default', array('controller' => 'category', 'action' => 'edit', 'id' => $category->id)) ?>">Edit</a> |
								<a title="Delete" href="<?php echo Route::url('default', array('controller' => 'category', 'action' => 'delete', 'id' => $category->id)) ?>">Delete</a>
							</td>
						</tr>
					<?php endforeach; ?>
				<?php else : ?>
					<tr><td colspan="3">Tree of categories is empty</td></tr>
				<?php endif; ?>
			</table>
		</div>
	</body>
</html>

category/modify.php — отображение формы для редактирования или создания узла

<?php defined('SYSPATH') or die('No direct script access.');
/**
 * @var Model_Category $node
 * @var Model_Category $root
 * @var Boolean|Database_Result $categories
 * @var string $message
 *
 * @author     Novichkov Sergey(Radik) <novichkovsergey@yandex.ru>
 * @copyright  Copyrights (c) 2012 Novichkov Sergey
 */
?><!DOCTYPE html>
<html>
	<head>
		<title>Categories</title>
		<style type="text/css">
			*{ margin: 0; padding: 0; }
			html, body{ width: 100%; height: 100%; }
			a:hover{ text-decoration: none; }
			#wrap{ margin: 0 auto; width: 960px; }
			#wrap h2{ margin: 20px 0; }
			#wrap .message{ padding: 5px; border: 3px solid #00f; color: #00f; margin-bottom: 20px; }
			#wrap .row{ margin-bottom: 5px; }
			#wrap .row label{ display: block; margin-bottom: 5px; }
			#wrap .row input, #wrap .row textarea, #wrap .row select{ width: 100%; }
			#wrap .row textarea{ height: 100px; resize: vertical; }
			#wrap .controls{ text-align: right }
		</style>
	</head>

	<body>
		<div id="wrap">
			<h2><?php echo $node->loaded() ? 'Edit' : 'New' ?> Category</h2>
			<?php if ($message) : ?>
			<div class="message"><?php echo HTML::chars($message) ?></div>
			<?php endif; ?>
			<form method="post" action="<?php echo Route::url('default', array('controller' => 'category', 'action' => 'save')) ?>">
				<div class="row">
					<?php $parent = $node->get_parent() ?>
					<label for="category_parent">Parent</label>
					<select name="parent" id="category_parent">
						<option value="<?php echo $root->id ?>">Root</option>
						<?php if ($categories AND $categories->count() > 0) : ?>
						<?php foreach ($categories as $category) : ?>
							<option<?php if ($category->id == $parent->id) : ?> selected="selected"<?php endif ?> value="<?php echo $category->id ?>"><?php echo str_repeat(' ', $category->level * 3), HTML::chars($category->name) ?></option>
							<?php endforeach; ?>
						<?php endif; ?>
					</select>
				</div>
				<div class="row">
					<label for="category_name">Name</label>
					<input type="text" name="name" value="<?php echo HTML::chars($node->name) ?>" id="category_name">
				</div>
				<div class="row">
					<label for="category_description">Description</label>
					<textarea rows="10" cols="10" name="description" id="category_description"><?php echo HTML::chars($node->description) ?></textarea>
				</div>
				<div class="controls">
					<a href="/" title="Back to list">Back to list</a>
					<input type="submit" value="<?php echo $node->loaded() ? 'Save' : 'Create' ?>">
					<input type="hidden" name="id" value="<?php echo HTML::chars($node->id) ?>">
				</div>
			</form>
		</div>
	</body>
</html>

Ну вот и все мы на простом примере рассмотрели как использовать модуль Kohana Nested Sets, используйте его на здоровье :), а так же все свои предложения или пожелания оставляйте в комментариях к этому посту или на странице проекта на github.

Скачать исходные коды урока

5 комментариев

  1. Kuba:

    I am afraid I’ve found bug. Your module does not work with transactions!

    $db = Database::instance();
    $db>begin();

    try {
    // I assumed $id was initited early in code and that $category_parent exists;
    $category_parent = $category = ORM::factory(‘category’,$id);
    $category = ORM::factory(‘category’)
    //do something with category
    $category->save()
    $db->commit()
    } catch (Exception $e) {
    $db->rollback()
    }

    Even if $category->save() throws an Exception changes made to database are not rolled back.

  2. Александр:

    Хороший модуль. Только по стилю кода вы в кохановское соглашение не попали. А за перенос из Doctrine — отдельное мерси!!!

    • radik:

      Рад, что вам пригодился модуль. Касательно соглашений Kohana я всегда готов к критике именно по этому код выложен на github, и исправить все недостатки вы можете сами и сделать pull request.

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *