📜 ⬆️ ⬇️

Unlimited nesting and URL section tree

In this article, we will consider one of the possible approaches to the generation of a full path to a partition, which can have unlimited nesting in other sections, as well as quickly obtain the desired partition from a given path.

Imagine that we are programming an online store in which there should be a tree of various sections, and also there should be "pleasant" links to sections that would include all subsections. Example: http://example.com/catalog/category/sub-category .

Sections


The most obvious option is to create a link to the parent through the parent_id attribute and the parent relation.

 class Category extends Model { public function parent() { return $this->belongsTo(self::class); } } 

Also, our model has an attribute slug - a stub that reflects the section in the URL. It can be generated from the name, or specified by the user manually. Most importantly, the stub must pass the alphadash validation alphadash (that is, consist of letters, numbers, and signs, _ ), and also be unique within the parent section. For the latter, it is enough to create a unique index in the database (parent_id, slug) .
')
To get a link to the section, you need to pull out all his parents sequentially. The URL generation function looks like this:

 public function getUrl() { $url = $this->slug; $category = $this; while ($category = $category->parent) { $url = $category->slug.'/'.$url; } return 'catalog/'.$url; } 

The larger the section has ancestors, the more requests to the database will be executed. But this is only part of the problem. How to create a route to the section? Let's try this:

 $router->get('catalog/{category}', ...); 

Feed the browser link http://example.com/catalog/category . The route will work. Now this link: http://example.com/catalog/category/sub-category . The route will no longer work, because backslash is a parameter delimiter. Hmm, then add another parameter and make it optional:

 $router->get('catalog/{category}/{subcategory?}', ...); 

This route will already work, but if you add another subsection to the URL, then nothing will work. And the problem is that the number of such subsections is not limited.

Further, in order to pull out the necessary section from the database, you must first find the section with the category identifier, then, if specified, the subcategory subsection, etc. All this causes inconvenience and server load, the number of requests is proportional to the number of subsections.

Optimization


The extension for laravel kalnoy / nestedset will help us greatly reduce the number of requests. It is designed to simplify work with trees.

Installation


Installation is very simple. First you need to install the extension through composer:

 composer require kalnoy/nestedset 

The model will need two additional attributes that need to be added to the new migration:

 Schema::table('categories', function (Blueprint $table) { $table->unsignedInteger('_lft'); $table->unsignedInteger('_rgt'); }); 

Now you only need to delete the old parent and children relations, if they have been set, and also add a trait Kalnoy\Nestedset\NodeTrait . After the upgrade, our model looks like this:

 class Category extends Model { use Kalnoy\Nestedset\NodeTrait; } 

However, the _lft and _rgt not filled in, so that everything _rgt , the final touch remains:

 Category::fixTree(); 

This code will " parent_id " the tree based on the parent_id attribute.

Simplified Generation


