diff --git a/README.md b/README.md index 00f6d4a..b993cd5 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ -

Nested sets behavior.

+

Nested sets behavior


@@ -19,25 +19,265 @@ PHPUnit + + Mutation Testing + Static Analysis

+A powerful behavior for managing hierarchical data structures using the nested sets pattern in Yii ActiveRecord models. + +Efficiently store and query tree structures like categories, menus, organizational charts, and any hierarchical data +with high-performance database operations. + +## Features + +- ✅ **Efficient Tree Operations** - Insert, move, delete nodes with automatic boundary management. +- ✅ **Flexible Queries** - Find ancestors, descendants, siblings, leaves, and roots. +- ✅ **Multiple Trees Support** - Manage multiple independent trees in the same table. +- ✅ **Query Optimization** - Single-query operations for maximum performance. +- ✅ **Transaction Safety** - All operations are wrapped in database transactions. +- ✅ **Validation & Error Handling** - Comprehensive validation with clear error messages. + ## Quick start ### Installation ```bash -composer require --prefer-dist yii2-extensions/nested-sets-behavior +composer require yii2-extensions/nested-sets-behavior +``` + +### How it works + +1. **Creates root nodes** using the nested sets pattern with `lft`, `rgt`, and `depth` fields. +2. **Manages hierarchy** automatically when inserting, moving, or deleting nodes. +3. **Optimizes queries** using boundary values for efficient tree traversal. +4. **Supports transactions** to ensure data integrity during complex operations. + +### Basic Configuration + +Add the behavior to your ActiveRecord model. + +```php + [ + 'class' => NestedSetsBehavior::class, + // 'treeAttribute' => 'tree', // Enable for multiple trees + // 'leftAttribute' => 'lft', // Default: 'lft' + // 'rightAttribute' => 'rgt', // Default: 'rgt' + // 'depthAttribute' => 'depth', // Default: 'depth' + ], + ]; + } + + public function transactions(): array + { + return [ + self::SCENARIO_DEFAULT => self::OP_ALL, + ]; + } +} +``` + +### Database schema + +Create the required database fields. + +```sql +-- Single tree structure +CREATE TABLE category ( + id INT PRIMARY KEY AUTO_INCREMENT, + name VARCHAR(255) NOT NULL, + lft INT NOT NULL, + rgt INT NOT NULL, + depth INT NOT NULL +); + +-- Multiple trees structure +CREATE TABLE category ( + id INT PRIMARY KEY AUTO_INCREMENT, + tree INT, + name VARCHAR(255) NOT NULL, + lft INT NOT NULL, + rgt INT NOT NULL, + depth INT NOT NULL +); +``` + +### Basic Usage + +#### Creating and building trees + +```php + 'Electronics']); +$root->makeRoot(); + +// Add children +$phones = new Category(['name' => 'Mobile Phones']); +$phones->appendTo($root); + +$computers = new Category(['name' => 'Computers']); +$computers->appendTo($root); + +// Add grandchildren +$smartphone = new Category(['name' => 'Smartphones']); +$smartphone->appendTo($phones); + +$laptop = new Category(['name' => 'Laptops']); +$laptop->appendTo($computers); +``` + +#### Querying the tree + +```php +children()->all(); + +// Get only direct children +$directChildren = $root->children(1)->all(); + +// Get all ancestors of a node +$parents = $smartphone->parents()->all(); + +// Get all leaf nodes (nodes without children) +$leaves = $root->leaves()->all(); + +// Navigate siblings +$nextSibling = $phones->next()->one(); +$prevSibling = $computers->prev()->one(); +``` + +#### Moving nodes + +```php +appendTo($computers); + +// Move as first child +$smartphone->prependTo($phones); + +// Move as next sibling +$smartphone->insertAfter($laptop); + +// Move as previous sibling +$smartphone->insertBefore($laptop); + +// Make node a new root (multiple trees only) +$smartphone->makeRoot(); +``` + +#### Deleting nodes + +```php +delete(); + +// Delete node with all descendants +$phones->deleteWithChildren(); +``` + +### Query builder integration + +Add query behavior for advanced tree queries. + +```php + + */ +class CategoryQuery extends ActiveQuery +{ + public function behaviors(): array + { + return [ + 'nestedSetsQuery' => NestedSetsQueryBehavior::class, + ]; + } +} + +// In your Category model +/** + * @phpstan-return CategoryQuery + */ +public static function find(): CategoryQuery +{ + return new CategoryQuery(static::class); +} +``` + +Now you can use enhanced queries. + +```php +roots()->all(); + +// Find all leaf nodes +$leaves = Category::find()->leaves()->all(); ``` ## Documentation For detailed configuration options and advanced usage. +- 📚 [Installation Guide](docs/installation.md) +- ⚙️ [Configuration Reference](docs/configuration.md) +- 💡 [Usage Examples](docs/examples.md) - 🧪 [Testing Guide](docs/testing.md) + ## Quality code [![Latest Stable Version](https://poser.pugx.org/yii2-extensions/nested-sets-behavior/v)](https://github.com/yii2-extensions/nested-sets-behavior/releases) diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..1867b00 --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,582 @@ +# Configuration reference + +## Overview + +This guide covers all configuration options for the Yii Nested Sets Behavior, from basic setup to advanced hierarchical +data management scenarios. + +## Basic configuration + +### Minimal setup + +```php + [ + 'nestedSets' => NestedSetsBehavior::class, + ], +]; +``` + +### Standard model configuration + +```php + [ + 'class' => NestedSetsBehavior::class, + 'leftAttribute' => 'lft', + 'rightAttribute' => 'rgt', + 'depthAttribute' => 'depth', + // 'treeAttribute' => 'tree', // Enable for multiple trees + ], + ]; + } + + public function transactions(): array + { + return [ + self::SCENARIO_DEFAULT => self::OP_ALL, + ]; + } +} +``` + +## Attribute configuration + +### Core nested sets attributes + +Configure the database field names used by the behavior. + +```php +'behaviors' => [ + 'nestedSets' => [ + 'class' => NestedSetsBehavior::class, + 'leftAttribute' => 'lft', // Left boundary field + 'rightAttribute' => 'rgt', // Right boundary field + 'depthAttribute' => 'depth', // Depth/level field + ], +], +``` + +### Custom attribute names + +Use custom field names if your database schema differs. + +```php +'behaviors' => [ + 'nestedSets' => [ + 'class' => NestedSetsBehavior::class, + 'leftAttribute' => 'left_boundary', + 'rightAttribute' => 'right_boundary', + 'depthAttribute' => 'tree_level', + ], +], +``` + +### Multiple trees support + +Enable multiple independent trees in the same table. + +```php +'behaviors' => [ + 'nestedSets' => [ + 'class' => NestedSetsBehavior::class, + 'treeAttribute' => 'tree', // Field to distinguish trees + 'leftAttribute' => 'lft', + 'rightAttribute' => 'rgt', + 'depthAttribute' => 'depth', + ], +], +``` + +## Query behavior configuration + +Add enhanced query capabilities to your models. + +### Basic query behavior + +```php + + */ +class CategoryQuery extends ActiveQuery +{ + public function behaviors(): array + { + return [ + 'nestedSetsQuery' => NestedSetsQueryBehavior::class, + ]; + } +} +``` + +### Integration with model + +```php + + */ + public static function find(): CategoryQuery + { + return new CategoryQuery(static::class); + } +} +``` + +## Database schema configurations + +### Single tree schema + +For applications with one tree per table. + +```sql +CREATE TABLE category ( + id INT PRIMARY KEY AUTO_INCREMENT, + name VARCHAR(255) NOT NULL, + lft INT NOT NULL, + rgt INT NOT NULL, + depth INT NOT NULL, + + INDEX idx_lft_rgt (lft, rgt), + INDEX idx_depth (depth) +); +``` + +### Multiple trees schema + +For applications with multiple independent trees. + +```sql +CREATE TABLE category ( + id INT PRIMARY KEY AUTO_INCREMENT, + tree INT, + name VARCHAR(255) NOT NULL, + lft INT NOT NULL, + rgt INT NOT NULL, + depth INT NOT NULL, + + INDEX idx_tree_lft_rgt (tree, lft, rgt), + INDEX idx_tree_depth (tree, depth) +); +``` + +### Custom schema with different field names + +```sql +CREATE TABLE hierarchy ( + id INT PRIMARY KEY AUTO_INCREMENT, + tree_id INT, + title VARCHAR(255) NOT NULL, + left_boundary INT NOT NULL, + right_boundary INT NOT NULL, + tree_level INT NOT NULL, + + INDEX idx_tree_boundaries (tree_id, left_boundary, right_boundary), + INDEX idx_tree_level (tree_id, tree_level) +); +``` + +Corresponding behavior configuration. + +```php +'behaviors' => [ + 'nestedSets' => [ + 'class' => NestedSetsBehavior::class, + 'treeAttribute' => 'tree_id', + 'leftAttribute' => 'left_boundary', + 'rightAttribute' => 'right_boundary', + 'depthAttribute' => 'tree_level', + ], +], +``` + +## Transaction configuration + +### Enable transactions + +Ensure data integrity during tree operations. + +```php +public function transactions(): array +{ + return [ + self::SCENARIO_DEFAULT => self::OP_ALL, + ]; +} +``` + +### Selective transaction control + +Enable transactions only for specific operations. + +```php +public function transactions(): array +{ + return [ + self::SCENARIO_DEFAULT => self::OP_INSERT | self::OP_UPDATE | self::OP_DELETE, + ]; +} +``` + +### Disable transactions for specific operations + +```php +public function isTransactional($operation): bool +{ + if ($operation === ActiveRecord::OP_DELETE) { + return false; // Disable transactions for delete operations + } + + return parent::isTransactional($operation); +} +``` + +## Validation configuration + +### Basic validation rules + +```php +public function rules(): array +{ + return [ + ['name', 'required'], + ['name', 'string', 'max' => 255], + // IMPORTANT: Do NOT add validation rules for nested sets fields + // lft, rgt, depth, tree are managed automatically + ]; +} +``` + +### Validation with additional fields + +```php +public function rules(): array +{ + return [ + [['name', 'description'], 'required'], + ['name', 'string', 'max' => 255], + ['description', 'string'], + ['status', 'in', 'range' => ['active', 'inactive']], + ['sort_order', 'integer', 'min' => 0], + // Nested sets fields are excluded from validation + ]; +} +``` + +## Performance optimization + +### Database indexes + +Critical indexes for optimal performance. + +```sql +-- Single tree indexes +CREATE INDEX idx_lft_rgt ON table_name (lft, rgt); +CREATE INDEX idx_depth ON table_name (depth); + +-- Multiple trees indexes +CREATE INDEX idx_tree_lft_rgt ON table_name (tree, lft, rgt); +CREATE INDEX idx_tree_depth ON table_name (tree, depth); + +-- Composite indexes for complex queries +CREATE INDEX idx_tree_depth_lft ON table_name (tree, depth, lft); +``` + +### Query optimization + +Use appropriate query methods for different scenarios. + +```php +// Efficient: Get direct children only +$directChildren = $node->children(1)->all(); + +// Less efficient: Get all descendants then filter +$allDescendants = $node->children()->all(); +$directChildren = array_filter($allDescendants, static fn($child) => $child->depth === $node->depth + 1); +``` + +## Advanced configuration + +### Custom behavior extension + +Extend the behavior for additional functionality. + +```php +getOwner() + ->children() + ->orderBy([$this->sortAttribute => SORT_ASC]) + ->all(); + } +} +``` + +### Event handling + +Handle nested sets events in your model. + +```php +public function init(): void +{ + parent::init(); + + $this->on(ActiveRecord::EVENT_AFTER_INSERT, [$this, 'afterNestedInsert']); + $this->on(ActiveRecord::EVENT_AFTER_UPDATE, [$this, 'afterNestedUpdate']); + $this->on(ActiveRecord::EVENT_AFTER_DELETE, [$this, 'afterNestedDelete']); +} + +public function afterNestedInsert($event): void +{ + // Custom logic after node insertion + $this->updateCacheCounters(); +} + +public function afterNestedUpdate($event): void +{ + // Custom logic after node movement + $this->refreshRelatedData(); +} + +public function afterNestedDelete($event): void +{ + // Custom logic after node deletion + $this->cleanupOrphanedData(); +} +``` + +### Integration with other behaviors + +Combine with other Yii behaviors. + +```php +public function behaviors(): array +{ + return [ + 'timestamp' => [ + 'class' => TimestampBehavior::class, + 'createdAtAttribute' => 'created_at', + 'updatedAtAttribute' => 'updated_at', + ], + 'nestedSets' => [ + 'class' => NestedSetsBehavior::class, + 'treeAttribute' => 'tree', + ], + 'sluggable' => [ + 'class' => SluggableBehavior::class, + 'attribute' => 'name', + ], + ]; +} +``` + +### Common configuration errors + +Avoid these common mistakes: + +```php +// ❌ Wrong: Adding validation for nested sets fields +public function rules(): array +{ + return [ + ['lft', 'integer'], // Don't validate these fields + ['rgt', 'integer'], // They're managed automatically + ['depth', 'integer'], // Behavior handles these + ['tree', 'integer'], + ]; +} + +// ✅ Correct: Only validate your business fields +public function rules(): array +{ + return [ + ['name', 'required'], + ['name', 'string', 'max' => 255], + // Nested sets fields are excluded + ]; +} + +// ❌ Wrong: Missing transactions configuration +public function behaviors(): array +{ + return [ + 'nestedSets' => NestedSetsBehavior::class, + // Missing transactions() method + ]; +} + +// ✅ Correct: Enable transactions for data integrity +public function behaviors(): array +{ + return [ + 'nestedSets' => NestedSetsBehavior::class, + ]; +} + +public function transactions(): array +{ + return [ + self::SCENARIO_DEFAULT => self::OP_ALL, + ]; +} +``` + +## Migration configurations + +### Adding nested sets to existing table + +Add nested sets fields to an existing table. + +```php +addColumn('{{%category}}', 'lft', $this->integer()->notNull()->defaultValue(1)); + $this->addColumn('{{%category}}', 'rgt', $this->integer()->notNull()->defaultValue(2)); + $this->addColumn('{{%category}}', 'depth', $this->integer()->notNull()->defaultValue(0)); + + // Add tree column for multiple trees (optional) + $this->addColumn('{{%category}}', 'tree', $this->integer()); + + // Add performance indexes + $this->createIndex('idx_category_lft_rgt', '{{%category}}', ['lft', 'rgt']); + $this->createIndex('idx_category_depth', '{{%category}}', ['depth']); + $this->createIndex('idx_category_tree', '{{%category}}', ['tree']); + + // Initialize existing records as root nodes + $this->execute(" + UPDATE {{%category}} + SET tree = id, lft = 1, rgt = 2, depth = 0 + WHERE lft IS NULL OR lft = 0 + "); + } + + public function safeDown(): void + { + $this->dropIndex('idx_category_tree', '{{%category}}'); + $this->dropIndex('idx_category_depth', '{{%category}}'); + $this->dropIndex('idx_category_lft_rgt', '{{%category}}'); + + $this->dropColumn('{{%category}}', 'tree'); + $this->dropColumn('{{%category}}', 'depth'); + $this->dropColumn('{{%category}}', 'rgt'); + $this->dropColumn('{{%category}}', 'lft'); + } +} +``` + +### Converting from adjacency list + +Migrate from parent_id structure to nested sets. + +```php +addColumn('{{%category}}', 'lft', $this->integer()); + $this->addColumn('{{%category}}', 'rgt', $this->integer()); + $this->addColumn('{{%category}}', 'depth', $this->integer()); + + // Convert adjacency list to nested sets + $this->convertAdjacencyToNestedSets(); + + // Make columns NOT NULL after conversion + $this->alterColumn('{{%category}}', 'lft', $this->integer()->notNull()); + $this->alterColumn('{{%category}}', 'rgt', $this->integer()->notNull()); + $this->alterColumn('{{%category}}', 'depth', $this->integer()->notNull()); + + // Add indexes + $this->createIndex('idx_category_lft_rgt', '{{%category}}', ['lft', 'rgt']); + $this->createIndex('idx_category_depth', '{{%category}}', ['depth']); + + // Drop old parent_id column (optional) + // $this->dropColumn('{{%category}}', 'parent_id'); + } + + private function convertAdjacencyToNestedSets(): void + { + // This is a simplified conversion - you may need more complex logic + $this->execute(" + -- Set root nodes + UPDATE {{%category}} + SET lft = 1, rgt = 2, depth = 0 + WHERE parent_id IS NULL; + + -- You'll need a recursive procedure or application logic + -- to properly convert the entire tree structure + "); + } +} +``` + +## Next steps + +- 💡 [Usage Examples](examples.md) +- 🧪 [Testing Guide](testing.md) diff --git a/docs/examples.md b/docs/examples.md new file mode 100644 index 0000000..2db12ef --- /dev/null +++ b/docs/examples.md @@ -0,0 +1,685 @@ +# Usage examples + +This document provides comprehensive examples of how to use the Yii Nested Sets Behavior in real-world hierarchical data +management scenarios. + +## Building tree structures + +### Creating root nodes + +```php + 'All Categories']); +$root->makeRoot(); + +echo "Root created: ID={$root->id}, Left={$root->lft}, Right={$root->rgt}, Depth={$root->depth}\n"; +// Output: Root created: ID=1, Left=1, Right=2, Depth=0 +``` + +### Creating multiple tree roots + +```php + 'Electronics']); +$electronicsRoot->makeRoot(); + +$clothingRoot = new Category(['name' => 'Clothing']); +$clothingRoot->makeRoot(); + +$homeRoot = new Category(['name' => 'Home & Garden']); +$homeRoot->makeRoot(); + +// Each root has its own tree identifier +echo "Electronics tree: {$electronicsRoot->tree}\n"; +echo "Clothing tree: {$clothingRoot->tree}\n"; +echo "Home tree: {$homeRoot->tree}\n"; +``` + +### Building a complete category hierarchy + +```php + 'Store Categories']); +$root->makeRoot(); + +// Add main categories +$electronics = new Category(['name' => 'Electronics']); +$electronics->appendTo($root); + +$clothing = new Category(['name' => 'Clothing']); +$clothing->appendTo($root); + +// Add subcategories to Electronics +$phones = new Category(['name' => 'Mobile Phones']); +$phones->appendTo($electronics); + +$computers = new Category(['name' => 'Computers']); +$computers->appendTo($electronics); + +// Add sub-subcategories +$smartphones = new Category(['name' => 'Smartphones']); +$smartphones->appendTo($phones); + +$featurePhones = new Category(['name' => 'Feature Phones']); +$featurePhones->appendTo($phones); + +$laptops = new Category(['name' => 'Laptops']); +$laptops->appendTo($computers); + +$desktops = new Category(['name' => 'Desktop Computers']); +$desktops->appendTo($computers); + +// Result tree structure: +// Store Categories (1,16) +// ├── Electronics (2,13) +// │ ├── Mobile Phones (3,8) +// │ │ ├── Smartphones (4,5) +// │ │ └── Feature Phones (6,7) +// │ └── Computers (9,12) +// │ ├── Laptops (10,11) +// │ └── Desktop Computers (12,13) +// └── Clothing (14,15) +``` + +## Inserting and positioning nodes + +### Adding nodes as children + +```php + 'Accessories']); +$accessories->appendTo($electronics); // Becomes last child + +// Prepend as first child +$tablets = new Category(['name' => 'Tablets']); +$tablets->prependTo($electronics); // Becomes first child + +// Result: Electronics now has Tablets, Mobile Phones, Computers, Accessories +``` + +### Inserting nodes as siblings + +```php + 'Gaming']); +$gaming->insertAfter($computers); // Places Gaming after Computers + +// Insert before a specific node +$audio = new Category(['name' => 'Audio']); +$audio->insertBefore($phones); // Places Audio before Mobile Phones + +// Result: Electronics now has Audio, Mobile Phones, Computers, Gaming, Accessories +``` + +### Positioning nodes with specific ordering + +```php + 'Electronics']); + + // Create categories in desired order + $categories = [ + 'Computers', + 'Mobile Phones', + 'Audio', + 'Gaming', + 'Accessories' + ]; + + $previousCategory = null; + + foreach ($categories as $categoryName) { + $category = new Category(['name' => $categoryName]); + + if ($previousCategory === null) { + // First category becomes first child + $category->prependTo($electronics); + } else { + // Subsequent categories inserted after previous + $category->insertAfter($previousCategory); + } + + $previousCategory = $category; + } + } +} +``` + +## Querying tree data + +### Finding ancestors and descendants + +```php + 'Smartphones']); +$breadcrumbs = $smartphone->parents()->all(); + +foreach ($breadcrumbs as $ancestor) { + echo "{$ancestor->name} > "; +} + +echo $smartphone->name; +// Output: Store Categories > Electronics > Mobile Phones > Smartphones + +// Get all descendants of a node +$electronics = Category::findOne(['name' => 'Electronics']); +$allDescendants = $electronics->children()->all(); + +echo "Electronics has " . count($allDescendants) . " descendants:\n"; + +foreach ($allDescendants as $descendant) { + echo str_repeat(' ', $descendant->depth - $electronics->depth) . $descendant->name . "\n"; +} +``` + +### Finding direct children and parents + +```php + 'Electronics']); +$directChildren = $electronics->children(1)->all(); + +echo "Direct children of Electronics:\n"; + +foreach ($directChildren as $child) { + echo "- {$child->name}\n"; +} + +// Get only direct parent +$smartphone = Category::findOne(['name' => 'Smartphones']); +$directParent = $smartphone->parents(1)->one(); + +echo "Direct parent of {$smartphone->name}: {$directParent->name}\n"; +``` + +### Finding siblings + +```php + 'Mobile Phones']); +$nextSibling = $phones->next()->one(); + +if ($nextSibling) { + echo "Next sibling: {$nextSibling->name}\n"; +} + +// Get previous sibling +$prevSibling = $phones->prev()->one(); + +if ($prevSibling) { + echo "Previous sibling: {$prevSibling->name}\n"; +} + +// Get all siblings (including self) +$allSiblings = $phones->parents(1)->one()->children(1)->all(); + +echo "All siblings:\n"; + +foreach ($allSiblings as $sibling) { + $current = ($sibling->id === $phones->id) ? ' (current)' : ''; + echo "- {$sibling->name}{$current}\n"; +} +``` + +### Finding leaf nodes + +```php + 'Electronics']); +$leaves = $electronics->leaves()->all(); + +echo "Leaf categories in Electronics:\n"; + +foreach ($leaves as $leaf) { + echo "- {$leaf->name}\n"; +} + +// Get all leaf nodes in the entire tree +$allLeaves = Category::find()->leaves()->all(); + +echo "All leaf categories:\n"; + +foreach ($allLeaves as $leaf) { + echo "- {$leaf->name}\n"; +} +``` + +### Finding root nodes + +```php +roots()->all(); + +echo "All root categories:\n"; + +foreach ($roots as $root) { + echo "- {$root->name} (Tree ID: {$root->tree})\n"; +} +``` + +## Moving and reorganizing nodes + +### Moving nodes within the same tree + +```php + 'Smartphones']); +$accessories = Category::findOne(['name' => 'Accessories']); + +// Move Smartphones to be under Accessories +$smartphones->appendTo($accessories); + +// Move as sibling +$tablets = Category::findOne(['name' => 'Tablets']); +$computers = Category::findOne(['name' => 'Computers']); + +// Move Tablets to be after Computers +$tablets->insertAfter($computers); +``` + +### Reorganizing tree structure + +```php + 'Electronics']); + + // Create a new subcategory structure + $mobile = new Category(['name' => 'Mobile Devices']); + $mobile->appendTo($electronics); + + // Move existing categories under new structure + $phones = Category::findOne(['name' => 'Mobile Phones']); + $tablets = Category::findOne(['name' => 'Tablets']); + + $phones->appendTo($mobile); + $tablets->appendTo($mobile); + + // Result: Electronics > Mobile Devices > (Mobile Phones, Tablets) + } +} +``` + +### Moving nodes between trees (multiple trees only) + +```php + 'Gaming']); +$entertainmentRoot = Category::findOne(['name' => 'Entertainment']); + +// Move Gaming from Electronics tree to Entertainment tree +$gaming->appendTo($entertainmentRoot); + +// The entire Gaming subtree moves to the new tree +echo "Gaming moved to tree: {$gaming->tree}\n"; +``` + +### Making existing nodes into new roots + +```php + 'Gaming']); + +// Make Gaming a root of its own tree (multiple trees only) +$gaming->makeRoot(); + +echo "Gaming is now root of tree: {$gaming->tree}\n"; + +// All descendants of Gaming maintain their relative positions +$gamingChildren = $gaming->children()->all(); + +foreach ($gamingChildren as $child) { + echo "- {$child->name} (Tree: {$child->tree})\n"; +} +``` + +## Deleting nodes + +### Deleting individual nodes + +```php + 'Mobile Phones']); +$children = $mobilePhones->children()->all(); + +echo "Before deletion, Mobile Phones has " . count($children) . " children\n"; + +// Delete the node - children become children of Electronics +$mobilePhones->delete(); + +// Verify children moved up +$electronics = Category::findOne(['name' => 'Electronics']); +$newChildren = $electronics->children(1)->all(); + +echo "After deletion, Electronics direct children:\n"; + +foreach ($newChildren as $child) { + echo "- {$child->name}\n"; +} +``` + +### Deleting nodes with all descendants + +```php + 'Electronics']); +$descendantsCount = count($electronics->children()->all()); + +echo "Electronics has {$descendantsCount} descendants\n"; + +// Delete Electronics and everything under it +$deletedCount = $electronics->deleteWithChildren(); + +echo "Deleted {$deletedCount} nodes total\n"; +``` + +### Batch deletion with validation + +```php + false, 'error' => 'Category not found']; + } + + // Check if category has children + $hasChildren = $category->children()->exists(); + + if ($hasChildren && !$deleteChildren) { + return [ + 'success' => false, + 'error' => 'Category has children. Specify deleteChildren=true to proceed' + ]; + } + + try { + if ($deleteChildren) { + $deletedCount = $category->deleteWithChildren(); + + return [ + 'success' => true, + 'message' => "Deleted category and {$deletedCount} descendants" + ]; + } else { + $category->delete(); + + return ['success' => true, 'message' => 'Category deleted']; + } + } catch (Exception $e) { + return ['success' => false, 'error' => $e->getMessage()]; + } + } +} +``` + +## Advanced queries and operations + +### Finding nodes by depth level + +```php + 'Electronics']); + +// Get all grandchildren (depth = electronics.depth + 2) +$grandchildren = $electronics->children(2) + ->andWhere(['depth' => $electronics->depth + 2]) + ->all(); + +echo "Grandchildren of Electronics:\n"; + +foreach ($grandchildren as $grandchild) { + echo "- {$grandchild->name}\n"; +} +``` + +### Building tree menus + +```php +rootId); + + if (!$root) { + return ''; + } + + $tree = $this->buildTreeArray($root); + + return $this->renderTree($tree); + } + + private function buildTreeArray($root): array + { + $children = $root->children($this->maxDepth)->all(); + + $tree = []; + $currentDepth = $root->depth; + + foreach ($children as $node) { + $tree[] = [ + 'id' => $node->id, + 'name' => $node->name, + 'depth' => $node->depth - $currentDepth, + 'url' => Url::to(['category/view', 'id' => $node->id]), + ]; + } + + return $tree; + } + + private function renderTree(array $tree): string + { + $html = ''; + + return $html; + } +} +``` + +### Checking node relationships + +```php +isChildOf($ancestor); + } + + public function isDescendant(Category $descendant, Category $ancestor): bool + { + return $descendant->isChildOf($ancestor); + } + + public function isSibling(Category $node1, Category $node2): bool + { + // Nodes are siblings if they have the same direct parent + $parent1 = $node1->parents(1)->one(); + $parent2 = $node2->parents(1)->one(); + + return $parent1 && $parent2 && $parent1->id === $parent2->id; + } + + public function getCommonAncestor(Category $node1, Category $node2): ?Category + { + $ancestors1 = $node1->parents()->all(); + $ancestors2 = $node2->parents()->all(); + + // Find common ancestors + $commonAncestors = array_intersect( + array_column($ancestors1, 'id'), + array_column($ancestors2, 'id') + ); + + if (empty($commonAncestors)) { + return null; + } + + // Return the deepest common ancestor + return Category::findOne(max($commonAncestors)); + } +} +``` + +### Tree validation and integrity + +```php +andWhere(['tree' => $treeId]); + } + + $categories = $query->all(); + + foreach ($categories as $category) { + // Check left < right + if ($category->lft >= $category->rgt) { + $errors[] = "Invalid boundaries for {$category->name}: lft={$category->lft}, rgt={$category->rgt}"; + } + + // Check children boundaries + $children = $category->children(1)->all(); + foreach ($children as $child) { + if ($child->lft <= $category->lft || $child->rgt >= $category->rgt) { + $errors[] = "Child {$child->name} boundaries invalid relative to parent {$category->name}"; + } + + if ($child->depth !== $category->depth + 1) { + $errors[] = "Child {$child->name} has incorrect depth"; + } + } + } + + return $errors; + } +} +``` + +This comprehensive examples guide demonstrates practical usage patterns for the Yii Nested Sets Behavior across different +scenarios, from basic tree building to complex hierarchical data management in real-world applications. + +## Next steps + +- 📚 [Installation Guide](installation.md) +- ⚙️ [Configuration Guide](configuration.md) +- 🧪 [Testing Guide](testing.md) diff --git a/docs/installation.md b/docs/installation.md new file mode 100644 index 0000000..f03f535 --- /dev/null +++ b/docs/installation.md @@ -0,0 +1,42 @@ +# Installation guide + +## System requirements + +- [`PHP`](https://www.php.net/downloads) 8.1 or higher. +- [`Yii2`](https://github.com/yiisoft/yii2) 2.0.53+ or 22.x. + +## Installation + +### Method 1: Using [Composer](https://getcomposer.org/download/) (recommended) + +Install the extension. + +```bash +composer require yii2-extensions/nested-sets-behavior +``` + +### Method 2: Manual installation + +Add to your `composer.json`. + +```json +{ + "require": { + "yii2-extensions/nested-sets-behavior": "^0.1" + } +} +``` + +Then run. + +```bash +composer update +``` + +## Next steps + +Once the installation is complete. + +- ⚙️ [Configuration Reference](configuration.md) +- 💡 [Usage Examples](examples.md) +- 🧪 [Testing Guide](testing.md) diff --git a/docs/testing.md b/docs/testing.md index 46a4946..57eac39 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -4,23 +4,40 @@ This package uses [composer-require-checker](https://github.com/maglnet/ComposerRequireChecker) to check if all dependencies are correctly defined in `composer.json`. -To run the checker, execute the following command: +To run the checker, execute the following command. ```shell composer run check-dependencies ``` +## Easy coding standard + +The code is checked with [Easy Coding Standard](https://github.com/easy-coding-standard/easy-coding-standard) and +[PHP CS Fixer](https://github.com/PHP-CS-Fixer/PHP-CS-Fixer). To run it. + +```shell +composer run ecs +``` + +## Mutation testing + +Mutation testing is checked with [Infection](https://infection.github.io/). To run it. + +```shell +composer run mutation +``` + ## Static analysis -The code is statically analyzed with [Phpstan](https://phpstan.org/). To run static analysis: +The code is statically analyzed with [Phpstan](https://phpstan.org/). To run static analysis. ```shell -composer run phpstan +composer run static ``` ## Unit tests -The code is tested with [PHPUnit](https://phpunit.de/). To run tests: +The code is tested with [PHPUnit](https://phpunit.de/). To run tests. ``` composer run test diff --git a/tests/support/model/ExtendableMultipleTree.php b/tests/support/model/ExtendableMultipleTree.php index 09ce8f6..55cfb5e 100644 --- a/tests/support/model/ExtendableMultipleTree.php +++ b/tests/support/model/ExtendableMultipleTree.php @@ -8,12 +8,12 @@ use yii2\extensions\nestedsets\tests\support\stub\ExtendableNestedSetsBehavior; /** - * @property int $id - * @property int $depth - * @property int $lft - * @property int $rgt - * @property int $tree - * @property string $name + * @phpstan-property int $depth + * @phpstan-property int $id + * @phpstan-property int $lft + * @phpstan-property int $rgt + * @phpstan-property int $tree + * @phpstan-property string $name */ class ExtendableMultipleTree extends ActiveRecord { diff --git a/tests/support/model/MultipleTree.php b/tests/support/model/MultipleTree.php index 72c312a..66b01a0 100644 --- a/tests/support/model/MultipleTree.php +++ b/tests/support/model/MultipleTree.php @@ -8,12 +8,12 @@ use yii2\extensions\nestedsets\NestedSetsBehavior; /** - * @property int $id - * @property int $depth - * @property int $lft - * @property int $rgt - * @property int $tree - * @property string $name + * @phpstan-property int $depth + * @phpstan-property int $id + * @phpstan-property int $lft + * @phpstan-property int $rgt + * @phpstan-property int $tree + * @phpstan-property string $name */ class MultipleTree extends ActiveRecord { diff --git a/tests/support/model/Tree.php b/tests/support/model/Tree.php index 8792953..b52abf1 100644 --- a/tests/support/model/Tree.php +++ b/tests/support/model/Tree.php @@ -8,11 +8,11 @@ use yii2\extensions\nestedsets\NestedSetsBehavior; /** - * @property int $id - * @property int $lft - * @property int $rgt - * @property int $depth - * @property string $name + * @phpstan-property int $depth + * @phpstan-property int $id + * @phpstan-property int $lft + * @phpstan-property int $rgt + * @phpstan-property string $name */ class Tree extends ActiveRecord { diff --git a/tests/support/model/TreeWithStrictValidation.php b/tests/support/model/TreeWithStrictValidation.php index 8f61831..ba92a34 100644 --- a/tests/support/model/TreeWithStrictValidation.php +++ b/tests/support/model/TreeWithStrictValidation.php @@ -5,11 +5,11 @@ namespace yii2\extensions\nestedsets\tests\support\model; /** - * @property int $id - * @property int $lft - * @property int $rgt - * @property int $depth - * @property string $name + * @phpstan-property int $depth + * @phpstan-property int $id + * @phpstan-property int $lft + * @phpstan-property int $rgt + * @phpstan-property string $name */ final class TreeWithStrictValidation extends Tree {