diff --git a/composer.json b/composer.json index 3cb2e96a..14078f01 100644 --- a/composer.json +++ b/composer.json @@ -25,7 +25,7 @@ }, "require": { "php": "^8.3", - "statamic/cms": "^6.10" + "statamic/cms": "dev-feature/hierarchical-taxonomies" }, "require-dev": { "doctrine/dbal": "^3.8", diff --git a/config/eloquent-driver.php b/config/eloquent-driver.php index a59ea0c4..bf36110f 100644 --- a/config/eloquent-driver.php +++ b/config/eloquent-driver.php @@ -93,9 +93,17 @@ 'model' => \Statamic\Eloquent\Taxonomies\TaxonomyModel::class, ], + 'taxonomy_trees' => [ + 'driver' => 'file', + 'model' => \Statamic\Eloquent\Structures\TreeModel::class, + 'tree' => \Statamic\Eloquent\Structures\TaxonomyTree::class, + ], + 'terms' => [ 'driver' => 'file', 'model' => \Statamic\Eloquent\Taxonomies\TermModel::class, + 'update_term_order_queue' => 'default', + 'update_term_order_connection' => 'default', ], 'tokens' => [ diff --git a/database/migrations/2024_03_07_100000_create_terms_table.php b/database/migrations/2024_03_07_100000_create_terms_table.php index 2e5a136d..a39d713e 100644 --- a/database/migrations/2024_03_07_100000_create_terms_table.php +++ b/database/migrations/2024_03_07_100000_create_terms_table.php @@ -15,6 +15,7 @@ public function up() $table->string('uri')->nullable()->index(); $table->string('taxonomy')->index(); $table->jsonb('data'); + $table->integer('order')->nullable()->index(); $table->timestamps(); $table->unique(['slug', 'taxonomy', 'site']); diff --git a/database/migrations/updates/add_order_to_terms_table.php.stub b/database/migrations/updates/add_order_to_terms_table.php.stub new file mode 100644 index 00000000..67b065ee --- /dev/null +++ b/database/migrations/updates/add_order_to_terms_table.php.stub @@ -0,0 +1,21 @@ +prefix('taxonomy_terms'), function (Blueprint $table) { + $table->integer('order')->after('data')->nullable(); + }); + } + + public function down() + { + Schema::table($this->prefix('taxonomy_terms'), function (Blueprint $table) { + $table->dropColumn('order'); + }); + } +}; diff --git a/src/Jobs/UpdateTaxonomyTermOrder.php b/src/Jobs/UpdateTaxonomyTermOrder.php new file mode 100644 index 00000000..68fd57fe --- /dev/null +++ b/src/Jobs/UpdateTaxonomyTermOrder.php @@ -0,0 +1,28 @@ +termId = $termId; + } + + public function handle() + { + if ($term = Term::find($this->termId)) { + $term->save(); + } + } +} diff --git a/src/Listeners/UpdateTaxonomyTermOrder.php b/src/Listeners/UpdateTaxonomyTermOrder.php new file mode 100644 index 00000000..968c201a --- /dev/null +++ b/src/Listeners/UpdateTaxonomyTermOrder.php @@ -0,0 +1,18 @@ +updateOrders($event->tree->taxonomy()); + } +} diff --git a/src/ServiceProvider.php b/src/ServiceProvider.php index c25ae444..e6f67872 100644 --- a/src/ServiceProvider.php +++ b/src/ServiceProvider.php @@ -17,6 +17,7 @@ use Statamic\Contracts\Structures\CollectionTreeRepository as CollectionTreeRepositoryContract; use Statamic\Contracts\Structures\NavigationRepository as NavigationRepositoryContract; use Statamic\Contracts\Structures\NavTreeRepository as NavTreeRepositoryContract; +use Statamic\Contracts\Structures\TaxonomyTreeRepository as TaxonomyTreeRepositoryContract; use Statamic\Contracts\Taxonomies\TaxonomyRepository as TaxonomyRepositoryContract; use Statamic\Contracts\Taxonomies\TermRepository as TermRepositoryContract; use Statamic\Contracts\Tokens\TokenRepository as TokenRepositoryContract; @@ -38,6 +39,7 @@ use Statamic\Eloquent\Structures\CollectionTreeRepository; use Statamic\Eloquent\Structures\NavigationRepository; use Statamic\Eloquent\Structures\NavTreeRepository; +use Statamic\Eloquent\Structures\TaxonomyTreeRepository; use Statamic\Eloquent\Taxonomies\TaxonomyRepository; use Statamic\Eloquent\Taxonomies\TermQueryBuilder; use Statamic\Eloquent\Taxonomies\TermRepository; @@ -65,6 +67,7 @@ class ServiceProvider extends AddonServiceProvider \Statamic\Eloquent\Updates\AddOrderToSitesTable::class, \Statamic\Eloquent\Updates\DropOriginOnGlobalSetVariables::class, \Statamic\Eloquent\Updates\UpdateGlobalVariables::class, + \Statamic\Eloquent\Updates\AddOrderToTermsTable::class, ]; public function boot() @@ -229,6 +232,7 @@ public function register() $this->registerStructures(); $this->registerStructureTrees(); $this->registerTaxonomies(); + $this->registerTaxonomyTrees(); $this->registerTerms(); $this->registerTokens(); $this->registerSites(); @@ -599,6 +603,25 @@ public function registerTaxonomies() Stache::exclude('taxonomies'); } + private function registerTaxonomyTrees() + { + $this->app->bind('statamic.eloquent.taxonomies.tree', function () { + return config('statamic.eloquent-driver.taxonomy_trees.tree'); + }); + + $this->app->bind('statamic.eloquent.taxonomies.tree_model', function () { + return config('statamic.eloquent-driver.taxonomy_trees.model'); + }); + + if (config('statamic.eloquent-driver.taxonomy_trees.driver', 'file') != 'eloquent') { + return; + } + + Statamic::repository(TaxonomyTreeRepositoryContract::class, TaxonomyTreeRepository::class); + + Stache::exclude('taxonomy-trees'); + } + public function registerTerms() { $this->app->bind('statamic.eloquent.terms.model', function () { diff --git a/src/Structures/TaxonomyTree.php b/src/Structures/TaxonomyTree.php new file mode 100644 index 00000000..f4bff876 --- /dev/null +++ b/src/Structures/TaxonomyTree.php @@ -0,0 +1,52 @@ +tree($model->tree) + ->handle($model->handle) + ->locale($model->locale) + ->syncOriginal() + ->model($model); + } + + public function toModel() + { + return self::makeModelFromContract($this); + } + + public static function makeModelFromContract($source) + { + $class = app('statamic.eloquent.taxonomies.tree_model'); + + return $class::firstOrNew([ + 'handle' => $source->handle(), + 'type' => 'taxonomy', + 'locale' => Site::default()->handle(), + ])->fill([ + 'tree' => $source->tree(), + 'settings' => [], + ]); + } + + public function model($model = null) + { + if (func_num_args() === 0) { + return $this->model; + } + + $this->model = $model; + + return $this; + } +} diff --git a/src/Structures/TaxonomyTreeRepository.php b/src/Structures/TaxonomyTreeRepository.php new file mode 100644 index 00000000..f39dfba0 --- /dev/null +++ b/src/Structures/TaxonomyTreeRepository.php @@ -0,0 +1,64 @@ +handle(); + + return Blink::once("eloquent-taxonomy-tree-{$handle}-{$site}", function () use ($handle, $site) { + $model = app('statamic.eloquent.taxonomies.tree_model')::whereHandle($handle) + ->where('locale', $site) + ->whereType('taxonomy') + ->first(); + + return $model ? app(app('statamic.eloquent.taxonomies.tree'))->fromModel($model) : null; + }); + } + + public function save($tree) + { + $model = $tree->toModel(); + $model->save(); + + Blink::forget("eloquent-taxonomy-tree-{$model->handle}-{$model->locale}"); + + $tree->model($model->fresh()); + + return true; + } + + public function delete($tree) + { + if (! $tree instanceof TaxonomyTree) { + return parent::delete($tree); + } + + Blink::forget("eloquent-taxonomy-tree-{$tree->handle()}-{$tree->locale()}"); + + // TaxonomyStructure::in() caches the tree object under this key. Without + // forgetting it, code still holding the structure (e.g. Taxonomy::delete() + // deleting the tree's terms afterwards) would keep using the deleted, + // in-memory tree instance and re-insert it on save. + Blink::forget("taxonomy-structure-tree-{$tree->handle()}"); + + $tree->model()?->delete(); + + return true; + } + + public static function bindings() + { + return [ + TaxonomyTreeContract::class => TaxonomyTree::class, + ]; + } +} diff --git a/src/Taxonomies/Taxonomy.php b/src/Taxonomies/Taxonomy.php index 5fbfdba8..f0e21d3b 100644 --- a/src/Taxonomies/Taxonomy.php +++ b/src/Taxonomies/Taxonomy.php @@ -23,6 +23,8 @@ public static function fromModel(Model $model) ->termTemplate($model->settings['term_template'] ?? null) ->template($model->settings['template'] ?? null) ->layout($model->settings['layout'] ?? null) + ->routes($model->settings['routes'] ?? null) + ->structureContents($model->settings['structure'] ?? null) ->model($model); } @@ -48,6 +50,8 @@ public static function makeModelFromContract(Contract $source) 'term_template' => $source->hasCustomTermTemplate() ? $source->termTemplate() : null, 'template' => $source->hasCustomTemplate() ? $source->template() : null, 'layout' => $source->layout, + 'routes' => $source->routes, + 'structure' => $source->structureContents(), ]); return $model; diff --git a/src/Taxonomies/Term.php b/src/Taxonomies/Term.php index f77d1937..1ccbfcda 100644 --- a/src/Taxonomies/Term.php +++ b/src/Taxonomies/Term.php @@ -84,6 +84,10 @@ public static function makeModelFromContract(Contract $source) 'uri' => $source->uri(), 'data' => collect($data)->filter(fn ($v) => $v !== null), 'updated_at' => $source->lastModified(), + // Calling ->order() through Term's __call proxy compares the return + // value against a LocalizedTerm instance, which throws for scalars. + // Go straight through inDefaultLocale() to avoid it. + 'order' => $source->inDefaultLocale()->order(), ]); } diff --git a/src/Taxonomies/TermQueryBuilder.php b/src/Taxonomies/TermQueryBuilder.php index ddd67138..01311cb7 100644 --- a/src/Taxonomies/TermQueryBuilder.php +++ b/src/Taxonomies/TermQueryBuilder.php @@ -24,7 +24,7 @@ class TermQueryBuilder extends EloquentQueryBuilder protected $taxonomies = []; protected $columns = [ - 'id', 'data', 'site', 'slug', 'uri', 'taxonomy', 'created_at', 'updated_at', + 'id', 'data', 'site', 'slug', 'uri', 'taxonomy', 'created_at', 'updated_at', 'order', ]; protected function transform($items, $columns = []) diff --git a/src/Taxonomies/TermRepository.php b/src/Taxonomies/TermRepository.php index 118c17a4..3e35ca93 100644 --- a/src/Taxonomies/TermRepository.php +++ b/src/Taxonomies/TermRepository.php @@ -3,6 +3,7 @@ namespace Statamic\Eloquent\Taxonomies; use Statamic\Contracts\Taxonomies\Term as TermContract; +use Statamic\Eloquent\Jobs\UpdateTaxonomyTermOrder; use Statamic\Facades\Blink; use Statamic\Facades\Collection; use Statamic\Facades\Entry; @@ -39,7 +40,10 @@ public function find($id): ?TermContract return null; } - return $term; + // A term saved earlier in the request is cached here in its raw, locale-less + // form (see save()). Resolve it the same way the freshly-queried path does, + // since some Term methods only behave correctly on a LocalizedTerm. + return $term instanceof LocalizedTerm ? $term : $term->inDefaultLocale(); } public function findByUri(string $uri, ?string $site = null): ?TermContract @@ -106,6 +110,13 @@ public function save($entry) $entry->model($model->fresh()); + // Building the model (e.g. resolving a hierarchical term's URI) may have + // queried and cached the taxonomy's existing term slugs before this term + // was persisted. Forget it so the next read reflects the saved term. + if (($taxonomy = $entry->taxonomy()) && $taxonomy->hasStructure()) { + Blink::forget('taxonomy-structure-term-slugs-'.$taxonomy->handle()); + } + Blink::put("eloquent-term-{$entry->id()}", $entry); Blink::put("eloquent-term-{$entry->uri()}", $entry); } @@ -157,4 +168,21 @@ public function entriesCount(TermContract $term, ?string $status = null): int return $query->count(); } + + public function updateOrders($taxonomy) + { + $taxonomy->queryTerms() + ->get() + ->each(function ($term) { + $dispatch = UpdateTaxonomyTermOrder::dispatch($term->id()); + + $connection = config('statamic.eloquent-driver.terms.update_term_order_connection', 'default'); + + if ($connection != 'default') { + $dispatch->onConnection($connection); + } + + $dispatch->onQueue(config('statamic.eloquent-driver.terms.update_term_order_queue', 'default')); + }); + } } diff --git a/src/Updates/AddOrderToTermsTable.php b/src/Updates/AddOrderToTermsTable.php new file mode 100644 index 00000000..8f663739 --- /dev/null +++ b/src/Updates/AddOrderToTermsTable.php @@ -0,0 +1,27 @@ +isUpdatingTo('5.12.0') + && Schema::hasTable(config('statamic.eloquent-driver.table_prefix', '').'taxonomy_terms') + && ! Schema::hasColumn(config('statamic.eloquent-driver.table_prefix', '').'taxonomy_terms', 'order'); + } + + public function update() + { + $source = __DIR__.'/../../database/migrations/updates/add_order_to_terms_table.php.stub'; + $dest = database_path('migrations/'.date('Y_m_d_His').'_add_order_to_terms_table.php'); + + $this->files->copy($source, $dest); + + $this->console()->info('Migration created'); + $this->console()->comment('Remember to run `php artisan migrate` to apply it to your database.'); + } +} diff --git a/tests/Structures/TaxonomyTreeTest.php b/tests/Structures/TaxonomyTreeTest.php new file mode 100644 index 00000000..fce91c9f --- /dev/null +++ b/tests/Structures/TaxonomyTreeTest.php @@ -0,0 +1,128 @@ +title('Categories')->structureContents([]))->save(); + + foreach (['animals', 'cat', 'calico', 'furniture'] as $slug) { + tap(Term::make($slug)->taxonomy('categories')->data(['title' => ucfirst($slug)]))->save(); + } + + $taxonomy->structure()->tree()->tree([ + ['term' => 'animals', 'children' => [ + ['term' => 'cat', 'children' => [ + ['term' => 'calico'], + ]], + ]], + ['term' => 'furniture'], + ])->save(); + + return $taxonomy; + } + + #[Test] + public function saving_a_taxonomy_tree_persists_it_to_the_trees_table() + { + $this->makeHierarchicalTaxonomy(); + + $this->assertCount(1, TreeModel::where('type', 'taxonomy')->get()); + + $model = TreeModel::where('type', 'taxonomy')->first(); + + $this->assertEquals('categories', $model->handle); + $this->assertEquals('en', $model->locale); + $this->assertEquals('animals', $model->tree[0]['term']); + } + + #[Test] + public function it_finds_a_saved_taxonomy_tree() + { + $this->makeHierarchicalTaxonomy(); + + $taxonomy = TaxonomyFacade::findByHandle('categories'); + + $tree = $taxonomy->structure()->tree(); + + $this->assertEquals([ + ['term' => 'animals', 'children' => [ + ['term' => 'cat', 'children' => [ + ['term' => 'calico'], + ]], + ]], + ['term' => 'furniture'], + ], $tree->tree()); + } + + #[Test] + public function it_gets_hierarchy_from_the_tree() + { + $this->makeHierarchicalTaxonomy(); + + $calico = Term::find('categories::calico'); + $animals = Term::find('categories::animals'); + + $this->assertEquals(3, $calico->depth()); + $this->assertEquals('categories::cat', $calico->parent()->id()); + $this->assertEquals(['animals', 'cat'], $calico->ancestors()->map->slug()->all()); + $this->assertEquals(['cat'], $animals->in('en')->children()->map->slug()->all()); + } + + #[Test] + public function deleting_a_taxonomy_deletes_its_tree() + { + $this->makeHierarchicalTaxonomy(); + + $this->assertCount(1, TreeModel::where('type', 'taxonomy')->get()); + + TaxonomyFacade::findByHandle('categories')->delete(); + + $this->assertCount(0, TreeModel::where('type', 'taxonomy')->get()); + } + + #[Test] + public function structured_taxonomies_sort_terms_by_tree_order() + { + // Deliberately created out of tree order, so a query that quietly falls + // back to insertion order (rather than genuinely sorting by tree + // position) can't accidentally produce the right-looking result. + $taxonomy = tap(Taxonomy::make('categories')->title('Categories')->structureContents([]))->save(); + + foreach (['furniture', 'calico', 'cat', 'animals'] as $slug) { + tap(Term::make($slug)->taxonomy('categories')->data(['title' => ucfirst($slug)]))->save(); + } + + $taxonomy->structure()->tree()->tree([ + ['term' => 'animals', 'children' => [ + ['term' => 'cat', 'children' => [ + ['term' => 'calico'], + ]], + ]], + ['term' => 'furniture'], + ])->save(); + + $this->assertEquals('order', $taxonomy->sortField()); + $this->assertEquals(1, Term::find('categories::animals')->order()); + $this->assertEquals(2, Term::find('categories::cat')->order()); + $this->assertEquals(3, Term::find('categories::calico')->order()); + $this->assertEquals(4, Term::find('categories::furniture')->order()); + + $this->assertEquals( + ['animals', 'cat', 'calico', 'furniture'], + $taxonomy->queryTerms()->orderBy('order')->get()->map->slug()->all() + ); + } +}