feat(purge): "!field" entries in onlyWhenFieldsChange ignore a field #21

Merged
KofTwentyTwo merged 1 commits from feature/tag-rule-ignored-fields into develop 2026-10-14 16:32:41 +00:00
6 changed files with 132 additions and 5 deletions

No files matched your search

+1
View File
@@ -10,6 +10,7 @@ All notable changes to this project are documented here. The format follows Keep
### Fixed
- Tag rules accept ignored fields in `onlyWhenFieldsChange`, such as `!viewCount`, so counter-only updates do not purge rendered pages. Plain allow-lists still work, and ignored fields take precedence in mixed lists. No schema change is needed.
- Purge actions warn when providers are NONE or hostnames are OFF, and report requests as recorded but not sent. The home explains recorded-only purges and that this version does not run origin header checks.
- Tag rule `{prefix.field}` placeholders now resolve the joined table from the `<prefix>Id` field's possible-value source, so prefixed tables (`cms_blog`) work; a missing joined table is logged instead of silently skipped.
+15 -1
View File
@@ -31,7 +31,21 @@ Design: QRun-IO/qqq `docs/specs/2026-10-12-qbit-cdn-design.md`.
NONE. See `docs/cloudfront-expected-config.md`.
- **Permissions:** `cdn.view`, `cdn.purge`, `cdn.purgeAll`, `cdn.admin`.
## Purge-path placeholders
## Tag rules
`cdn_table_tag_rule.onlyWhenFieldsChange` is a comma-separated list of QQQ field
names. Plain entries (for example `title,slug`) purge only when at least one
listed field changes. Prefix a field with `!` to ignore it: `!viewCount` prevents
a view-count-only update from purging, while a title change still purges.
With `!viewCount, !lastIndexedAt`, any change outside those fields counts.
Mixed lists keep the allow-list and exclude ignored fields, even if also listed
plainly: `title,viewCount,!viewCount` counts only title changes.
Names are trimmed and case-sensitive. Partial updates retain omitted old values;
only unequal old and new values count. This filter does not restrict inserts or
deletes. The separate `publishedField` filter still applies to all changes.
### Purge-path placeholders
`cdn_table_tag_rule` templates (`tagTemplates`, `invalidationPaths`) can use `{field}` for the changed record's own fields and `{prefix.field}` for a joined record, e.g. `/blog/{blog.slug}/{slug}*`.
@@ -24,6 +24,7 @@ import com.kingsrook.qbits.cdn.spi.CdnProviderType;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
import com.kingsrook.qqq.backend.core.model.metadata.help.QHelpContent;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
@@ -273,7 +274,8 @@ public final class CdnTables
.withField(new QFieldMetaData("tableName", QFieldType.STRING).withIsRequired(true).withMaxLength(100))
.withField(new QFieldMetaData("tagTemplates", QFieldType.TEXT))
.withField(new QFieldMetaData("invalidationPaths", QFieldType.TEXT))
.withField(new QFieldMetaData("onlyWhenFieldsChange", QFieldType.STRING).withMaxLength(1000))
.withField(new QFieldMetaData("onlyWhenFieldsChange", QFieldType.STRING).withMaxLength(1000)
.withHelpContent(new QHelpContent("Comma-separated, case-sensitive field names. Plain entries allow only those changes; !viewCount ignores viewCount changes. With only ignored entries, any other changed field counts. In mixed lists, ignored fields never count. Inserts and deletes are unaffected by this filter.")))
.withField(new QFieldMetaData("publishedField", QFieldType.STRING).withMaxLength(100))
.withField(new QFieldMetaData("enabled", QFieldType.BOOLEAN).withDefaultValue(true)));
}
@@ -55,8 +55,8 @@ import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
** expires by TTL instead of being invalidated;
** - publishedField: when set, only records that are (or were) published
** count, so draft edits purge nothing;
** - onlyWhenFieldsChange: comma list; an update that changes none of them
** purges nothing.
** - onlyWhenFieldsChange: comma list of watched fields; !field ignores a
** field. With only ignored fields, any other changed field counts.
**
** A dotted placeholder "{blog.slug}" reads field "slug" of the record that
** field "blogId" points to (the xId convention). The joined table is the one
@@ -159,7 +159,27 @@ public class TagRuleEngine
String watched = rule.getValueString("onlyWhenFieldsChange");
if(current != null && old != null && StringUtils.hasContent(watched))
{
return (Arrays.stream(watched.split(",")).map(String::strip).filter(StringUtils::hasContent)
Set<String> included = new LinkedHashSet<>();
Set<String> ignored = new LinkedHashSet<>();
for(String entry : watched.split(","))
{
String field = entry.strip();
if(field.startsWith("!"))
{
ignored.add(field.substring(1).strip());
}
else if(StringUtils.hasContent(field))
{
included.add(field);
}
}
if(included.isEmpty() && !ignored.isEmpty())
{
included.addAll(current.getValues().keySet());
included.addAll(old.getValues().keySet());
}
included.removeAll(ignored);
return (included.stream()
.anyMatch(field -> !Objects.equals(current.getValue(field), old.getValue(field))));
}
return (true);
@@ -28,11 +28,14 @@ import com.kingsrook.qbits.cdn.spi.CdnProviderType;
import com.kingsrook.qbits.cdn.spi.CdnPurgeKind;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -138,6 +141,32 @@ class PurgePipelineTest
/*******************************************************************************
** The record-change listener skips counter updates but records title changes.
*******************************************************************************/
@Test
void ignoredCounterChangeRecordsNoPurge() throws Exception
{
QContext.getQInstance().getTable("post").withField(new QFieldMetaData("viewCount", QFieldType.INTEGER));
CdnTestSupport.liveProvider(CdnProviderType.CLOUDFRONT, null);
QRecord rule = new QueryAction().execute(new QueryInput(CdnTables.TABLE_TAG_RULE)).getRecords().get(0);
new UpdateAction().execute(new UpdateInput(CdnTables.TABLE_TAG_RULE).withRecord(new QRecord().withValue("id", rule.getValue("id"))
.withValue("onlyWhenFieldsChange", "!viewCount")));
QRecord post = CdnTestSupport.insert("post", new QRecord().withValue("slug", "counter-post").withValue("title", "Old")
.withValue("blogId", 1).withValue("isPublished", true).withValue("viewCount", 10)).get(0);
assertThat(purges()).hasSize(1);
new UpdateAction().execute(new UpdateInput("post").withRecord(new QRecord().withValue("id", post.getValue("id")).withValue("viewCount", 11)));
assertThat(purges()).as("counter-only update adds no purge request").hasSize(1);
new UpdateAction().execute(new UpdateInput("post").withRecord(new QRecord().withValue("id", post.getValue("id")).withValue("title", "New")));
assertThat(purges()).hasSize(2);
assertThat(purges().get(1).getValueString("paths")).contains("/blog/quick-bits/counter-post*");
assertThat(purges().get(1).getValueString("tags")).contains("post:" + post.getValue("id"));
}
/*******************************************************************************
** Retryable failures back off and end DEAD; permanent ones go DEAD at once.
*******************************************************************************/
@@ -135,6 +135,67 @@ class TagRuleEngineTest
/*******************************************************************************
** Ignore-only rules compare actual values after merging partial updates.
*******************************************************************************/
@Test
void ignoresCounterOnlyUpdates() throws Exception
{
QRecord rule = new QRecord().withValue("onlyWhenFieldsChange", " !viewCount, !lastIndexedAt ").withValue("tagTemplates", "post:{id}");
QRecord old = new QRecord().withValue("id", 1).withValue("title", "Old").withValue("viewCount", 10).withValue("lastIndexedAt", "yesterday");
TagRuleEngine engine = new TagRuleEngine();
assertTrue(engine.evaluate(rule, List.of(new QRecord(old)), List.of(old)).isEmpty());
assertTrue(engine.evaluate(rule, List.of(new QRecord().withValue("id", 1).withValue("viewCount", 11).withValue("lastIndexedAt", "today")), List.of(old)).isEmpty());
assertTrue(engine.evaluate(rule, List.of(new QRecord().withValue("id", 1).withValue("title", "Old")), List.of(old)).isEmpty());
assertThat(engine.evaluate(rule, List.of(new QRecord().withValue("id", 1).withValue("title", "New").withValue("viewCount", 11)), List.of(old)).tags()).containsExactly("post:1");
assertFalse(engine.evaluate(rule, List.of(new QRecord().withValue("id", 1).withValue("title", null)), List.of(old)).isEmpty());
assertFalse(engine.evaluate(rule, List.of(new QRecord().withValue("id", 1).withValue("newField", "value")), List.of(old)).isEmpty());
assertFalse(engine.evaluate(rule, List.of(new QRecord().withValue("id", 1).withValue("ViewCount", 11)), List.of(old)).isEmpty());
}
/*******************************************************************************
** Plain lists retain allow-list behavior; ignored entries always win.
*******************************************************************************/
@Test
void appliesAllowListAndMixedRules()
{
QRecord old = new QRecord().withValue("title", "Old").withValue("viewCount", 10);
QRecord counterChange = new QRecord(old).withValue("viewCount", 11);
QRecord titleChange = new QRecord(old).withValue("title", "New");
QRecord rule = new QRecord().withValue("onlyWhenFieldsChange", " title, ,slug ");
assertFalse(TagRuleEngine.counts(rule, counterChange, old));
assertTrue(TagRuleEngine.counts(rule, titleChange, old));
assertFalse(TagRuleEngine.counts(rule, new QRecord(old), old));
rule.setValue("onlyWhenFieldsChange", "title, viewCount, !viewCount");
assertFalse(TagRuleEngine.counts(rule, counterChange, old));
assertTrue(TagRuleEngine.counts(rule, titleChange, old));
assertFalse(TagRuleEngine.counts(rule, new QRecord(old).withValue("slug", "new"), old));
rule.setValue("onlyWhenFieldsChange", "viewCount,!viewCount");
assertFalse(TagRuleEngine.counts(rule, titleChange, old));
assertFalse(TagRuleEngine.counts(rule, counterChange, old));
}
/*******************************************************************************
** Changed-field filters never suppress inserts or deletes.
*******************************************************************************/
@Test
void insertsAndDeletesIgnoreFieldFilters() throws Exception
{
QRecord post = new QRecord().withValue("id", 1).withValue("viewCount", 10);
for(String fields : List.of("!viewCount", "title", "title,!viewCount"))
{
QRecord rule = new QRecord().withValue("onlyWhenFieldsChange", fields).withValue("tagTemplates", "post:{id}");
assertThat(new TagRuleEngine().evaluate(rule, List.of(post), List.of()).tags()).containsExactly("post:1");
assertThat(new TagRuleEngine().evaluate(rule, List.of(), List.of(post)).tags()).containsExactly("post:1");
}
}
/*******************************************************************************
** Stored text round-trips; invalid lines are dropped.
*******************************************************************************/