├── resources └── utility.png ├── src ├── events │ └── FlaggedTemplateCachesEvent.php ├── icon.svg ├── assetbundles │ └── CpBundle.php ├── console │ └── controllers │ │ └── CachesController.php ├── records │ └── Flags.php ├── resources │ ├── cacheflag.css │ └── cacheflag.js ├── controllers │ ├── CachesController.php │ └── DefaultController.php ├── twigextensions │ ├── Extension.php │ ├── CacheFlagTokenParser.php │ └── CacheFlagNode.php ├── utilities │ └── CacheFlagUtility.php ├── templates │ └── _utility.twig ├── migrations │ └── Install.php ├── services │ ├── ProjectConfig.php │ ├── TemplateCachesService.php │ └── CacheFlagService.php └── CacheFlag.php ├── .gitignore ├── LICENSE.md ├── composer.json ├── CHANGELOG.md └── README.md /resources/utility.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mmikkel/CacheFlag-Craft3/HEAD/resources/utility.png -------------------------------------------------------------------------------- /src/events/FlaggedTemplateCachesEvent.php: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # CRAFT ENVIRONMENT 2 | .env.php 3 | .env.sh 4 | .env 5 | 6 | # COMPOSER 7 | /vendor 8 | /composer.lock 9 | 10 | # BUILD FILES 11 | /bower_components/* 12 | /node_modules/* 13 | /build/* 14 | /yarn-error.log 15 | 16 | # MISC FILES 17 | .cache 18 | .DS_Store 19 | .idea 20 | .project 21 | .settings 22 | *.esproj 23 | *.sublime-workspace 24 | *.sublime-project 25 | *.tmproj 26 | *.tmproject 27 | .vscode/* 28 | !.vscode/settings.json 29 | !.vscode/tasks.json 30 | !.vscode/launch.json 31 | !.vscode/extensions.json 32 | config.codekit3 33 | prepros-6.config 34 | -------------------------------------------------------------------------------- /src/assetbundles/CpBundle.php: -------------------------------------------------------------------------------- 1 | sourcePath = '@mmikkel/cacheflag/resources'; 22 | 23 | // define the dependencies 24 | $this->depends = [ 25 | CpAsset::class, 26 | ]; 27 | 28 | // define the relative path to CSS/JS files that should be registered with the page 29 | // when this asset bundle is registered 30 | $this->js = [ 31 | 'cacheflag.js', 32 | ]; 33 | 34 | $this->css = [ 35 | 'cacheflag.css', 36 | ]; 37 | 38 | parent::init(); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2018 Mats Mikkel Rummelhoff 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /src/console/controllers/CachesController.php: -------------------------------------------------------------------------------- 1 | cacheFlag->invalidateAllFlaggedCaches(); 33 | return ExitCode::OK; 34 | } 35 | 36 | CacheFlag::getInstance()->cacheFlag->invalidateFlaggedCachesByFlags($flags); 37 | 38 | return ExitCode::OK; 39 | 40 | } 41 | 42 | } 43 | -------------------------------------------------------------------------------- /src/records/Flags.php: -------------------------------------------------------------------------------- 1 | getRequest()->getParam('flags', []); 29 | 30 | if (\is_string($flags)) { 31 | $flags = \preg_replace('/\s+/', '', $flags); 32 | $flags = \array_filter(\explode(',', $flags)); 33 | } else if (\is_array($flags)) { 34 | $flags = \array_reduce($flags, function (array $carry, string $flag) { 35 | $flag = \preg_replace('/\s+/', '', $flag); 36 | if (\strlen($flag)) { 37 | $carry[] = $flag; 38 | } 39 | return $carry; 40 | }, []); 41 | } 42 | 43 | /** @var array $flags */ 44 | if (empty($flags)) { 45 | CacheFlag::getInstance()->cacheFlag->invalidateAllFlaggedCaches(); 46 | return true; 47 | } 48 | 49 | $flags = \array_unique($flags); 50 | 51 | CacheFlag::getInstance()->cacheFlag->invalidateFlaggedCachesByFlags($flags); 52 | return true; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/twigextensions/Extension.php: -------------------------------------------------------------------------------- 1 | getRequest()->getIsCpRequest() ? new TwigFilter('cacheFlagUnCamelCase', [$this, 'cacheFlagUnCamelCaseFilter']) : null, 35 | ]); 36 | } 37 | 38 | /** 39 | * @return array 40 | */ 41 | public function getTokenParsers(): array 42 | { 43 | return [ 44 | new CacheFlagTokenParser(), 45 | ]; 46 | } 47 | 48 | /** 49 | * @param string|null $value 50 | * 51 | * @return string 52 | */ 53 | public function cacheFlagUnCamelCaseFilter(?string $value = null): string 54 | { 55 | if (empty($value)) { 56 | return ''; 57 | } 58 | if (preg_match('/[A-Z]/', $value) === 0) { 59 | return $value; 60 | } 61 | $pattern = '/([a-z])([A-Z])/'; 62 | $r = strtolower(preg_replace_callback($pattern, function ($a) { 63 | return $a[1] . ' ' . strtolower($a[2]); 64 | }, $value)); 65 | return $r; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/utilities/CacheFlagUtility.php: -------------------------------------------------------------------------------- 1 | [ 42 | 'column' => 'sectionId', 43 | 'name' => Craft::t('app', 'Sections'), 44 | 'sources' => Craft::$app->getEntries()->getAllSections(), 45 | ], 46 | 'categoryGroups' => [ 47 | 'column' => 'categoryGroupId', 48 | 'name' => Craft::t('app', 'Category Groups'), 49 | 'sources' => Craft::$app->getCategories()->getAllGroups(), 50 | ], 51 | 'volumes' => [ 52 | 'column' => 'volumeId', 53 | 'name' => Craft::t('app', 'Asset Volumes'), 54 | 'sources' => Craft::$app->getVolumes()->getAllVolumes(), 55 | ], 56 | 'globalSets' => [ 57 | 'column' => 'globalSetId', 58 | 'name' => Craft::t('app', 'Global Sets'), 59 | 'sources' => Craft::$app->getGlobals()->getAllSets(), 60 | ], 61 | 'elementTypes' => [ 62 | 'column' => 'elementType', 63 | 'name' => Craft::t('app', 'Element Types'), 64 | 'sources' => array_map(function (string $elementType) { 65 | return [ 66 | 'id' => $elementType, 67 | 'name' => $elementType, 68 | ]; 69 | }, Craft::$app->getElements()->getAllElementTypes()), 70 | ], 71 | ]; 72 | 73 | if (Craft::$app->getEdition() === 1) { 74 | $sources['userGroups'] = [ 75 | 'column' => 'userGroupId', 76 | 'name' => Craft::t('app', 'User Groups'), 77 | 'sources' => Craft::$app->getUserGroups()->getAllGroups(), 78 | ]; 79 | } 80 | 81 | return Craft::$app->getView()->renderTemplate('cache-flag/_utility.twig', [ 82 | 'sources' => $sources, 83 | 'allFlags' => CacheFlag::getInstance()->cacheFlag->getAllFlags(), 84 | 'version' => Craft::$app->getPlugins()->getPlugin('cache-flag')->getVersion(), 85 | 'documentationUrl' => Craft::$app->getPlugins()->getComposerPluginInfo('cache-flag')['documentationUrl'] ?? null, 86 | ]); 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/resources/cacheflag.js: -------------------------------------------------------------------------------- 1 | /** global: Craft */ 2 | 3 | $(function () { 4 | 5 | var $form = $('.cacheFlag-form'); 6 | var $submitRequest = null; 7 | 8 | $form 9 | .on('submit', onFormSubmit) 10 | .on('click', '[data-clearflags]', clearCaches); 11 | 12 | function onFormSubmit(e) { 13 | e.preventDefault(); 14 | submitForm(); 15 | } 16 | 17 | function submitForm() { 18 | 19 | if ($submitRequest) { 20 | $submitRequest.abort(); 21 | } 22 | 23 | $form.addClass('js-submitting'); 24 | $form.find('.spinner').removeClass('hidden'); 25 | $form.find('input[type="submit"]').prop('disabled', true).addClass('disabled'); 26 | 27 | $submitRequest = $.ajax($form.attr('action'), { 28 | data: $form.serialize(), 29 | type: 'POST', 30 | dataType: 'json', 31 | success: function (response) { 32 | if (response.success) { 33 | Craft.cp.displayNotice(response.message); 34 | $form.find('input[type="text"][name^="cacheflags"]').each(function () { 35 | var $input = $(this); 36 | var source = $input.attr('name').replace('cacheflags[', '').replace(']', '').split(':'); 37 | var sourceColumn = source[0] || null; 38 | var sourceValue = (source[1] || '').toString(); 39 | if (!sourceColumn || !sourceValue) { 40 | return; 41 | } 42 | var flags = ''; 43 | for (var i = 0; i < response.flags.length; ++i) { 44 | if ((response.flags[i][sourceColumn] || '').toString() === sourceValue) { 45 | flags = response.flags[i].flags || ''; 46 | break; 47 | } 48 | } 49 | $input.val(flags); 50 | }); 51 | } else { 52 | Craft.cp.displayError(response.message); 53 | } 54 | }, 55 | error: function (response) { 56 | if (response.statusText !== 'abort') { 57 | Craft.cp.displayError(response.statusText); 58 | } 59 | }, 60 | complete: function () { 61 | $submitRequest = null; 62 | $form.removeClass('js-submitting'); 63 | $form.find('.spinner').addClass('hidden'); 64 | $form.find('input[type="submit"]').prop('disabled', false).removeClass('disabled'); 65 | } 66 | }); 67 | 68 | } 69 | 70 | function clearCaches(e) { 71 | 72 | e.preventDefault(); 73 | 74 | var actionUrl = Craft.getActionUrl('cache-flag/default/invalidate-flagged-caches-by-flags'), 75 | $target = $(e.currentTarget), 76 | flagsInputId = $target.data('clearflags'), 77 | $flagsInput = $form.find('input[name="cacheflags[' + flagsInputId + ']"]'); 78 | 79 | var data = { 80 | flags: $flagsInput.val() 81 | }; 82 | 83 | data[$form.data('csrf-name')] = $form.data('csrf-token'); 84 | 85 | $.ajax(actionUrl, { 86 | type: 'POST', 87 | data: data, 88 | dataType: 'json', 89 | success: function (response) { 90 | if (response.success) { 91 | Craft.cp.displayNotice(response.message); 92 | } else { 93 | Craft.cp.displayError(response.message); 94 | } 95 | }, 96 | error: function (response) { 97 | if (response.statusText !== 'abort') { 98 | Craft.cp.displayError(response.statusText); 99 | } 100 | } 101 | }); 102 | 103 | } 104 | 105 | }); 106 | -------------------------------------------------------------------------------- /src/twigextensions/CacheFlagTokenParser.php: -------------------------------------------------------------------------------- 1 | getLine(); 32 | /** @var Parser $parser */ 33 | $parser = $this->parser; 34 | $stream = $parser->getStream(); 35 | 36 | $nodes = []; 37 | 38 | $attributes = [ 39 | 'global' => false, 40 | 'durationNum' => null, 41 | 'durationUnit' => null, 42 | 'elements' => false, 43 | ]; 44 | 45 | if ($stream->test(Token::NAME_TYPE, 'flagged')) { 46 | $stream->next(); 47 | $nodes['flags'] = $parser->getExpressionParser()->parseExpression(); 48 | } 49 | 50 | if ($stream->test(Token::NAME_TYPE, 'with')) { 51 | $stream->next(); 52 | $stream->expect(Token::NAME_TYPE, 'elements'); 53 | $attributes['elements'] = true; 54 | } 55 | 56 | if ($stream->test(Token::NAME_TYPE, 'globally')) { 57 | $attributes['global'] = true; 58 | $stream->next(); 59 | } 60 | 61 | if ($stream->test(Token::NAME_TYPE, 'using')) { 62 | $stream->next(); 63 | $stream->expect(Token::NAME_TYPE, 'key'); 64 | $nodes['key'] = $parser->getExpressionParser()->parseExpression(); 65 | } 66 | 67 | if ($stream->test(Token::NAME_TYPE, 'for')) { 68 | $stream->next(); 69 | $attributes['durationNum'] = $stream->expect(Token::NUMBER_TYPE)->getValue(); 70 | $attributes['durationUnit'] = $stream->expect(Token::NAME_TYPE, 71 | [ 72 | 'sec', 73 | 'secs', 74 | 'second', 75 | 'seconds', 76 | 'min', 77 | 'mins', 78 | 'minute', 79 | 'minutes', 80 | 'hour', 81 | 'hours', 82 | 'day', 83 | 'days', 84 | 'fortnight', 85 | 'fortnights', 86 | 'forthnight', 87 | 'forthnights', 88 | 'month', 89 | 'months', 90 | 'year', 91 | 'years', 92 | 'week', 93 | 'weeks' 94 | ])->getValue(); 95 | } else if ($stream->test(Token::NAME_TYPE, 'until')) { 96 | $stream->next(); 97 | $nodes['expiration'] = $parser->getExpressionParser()->parseExpression(); 98 | } 99 | 100 | if ($stream->test(Token::NAME_TYPE, 'if')) { 101 | $stream->next(); 102 | $nodes['conditions'] = $parser->getExpressionParser()->parseExpression(); 103 | } else if ($stream->test(Token::NAME_TYPE, 'unless')) { 104 | $stream->next(); 105 | $nodes['ignoreConditions'] = $parser->getExpressionParser()->parseExpression(); 106 | } 107 | 108 | $stream->expect(Token::BLOCK_END_TYPE); 109 | $nodes['body'] = $parser->subparse([ 110 | $this, 111 | 'decideCacheEnd' 112 | ], true); 113 | $stream->expect(Token::BLOCK_END_TYPE); 114 | 115 | return new CacheFlagNode($nodes, $attributes, $lineno, $this->getTag()); 116 | } 117 | 118 | /** 119 | * @param Token $token 120 | * @return bool 121 | */ 122 | public function decideCacheEnd(Token $token): bool 123 | { 124 | return $token->test('endcacheflag'); 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /src/controllers/DefaultController.php: -------------------------------------------------------------------------------- 1 | requirePostRequest(); 32 | $this->requireAcceptsJson(); 33 | 34 | if (!Craft::$app->getConfig()->getGeneral()->allowAdminChanges) { 35 | throw new ForbiddenHttpException('Administrative changes are disallowed in this environment.'); 36 | } 37 | 38 | $params = Craft::$app->getRequest()->getBodyParams(); 39 | $cacheFlags = $params['cacheflags'] ?? null; 40 | 41 | $error = null; 42 | 43 | foreach ($cacheFlags as $source => $flags) { 44 | 45 | $sourceArray = explode(':', $source); 46 | $sourceColumn = $sourceArray[0] ?? null; 47 | $sourceId = $sourceArray[1] ?? null; 48 | 49 | if (!$sourceColumn || !$sourceId) { 50 | continue; 51 | } 52 | 53 | $flags = preg_replace('/\s+/', '', $flags); 54 | 55 | try { 56 | if (!$flags) { 57 | CacheFlag::getInstance()->cacheFlag->deleteFlagsBySource($sourceColumn, $sourceId); 58 | continue; 59 | } 60 | CacheFlag::getInstance()->cacheFlag->saveFlags($flags, $sourceColumn, $sourceId); 61 | } catch (\Throwable $e) { 62 | $error = $e->getMessage(); 63 | } 64 | 65 | if ($error) { 66 | break; 67 | } 68 | 69 | } 70 | 71 | if ($error) { 72 | return $this->asJson([ 73 | 'success' => false, 74 | 'message' => $error, 75 | ]); 76 | } 77 | 78 | return $this->asJson([ 79 | 'success' => true, 80 | 'message' => Craft::t('cache-flag', 'Cache flags saved'), 81 | 'flags' => CacheFlag::getInstance()->cacheFlag->getAllFlags(), 82 | ]); 83 | 84 | } 85 | 86 | /** 87 | * @return \yii\web\Response 88 | * @throws BadRequestHttpException 89 | * @throws \yii\base\InvalidConfigException 90 | */ 91 | public function actionInvalidateFlaggedCachesByFlags() 92 | { 93 | $this->requirePostRequest(); 94 | $this->requireAcceptsJson(); 95 | 96 | $params = Craft::$app->getRequest()->getBodyParams(); 97 | $flags = $params['flags'] ?? null; 98 | 99 | if (!$flags) { 100 | return $this->asJson([ 101 | 'success' => false, 102 | 'message' => Craft::t('cache-flag', 'No flags to invalidate caches for'), 103 | ]); 104 | } 105 | 106 | $error = null; 107 | 108 | try { 109 | CacheFlag::getInstance()->cacheFlag->invalidateFlaggedCachesByFlags($flags); 110 | } catch (\Throwable $e) { 111 | $error = $e->getMessage(); 112 | } 113 | 114 | if ($error) { 115 | return $this->asJson([ 116 | 'success' => false, 117 | 'message' => $error, 118 | ]); 119 | } 120 | 121 | return $this->asJson([ 122 | 'success' => true, 123 | 'message' => Craft::t('cache-flag', 'Flagged caches invalidated'), 124 | ]); 125 | } 126 | 127 | /** 128 | * @return \yii\web\Response 129 | * @throws BadRequestHttpException 130 | * @throws \craft\errors\MissingComponentException 131 | */ 132 | public function actionInvalidateAllFlaggedCaches() 133 | { 134 | $this->requirePostRequest(); 135 | 136 | $error = null; 137 | 138 | try { 139 | CacheFlag::getInstance()->cacheFlag->invalidateAllFlaggedCaches(); 140 | } catch (\Throwable $e) { 141 | $error = $e; 142 | } 143 | 144 | if ($error) { 145 | Craft::$app->getSession()->setError($error); 146 | } else { 147 | Craft::$app->getSession()->setNotice(Craft::t('cache-flag', 'All flagged caches invalidated')); 148 | } 149 | 150 | return $this->redirectToPostedUrl(); 151 | } 152 | 153 | } 154 | -------------------------------------------------------------------------------- /src/templates/_utility.twig: -------------------------------------------------------------------------------- 1 | {% import '_includes/forms' as forms %} 2 | 3 | {% do craft.app.view.registerAssetBundle('mmikkel\\cacheflag\\assetbundles\\CpBundle') %} 4 | 5 | {% set allowAdminChanges = craft.app.getConfig().getGeneral().allowAdminChanges %} 6 | 7 |