The process of generating a URL looks like this:

 public function getUrl() { //     $slugs = $this->ancestors()->lists('slug'); //     $slugs[] = $this->slug; //     return 'catalog/'.implode('/', $slugs); } 

Much easier, right? No matter how many descendants of this section, they will all be received in one request. But with the routes is not so simple. Still it is not possible to get a chain of sections for one request.

Routes


Task number 1. How to set a route to a section with all its ancestors in the link?

Task number 2. How to get all the way to the desired section in one request?

Description of the route


The answer to the first task is to use the entire path as a route parameter .

 $router->get('catalog/{path}', 'CategoriesController@show') ->where('path', '[a-zA-Z0-9/_-]+'); 

We simply indicate that the {path} parameter can contain not only the usual string, but also a backslash. Thus, this parameter immediately captures the entire path that follows the control word catalog .

Now in the controller at the input we get only one parameter, but we can break it into all subsections:

 public function show($path) { $path = explode('/', $path); } 

However, this did not simplify the task of obtaining the section specified in the link.

Bundle path with section


So how to optimize this process? Store the full path for each section in the database .

Suppose there is such a simple tree:

 - Category -- Sub category --- Sub sub category 

These sections will correspond to the following ways:

 - category -- category/sub-category --- category/sub-category/sub-sub-category 

Then the desired category can be obtained very simply:

 public function show($path) { $category = Category::where('path', '=', $path)->firstOrFail(); } 

Now we save in the database what we previously generated for the link, and the generation of the link is now much simpler:

 //   public function generatePath() { $slugs = $this->ancestors()->lists('slug'); $slugs[] = $this->slug; $this->path = implode('/', $slugs); return $this; } //   public function getUrl() { return 'catalog/'.$this->path; } 

If you look at the list of paths in the example, you can see that the path for each model is -/- . Therefore, the path generation can be further optimized:

 public function generatePath() { $slug = $this->slug; $this->path = $this->isRoot() ? $slug : $this->parent->path.'/'.$slug; return $this; } 

The following problem remains. When the stub of one section is updated, or the section changes the parent, the links of all its descendants must be updated. The algorithm is simple: get all descendants and generate a new path for them. The following is a method for updating descendants:

 public function updateDescendantsPaths() { //       $descendants = $this->descendants()->defaultOrder()->get(); //     parent  children $descendants->push($this)->linkNodes()->pop(); foreach ($descendants as $model) { $model->generatePath()->save(); } } 

Consider in more detail.

In the first line we get all descendants (for one request). defaultOrder here applies tree sorting. Its meaning is that in the list each section will stand after its ancestor . The path construction algorithm uses the parent, so it is necessary that the parent update its path before the path of any of its descendants is updated.

The second line looks a bit strange. Its meaning is that it fills the parent relationship, which is used in the path generation algorithm. If you do not use this optimization, each generatePath call will execute a query to get the value of the parent relationship. In this case, linkNodes works with the collection of sections and does not make any queries to the database. Therefore, for this to work for the immediate children of the current section, you need to add it to the collection. We add the current section, we connect all sections among themselves and we remove it.

Well, at the end of the passage through all descendants and updating their paths.

It remains only to decide when to call this method. For this great event:

  1. Before saving the model, check if the slug or parent_id attributes have changed. If changed, we call the generatePath method;

  2. After the model has been successfully saved, we check if the path attribute has changed, and if it has changed, call the updateDescendantsPaths method.

 protected static function boot() { static::saving(function (self $model) { if ($model->isDirty('slug', 'parent_id')) { $model->generatePath(); } }); static::saved(function (self $model) { //     ,      // , ..      static $updating = false; if ( ! $updating && $model->isDirty('path')) { $updating = true; $model->updateDescendantsPaths(); $updating = false; } }); } 

results


The advantages of this approach:


Disadvantages:


In fact, the advantages outweigh the disadvantages very much in view of the fact that you need to generate links and get sections more often than update the stubs; and space overrun is miserable.

Products


Consider approaches to generating links to products that would include the path to the section. For example: http://example.com/catalog/category/sub-catagory/product . The main problem here is to form the correct route.

The product, as well as the section, has a stub, which can be specified manually or generated based on the name. It is important that this stub must be unique within the section in order to avoid conflicts. It is best to create a unique index in the database (category_id, slug) .

Let's try the easiest option and consider the following routes:

 //    $router->get('catalog/{path}', function ($path) { return 'category = '.$path; })->where('path', '[a-zA-Z0-9\-/_]+'); //    $router->get('catalog/{category}/{product}', function ($category, $product) { return 'category = '.$category.'<br>product = '.$product; })->where('category', '[a-zA-Z0-9\-/_]+'); 

The first route should already be familiar - this is the route of the section output. The second route is practically the same, only one more parameter was added to the end, which should point to a specific product in this section. If you try to enter the above example in the browser line, we get the following:

 category = category/sub-category/product 

The first route worked; not exactly what was expected to get. This is because the first route will work for any line that starts with the catalog keyword. Need to swap routes. Then we get:

 category = category/sub-category product = product 

Fine! This is better, but not all. Let's try this URL: http://example.com/catalog/category/sub-category . We get the following:

 category = category product = sub-category 

Now only route to the goods is triggered. It is necessary to unambiguously separate the cap of the goods from the section cap. For this you can use any prefix / postfix. For example, to add a numeric identifier to the end or to the beginning of a product stub:

http://example.com/catalog/category/sub-category/123-product

It remains only to add a limit on the parameter {product} :

 $router->get(...)->where('product', '[0-9]+-[a-zA-Z0-9_-]+'); 

In this case, the generation of a product stub looks like this:

 $product->slug = $product->id.'-'.str_slug($product->name); 

Link generation:

 $url = 'catalog/'.$product->category->path.'/'.$product->slug; 

Receipt of goods in the controller:

 public function show($categoryPath, $productSlug) { //      $category = Category::where('path', '=', $categoryPath)->firstOrFail(); //          $product = $category->products() ->where('slug', '=', $productSlug) ->firstOrFail(); } 

Here, however, a condition arises: the stubs of sections should not begin with a number. Otherwise, the route to the goods will work, instead of the route to the section.

You can use any static prefix, for example p- :

http://example.com/catalog/category/sub-category/p-product

 $router->get('catalog/{category}/p-{product}', ...); 

 $product->slug = str_slug($product->name); 

 $url = 'catalog/'.$product->category->path.'/p-'.$product->slug; 

The controller code remains as in the previous case.

The last option is the most difficult. Its essence is to keep links to sections and products in a separate table.

The model looks like this:

 class Url extends Model { //   public function model() { return $this->morphTo(); } } 

With this approach, only one route is sufficient:

 $router->get('catalog/{path}', function ($path) { $url = Url::findOrFail($path); //     $model = $url->model; if ($model instanceof Product) { return $this->renderProduct($model); } return $this->renderCategory($model); }) ->where('path', '[a-zA-Z0-9\-/_]+'); 

The Url model has a polymorphic relationship with other models and stores full paths on them. What it gives:


This approach is described quite arbitrarily as food for thought. Perhaps it will even pull on a separate extension.

findings


In this article, we reviewed the main expansion options for the kalnoy/nestedset , as well as approaches to forming links to sections and products in the case where the nesting depth of sections is not limited.

As a result, a method was obtained that allows you to generate links without making queries to the database, as well as receive sections by reference in one request.

As an alternative to storing paths in the database, you can use the caching of generated links. Then there is no need to update the links and just reset the cache.

Source: https://habr.com/ru/post/279233/


All Articles