diff --git a/config/filament-fabricator.php b/config/filament-fabricator.php index a3a9fcb..6575906 100644 --- a/config/filament-fabricator.php +++ b/config/filament-fabricator.php @@ -39,6 +39,20 @@ 'enable-view-page' => false, + /** + * Whether to hook into artisan's core commands to clear and refresh page route caches along with the rest. + * Disable for manual control over cache. + * + * This is the list of commands that will be hooked into: + * - cache:clear -> clear routes cache + * - config:cache -> refresh routes cache + * - config:clear -> clear routes cache + * - optimize -> refresh routes cache + * - optimize:clear -> clear routes cache + * - route:clear -> clear routes cache + */ + 'hook-to-commands' => true, + /* * This is the name of the table that will be created by the migration and * used by the above page-model shipped with this package. diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 0d4b012..aa658b0 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -20,7 +20,7 @@ > - tests + tests/ diff --git a/src/Commands/ClearRoutesCacheCommand.php b/src/Commands/ClearRoutesCacheCommand.php new file mode 100644 index 0000000..b5be09a --- /dev/null +++ b/src/Commands/ClearRoutesCacheCommand.php @@ -0,0 +1,68 @@ +option('refresh'); + + /** + * @var PageContract[] $pages + */ + $pages = FilamentFabricator::getPageModel()::query() + ->whereNull('parent_id') + ->with('allChildren') + ->get(); + + foreach ($pages as $page) { + $this->clearPageCache($page, $shouldRefresh); + + if ($shouldRefresh) { + $this->pageRoutesService->updateUrlsOf($page); + } + } + + return static::SUCCESS; + } + + protected function clearPageCache(PageContract $page, bool $shouldRefresh = false) + { + $this->pageRoutesService->removeUrlsOf($page); + $argSets = $page->getAllUrlCacheKeysArgs(); + + foreach ($argSets as $args) { + $key = $page->getUrlCacheKey($args); + Cache::forget($key); + + if ($shouldRefresh) { + // Caches the URL before returning it + /* $noop = */ $page->getUrl($args); + } + } + + $childPages = $page->allChildren; + + if (filled($childPages)) { + foreach ($childPages as $childPage) { + $this->clearPageCache($childPage, $shouldRefresh); + } + } + } +} diff --git a/src/Facades/FilamentFabricator.php b/src/Facades/FilamentFabricator.php index a6106ca..9f214cf 100644 --- a/src/Facades/FilamentFabricator.php +++ b/src/Facades/FilamentFabricator.php @@ -2,6 +2,7 @@ namespace Z3d0X\FilamentFabricator\Facades; +use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Facades\Facade; use Z3d0X\FilamentFabricator\Models\Contracts\Page as PageContract; @@ -25,8 +26,8 @@ * @method static array getScripts() * @method static array getStyles() * @method static ?string getFavicon() - * @method static class-string getPageModel() - * @method static string getRoutingPrefix() + * @method static class-string getPageModel() + * @method static ?string getRoutingPrefix() * @method static array getPageUrls() * @method static ?string getPageUrlFromId(int $id, bool $prefixSlash = false) * diff --git a/src/FilamentFabricatorManager.php b/src/FilamentFabricatorManager.php index 17d53b7..0dfbb34 100644 --- a/src/FilamentFabricatorManager.php +++ b/src/FilamentFabricatorManager.php @@ -4,12 +4,12 @@ use Closure; use Illuminate\Support\Collection; -use Illuminate\Support\Facades\Cache; use Illuminate\Support\Str; use Z3d0X\FilamentFabricator\Layouts\Layout; use Z3d0X\FilamentFabricator\Models\Contracts\Page as PageContract; use Z3d0X\FilamentFabricator\Models\Page; use Z3d0X\FilamentFabricator\PageBlocks\PageBlock; +use Z3d0X\FilamentFabricator\Services\PageRoutesService; class FilamentFabricatorManager { @@ -33,8 +33,16 @@ class FilamentFabricatorManager protected array $pageUrls = []; - public function __construct() + /** + * @note It's only separated to not cause a major version change. + * In the next major release, feel free to make it a constructor promoted property + */ + protected PageRoutesService $routesService; + + public function __construct(?PageRoutesService $routesService = null) { + $this->routesService = $routesService ?? resolve(PageRoutesService::class); + /** @var Collection */ $pageBlocks = collect([]); @@ -172,44 +180,25 @@ public function getRoutingPrefix(): ?string return null; } - return Str::start(config('filament-fabricator.routing.prefix'), '/'); - } + $prefix = Str::start($prefix, '/'); - public function getPageUrls(): array - { - return Cache::rememberForever('filament-fabricator::page-urls', function () { - $this->getPageModel()::query() - ->select('id', 'slug', 'title') - ->whereNull('parent_id') - ->with(['allChildren']) - ->get() - ->each(fn (PageContract $page) => $this->setPageUrl($page)); // @phpstan-ignore-line + if ($prefix === '/') { + return $prefix; + } - return $this->pageUrls; - }); + return rtrim($prefix, '/'); } - public function getPageUrlFromId(int|string $id, bool $prefixSlash = false): ?string + public function getPageUrls(): array { - $url = $this->getPageUrls()[$id]; - - if ($routingPrefix = $this->getRoutingPrefix()) { - $url = Str::start($url, $routingPrefix); - } - - return $url; + return $this->routesService->getAllUrls(); } - protected function setPageUrl(PageContract $page, ?string $parentUrl = null): string + public function getPageUrlFromId(int|string $id, bool $prefixSlash = false, array $args = []): ?string { - $pageUrl = $parentUrl ? $parentUrl . '/' . trim($page->slug, " \n\r\t\v\x00/") : trim($page->slug); - - if (filled($page->allChildren)) { - foreach ($page->allChildren as $child) { - $this->setPageUrl($child, $pageUrl); - } - } + /** @var ?PageContract $page */ + $page = $this->getPageModel()::query()->find($id); - return $this->pageUrls[$page->id] = Str::start($pageUrl, '/'); + return $page?->getUrl($args); } } diff --git a/src/FilamentFabricatorServiceProvider.php b/src/FilamentFabricatorServiceProvider.php index cbbd638..2e68910 100644 --- a/src/FilamentFabricatorServiceProvider.php +++ b/src/FilamentFabricatorServiceProvider.php @@ -2,7 +2,9 @@ namespace Z3d0X\FilamentFabricator; +use Illuminate\Console\Events\CommandFinished; use Illuminate\Filesystem\Filesystem; +use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Route; use Illuminate\Support\Str; use ReflectionClass; @@ -12,7 +14,10 @@ use Symfony\Component\Finder\SplFileInfo; use Z3d0X\FilamentFabricator\Facades\FilamentFabricator; use Z3d0X\FilamentFabricator\Layouts\Layout; +use Z3d0X\FilamentFabricator\Listeners\OptimizeWithLaravel; +use Z3d0X\FilamentFabricator\Observers\PageRoutesObserver; use Z3d0X\FilamentFabricator\PageBlocks\PageBlock; +use Z3d0X\FilamentFabricator\Services\PageRoutesService; class FilamentFabricatorServiceProvider extends PackageServiceProvider { @@ -43,6 +48,7 @@ protected function getCommands(): array $commands = [ Commands\MakeLayoutCommand::class, Commands\MakePageBlockCommand::class, + Commands\ClearRoutesCacheCommand::class, ]; $aliases = []; @@ -65,7 +71,7 @@ public function packageRegistered(): void parent::packageRegistered(); $this->app->singleton('filament-fabricator', function () { - return new FilamentFabricatorManager; + return resolve(FilamentFabricatorManager::class); }); } @@ -73,17 +79,12 @@ public function bootingPackage(): void { if (! $this->app->runningInConsole()) { Route::bind('filamentFabricatorPage', function ($value) { - $pageModel = FilamentFabricator::getPageModel(); + /** + * @var PageRoutesService $routesService + */ + $routesService = resolve(PageRoutesService::class); - $pageUrls = FilamentFabricator::getPageUrls(); - - $value = Str::start($value, '/'); - - $pageId = array_search($value, $pageUrls); - - return $pageModel::query() - ->where('id', $pageId) - ->firstOrFail(); + return $routesService->findPageOrFail($value); }); $this->registerComponentsFromDirectory( @@ -92,7 +93,7 @@ public function bootingPackage(): void config('filament-fabricator.layouts.path'), config('filament-fabricator.layouts.namespace') ); - + $this->registerComponentsFromDirectory( PageBlock::class, config('filament-fabricator.page-blocks.register'), @@ -102,6 +103,17 @@ public function bootingPackage(): void } } + public function packageBooted() + { + parent::packageBooted(); + + FilamentFabricator::getPageModel()::observe(PageRoutesObserver::class); + + if ((bool) config('filament-fabricator.hook-to-commands')) { + Event::listen(CommandFinished::class, OptimizeWithLaravel::class); + } + } + protected function registerComponentsFromDirectory(string $baseClass, array $register, ?string $directory, ?string $namespace): void { if (blank($directory) || blank($namespace)) { diff --git a/src/Http/Controllers/PageController.php b/src/Http/Controllers/PageController.php index 900e2cf..9422b1f 100644 --- a/src/Http/Controllers/PageController.php +++ b/src/Http/Controllers/PageController.php @@ -6,6 +6,7 @@ use Z3d0X\FilamentFabricator\Facades\FilamentFabricator; use Z3d0X\FilamentFabricator\Layouts\Layout; use Z3d0X\FilamentFabricator\Models\Contracts\Page; +use Z3d0X\FilamentFabricator\Services\PageRoutesService; class PageController { @@ -13,14 +14,13 @@ public function __invoke(?Page $filamentFabricatorPage = null): string { // Handle root (home) page if (blank($filamentFabricatorPage)) { - $pageUrls = FilamentFabricator::getPageUrls(); - - $pageId = array_search('/', $pageUrls); + /** + * @var PageRoutesService $routesService + */ + $routesService = resolve(PageRoutesService::class); /** @var Page $filamentFabricatorPage */ - $filamentFabricatorPage = FilamentFabricator::getPageModel()::query() - ->where('id', $pageId) - ->firstOrFail(); + $filamentFabricatorPage = $routesService->findPageOrFail('/'); } /** @var ?class-string $layout */ diff --git a/src/Listeners/OptimizeWithLaravel.php b/src/Listeners/OptimizeWithLaravel.php new file mode 100644 index 0000000..6dffeb1 --- /dev/null +++ b/src/Listeners/OptimizeWithLaravel.php @@ -0,0 +1,66 @@ +shouldHandleEvent($event)) { + return; + } + + if ($this->shouldRefresh($event)) { + $this->refresh(); + } else { + $this->clear(); + } + } + + public function shouldHandleEvent(CommandFinished $event) + { + return $event->exitCode === Command::SUCCESS + && in_array($event->command, static::COMMANDS); + } + + public function shouldRefresh(CommandFinished $event) + { + return in_array($event->command, static::REFRESH_COMMANDS); + } + + public function refresh() + { + $this->callCommand([ + '--refresh' => true, + ]); + } + + public function clear() + { + $this->callCommand(); + } + + public function callCommand(array $params = []) + { + Artisan::call(ClearRoutesCacheCommand::class, $params); + } +} diff --git a/src/Models/Concerns/HandlesPageUrls.php b/src/Models/Concerns/HandlesPageUrls.php new file mode 100644 index 0000000..405a422 --- /dev/null +++ b/src/Models/Concerns/HandlesPageUrls.php @@ -0,0 +1,112 @@ + $args + */ + public function getUrlCacheKey(array $args = []): string + { + // $keyArgs = collect($this->getDefaultUrlArgs())->merge($args)->all(); + $id = $this->id; + + return "filament-fabricator::page-url--$id"; + } + + /** + * Get the URL determined by this entity and the provided arguments + * + * @param array $args + */ + public function getUrl(array $args = []): string + { + $cacheKey = $this->getUrlCacheKey($args); + + //NOTE: Users must run the command that clears the routes cache if the routing prefix ever changes + + return Cache::rememberForever($cacheKey, function () use ($args) { + /** + * @var ?Page $parent + */ + $parent = $this->parent; + + // If there's no parent page, then the "parent" URI is just the routing prefix. + $parentUri = is_null($parent) ? (FilamentFabricator::getRoutingPrefix() ?? '/') : $parent->getUrl($args); + + // Every URI in cache has a leading slash, this ensures it's + // present even if the prefix doesn't have it set explicitly + $parentUri = Str::start($parentUri, '/'); + + // This page's part of the URL (i.e. its URI) is defined as the slug. + // For the same reasons as above, we need to add a leading slash. + $selfUri = $this->slug; + $selfUri = Str::start($selfUri, '/'); + + // If the parent URI is the root, then we have nothing to glue on. + // Therefore the page's URL is simply its URI. + // This avoids having two consecutive slashes. + if ($parentUri === '/') { + return $selfUri; + } + + // Remove any trailing slash in the parent URI since + // every URIs we'll use has a leading slash. + // This avoids having two consecutive slashes. + $parentUri = rtrim($parentUri, '/'); + + return "{$parentUri}{$selfUri}"; + }); + } + + /** + * Get all the available argument sets for the available cache keys + * + * @return array[] + */ + public function getAllUrlCacheKeysArgs(): array + { + // By default, the entire list of available URL cache keys + // is simply a list containing the default one since we can't + // magically infer all the possible state for the library user's customizations. + return [ + $this->getDefaultUrlCacheArgs(), + ]; + } + + /** + * Get all the available URLs for this entity + * + * @return string[] + */ + public function getAllUrls(): array + { + return array_map([$this, 'getUrl'], $this->getAllUrlCacheKeysArgs()); + } + + /** + * Get all the cache keys for the available URLs for this entity + * + * @return string[] + */ + public function getAllUrlCacheKeys(): array + { + return array_map([$this, 'getUrlCacheKey'], $this->getAllUrlCacheKeysArgs()); + } +} diff --git a/src/Models/Contracts/HasPageUrls.php b/src/Models/Contracts/HasPageUrls.php new file mode 100644 index 0000000..d245a5d --- /dev/null +++ b/src/Models/Contracts/HasPageUrls.php @@ -0,0 +1,44 @@ +table)) { @@ -32,12 +34,6 @@ public function __construct(array $attributes = []) 'parent_id' => 'integer', ]; - protected static function booted() - { - static::saved(fn () => Cache::forget('filament-fabricator::page-urls')); - static::deleted(fn () => Cache::forget('filament-fabricator::page-urls')); - } - public function parent(): BelongsTo { return $this->belongsTo(static::class, 'parent_id'); @@ -50,7 +46,7 @@ public function children(): HasMany public function allChildren(): HasMany { - return $this->hasMany(static::class, 'parent_id') + return $this->children() ->select('id', 'slug', 'title', 'parent_id') ->with('allChildren:id,slug,title,parent_id'); } diff --git a/src/Observers/PageRoutesObserver.php b/src/Observers/PageRoutesObserver.php new file mode 100644 index 0000000..bfb0d08 --- /dev/null +++ b/src/Observers/PageRoutesObserver.php @@ -0,0 +1,112 @@ +pageRoutesService->updateUrlsOf($page); + } + + /** + * Handle the Page "updated" event. + */ + public function updated(PageContract&Model $page): void + { + // If the parent_id has changed, and if the relationship has already been loaded + // then after an update we might not read the right parent. That's why we always + // load it on update, this ensures we clear the old URLs properly (they were cached) + // and set the new ones properly (we have the right parent to do so). + if ($page->wasChanged('parent_id')) { + $page->load('parent'); + } + + $this->pageRoutesService->updateUrlsOf($page); + } + + /** + * Handle the Page "deleting" event. + */ + public function deleting(PageContract&Model $page): void + { + // We do the logic in `deleting` instead of `deleted` since we need access to the object + // both in memory and in database (e.g. to load relationship data). + + // Before properly deleting it, remove its URLs from + // all the mappings and caches. + $this->pageRoutesService->removeUrlsOf($page); + + // Doubly-linked list style deletion: + // - Re-attache the given page children to the parent of the given page + // - Promote the pages to a "root page" (i.e. page with no parent) if the given page had no parent + + // Only load one level of children since they're the ones that will be re-attached + $page->load('children'); + $children = $page->children; + + foreach ($children as $childPage) { + /** + * @var Model|PageContract $childPage + */ + + // We use `?? null` followed by `?: null` to go around the cast to integer + // and make sure we have `null` instead of `0` when there's no parent. + $parentId = $page->parent_id ?? null; + $parentId = $parentId ?: null; + + // Using update, instead of associate or dissociate, we trigger DB events (which we need) + $childPage->update([ + 'parent_id' => $parentId, + ]); + } + } + + /** + * Handle the Page "deleted" event. + */ + public function deleted(PageContract&Model $page): void + { + // Since `deleting` is called before `deleted`, and + // since everything is handled there, do nothing. + } + + /** + * Handle the Page "restored" event. + */ + public function restored(PageContract&Model $page): void + { + $this->pageRoutesService->updateUrlsOf($page); + } + + /** + * Handle the Page "force deleted" event. + */ + public function forceDeleting(PageContract&Model $page): void + { + // You always go through `deleting` before going through `forceDeleting`. + // Since everything is properly handled in `deleting`, do nothing here. + } + + /** + * Handle the Page "force deleted" event. + */ + public function forceDeleted(PageContract&Model $page): void + { + // You always go through `deleted` before going through `forceDeleted`. + // Since everything is properly handled in `deleted`, do nothing here. + } +} diff --git a/src/Services/PageRoutesService.php b/src/Services/PageRoutesService.php new file mode 100644 index 0000000..ead360b --- /dev/null +++ b/src/Services/PageRoutesService.php @@ -0,0 +1,332 @@ + ID) mapping based on the user provided URI. + // The mapping expect a URI that starts with a / + // thus we "normalize" the URI by ensuring it starts with one. + // Not doing so would result in a false negative. + $mapping = $this->getUriToIdMapping(); + $uri = Str::start($uri, '/'); + + return $mapping[$uri] ?? -1; + } + + /** + * Get an instance of your Page model from a URI, or NULL if none matches + * + * @return null|(Page&Model) + */ + public function getPageFromUri(string $uri): ?Page + { + $id = $this->getPageIdFromUri($uri); + + // We know the getPageIdFromUri uses -1 as a "sentinel" value + // for when the page is not found, so return null in those cases + if ($id === -1) { + return null; + } + + return FilamentFabricator::getPageModel()::find($id); + } + + /** + * Update the cached URLs for the given page (as well as all its descendants') + */ + public function updateUrlsOf(Page&Model $page): void + { + // We mutate the mapping without events to ensure we don't have "concurrent" + // modifications of the same mapping. This allows us to skip the use of locks + // in an environment where only unrelated pages can be modified by separate + // users at the same time, which is a responsibility the library users + // should enforce themselves. + FilamentFabricator::getPageModel()::withoutEvents(function () use ($page) { + $mapping = $this->getUriToIdMapping(); + $this->updateUrlsAndDescendantsOf($page, $mapping); + $this->replaceUriToIdMapping($mapping); + }); + } + + /** + * Remove the cached URLs for the given page + */ + public function removeUrlsOf(Page $page): void + { + // First remove the entries from the (ID -> URI) mapping + $idToUrlsMapping = $this->getIdToUrisMapping(); + $urls = $idToUrlsMapping[$page->id]; + $idToUrlsMapping[$page->id] = null; + unset($idToUrlsMapping[$page->id]); + $this->replaceIdToUriMapping($idToUrlsMapping); + + // Then remove the entries from the (URI -> ID) mapping + $uriToIdMapping = $this->getUriToIdMapping(); + foreach ($urls as $uri) { + unset($uriToIdMapping[$uri]); + } + $this->replaceUriToIdMapping($uriToIdMapping); + + // Finally, clear the page's local caches of its own URL. + // This means that Page::getAllUrls() and such will now compute + // fresh values. + $this->forgetPageLocalCache($page); + } + + /** + * Get an instance of your Page model from a URI, or throw if there is none + */ + public function findPageOrFail(string $uri): Page&Model + { + $id = $this->getPageIdFromUri($uri); + + // If the page doesn't exists, we know getPageIdFromUri + // will return -1. Thus, findOrFail will fail as expected. + return FilamentFabricator::getPageModel()::findOrFail($id); + } + + /** + * Get the list of all the registered URLs + * + * @return string[] + */ + public function getAllUrls(): array + { + $uriToIdMapping = $this->getUriToIdMapping(); + + // $uriToIdMapping is an associative array that maps URIs to IDs. + // Thus, the list of URLs is the keys of that array. + // Since PHP handles keys very weirdly when using array_keys, + // we simply get its array_values to have a truly regular array + // instead of an associative array where the keys are all numbers + // but possibly non-sorted. + return array_values(array_keys($uriToIdMapping)); + } + + /** + * Get the URI -> ID mapping + * + * @return array + */ + protected function getUriToIdMapping(): array + { + // The mapping will be cached for most requests. + // The very first person hitting the cache when it's not readily available + // will sadly have to recompute the whole thing. + return Cache::rememberForever(static::URI_TO_ID_MAPPING, function () { + // Even though we technically have 2 separate caches + // we want them to not really be independent. + // Here we ensure our initial state depends on the other + // cache's initial state. + $idsToUrisMapping = $this->getIdToUrisMapping(); + $uriToIdMapping = []; + + // We simply "reverse" the one-to-many mapping to a many-to-one + foreach ($idsToUrisMapping as $id => $uris) { + foreach ($uris as $uri) { + $uriToIdMapping[$uri] = $id; + } + } + + return $uriToIdMapping; + }); + } + + /** + * Get the ID -> URI[] mapping + * + * @return array + */ + protected function getIdToUrisMapping(): array + { + // The mapping will be cached for most requests. + // The very first person hitting the cache when it's not readily available + // will sadly have to recompute the whole thing. + // This could be a critical section and bottleneck depending on the use cases. + // Any optimization to this can greatly improve the entire package's performances + // in one fell swoop. + return Cache::rememberForever(static::ID_TO_URI_MAPPING, function () { + $pages = FilamentFabricator::getPageModel()::all(); + $mapping = []; + $pages->each(function (Page $page) use (&$mapping) { + // Note that this also has the benefits of computing + // the page's local caches. + $mapping[$page->id] = $page->getAllUrls(); + }); + + return $mapping; + }); + } + + /** + * Get the cached URIs for the given page + * + * @return string[] + */ + protected function getUrisForPage(Page $page): array + { + $mapping = $this->getIdToUrisMapping(); + + return $mapping[$page->id] ?? []; + } + + /** + * Update routine for the given page + * + * @param array $uriToIdMapping - The URI -> ID mapping (as a reference, to be modified in-place) + * @return void + */ + protected function updateUrlsAndDescendantsOf(Page&Model $page, array &$uriToIdMapping) + { + // First ensure consistency by removing any trace of the old URLs + // for the given page. Whether local or in the URI to ID mapping. + $this->unsetOldUrlsOf($page, $uriToIdMapping); + + // These URLs will always be fresh since we unset the old ones just above + $urls = $page->getAllUrls(); + + foreach ($urls as $uri) { + $id = $uriToIdMapping[$uri] ?? -1; + + // If while iterating the fresh URLs we encounter one + // that is already mapped to the right page ID + // then there's nothing to do for this URL + // and thus continue onward with the next one. + if ($id === $page->id) { + continue; + } + + // Otherwise, we have a URI that already exists + // and is mapped to the wrong ID, or it wasn't + // in the mapping yet. In both cases we just need + // to add it to the mapping at the correct spot. + $uriToIdMapping[$uri] = $page->id; + } + + // Since we're recursing down the tree, we preload the relationships + // once, and traverse down the tree. This helps with performances. + // TODO: Make it work with loadMissing instead of load to reduce the number of useless DB queries + $page->load(['allChildren']); + foreach ($page->allChildren as $childPage) { + /** + * @var Page&Model $childPage + */ + + // A change in a parent page will always result + // in a change to its descendant. As such, + // we need to recompute everything that's + // a descendant of this page. + $this->updateUrlsAndDescendantsOf($childPage, $uriToIdMapping); + } + } + + /** + * Remove old URLs of the given page from the cached mappings + * + * @param array $uriToIdMapping - The URI -> ID mapping (as a reference, to be modified in-place) + * @return void + */ + protected function unsetOldUrlsOf(Page $page, array &$uriToIdMapping) + { + // When we're hitting this path, caches haven't been invalidated yet. + // Thus, we don't need to query the mappings to get the old URLs. + $oldUrlSet = collect($page->getAllUrls())->lazy()->sort()->all(); + + // Once we're done collecting the previous URLs, and since we want + // to unset ALL old URLs for this given page, we might as well + // forget its local caches here. + $this->forgetPageLocalCache($page); + + // Since we just forgot the page's local caches, this doesn't + // return the old set of URLs, but instead computes and caches + // the new URLs based on the page's currently loaded data. + $newUrlSet = collect($page->getAllUrls())->lazy()->sort()->all(); + + // The old URLs are those that are present in the $oldUrlSet + // but are not present in $newUrlSet. Hence the use of array_diff + // whose role is to return exactly that. Also note we sorted the arrays + // in order to make sure the diff algorithm has every chances to be + // optimal in performance. + $oldUrls = array_diff($oldUrlSet, $newUrlSet); + + // Simply go through the list of old URLs and remove them from the mapping. + // This is one of the reasons we pass it by reference. + foreach ($oldUrls as $oldUrl) { + unset($uriToIdMapping[$oldUrl]); + } + + $idToUrlsMapping = $this->getIdToUrisMapping(); + $idToUrlsMapping[$page->id] = $newUrlSet; + $this->replaceIdToUriMapping($idToUrlsMapping); + } + + /** + * Forget all URL caches tied to the page (cf. Page::getAllUrlCacheKeys) + */ + protected function forgetPageLocalCache(Page $page) + { + // The page local caches are simply those behind the + // URL cache keys. Compute the keys, forget the caches. + $cacheKeys = $page->getAllUrlCacheKeys(); + foreach ($cacheKeys as $cacheKey) { + Cache::forget($cacheKey); + } + } + + /** + * Completely replaced the cached ID -> URI[] mapping + * + * @param array $idToUriMapping + */ + protected function replaceIdToUriMapping(array $idToUriMapping): void + { + // Replace the ID -> URI[] mapping with the given one. + // This is done "atomically" with regards to the cache. + // Note that concurrent read and writes can result in lost updates. + // And thus in an invalid state. + Cache::forever(static::ID_TO_URI_MAPPING, $idToUriMapping); + } + + /** + * Completely replace the cached URI -> ID mapping + * + * @param array $uriToIdMapping + */ + protected function replaceUriToIdMapping(array $uriToIdMapping): void + { + // Replace the URI -> ID mapping with the given one. + // This is done "atomically" with regards to the cache. + // Note that concurrent read and writes can result in lost updates. + // And thus in an invalid state. + Cache::forever(static::URI_TO_ID_MAPPING, $uriToIdMapping); + } +} diff --git a/tests/Commands/ClearRoutesCacheCommand.test.php b/tests/Commands/ClearRoutesCacheCommand.test.php new file mode 100644 index 0000000..abf21e4 --- /dev/null +++ b/tests/Commands/ClearRoutesCacheCommand.test.php @@ -0,0 +1,139 @@ +toBeInstanceOf(ClearRoutesCacheCommand::class); + }); + + it('clears all route caches', function () { + /** + * @var PageRoutesService $service + */ + $service = resolve(PageRoutesService::class); + + /** + * @var Page $page + */ + $page = Page::create([ + 'title' => 'My title', + 'slug' => 'my-slug', + 'blocks' => [], + 'parent_id' => null, + ]); + + /** + * @var Page $child + */ + $child = Page::create([ + 'title' => 'My child page', + 'slug' => 'my-child-page', + 'blocks' => [], + 'parent_id' => $page->id, + ]); + + $service->getAllUrls(); // ensure everything is cached beforehand + + artisan('filament-fabricator:clear-routes-cache') + ->assertSuccessful(); + + expect(Cache::get('filament-fabricator::PageRoutesService::uri-to-id'))->toBeEmpty(); + expect(Cache::get('filament-fabricator::PageRoutesService::id-to-uri'))->toBeEmpty(); + + $cacheKeys = [...$page->getAllUrlCacheKeys(), ...$child->getAllUrlCacheKeys()]; + + expect($cacheKeys)->not->toBeEmpty(); + + expect( + collect($cacheKeys) + ->every(fn (string $cacheKey) => ! Cache::has($cacheKey)) + )->toBeTrue(); + }); + + it('refreshes the cache properly', function (string $flag, string $newPrefix) { + /** + * @var PageRoutesService $service + */ + $service = resolve(PageRoutesService::class); + + /** + * @var Page $page + */ + $page = Page::create([ + 'title' => 'My title', + 'slug' => 'my-slug', + 'blocks' => [], + 'parent_id' => null, + ]); + + /** + * @var Page $child + */ + $child = Page::create([ + 'title' => 'My child page', + 'slug' => 'my-child-page', + 'blocks' => [], + 'parent_id' => $page->id, + ]); + + $urls = collect([...$page->getAllUrls(), ...$child->getAllUrls()])->sort()->toArray(); + + $prevUTI = Cache::get('filament-fabricator::PageRoutesService::uri-to-id'); + $prevUTI = collect($prevUTI)->sort()->toArray(); + + $prevITU = Cache::get('filament-fabricator::PageRoutesService::id-to-uri'); + $prevITU = collect($prevITU)->sort()->toArray(); + + Config::set('filament-fabricator.routing.prefix', $newPrefix); + + artisan('filament-fabricator:clear-routes-cache', [ + $flag => true, + ]) + ->assertSuccessful(); + + $newUrls = collect([...$page->getAllUrls(), ...$child->getAllUrls()])->sort()->toArray(); + expect($newUrls)->not->toEqualCanonicalizing($urls); + expect($newUrls)->not->toBeEmpty(); + expect( + collect($newUrls) + ->every(fn (string $url) => str_starts_with($url, "/$newPrefix")) + )->toBeTrue(); + + $newUTI = Cache::get('filament-fabricator::PageRoutesService::uri-to-id'); + $newUTI = collect($newUTI)->sort()->toArray(); + expect($newUTI)->not->toEqual($prevUTI); + expect($newUTI)->not->toBeEmpty(); + expect( + collect($newUTI) + ->keys() + ->every(fn (string $uri) => str_starts_with($uri, "/$newPrefix")) + ); + + $newITU = Cache::get('filament-fabricator::PageRoutesService::id-to-uri'); + $newITU = collect($newITU)->sort()->toArray(); + expect($newITU)->not->toEqual($prevITU); + expect($newITU)->not->toBeEmpty(); + expect( + collect($newITU) + ->values() + ->flatten() + ->every(fn (string $uri) => str_starts_with($uri, "/$newPrefix")) + ); + })->with([ + ['--refresh', 'newprefix'], + ['-R', 'np'], + ]); +}); diff --git a/tests/Observers/PageRoutesObserver.test.php b/tests/Observers/PageRoutesObserver.test.php new file mode 100644 index 0000000..dca2218 --- /dev/null +++ b/tests/Observers/PageRoutesObserver.test.php @@ -0,0 +1,656 @@ +delete(); + }); + + describe('#created($page)', function () { + it('properly adds all the page\'s URLs to the mapping', function () { + $beforeUrls = FilamentFabricator::getPageUrls(); + + /** + * @var Page $page + */ + $page = Page::create([ + 'title' => 'My title', + 'slug' => 'my-slug', + 'blocks' => [], + 'parent_id' => null, + ]); + + $sortUrls = fn (array $urls) => collect($urls) + ->sort() + ->values() + ->toArray(); + + $afterUrls = $sortUrls(FilamentFabricator::getPageUrls()); + + expect($afterUrls)->not->toEqual($beforeUrls); + + $pageUrls = $sortUrls($page->getAllUrls()); + + expect($afterUrls)->toEqual($pageUrls); + }); + + it('properly works on child pages', function () { + /** + * @var Page $page + */ + $page = Page::create([ + 'title' => 'My title', + 'slug' => 'my-slug', + 'blocks' => [], + 'parent_id' => null, + ]); + + /** + * @var Page $page + */ + $child = Page::create([ + 'title' => 'My stuff', + 'slug' => 'my-stuff', + 'blocks' => [], + 'parent_id' => $page->id, + ]); + + $sortUrls = fn (array $urls) => collect($urls) + ->sort() + ->values() + ->toArray(); + + $allUrls = FilamentFabricator::getPageUrls(); + $allUrls = $sortUrls($allUrls); + + $fromPages = $sortUrls([ + ...$page->getAllUrls(), + ...$child->getAllUrls(), + ]); + + $expectedUrls = $sortUrls([ + '/my-slug', + '/my-slug/my-stuff', + ]); + + expect($allUrls)->toEqual($expectedUrls); + expect($fromPages)->toEqual($expectedUrls); + + /** + * @var Page $page + */ + $descendant = Page::create([ + 'title' => 'Abc xyz', + 'slug' => 'abc-xyz', + 'blocks' => [], + 'parent_id' => $child->id, + ]); + + $allUrls = FilamentFabricator::getPageUrls(); + $allUrls = $sortUrls($allUrls); + + $fromPages = $sortUrls([ + ...$page->getAllUrls(), + ...$child->getAllUrls(), + ...$descendant->getAllUrls(), + ]); + + $expectedUrls = $sortUrls([ + '/my-slug', + '/my-slug/my-stuff', + '/my-slug/my-stuff/abc-xyz', + ]); + + expect($allUrls)->toEqual($expectedUrls); + expect($fromPages)->toEqual($expectedUrls); + }); + }); + + describe('#updated($page)', function () { + it('removes the old URLs from the mapping', function () { + /** + * @var Page $page + */ + $page = Page::create([ + 'title' => 'My title', + 'slug' => 'my-slug', + 'blocks' => [], + 'parent_id' => null, + ]); + + $oldUrls = $page->getAllUrls(); + + $page->slug = 'not-my-slug'; + $page->save(); + + $allUrls = FilamentFabricator::getPageUrls(); + + expect($allUrls)->not->toContain(...$oldUrls); + }); + + it('adds the new URLs to the mapping', function () { + /** + * @var Page $page + */ + $page = Page::create([ + 'title' => 'My title', + 'slug' => 'my-slug', + 'blocks' => [], + 'parent_id' => null, + ]); + + $page->slug = 'not-my-slug'; + $page->save(); + + $sortUrls = fn (array $urls) => collect($urls) + ->sort() + ->values() + ->toArray(); + + $expected = $sortUrls(['/not-my-slug']); + + $newUrls = $sortUrls($page->getAllUrls()); + + $allUrls = $sortUrls(FilamentFabricator::getPageUrls()); + + expect($allUrls)->toEqual($expected); + expect($newUrls)->toEqual($expected); + }); + + it('properly updates all child (and descendant) routes', function () { + /** + * @var Page $page + */ + $page = Page::create([ + 'title' => 'My title', + 'slug' => 'my-slug', + 'blocks' => [], + 'parent_id' => null, + ]); + + $child1 = Page::create([ + 'title' => 'My child 1', + 'slug' => 'child-1', + 'blocks' => [], + 'parent_id' => $page->id, + ]); + + $child2 = Page::create([ + 'title' => 'My child 2', + 'slug' => 'child-2', + 'blocks' => [], + 'parent_id' => $page->id, + ]); + + $child3 = Page::create([ + 'title' => 'My child 3', + 'slug' => 'child-3', + 'blocks' => [], + 'parent_id' => $page->id, + ]); + + $childOfChild = Page::create([ + 'title' => 'Subchild 1', + 'slug' => 'subchild-1', + 'blocks' => [], + 'parent_id' => $child2->id, + ]); + + $sortUrls = fn (array $urls) => collect($urls) + ->sort() + ->values() + ->toArray(); + + /** + * @var Page[] $descendants + */ + $descendants = [$child1, $child2, $child3, $childOfChild]; + $pages = [$page, ...$descendants]; + $oldUrlSets = array_map(fn (Page $page) => $page->getAllUrls(), $descendants); + + $page->slug = 'not-my-slug'; + $page->save(); + + foreach ($descendants as $descendant) { + $descendant->refresh(); + } + + $newUrlSets = array_map(fn (Page $page) => $page->getAllUrls(), $descendants); + + expect($newUrlSets)->not->toEqual($oldUrlSets); + + $allUrls = $sortUrls(FilamentFabricator::getPageUrls()); + + $fromPages = $sortUrls(collect($pages)->flatMap(fn (Page $page) => $page->getAllUrls())->toArray()); + + $expectedUrls = $sortUrls([ + '/not-my-slug', + '/not-my-slug/child-1', + '/not-my-slug/child-2', + '/not-my-slug/child-3', + '/not-my-slug/child-2/subchild-1', + ]); + + expect($allUrls)->toEqual($expectedUrls); + expect($fromPages)->toEqual($expectedUrls); + + $child2->slug = 'not-child-2-xyz'; + $child2->save(); + + foreach ($descendants as $descendant) { + $descendant->refresh(); + } + + $allUrls = $sortUrls(FilamentFabricator::getPageUrls()); + $fromPages = $sortUrls(collect($pages)->flatMap(fn (Page $page) => $page->getAllUrls())->toArray()); + + $expectedUrls = $sortUrls([ + '/not-my-slug', + '/not-my-slug/child-1', + '/not-my-slug/not-child-2-xyz', + '/not-my-slug/child-3', + '/not-my-slug/not-child-2-xyz/subchild-1', + ]); + + expect($allUrls)->toEqual($expectedUrls); + expect($fromPages)->toEqual($expectedUrls); + }); + + it('properly updates itself and descendants when changing which page is the parent (BelongsTo#associate and BelongsTo#dissociate)', function () { + /** + * @var Page $page + */ + $page = Page::create([ + 'title' => 'My title', + 'slug' => 'my-slug', + 'blocks' => [], + 'parent_id' => null, + ]); + + /** + * @var Page $child1 + */ + $child1 = Page::create([ + 'title' => 'My child 1', + 'slug' => 'child-1', + 'blocks' => [], + 'parent_id' => $page->id, + ]); + + /** + * @var Page $child2 + */ + $child2 = Page::create([ + 'title' => 'My child 2', + 'slug' => 'child-2', + 'blocks' => [], + 'parent_id' => $page->id, + ]); + + /** + * @var Page $child3 + */ + $child3 = Page::create([ + 'title' => 'My child 3', + 'slug' => 'child-3', + 'blocks' => [], + 'parent_id' => $page->id, + ]); + + /** + * @var Page $childOfChild + */ + $childOfChild = Page::create([ + 'title' => 'Subchild 1', + 'slug' => 'subchild-1', + 'blocks' => [], + 'parent_id' => $child2->id, + ]); + + $sortUrls = fn (array $urls) => collect($urls) + ->sort() + ->values() + ->toArray(); + + /** + * @var Page[] $descendants + */ + $descendants = [$child1, $child2, $child3, $childOfChild]; + $pages = [$page, ...$descendants]; + $oldUrlSets = array_map(fn (Page $page) => $page->getAllUrls(), $descendants); + + $child2->parent()->associate($child1); + $child2->save(); + + $child3->parent()->dissociate(); + $child3->save(); + + foreach ($descendants as $descendant) { + $descendant->refresh(); + } + + $newUrlSets = array_map(fn (Page $page) => $page->getAllUrls(), $descendants); + + $fromPages = $sortUrls(collect($pages)->flatMap(fn (Page $page) => $page->getAllUrls())->toArray()); + + expect($newUrlSets)->not->toEqual($oldUrlSets); + + $allUrls = $sortUrls(FilamentFabricator::getPageUrls()); + + $expectedUrls = $sortUrls([ + '/my-slug', + '/my-slug/child-1', + '/my-slug/child-1/child-2', + '/child-3', + '/my-slug/child-1/child-2/subchild-1', + ]); + + expect($allUrls)->toEqual($expectedUrls); + expect($fromPages)->toEqual($expectedUrls); + }); + + it('properly updates itself and descendants when changing which page is the parent (Model#update)', function () { + /** + * @var Page $page + */ + $page = Page::create([ + 'title' => 'My title', + 'slug' => 'my-slug', + 'blocks' => [], + 'parent_id' => null, + ]); + + /** + * @var Page $child1 + */ + $child1 = Page::create([ + 'title' => 'My child 1', + 'slug' => 'child-1', + 'blocks' => [], + 'parent_id' => $page->id, + ]); + + /** + * @var Page $child2 + */ + $child2 = Page::create([ + 'title' => 'My child 2', + 'slug' => 'child-2', + 'blocks' => [], + 'parent_id' => $page->id, + ]); + + /** + * @var Page $child3 + */ + $child3 = Page::create([ + 'title' => 'My child 3', + 'slug' => 'child-3', + 'blocks' => [], + 'parent_id' => $page->id, + ]); + + /** + * @var Page $childOfChild + */ + $childOfChild = Page::create([ + 'title' => 'Subchild 1', + 'slug' => 'subchild-1', + 'blocks' => [], + 'parent_id' => $child2->id, + ]); + + $sortUrls = fn (array $urls) => collect($urls) + ->sort() + ->values() + ->toArray(); + + /** + * @var Page[] $descendants + */ + $descendants = [$child1, $child2, $child3, $childOfChild]; + $pages = [$page, ...$descendants]; + $oldUrlSets = array_map(fn (Page $page) => $page->getAllUrls(), $descendants); + + $child2->update([ + 'parent_id' => $child1->id, + ]); + + $child3->update([ + 'parent_id' => null, + ]); + + foreach ($descendants as $descendant) { + $descendant->refresh(); + } + + $newUrlSets = array_map(fn (Page $page) => $page->getAllUrls(), $descendants); + + $fromPages = $sortUrls(collect($pages)->flatMap(fn (Page $page) => $page->getAllUrls())->toArray()); + + expect($newUrlSets)->not->toEqual($oldUrlSets); + + $allUrls = $sortUrls(FilamentFabricator::getPageUrls()); + + $expectedUrls = $sortUrls([ + '/my-slug', + '/my-slug/child-1', + '/my-slug/child-1/child-2', + '/child-3', + '/my-slug/child-1/child-2/subchild-1', + ]); + + expect($allUrls)->toEqual($expectedUrls); + expect($fromPages)->toEqual($expectedUrls); + }); + + it('properly updates itself and descendants when changing which page is the parent (manual change and Model#save)', function () { + /** + * @var Page $page + */ + $page = Page::create([ + 'title' => 'My title', + 'slug' => 'my-slug', + 'blocks' => [], + 'parent_id' => null, + ]); + + /** + * @var Page $child1 + */ + $child1 = Page::create([ + 'title' => 'My child 1', + 'slug' => 'child-1', + 'blocks' => [], + 'parent_id' => $page->id, + ]); + + /** + * @var Page $child2 + */ + $child2 = Page::create([ + 'title' => 'My child 2', + 'slug' => 'child-2', + 'blocks' => [], + 'parent_id' => $page->id, + ]); + + /** + * @var Page $child3 + */ + $child3 = Page::create([ + 'title' => 'My child 3', + 'slug' => 'child-3', + 'blocks' => [], + 'parent_id' => $page->id, + ]); + + /** + * @var Page $childOfChild + */ + $childOfChild = Page::create([ + 'title' => 'Subchild 1', + 'slug' => 'subchild-1', + 'blocks' => [], + 'parent_id' => $child2->id, + ]); + + $sortUrls = fn (array $urls) => collect($urls) + ->sort() + ->values() + ->toArray(); + + $descendants = [$child1, $child2, $child3, $childOfChild]; + $pages = [$page, ...$descendants]; + $oldUrlSets = array_map(fn (Page $page) => $page->getAllUrls(), $descendants); + + $child2->parent_id = $child1->id; + $child2->save(); + + $child3->parent_id = null; + $child3->save(); + + foreach ($descendants as $descendant) { + $descendant->refresh(); + } + + $newUrlSets = array_map(fn (Page $page) => $page->getAllUrls(), $descendants); + + $fromPages = $sortUrls(collect($pages)->flatMap(fn (Page $page) => $page->getAllUrls())->toArray()); + + expect($newUrlSets)->not->toEqual($oldUrlSets); + + $allUrls = FilamentFabricator::getPageUrls(); + $allUrls = $sortUrls($allUrls); + + $expectedUrls = $sortUrls([ + '/my-slug', + '/my-slug/child-1', + '/my-slug/child-1/child-2', + '/child-3', + '/my-slug/child-1/child-2/subchild-1', + ]); + + expect($allUrls)->toEqual($expectedUrls); + expect($fromPages)->toEqual($expectedUrls); + }); + }); + + describe('#deleting($page)', function () { + it('removes the page\'s URLs from the mapping', function () { + /** + * @var Page $page + */ + $page = Page::create([ + 'title' => 'My title', + 'slug' => 'my-slug', + 'blocks' => [], + 'parent_id' => null, + ]); + + $beforeUrls = FilamentFabricator::getPageUrls(); + + $page->delete(); + + $afterUrls = FilamentFabricator::getPageUrls(); + + expect($afterUrls)->not->toEqual($beforeUrls); + + expect($afterUrls)->toBeEmpty(); + }); + + it('sets the childrens\' parent to null if $page had no parent', function () { + /** + * @var Page $page + */ + $page = Page::create([ + 'title' => 'My title', + 'slug' => 'my-slug', + 'blocks' => [], + 'parent_id' => null, + ]); + + /** + * @var Page $child + */ + $child = Page::create([ + 'title' => 'My child page', + 'slug' => 'my-child-page', + 'blocks' => [], + 'parent_id' => $page->id, + ]); + + $page->delete(); + + $child->refresh(); + + expect($child->parent_id)->toBeNull(); + + $urls = FilamentFabricator::getPageUrls(); + + $expected = ['/my-child-page']; + + expect($urls)->toEqual($expected); + expect($child->getAllUrls())->toEqual($expected); + }); + + it('attaches the children to $page\'s parent if it had one', function () { + /** + * @var Page $page + */ + $page = Page::create([ + 'title' => 'My title', + 'slug' => 'my-slug', + 'blocks' => [], + 'parent_id' => null, + ]); + + /** + * @var Page $child + */ + $child = Page::create([ + 'title' => 'My child page', + 'slug' => 'my-child-page', + 'blocks' => [], + 'parent_id' => $page->id, + ]); + + /** + * @var Page $descendant + */ + $descendant = Page::create([ + 'title' => 'My sub page', + 'slug' => 'my-sub-page', + 'blocks' => [], + 'parent_id' => $child->id, + ]); + + $sortUrls = fn (array $urls) => collect($urls) + ->sort() + ->values() + ->toArray(); + + $child->delete(); + $descendant->refresh(); + $page->refresh(); + + expect($descendant->parent_id)->toBe($page->id); + + $urls = $sortUrls(FilamentFabricator::getPageUrls()); + + $expected = $sortUrls([ + '/my-slug', + '/my-slug/my-sub-page', + ]); + + $fromPages = $sortUrls(collect([$page, $descendant])->flatMap(fn (Page $page) => $page->getAllUrls())->toArray()); + + expect($urls)->toEqual($expected); + expect($fromPages)->toEqual($expected); + }); + }); +}); diff --git a/tests/TestCase.php b/tests/TestCase.php index 102661b..2040ff0 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -41,9 +41,11 @@ public function getEnvironmentSetUp($app) { config()->set('database.default', 'testing'); - /* - $migration = include __DIR__.'/../database/migrations/create_filament-fabricator_table.php.stub'; + $migration = include __DIR__ . '/../database/migrations/create_pages_table.php.stub'; $migration->up(); - */ + + $migration = include __DIR__ . '/../database/migrations/fix_slug_unique_constraint_on_pages_table.php.stub'; + $migration->up(); + } }