Cache flags

8 | 9 |
10 | 11 | {% if allowAdminChanges %} 12 |
13 |
14 |
15 |

16 | {{ 'Separate multiple flags with commas.'|t('cache-flag') }} 17 |

18 |
19 |
20 |
21 | {% endif %} 22 | 23 | {% tag (allowAdminChanges ? 'form' : 'div') with { 24 | class: 'cacheFlag-form', 25 | method: allowAdminChanges ? 'post' : false, 26 | 'accept-charset': allowAdminChanges ? 'UTF-8' : false, 27 | 'data-saveshortcut': allowAdminChanges ? true : false, 28 | 'data-csrf-name': craft.app.config.general.csrfTokenName, 29 | 'data-csrf-token': craft.app.request.csrfToken 30 | } %} 31 | 32 | {% if allowAdminChanges %} 33 | {{ csrfInput() }} 34 | 35 | 36 | {% endif %} 37 | 38 | {% for sourceGroup in sources %} 39 | 40 | {% set sourceGroupSources = sourceGroup.sources %} 41 | 42 | {% if sourceGroupSources|length %} 43 | 44 |

{{ sourceGroup.name }}

45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | {% for source in sourceGroupSources %} 57 | 58 | 59 | 60 | 61 | 62 | 85 | 86 | 89 | 90 | 91 | 92 | {% endfor %} 93 | 94 | 95 |
{{ 'Name'|t('app') }}{{ 'Flags'|t('cache-flag') }}
{{ source.name|t('app') }} 63 | 64 | {% set sourceInputId = [sourceGroup.column, ':', source.id]|join %} 65 | {% set sourceFlags = null %} 66 | 67 | {% for flagRecord in allFlags %} 68 | {% set sourceFlags = (flagRecord[sourceGroup.column] ?? null) == source.id ? flagRecord.flags : sourceFlags %} 69 | {% endfor %} 70 | 71 | 83 | 84 | 87 | 88 |
96 | 97 | {% endif %} 98 | 99 | {% endfor %} 100 | 101 | {% if allowAdminChanges %} 102 | 103 | 104 | {% endif %} 105 | 106 | {% endtag %} 107 | 108 |
109 | 110 |
111 |
112 | {{ csrfInput() }} 113 | 115 | 116 |
117 |
118 | 119 |
120 | 121 | 124 | -------------------------------------------------------------------------------- /src/twigextensions/CacheFlagNode.php: -------------------------------------------------------------------------------- 1 | hasNode('flags') ? $this->getNode('flags') : null; 45 | 46 | $conditions = $this->hasNode('conditions') ? $this->getNode('conditions') : null; 47 | $ignoreConditions = $this->hasNode('ignoreConditions') ? $this->getNode('ignoreConditions') : null; 48 | $key = $this->hasNode('key') ? $this->getNode('key') : null; 49 | $expiration = $this->hasNode('expiration') ? $this->getNode('expiration') : null; 50 | 51 | $durationNum = $this->getAttribute('durationNum'); 52 | $durationUnit = $this->getAttribute('durationUnit'); 53 | $global = $this->getAttribute('global') ? 'true' : 'false'; 54 | $elements = $this->getAttribute('elements') ? 'true' : 'false'; 55 | 56 | $compiler 57 | ->addDebugInfo($this) 58 | ->write('$cacheService = ' . CacheFlag::class . "::getInstance()->templateCaches;\n") 59 | ->write('$request = ' . Craft::class . "::\$app->getRequest();\n") 60 | ->write("\$ignoreCache{$n} = (\$request->getIsLivePreview() || \$request->getToken()"); 61 | 62 | if ($conditions) { 63 | $compiler 64 | ->raw(' || !(') 65 | ->subcompile($conditions) 66 | ->raw(')'); 67 | } else if ($ignoreConditions) { 68 | $compiler 69 | ->raw(' || (') 70 | ->subcompile($ignoreConditions) 71 | ->raw(')'); 72 | } 73 | 74 | $compiler 75 | ->raw(");\n") 76 | ->write("if (!\$ignoreCache{$n}) {\n") 77 | ->indent() 78 | ->write("\$cacheKey{$n} = "); 79 | 80 | if ($key) { 81 | $compiler->subcompile($key); 82 | } else { 83 | $compiler->raw('"' . StringHelper::randomString() . '"'); 84 | } 85 | 86 | $compiler 87 | ->raw(";\n") 88 | ->write("\$cacheBody{$n} = \$cacheService->getTemplateCache(\$cacheKey{$n}, "); 89 | 90 | if ($flags) { 91 | $compiler->subcompile($flags); 92 | } else { 93 | $compiler->raw('null'); 94 | } 95 | 96 | $compiler 97 | ->raw(", {$elements}, {$global});\n") 98 | ->outdent() 99 | ->write("} else {\n") 100 | ->indent() 101 | ->write("\$cacheBody{$n} = null;\n") 102 | ->outdent() 103 | ->write("}\n") 104 | ->write("if (\$cacheBody{$n} === null) {\n") 105 | ->indent() 106 | ->write("if (!\$ignoreCache{$n}) {\n") 107 | ->indent() 108 | ->write("\$cacheService->startTemplateCache($global);\n") 109 | ->outdent() 110 | ->write("}\n") 111 | ->write("ob_start();\n") 112 | ->subcompile($this->getNode('body')) 113 | ->write("\$cacheBody{$n} = ob_get_clean();\n") 114 | ->write("if (!\$ignoreCache{$n}) {\n") 115 | ->indent() 116 | ->write("\$cacheService->endTemplateCache(\$cacheKey{$n}, "); 117 | 118 | if ($flags) { 119 | $compiler->subcompile($flags); 120 | } else { 121 | $compiler->raw('null'); 122 | } 123 | 124 | $compiler->raw(", {$global}, "); 125 | 126 | if ($durationNum) { 127 | // So silly that PHP doesn't support "+1 week" http://www.php.net/manual/en/datetime.formats.relative.php 128 | 129 | if ($durationUnit === 'week') { 130 | if ($durationNum == 1) { 131 | $durationNum = 7; 132 | $durationUnit = 'days'; 133 | } else { 134 | $durationUnit = 'weeks'; 135 | } 136 | } 137 | 138 | $compiler->raw("'+{$durationNum} {$durationUnit}'"); 139 | } else { 140 | $compiler->raw('null'); 141 | } 142 | 143 | $compiler->raw(', '); 144 | 145 | if ($expiration) { 146 | $compiler->subcompile($expiration); 147 | } else { 148 | $compiler->raw('null'); 149 | } 150 | 151 | $compiler 152 | ->raw(", \$cacheBody{$n});\n") 153 | ->outdent() 154 | ->write("}\n") 155 | ->outdent() 156 | ->write("}\n") 157 | ->write("echo \$cacheBody{$n};\n"); 158 | } 159 | 160 | } 161 | -------------------------------------------------------------------------------- /src/migrations/Install.php: -------------------------------------------------------------------------------- 1 | driver = Craft::$app->getConfig()->getDb()->driver; 42 | if ($this->createTables()) { 43 | $this->createIndexes(); 44 | $this->addForeignKeys(); 45 | // Refresh the db schema caches 46 | Craft::$app->db->schema->refresh(); 47 | $this->insertDefaultData(); 48 | } 49 | 50 | // Remove the old "cacheflag_flagged" table if it exists 51 | $this->dropTableIfExists('{{%cacheflag_flagged}}'); 52 | 53 | return true; 54 | } 55 | 56 | /** 57 | * @inheritdoc 58 | */ 59 | public function safeDown() 60 | { 61 | $this->driver = Craft::$app->getConfig()->getDb()->driver; 62 | $this->removeTables(); 63 | 64 | return true; 65 | } 66 | 67 | // Protected Methods 68 | // ========================================================================= 69 | 70 | /** 71 | * @return bool 72 | */ 73 | protected function createTables() 74 | { 75 | $tablesCreated = false; 76 | 77 | $tableSchema = Craft::$app->db->schema->getTableSchema('{{%cacheflag_flags}}'); 78 | if ($tableSchema === null) { 79 | $tablesCreated = true; 80 | $this->createTable( 81 | '{{%cacheflag_flags}}', 82 | [ 83 | 'id' => $this->primaryKey(), 84 | 'flags' => $this->string(255)->notNull(), 85 | 'sectionId' => $this->integer()->unique(), 86 | 'categoryGroupId' => $this->integer()->unique(), 87 | 'tagGroupId' => $this->integer()->unique(), 88 | 'userGroupId' => $this->integer()->unique(), 89 | 'volumeId' => $this->integer()->unique(), 90 | 'globalSetId' => $this->integer()->unique(), 91 | 'elementType' => $this->string(255)->unique(), 92 | 'dateCreated' => $this->dateTime()->notNull(), 93 | 'dateUpdated' => $this->dateTime()->notNull(), 94 | 'uid' => $this->uid(), 95 | ] 96 | ); 97 | } 98 | 99 | return $tablesCreated; 100 | } 101 | 102 | /** 103 | * @return void 104 | */ 105 | protected function createIndexes() 106 | { 107 | $this->createIndex( 108 | $this->db->getIndexName( 109 | '{{%cacheflag_flags}}', 110 | 'flags', 111 | false 112 | ), 113 | '{{%cacheflag_flags}}', 114 | 'flags', 115 | false 116 | ); 117 | } 118 | 119 | /** 120 | * @return void 121 | */ 122 | protected function addForeignKeys() 123 | { 124 | $this->addForeignKey( 125 | $this->db->getForeignKeyName('{{%cacheflag_flags}}', 'sectionId'), 126 | '{{%cacheflag_flags}}', 127 | 'sectionId', 128 | '{{%sections}}', 129 | 'id', 130 | 'CASCADE', 131 | 'CASCADE' 132 | ); 133 | 134 | $this->addForeignKey( 135 | $this->db->getForeignKeyName('{{%cacheflag_flags}}', 'categoryGroupId'), 136 | '{{%cacheflag_flags}}', 137 | 'categoryGroupId', 138 | '{{%categorygroups}}', 139 | 'id', 140 | 'CASCADE', 141 | 'CASCADE' 142 | ); 143 | 144 | $this->addForeignKey( 145 | $this->db->getForeignKeyName('{{%cacheflag_flags}}', 'tagGroupId'), 146 | '{{%cacheflag_flags}}', 147 | 'tagGroupId', 148 | '{{%taggroups}}', 149 | 'id', 150 | 'CASCADE', 151 | 'CASCADE' 152 | ); 153 | 154 | $this->addForeignKey( 155 | $this->db->getForeignKeyName('{{%cacheflag_flags}}', 'userGroupId'), 156 | '{{%cacheflag_flags}}', 157 | 'userGroupId', 158 | '{{%usergroups}}', 159 | 'id', 160 | 'CASCADE', 161 | 'CASCADE' 162 | ); 163 | 164 | $this->addForeignKey( 165 | $this->db->getForeignKeyName('{{%cacheflag_flags}}', 'volumeId'), 166 | '{{%cacheflag_flags}}', 167 | 'volumeId', 168 | '{{%volumes}}', 169 | 'id', 170 | 'CASCADE', 171 | 'CASCADE' 172 | ); 173 | 174 | $this->addForeignKey( 175 | $this->db->getForeignKeyName('{{%cacheflag_flags}}', 'globalSetId'), 176 | '{{%cacheflag_flags}}', 177 | 'globalSetId', 178 | '{{%globalsets}}', 179 | 'id', 180 | 'CASCADE', 181 | 'CASCADE' 182 | ); 183 | } 184 | 185 | /** 186 | * @return void 187 | */ 188 | protected function insertDefaultData() 189 | { 190 | } 191 | 192 | /** 193 | * @return void 194 | */ 195 | protected function removeTables() 196 | { 197 | $this->dropTableIfExists('{{%cacheflag_flagged}}'); 198 | $this->dropTableIfExists('{{%cacheflag_flags}}'); 199 | } 200 | } 201 | -------------------------------------------------------------------------------- /src/services/ProjectConfig.php: -------------------------------------------------------------------------------- 1 | tokenMatches[0]; 31 | 32 | $query = (new Query()) 33 | ->select(['id']) 34 | ->from(Flags::tableName()) 35 | ->where(['uid' => $uid]); 36 | 37 | $source = \explode(':', $event->newValue['source']); 38 | $sourceKey = $source[0] ?? null; 39 | $sourceValue = $source[1] ?? null; 40 | 41 | if (!$sourceKey || !$sourceValue) { 42 | return; 43 | } 44 | 45 | switch ($sourceKey) { 46 | case 'section': 47 | $column = 'sectionId'; 48 | $value = (int)Db::idByUid(Table::SECTIONS, $sourceValue); 49 | break; 50 | case 'categoryGroup': 51 | $column = 'categoryGroupId'; 52 | $value = (int)Db::idByUid(Table::CATEGORYGROUPS, $sourceValue); 53 | break; 54 | case 'tagGroup': 55 | $column = 'tagGroupId'; 56 | $value = (int)Db::idByUid(Table::TAGGROUPS, $sourceValue); 57 | break; 58 | case 'userGroup': 59 | $column = 'userGroupId'; 60 | $value = (int)Db::idByUid(Table::USERGROUPS, $sourceValue); 61 | break; 62 | case 'volume': 63 | $column = 'volumeId'; 64 | $value = (int)Db::idByUid(Table::VOLUMES, $sourceValue); 65 | break; 66 | case 'globalSet': 67 | $column = 'globalSetId'; 68 | $value = (int)Db::idByUid(Table::GLOBALSETS, $sourceValue); 69 | break; 70 | case 'elementType': 71 | $column = 'elementType'; 72 | $value = $sourceValue; 73 | break; 74 | default: 75 | return; 76 | } 77 | 78 | $query->orWhere([$column => $value]); 79 | 80 | $id = $query->scalar(); 81 | 82 | $isNew = empty($id); 83 | 84 | if ($isNew) { 85 | 86 | $flags = $event->newValue['flags']; 87 | 88 | Craft::$app->db->createCommand() 89 | ->insert(Flags::tableName(), [ 90 | 'flags' => $flags, 91 | $column => $value, 92 | 'uid' => $uid, 93 | ]) 94 | ->execute(); 95 | 96 | } else { 97 | 98 | Craft::$app->db->createCommand() 99 | ->update(Flags::tableName(), [ 100 | 'flags' => $event->newValue['flags'], 101 | 'uid' => $uid, 102 | ], ['id' => $id]) 103 | ->execute(); 104 | } 105 | 106 | } 107 | 108 | /** 109 | * @param ConfigEvent $event 110 | * @return void 111 | */ 112 | public function onProjectConfigDelete(ConfigEvent $event) 113 | { 114 | 115 | $uid = $event->tokenMatches[0]; 116 | 117 | try { 118 | $id = (new Query()) 119 | ->select(['id']) 120 | ->from(Flags::tableName()) 121 | ->where(['uid' => $uid]) 122 | ->scalar(); 123 | if ($id) { 124 | Craft::$app->db->createCommand() 125 | ->delete(Flags::tableName(), ['id' => $id]) 126 | ->execute(); 127 | } 128 | } catch (\Throwable $e) { 129 | Craft::error($e, __METHOD__); 130 | } 131 | } 132 | 133 | /** 134 | * @param RebuildConfigEvent $event 135 | * @return void 136 | */ 137 | public function onProjectConfigRebuild(RebuildConfigEvent $event) 138 | { 139 | 140 | Craft::$app->getProjectConfig()->remove('cacheFlags'); 141 | 142 | $rows = (new Query()) 143 | ->select(['flags', 'sectionId', 'categoryGroupId', 'tagGroupId', 'userGroupId', 'volumeId', 'globalSetId', 'elementType', 'uid']) 144 | ->from(Flags::tableName()) 145 | ->all(); 146 | 147 | foreach ($rows as $row) { 148 | 149 | $sourceKey = null; 150 | $sourceValue = null; 151 | 152 | if ($row['sectionId']) { 153 | $sourceKey = 'section'; 154 | $sourceValue = Db::uidById(Table::SECTIONS, $row['sectionId']); 155 | } else if ($row['categoryGroupId']) { 156 | $sourceKey = 'categoryGroup'; 157 | $sourceValue = Db::uidById(Table::CATEGORYGROUPS, $row['categoryGroupId']); 158 | } else if ($row['tagGroupId']) { 159 | $sourceKey = 'tagGroup'; 160 | $sourceValue = Db::uidById(Table::TAGGROUPS, $row['tagGroupId']); 161 | } else if ($row['userGroupId']) { 162 | $sourceKey = 'userGroup'; 163 | $sourceValue = Db::uidById(Table::USERGROUPS, $row['userGroupId']); 164 | } else if ($row['volumeId']) { 165 | $sourceKey = 'volume'; 166 | $sourceValue = Db::uidById(Table::VOLUMES, $row['volumeId']); 167 | } else if ($row['globalSetId']) { 168 | $sourceKey = 'globalSet'; 169 | $sourceValue = Db::uidById(Table::GLOBALSETS, $row['globalSetId']); 170 | } else if ($row['elementType']) { 171 | $sourceKey = 'elementType'; 172 | $sourceValue = $row['elementType']; 173 | } 174 | 175 | if (!$sourceKey || !$sourceValue) { 176 | return; 177 | } 178 | 179 | $event->config['cacheFlags'][$row['uid']] = [ 180 | 'source' => "$sourceKey:$sourceValue", 181 | 'flags' => $row['flags'], 182 | ]; 183 | } 184 | 185 | } 186 | 187 | } 188 | -------------------------------------------------------------------------------- /src/CacheFlag.php: -------------------------------------------------------------------------------- 1 | setComponents([ 64 | 'cacheFlag' => CacheFlagService::class, 65 | 'projectConfig' => CacheFlagProjectConfigService::class, 66 | 'templateCaches' => TemplateCachesService::class, 67 | ]); 68 | 69 | $this->_initProjectConfig(); 70 | $this->_addElementEventListeners(); 71 | 72 | // Register custom Twig extension 73 | Craft::$app->getView()->registerTwigExtension(new CacheFlagTwigExtension()); 74 | 75 | // Add tag option to the Clear Caches utility to invalidate all flagged caches 76 | Event::on(ClearCaches::class, ClearCaches::EVENT_REGISTER_TAG_OPTIONS, 77 | static function (RegisterCacheOptionsEvent $event) { 78 | $event->options[] = [ 79 | 'key' => 'cacheflag-flagged-caches', 80 | 'label' => Craft::t('cache-flag', 'Flagged template caches'), 81 | 'tag' => 'cacheflag', 82 | 'info' => Craft::t('cache-flag', 'Template caches flagged using Cache Flag'), 83 | ]; 84 | } 85 | ); 86 | 87 | // Register utility 88 | Event::on( 89 | Utilities::class, 90 | Utilities::EVENT_REGISTER_UTILITIES, 91 | static function(RegisterComponentTypesEvent $event) { 92 | $event->types[] = CacheFlagUtility::class; 93 | } 94 | ); 95 | 96 | } 97 | 98 | /** 99 | * @return void 100 | */ 101 | private function _initProjectConfig(): void 102 | { 103 | Event::on( 104 | ProjectConfig::class, 105 | ProjectConfig::EVENT_REBUILD, 106 | [$this->projectConfig, 'onProjectConfigRebuild'] 107 | ); 108 | 109 | Craft::$app->getProjectConfig() 110 | ->onAdd('cacheFlags.{uid}', [$this->projectConfig, 'onProjectConfigChange']) 111 | ->onUpdate('cacheFlags.{uid}', [$this->projectConfig, 'onProjectConfigChange']) 112 | ->onRemove('cacheFlags.{uid}', [$this->projectConfig, 'onProjectConfigDelete']); 113 | 114 | // Flush the project config when the plugin is uninstalled 115 | Event::on( 116 | Plugins::class, 117 | Plugins::EVENT_AFTER_UNINSTALL_PLUGIN, 118 | function (PluginEvent $event) { 119 | if ($event->plugin === $this) { 120 | Craft::$app->getProjectConfig()->remove('cacheFlags'); 121 | } 122 | } 123 | ); 124 | } 125 | 126 | /** 127 | * @return void 128 | */ 129 | private function _addElementEventListeners(): void 130 | { 131 | // Invalidate flagged caches when elements are saved 132 | Event::on( 133 | Elements::class, 134 | Elements::EVENT_AFTER_SAVE_ELEMENT, 135 | function (ElementEvent $event) { 136 | $this->_maybeInvalidateFlaggedCachesByElement($event->element); 137 | } 138 | ); 139 | 140 | // Invalidate flagged caches when elements are deleted 141 | Event::on( 142 | Elements::class, 143 | Elements::EVENT_BEFORE_DELETE_ELEMENT, 144 | function (ElementEvent $event) { 145 | $this->_maybeInvalidateFlaggedCachesByElement($event->element); 146 | } 147 | ); 148 | 149 | // Invalidate flagged caches when structure entries are moved 150 | Event::on( 151 | Structures::class, 152 | Structures::EVENT_AFTER_MOVE_ELEMENT, 153 | function (MoveElementEvent $event) { 154 | $this->_maybeInvalidateFlaggedCachesByElement($event->element); 155 | } 156 | ); 157 | 158 | // Invalidate flagged caches when elements change status 159 | Event::on( 160 | Elements::class, 161 | Elements::EVENT_AFTER_PERFORM_ACTION, 162 | function (ElementActionEvent $event) { 163 | 164 | /* @var ElementActionInterface|null $action */ 165 | $action = $event->action; 166 | if (!$action instanceof SetStatus) { 167 | return; 168 | } 169 | 170 | /* @var ElementQueryInterface|null $criteria */ 171 | $criteria = $event->criteria; 172 | if (empty($criteria)) { 173 | return; 174 | } 175 | 176 | /** @var ElementInterface[] $elements */ 177 | $elements = $criteria->all(); 178 | foreach ($elements as $element) { 179 | $this->_maybeInvalidateFlaggedCachesByElement($element); 180 | } 181 | } 182 | ); 183 | } 184 | 185 | /** 186 | * @param ElementInterface|null $element 187 | * @return void 188 | */ 189 | private function _maybeInvalidateFlaggedCachesByElement(?ElementInterface $element): void 190 | { 191 | /** @var Element $element */ 192 | // This try/catch is introduced to mitigate an edge case where a nested element could have an invalid (deleted) owner ID. 193 | // See https://github.com/mmikkel/CacheFlag-Craft3/issues/21 194 | try { 195 | if (ElementHelper::isDraftOrRevision($element)) { 196 | return; 197 | } 198 | } catch (\Throwable) { 199 | // We don't care about handling this exception 200 | } 201 | $this->cacheFlag->invalidateFlaggedCachesByElement($element); 202 | } 203 | 204 | } 205 | -------------------------------------------------------------------------------- /src/services/TemplateCachesService.php: -------------------------------------------------------------------------------- 1 | _isTemplateCachingEnabled($global) === false) { 57 | return null; 58 | } 59 | 60 | $this->_collectElementTags = $collectElementTags; 61 | 62 | $cacheKey = $this->_cacheKey($key, $global); 63 | $data = Craft::$app->getCache()->get($cacheKey); 64 | 65 | if ($data === false) { 66 | return null; 67 | } 68 | 69 | list($body, $tags) = $data; 70 | 71 | // Make sure the cache was tagged w/ the same flags 72 | $flagTags = $this->_getTagsForFlags($flags); 73 | $cachedFlagTags = \array_filter($tags, function (string $tag) { 74 | return \strpos($tag, 'cacheflag') === 0 || $tag === 'element' || $tag === 'template'; 75 | }); 76 | 77 | if (\array_diff($flagTags, $cachedFlagTags) != \array_diff($cachedFlagTags, $flagTags)) { 78 | return null; 79 | } 80 | 81 | // If we're actively collecting element cache tags, add this cache's tags to the collection 82 | Craft::$app->getElements()->collectCacheTags($tags); 83 | return $body; 84 | } 85 | 86 | /** 87 | * 88 | */ 89 | public function startTemplateCache(bool $global = false) 90 | { 91 | // Make sure template caching is enabled 92 | if ($this->_isTemplateCachingEnabled($global) === false) { 93 | return; 94 | } 95 | 96 | if ($this->_collectElementTags) { 97 | Craft::$app->getElements()->startCollectingCacheTags(); 98 | } 99 | } 100 | 101 | /** 102 | * Ends a template cache. 103 | * 104 | * @param string $key The template cache key. 105 | * @param string|null $flags The flags this cache should be flagged with. 106 | * @param bool $global Whether the cache should be stored globally. 107 | * @param string|null $duration How long the cache should be stored for. Should be a [relative time format](http://php.net/manual/en/datetime.formats.relative.php). 108 | * @param mixed|null $expiration When the cache should expire. 109 | * @param string $body The contents of the cache. 110 | * @throws \Throwable 111 | */ 112 | public function endTemplateCache(string $key, $flags, bool $global, string $duration = null, /** @scrutinizer ignore-unused */ $expiration, string $body) 113 | { 114 | 115 | // Make sure template caching is enabled 116 | if ($this->_isTemplateCachingEnabled($global) === false) { 117 | return; 118 | } 119 | 120 | // If there are any transform generation URLs in the body, don't cache it. 121 | // stripslashes($body) in case the URL has been JS-encoded or something. 122 | if (StringHelper::contains(stripslashes($body), 'assets/generate-transform')) { 123 | return; 124 | } 125 | 126 | // Get flag tags 127 | $flagTags = $this->_getTagsForFlags($flags); 128 | 129 | if ($this->_collectElementTags) { 130 | // If we're collecting element tags, collect the flag tags too, and end the collection 131 | Craft::$app->getElements()->collectCacheTags($flagTags); 132 | $dep = Craft::$app->getElements()->stopCollectingCacheTags(); 133 | } else { 134 | // If not, just tag it with the flags 135 | $dep = new TagDependency([ 136 | 'tags' => $flagTags, 137 | ]); 138 | } 139 | 140 | $cacheKey = $this->_cacheKey($key, $global); 141 | 142 | if ($duration !== null) { 143 | $duration = (new DateTime($duration))->getTimestamp() - time(); 144 | } 145 | 146 | Craft::$app->getCache()->set($cacheKey, [$body, $dep->tags], $duration, $dep); 147 | } 148 | 149 | // Private Methods 150 | // ========================================================================= 151 | /** 152 | * Returns whether template caching is enabled, based on the 'enableTemplateCaching' config setting. 153 | * 154 | * @return bool Whether template caching is enabled 155 | */ 156 | private function _isTemplateCachingEnabled(bool $global): bool 157 | { 158 | if (!isset($this->_enabled)) { 159 | if (!Craft::$app->getConfig()->getGeneral()->enableTemplateCaching) { 160 | $this->_enabled = $this->_enabledGlobally = false; 161 | } else { 162 | // Don't enable template caches for Live Preview/tokenized requests 163 | $request = Craft::$app->getRequest(); 164 | if ($request->getIsPreview() || $request->getHadToken()) { 165 | $this->_enabled = $this->_enabledGlobally = false; 166 | } else { 167 | $this->_enabled = !$request->getIsConsoleRequest(); 168 | $this->_enabledGlobally = true; 169 | } 170 | } 171 | } 172 | return $global ? $this->_enabledGlobally : $this->_enabled; 173 | } 174 | 175 | /** 176 | * Defines a data cache key that should be used for a template cache. 177 | * 178 | * @param string $key 179 | * @param bool $global 180 | */ 181 | private function _cacheKey(string $key, bool $global): string 182 | { 183 | $cacheKey = "template::$key::" . Craft::$app->getSites()->getCurrentSite()->id; 184 | 185 | if (!$global) { 186 | $cacheKey .= '::' . $this->_path(); 187 | } 188 | 189 | return $cacheKey; 190 | } 191 | 192 | /** 193 | * Returns the current request path, including a "site:" or "cp:" prefix. 194 | * 195 | * @return string 196 | * @throws \yii\base\InvalidConfigException 197 | */ 198 | private function _path(): string 199 | { 200 | if ($this->_path !== null) { 201 | return $this->_path; 202 | } 203 | 204 | if (Craft::$app->getRequest()->getIsCpRequest()) { 205 | $this->_path = 'cp:'; 206 | } else { 207 | $this->_path = 'site:'; 208 | } 209 | 210 | $this->_path .= Craft::$app->getRequest()->getPathInfo(); 211 | if (Craft::$app->getDb()->getIsMysql()) { 212 | $this->_path = StringHelper::encodeMb4($this->_path); 213 | } 214 | 215 | if (($pageNum = Craft::$app->getRequest()->getPageNum()) != 1) { 216 | $this->_path .= '/' . Craft::$app->getConfig()->getGeneral()->getPageTrigger() . $pageNum; 217 | } 218 | 219 | return $this->_path; 220 | } 221 | 222 | /** 223 | * @param string|array|null $flags 224 | * @param string $delimiter 225 | * @return array 226 | */ 227 | private function _getTagsForFlags($flags, string $delimiter = '|'): array 228 | { 229 | $tagsArray = ['template', 'cacheflag']; 230 | if (\is_array($flags)) { 231 | $flags = \implode(',', \array_map(function ($flag) { 232 | return \preg_replace('/\s+/', '', $flag); 233 | }, $flags)); 234 | } else { 235 | $flags = \preg_replace('/\s+/', '', $flags); 236 | } 237 | $flags = \array_filter(\explode($delimiter, $flags)); 238 | $tagsArray = \array_merge($tagsArray, \array_map(function (string $flag) { 239 | return "cacheflag::$flag"; 240 | }, $flags)); 241 | if ($this->_collectElementTags) { 242 | $tagsArray[] = 'element'; 243 | } 244 | return \array_unique(($tagsArray)); 245 | } 246 | 247 | } 248 | -------------------------------------------------------------------------------- /src/services/CacheFlagService.php: -------------------------------------------------------------------------------- 1 | select('*') 49 | ->from([Flags::tableName()]) 50 | ->all(); 51 | } 52 | 53 | /** 54 | * @param string|string[]|null $flags 55 | * @param string $sourceColumn 56 | * @param string $sourceValue 57 | * @throws \Throwable 58 | * @throws \yii\db\Exception 59 | */ 60 | public function saveFlags(string|array|null $flags, string $sourceColumn, string $sourceValue): void 61 | { 62 | 63 | if (empty($flags)) { 64 | return; 65 | } 66 | 67 | if (is_array($flags)) { 68 | $flags = implode(',', $flags); 69 | } 70 | 71 | $uid = (new Query()) 72 | ->select(['uid']) 73 | ->from(Flags::tableName()) 74 | ->where([$sourceColumn => $sourceValue]) 75 | ->scalar(); 76 | 77 | $isNew = !$uid; 78 | if ($isNew) { 79 | $uid = StringHelper::UUID(); 80 | } 81 | 82 | switch ($sourceColumn) { 83 | case 'sectionId': 84 | $sourceKey = 'section'; 85 | $sourceValue = Db::uidById(Table::SECTIONS, (int)$sourceValue); 86 | break; 87 | case 'categoryGroupId': 88 | $sourceKey = 'categoryGroup'; 89 | $sourceValue = Db::uidById(Table::CATEGORYGROUPS, (int)$sourceValue); 90 | break; 91 | case 'tagGroupId': 92 | $sourceKey = 'tagGroup'; 93 | $sourceValue = Db::uidById(Table::TAGGROUPS, (int)$sourceValue); 94 | break; 95 | case 'userGroupId': 96 | $sourceKey = 'userGroup'; 97 | $sourceValue = Db::uidById(Table::USERGROUPS, (int)$sourceValue); 98 | break; 99 | case 'volumeId': 100 | $sourceKey = 'volume'; 101 | $sourceValue = Db::uidById(Table::VOLUMES, (int)$sourceValue); 102 | break; 103 | case 'globalSetId': 104 | $sourceKey = 'globalSet'; 105 | $sourceValue = Db::uidById(Table::GLOBALSETS, (int)$sourceValue); 106 | break; 107 | case 'elementType': 108 | $sourceKey = 'elementType'; 109 | break; 110 | default: 111 | return; 112 | } 113 | 114 | if (!$sourceValue) { 115 | return; 116 | } 117 | 118 | // Save it to the project config 119 | $path = "cacheFlags.{$uid}"; 120 | Craft::$app->getProjectConfig()->set($path, [ 121 | 'source' => "$sourceKey:$sourceValue", 122 | 'flags' => $flags, 123 | ]); 124 | } 125 | 126 | /** 127 | * @param string $sourceColumn 128 | * @param string $sourceValue 129 | * @return false|void 130 | */ 131 | public function deleteFlagsBySource(string $sourceColumn, string $sourceValue) 132 | { 133 | 134 | $uid = (new Query()) 135 | ->select(['uid']) 136 | ->from(Flags::tableName()) 137 | ->where([$sourceColumn => $sourceValue]) 138 | ->scalar(); 139 | 140 | if (!$uid) { 141 | return false; 142 | } 143 | 144 | // Remove it from the project config 145 | $path = "cacheFlags.{$uid}"; 146 | Craft::$app->getProjectConfig()->remove($path); 147 | } 148 | 149 | /** 150 | * Invalidate all flagged template caches 151 | */ 152 | public function invalidateAllFlaggedCaches() 153 | { 154 | TagDependency::invalidate(Craft::$app->getCache(), 'cacheflag'); 155 | } 156 | 157 | /** 158 | * @param ElementInterface $element 159 | * @return bool 160 | */ 161 | public function invalidateFlaggedCachesByElement(ElementInterface $element): bool 162 | { 163 | // Collect all flags for this element 164 | $query = (new Query()) 165 | ->select(['flags']) 166 | ->from(Flags::tableName()); 167 | 168 | $elementType = \get_class($element); 169 | $dynamicFlags = ["element:$element->id", "element:$element->uid"]; 170 | 171 | switch ($elementType) { 172 | case 'craft\elements\Asset': 173 | /** @var Asset $element */ 174 | $query->orWhere([ 175 | 'volumeId' => $element->volumeId, 176 | ]); 177 | $dynamicFlags[] = "asset:$element->id"; 178 | $dynamicFlags[] = "asset:$element->uid"; 179 | break; 180 | case 'craft\elements\Category': 181 | /** @var Category $element */ 182 | $query->orWhere([ 183 | 'categoryGroupId' => $element->groupId, 184 | ]); 185 | $dynamicFlags[] = "category:$element->id"; 186 | $dynamicFlags[] = "category:$element->uid"; 187 | break; 188 | case 'craft\elements\Entry': 189 | /** @var Entry $element */ 190 | $query->orWhere([ 191 | 'sectionId' => $element->sectionId, 192 | ]); 193 | $dynamicFlags[] = "entry:$element->id"; 194 | $dynamicFlags[] = "entry:$element->uid"; 195 | break; 196 | case 'craft\elements\GlobalSet': 197 | /** @var GlobalSet $element */ 198 | $query->orWhere([ 199 | 'globalSetId' => $element->id, 200 | ]); 201 | $dynamicFlags[] = "globalSet:$element->id"; 202 | $dynamicFlags[] = "globalSet:$element->uid"; 203 | break; 204 | case 'craft\elements\Tag': 205 | /** @var Tag $element */ 206 | $query->orWhere([ 207 | 'tagGroupId' => $element->groupId, 208 | ]); 209 | $dynamicFlags[] = "tag:$element->id"; 210 | $dynamicFlags[] = "tag:$element->uid"; 211 | break; 212 | case 'craft\elements\User': 213 | /** @var User $element */ 214 | foreach ($element->getGroups() as $userGroup) { 215 | $query->orWhere([ 216 | 'userGroupId' => $userGroup->id, 217 | ]); 218 | } 219 | $dynamicFlags[] = "user:$element->id"; 220 | $dynamicFlags[] = "user:$element->uid"; 221 | break; 222 | } 223 | 224 | $query->orWhere([ 225 | 'elementType' => $elementType, 226 | ]); 227 | 228 | $flags = \array_unique(\array_merge($query->column(), $dynamicFlags)); 229 | 230 | return $this->invalidateFlaggedCachesByFlags($flags); 231 | } 232 | 233 | /** 234 | * @param string|string[]|null $flags 235 | * @return bool 236 | */ 237 | public function invalidateFlaggedCachesByFlags(string|array|null $flags): bool 238 | { 239 | 240 | if (empty($flags)) { 241 | return false; 242 | } 243 | 244 | if (is_array($flags)) { 245 | $flags = $this->implodeFlagsArray($flags); 246 | } else { 247 | $flags = preg_replace('/\s+/', '', $flags); 248 | } 249 | 250 | $flags = array_values(\array_unique(\explode(',', $flags))); 251 | 252 | if (empty($flags)) { 253 | return false; 254 | } 255 | 256 | // Fire a `beforeInvalidateFlaggedCaches` event 257 | if ($this->hasEventHandlers(self::EVENT_BEFORE_INVALIDATE_FLAGGED_CACHES)) { 258 | $this->trigger(self::EVENT_BEFORE_INVALIDATE_FLAGGED_CACHES, new FlaggedTemplateCachesEvent([ 259 | 'flags' => $flags, 260 | ])); 261 | } 262 | 263 | $flagTags = array_map(function (string $flag) { 264 | return "cacheflag::$flag"; 265 | }, $flags); 266 | 267 | TagDependency::invalidate(Craft::$app->getCache(), $flagTags); 268 | 269 | // Fire a 'afterInvalidateFlaggedCaches' event 270 | if ($this->hasEventHandlers(self::EVENT_AFTER_INVALIDATE_FLAGGED_CACHES)) { 271 | $this->trigger(self::EVENT_AFTER_INVALIDATE_FLAGGED_CACHES, new FlaggedTemplateCachesEvent([ 272 | 'flags' => $flags, 273 | ])); 274 | } 275 | 276 | return true; 277 | } 278 | 279 | /** 280 | * @param array $flagsArray 281 | * @return string 282 | */ 283 | protected function implodeFlagsArray(array $flagsArray): string 284 | { 285 | 286 | $flags = ''; 287 | 288 | foreach ($flagsArray as $item) { 289 | if (is_array($item)) { 290 | $flags .= "{$this->implodeFlagsArray($item)},"; 291 | } else { 292 | $flags .= preg_replace('/\s+/', '', $item) . ','; 293 | } 294 | } 295 | 296 | return substr($flags, 0, 0 - strlen(',')); 297 | } 298 | } 299 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Cache Flag plugin for Craft CMS 2 | 3 | [![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/mmikkel/CacheFlag-Craft3/badges/quality-score.png?b=master)](https://scrutinizer-ci.com/g/mmikkel/CacheFlag-Craft3/?branch=master) 4 | 5 | Cache Flag is a Craft CMS plugin that adds an alternative cache invalidation strategy to template caches, using manually defined keywords ("flags"). 6 | 7 | ## Why does this plugin exist? 8 | 9 | Cache Flag was originally designed to circumvent common performance issues with the native `{% cache %}` tag's element query based invalidation strategy. 10 | 11 | Since Craft 3.5.0, said performance issues have been solved in core, making Cache Flag redundant for its primary use case. **If you were previously using Cache Flag only to avoid performance issues with `{% cache %}`, you probably don't need it anymore!** 12 | 13 | However, Cache Flag is still a valid alternative to the native `{% cache %}` tag if you want to 14 | 15 | * Implement automatic or manual bulk template cache invalidation (optionally, in combination with Craft's native element-based cache invalidation) 16 | * Cache arbitrary HTML output and implement your own invalidation strategies for it 17 | * Have completely cold template caches (like the [Cold Cache plugin](https://github.com/pixelandtonic/ColdCache), which is not available for Craft 5) 18 | 19 | ## Table of contents 20 | 21 | * [Requirements](#requirements) 22 | * [Using Cache Flag](#using-cache-flag) 23 | * [Dynamic flags](#dynamic-flags) 24 | * [Arbitrary flags](#arbitrary-flags) 25 | * [Collecting element tags for automatic cache invalidation](#collecting-element-tags-for-automatic-cache-invalidation) 26 | * [Cold caches](#cold-caches) 27 | * [Invalidating flagged caches](#invalidating-flagged-caches) 28 | * [Additional parameters](#additional-parameters) 29 | * [Project Config](#project-config) 30 | * [Events](#events) 31 | 32 | ## Requirements 33 | 34 | **This plugin requires Craft CMS 5.0+** 35 | 36 | ## Using Cache Flag 37 | 38 | Cache Flag adds a new `{% cacheflag %}` Twig tag to Craft CMS, which works just like the native `{% cache %}` tag - except that by default, Cache Flag's template caches are "cold" (i.e. _Cache Flag will not save element queries for automatic cache invalidation_). 39 | 40 | For cache invalidation, Cache Flag adds the ability to "flag" template caches and content with keywords ("flags"). Whenever an element is saved, moved or deleted, Cache Flag will automatically invalidate any flagged template caches matching that element's flags. 41 | 42 | _Here's how it looks in action:_ 43 | 44 | ```twig 45 | {% cacheflag flagged "news|images" %} 46 | {% set entries = craft.entries.section('news').all() %} 47 | ... 48 | {% endcacheflag %} 49 | ``` 50 | 51 | **Note that multiple flags are separated using the pipe delimiter (`|`).** 52 | 53 | **Tip:** In addition to the `flagged` parameter it's also possible to have Cache Flag clear caches automatically in the same way the native `{% cache %}` tag does, using the new [`with elements`](#collecting-element-tags-for-automatic-cache-invalidation) directive. 54 | 55 | ### I'm going to need an example. 56 | 57 | Sure. Let's assume you have a section called "News", and there's a cache that you want to invalidate whenever the content in that section changes (i.e. if entries are saved, deleted, changes status etc). First, you add the flag `news` (or whatever, the flags can be anything, really) to the "News" section in Cache Flag's CP utility: 58 | 59 | ![The Cache Flag CP utility](resources/utility.png) 60 | 61 | Then, you add that same `news` flag to any relevant caches, using the `{% cacheflag %}` tag and the `flagged` parameter: 62 | 63 | ```twig 64 | {% cacheflag flagged "news" %} 65 | {% set entries = craft.entries... %} 66 | ... 67 | {% endcacheflag %} 68 | ``` 69 | 70 | Now, whenever an entry in the "News" section is saved, moved, deleted or changes status, any caches flagged with `news` will be automatically invalidated. 71 | 72 | ## Dynamic flags 73 | 74 | It's possible to flag caches using dynamic flags based on element IDs and/or UIDs. If you wanted to ensure that a cache is invalidated whenever a particular element is edited, moved or deleted, you can do this: 75 | 76 | ```twig 77 | {% cacheflag flagged "entry:#{entry.id}" %} 78 | ... 79 | {% endcacheflag %} 80 | ``` 81 | 82 | or if you prefer: 83 | 84 | ```twig 85 | {% cacheflag flagged "entry:#{entry.uid}" %} 86 | ... 87 | {% endcacheflag %} 88 | ``` 89 | 90 | All native element types can be used in dynamic flags: 91 | 92 | `entry:#{entry.id}` 93 | `asset:#{asset.id}` 94 | `category:#{category.id}` 95 | `tag:#{tag.id}` 96 | `globalSet:#{globalSet.id}` 97 | `user:#{user.id}` 98 | 99 | It's also possible to use the `element` prefix, which works for all element types (including custom/third party ones): 100 | 101 | `element:#{element.id}` 102 | `element:#{element.uid}` 103 | 104 | Of course, it's possible to combine both standard and dynamic cache flags for a single cache: 105 | 106 | ```twig 107 | {% cacheflag flagged "news|employees|entry:#{entry.id}|category:#{category.id}" %} 108 | ... 109 | {% endcacheflag %} 110 | ``` 111 | 112 | ## Arbitrary flags 113 | 114 | The flags you add to your `{% cachetags %}` caches can be literally anything - and they don't have to be added to an element source (or be dynamic). 115 | 116 | A good use case for _arbitrary flags_ is when you've got a cache that don't involve any elements, for example if you wanted to cache output dependent on an external API call or something else that is time-consuming to parse on every request, e.g. something like this: 117 | 118 | ```twig 119 | {% cacheflag flagged "somearbitraryflag" %} 120 | {% set data = craft.somePlugin.doExpensiveApiCall() %} 121 | ... 122 | {% endcacheflag %} 123 | ``` 124 | 125 | If you use arbitrary flags, keep in mind that there's nothing that will actually invalidate those caches automatically (they'll essentially be _cold_ caches, albeit flagged). Read up on [the different options available for invalidating these - and other - flagged caches here](#invalidating-flagged-caches). 126 | 127 | ## Collecting element tags for automatic cache invalidation 128 | 129 | Since Cache Flag 1.1.0 (Craft 3.5.0-RC1 or later), it's possible to collect element tags (in addition to your own flags) for automatic cache invalidation just like the native `{% cache %}` tag does. 130 | 131 | If you want Cache Flag to collect element tags for automatic cache invalidation, you can add the `with elements` directive like this: 132 | 133 | ```twig 134 | {% cacheflag flagged "awesome" with elements %} 135 | ... 136 | {% endcacheflag %} 137 | ``` 138 | 139 | Note: It's also possible to omit the `flagged` parameter and only use `with elements`, but at that point the `{% cacheflag %}` tag would work identically to the native `{% cache %}` tag, and you should probably just use the latter. 140 | 141 | ## Cold caches 142 | 143 | If both `flagged` and `with elements` are omitted from a `{% cacheflag %}` tag, that cache will be completely "cold", and it will only be invalidated if/when it expires, or if a user manually invalidates it (or clears the entire data cache) via the Control Panel or the Craft CLI (see also _[invalidating flagged caches](#invalidating-flagged-caches)_): 144 | 145 | ```twig 146 | {% cacheflag for 360 days %} 147 | ... 148 | {% endcacheflag %} 149 | ``` 150 | 151 | **Tip:** If you're upgrading a Craft 2 site that uses the [Cold Cache plugin](https://straightupcraft.com/craft-plugins/cold-cache), this is one way to achive the same thing on Craft 3. 152 | 153 | ## Invalidating flagged caches 154 | 155 | Cache Flag will automatically invalidate any caches with flags saved to one or multiple element sources defined in Cache Flag's CP utility, and caches using [dynamic flags](#dynamic-flags). These caches are invalidated whenever relevant elements are saved, deleted, moved or changes status. 156 | 157 | Cold caches and caches using [arbitrary flags](#arbitrary-flags) must be invalidated manually or programmatically (see below). 158 | 159 | ### Manual cache invalidation 160 | 161 | Flagged template caches can be manually invalidated by 162 | 163 | * Using the native Clear Caches utility in the Craft CP (check out the [CP Clear Cache plugin](https://plugins.craftcms.com/cp-clearcache) for easier access to this tool) 164 | * Clicking the "Invalidate all flagged caches" button in Cache Flag's CP utility 165 | * Using the native CLI command `invalidate-tags/cacheflag` 166 | * Using the native CLI command `invalidate-tags/template` (invalidates all template caches, including flagged ones) 167 | * Using the native CLI command `invalidate-tags/all` (invalidates all caches, including template caches) 168 | 169 | Additionally, Cache Flag exposes its own `cache-flag/caches/invalidate` CLI command, that can be used if you want to clear flagged template caches for specific flags (this also works with [arbitrary flags](#arbitrary-flags)): 170 | 171 | ./craft cache-flag/caches/invalidate news,images,awesome 172 | 173 | If you want to clear flagged caches over HTTP there's also a web controller action `cache-flag/caches/invalidate` which can be hit with a GET or POST request. This controller action will invalidate all flagged template caches, unless a parameter `flags` (string[]; array of flags) is present in the request. 174 | 175 | Finally, flushing the data cache will *delete* all template caches, including flagged ones. 176 | 177 | ### Programmatic cache invalidation 178 | 179 | ```php 180 | 181 | use mmikkel\cacheflag\CacheFlag; 182 | 183 | // Invalidate all flagged caches 184 | CacheFlag::getInstance()->cacheFlag->invalidateAllFlaggedCaches(); 185 | 186 | // Invalidate caches for a particular element 187 | CacheFlag::getInstance()->cacheFlag->invalidateFlaggedCachesByElement($entry); 188 | 189 | // Invalidate caches for one or several flags 190 | CacheFlag::getInstance()->cacheFlag->invalidateFlaggedCachesByFlags(['news', 'images']); 191 | 192 | ``` 193 | 194 | ## Additional parameters 195 | 196 | Beyond the `flagged` and `with elements` parameters, the `{% cacheflag %}` tag _supports all the same parameters_ as [the native `{% cache %}` tag[(https://docs.craftcms.com/v3/dev/tags/cache.html#app)]. 197 | 198 | ## Project Config and `allowAdminChanges` 199 | 200 | Cache Flag supports [Project Config](https://docs.craftcms.com/v3/project-config.html) since v. 1.2.0 (Craft 3.5.0 or later only). **If you're upgrading from an earlier version of Cache Flag, the relevant `.yaml` files will be automatically created after upgrading and running migrations.** 201 | 202 | ## Events 203 | 204 | Cache Flag dispatches two events: 205 | 206 | * `beforeInvalidateFlaggedCaches` 207 | _Dispatched just before Cache Flag invalidates one or several flagged template caches._ 208 | 209 | * `afterInvalidateFlaggedCaches` 210 | _Dispatched immediately after Cache Flag has invalidated one or several flagged template caches._ 211 | 212 | Both events include a parameter `flags`, which is an array of the flags Cache Flag is invalidating caches for. 213 | 214 | ### Listening to Cache Flag events 215 | 216 | ```php 217 | use mmikkel\cacheflag\events\FlaggedTemplateCachesEvent; 218 | use mmikkel\cacheflag\services\CacheFlagService; 219 | use yii\base\Event; 220 | 221 | Event::on( 222 | CacheFlagService::class, 223 | CacheFlagService::EVENT_BEFORE_INVALIDATE_FLAGGED_CACHES, 224 | function (FlaggedTemplateCachesEvent $event) { 225 | $flags = $event->flags; 226 | ... 227 | } 228 | ); 229 | 230 | Event::on( 231 | CacheFlagService::class, 232 | CacheFlagService::EVENT_AFTER_INVALIDATE_FLAGGED_CACHES, 233 | function (FlaggedTemplateCachesEvent $event) { 234 | $flags = $event->flags; 235 | ... 236 | } 237 | ); 238 | ``` 239 | 240 | Note: Before Cache Flag 1.1.0, the `EVENT_AFTER_DELETE_FLAGGED_CACHES` (now deprecated in favor of `EVENT_AFTER_INVALIDATE_FLAGGED_CACHES`) would only be dispatched if caches were actually deleted. In Cache Flag 1.1.0+, the `EVENT_AFTER_INVALIDATE_FLAGGED_CACHES` event is dispatched regardless of whether any caches were actually cleared. 241 | --------------------------------------------------------------------------------