From 26e62f3bcc316e4318c990c5e2c19232c02fc733 Mon Sep 17 00:00:00 2001 From: James Maes Date: Mon, 12 Oct 2026 19:41:32 -0500 Subject: [PATCH 1/2] fix(purge): resolve tag-rule joined tables via the FK field's possible-value source {blog.slug} used the prefix as both the FK field name and the table name, so apps with prefixed tables (cms_blog) silently lost those purge paths. The joined table now comes from the Id field's possible-value source, falling back to the bare prefix. A missing joined table is logged once. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 5 ++ README.md | 12 +++ .../qbits/cdn/purge/TagRuleEngine.java | 66 ++++++++++++++-- .../qbits/cdn/purge/TagRuleEngineTest.java | 76 +++++++++++++++++++ 4 files changed, 152 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d8b84d..34d6f79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ All notable changes to this project are documented here. The format follows Keep a Changelog; versions follow semantic versioning. +## [0.1.1] - Unreleased + +### Fixed +- Tag rule `{prefix.field}` placeholders now resolve the joined table from the `Id` field's possible-value source, so prefixed tables (`cms_blog`) work; a missing joined table is logged instead of silently skipped. + ## [0.1.0] - Unreleased ### Added diff --git a/README.md b/README.md index 5381034..5f19d75 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,18 @@ 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 + +`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}*`. + +A joined placeholder is resolved like this: + +1. The foreign-key field is `Id` on the rule's table (`blogId` for `{blog.slug}`). +2. The joined table is the one behind that field's possible-value source: the source's table name for a table-type source, or the source name when it names a table. So `{blog.slug}` reads `cms_blog` when `blogId` has a possible-value source on `cms_blog`. +3. With no such field or possible-value source, the bare prefix is the table name (`blog`). + +If the joined table does not exist, the placeholder is skipped (the rest of the template is dropped, as for any missing value) and a warning is logged once per table per rule evaluation. + ## Install ```java diff --git a/src/main/java/com/kingsrook/qbits/cdn/purge/TagRuleEngine.java b/src/main/java/com/kingsrook/qbits/cdn/purge/TagRuleEngine.java index f0df1a6..7d68acd 100644 --- a/src/main/java/com/kingsrook/qbits/cdn/purge/TagRuleEngine.java +++ b/src/main/java/com/kingsrook/qbits/cdn/purge/TagRuleEngine.java @@ -34,11 +34,16 @@ import com.kingsrook.qbits.cdn.tags.TagTemplate; import com.kingsrook.qqq.backend.core.actions.tables.GetAction; import com.kingsrook.qqq.backend.core.context.QContext; import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.logging.QLogger; import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput; 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.possiblevalues.QPossibleValueSource; +import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSourceType; import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; import com.kingsrook.qqq.backend.core.utils.StringUtils; import com.kingsrook.qqq.backend.core.utils.ValueUtils; +import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair; /******************************************************************************* @@ -54,13 +59,20 @@ import com.kingsrook.qqq.backend.core.utils.ValueUtils; ** purges nothing. ** ** A dotted placeholder "{blog.slug}" reads field "slug" of the record that - ** field "blogId" points to in table "blog" (the xId convention). + ** field "blogId" points to (the xId convention). The joined table is the one + ** named by that field's possible-value source (its table name, or the source + ** name when that names a table), so apps that prefix their tables ("cms_blog") + ** work; with no field or possible-value source, the bare prefix ("blog") is + ** the table name. An unresolvable join is skipped, and logged once. *******************************************************************************/ public class TagRuleEngine { private static final Pattern PLACEHOLDER = Pattern.compile("\\{([A-Za-z0-9_]+)\\.([A-Za-z0-9_]+)}"); - private final Map joinCache = new HashMap<>(); + private static final QLogger LOG = QLogger.getLogger(TagRuleEngine.class); + + private final Map joinCache = new HashMap<>(); + private final Set warnedMissing = new LinkedHashSet<>(); @@ -172,7 +184,7 @@ public class TagRuleEngine { continue; } - QRecord target = lookup(joined, foreignKey); + QRecord target = lookup(rule.getValueString("tableName"), joined, foreignKey); if(target != null) { values.put(joined + "." + field, target.getValue(field)); @@ -184,17 +196,57 @@ public class TagRuleEngine /******************************************************************************* - ** One joined record, cached for the life of this engine. + ** The table a placeholder prefix joins to: the table behind the possible-value + ** source of the prefix's "xId" field on the source table, else the bare + ** prefix. *******************************************************************************/ - private QRecord lookup(String tableName, Serializable id) throws QException + static String joinedTableName(String sourceTable, String prefix) { - String key = tableName + "#" + id; + QFieldMetaData field = null; + if(sourceTable != null) + { + QTableMetaData source = QContext.getQInstance().getTable(sourceTable); + field = source == null ? null : source.getFields().get(prefix + "Id"); + } + String pvsName = field == null ? null : field.getPossibleValueSourceName(); + if(pvsName == null) + { + return (prefix); + } + QPossibleValueSource pvs = QContext.getQInstance().getPossibleValueSource(pvsName); + if(pvs != null && pvs.getType() == QPossibleValueSourceType.TABLE && StringUtils.hasContent(pvs.getTableName())) + { + return (pvs.getTableName()); + } + return (pvsName); + } + + + + /******************************************************************************* + ** One joined record, cached for the life of this engine. A missing table is + ** logged once and yields null. + *******************************************************************************/ + private QRecord lookup(String sourceTable, String prefix, Serializable id) throws QException + { + String tableName = joinedTableName(sourceTable, prefix); + String key = tableName + "#" + id; if(joinCache.containsKey(key)) { return (joinCache.get(key)); } QTableMetaData table = QContext.getQInstance().getTable(tableName); - QRecord found = table == null ? null : new GetAction().executeForRecord(new GetInput(tableName).withPrimaryKey(id)); + if(table == null) + { + if(warnedMissing.add(tableName)) + { + LOG.warn("A CDN tag rule placeholder joins to a table that does not exist; its values are skipped", + logPair("placeholder", prefix), logPair("sourceTable", sourceTable), logPair("table", tableName)); + } + joinCache.put(key, null); + return (null); + } + QRecord found = new GetAction().executeForRecord(new GetInput(tableName).withPrimaryKey(id)); joinCache.put(key, found); return (found); } diff --git a/src/test/java/com/kingsrook/qbits/cdn/purge/TagRuleEngineTest.java b/src/test/java/com/kingsrook/qbits/cdn/purge/TagRuleEngineTest.java index 12599ed..2e686a7 100644 --- a/src/test/java/com/kingsrook/qbits/cdn/purge/TagRuleEngineTest.java +++ b/src/test/java/com/kingsrook/qbits/cdn/purge/TagRuleEngineTest.java @@ -18,6 +18,11 @@ package com.kingsrook.qbits.cdn.purge; import java.util.List; +import com.kingsrook.qqq.backend.core.context.QContext; +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.possiblevalues.QPossibleValueSource; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; import com.kingsrook.qbits.cdn.CdnTestSupport; import com.kingsrook.qbits.cdn.planning.InvalidationPath; import com.kingsrook.qqq.backend.core.model.data.QRecord; @@ -58,6 +63,77 @@ class TagRuleEngineTest } + /******************************************************************************* + ** The joined table comes from the FK field's table possible-value source, so + ** a prefixed table resolves {blog.slug}; a PVS named for a table also works. + *******************************************************************************/ + @Test + void resolvesPrefixedTableViaPossibleValueSource() throws Exception + { + CdnTestSupport.setUp(); + var instance = QContext.getQInstance(); + instance.addTable(new QTableMetaData().withName("cms_blog").withBackendName(CdnTestSupport.MEMORY).withPrimaryKeyField("id") + .withField(new QFieldMetaData("id", QFieldType.INTEGER)) + .withField(new QFieldMetaData("slug", QFieldType.STRING))); + instance.addPossibleValueSource(QPossibleValueSource.newForTable("cms_blog").withName("blogPvs")); + instance.getTable("post").getFields().get("blogId").setPossibleValueSourceName("blogPvs"); + CdnTestSupport.insert("cms_blog", new QRecord().withValue("slug", "prefixed")); + + QRecord rule = new QRecord().withValue("tableName", "post").withValue("invalidationPaths", "/blog/{blog.slug}/{slug}*"); + QRecord post = new QRecord().withValue("id", 1).withValue("slug", "a").withValue("blogId", 1); + assertThat(new TagRuleEngine().evaluate(rule, List.of(post), List.of()).paths()).containsExactly(InvalidationPath.leaf("/blog/prefixed/a*")); + + //////////////////////////////////////////////////////////// + // a PVS whose name is itself a table (not TABLE-typed) // + //////////////////////////////////////////////////////////// + instance.getTable("post").getFields().get("blogId").setPossibleValueSourceName("cms_blog"); + assertThat(TagRuleEngine.joinedTableName("post", "blog")).isEqualTo("cms_blog"); + } + + + + /******************************************************************************* + ** No field, no PVS, or no source table: the bare prefix is the table name. + *******************************************************************************/ + @Test + void fallsBackToBarePrefix() throws Exception + { + CdnTestSupport.setUp(); + assertThat(TagRuleEngine.joinedTableName("post", "blog")).isEqualTo("blog"); + assertThat(TagRuleEngine.joinedTableName("post", "nofield")).isEqualTo("nofield"); + assertThat(TagRuleEngine.joinedTableName("nosuchtable", "blog")).isEqualTo("blog"); + assertThat(TagRuleEngine.joinedTableName(null, "blog")).isEqualTo("blog"); + CdnTestSupport.insert("blog", new QRecord().withValue("slug", "bare")); + QRecord rule = new QRecord().withValue("tableName", "post").withValue("invalidationPaths", "/blog/{blog.slug}"); + QRecord post = new QRecord().withValue("id", 1).withValue("blogId", 1); + assertThat(new TagRuleEngine().evaluate(rule, List.of(post), List.of()).paths()).containsExactly(InvalidationPath.leaf("/blog/bare")); + } + + + + /******************************************************************************* + ** A missing FK field value, or a missing joined table, skips the placeholder + ** without throwing. + *******************************************************************************/ + @Test + void missingFieldOrTableIsSkipped() throws Exception + { + CdnTestSupport.setUp(); + QRecord rule = new QRecord().withValue("tableName", "post").withValue("invalidationPaths", "/blog/{blog.slug}\n/x/{ghost.slug}\n/y/{slug}"); + + QRecord noFk = new QRecord().withValue("id", 1).withValue("slug", "s"); + assertThat(new TagRuleEngine().evaluate(rule, List.of(noFk), List.of()).paths()).containsExactly(InvalidationPath.leaf("/y/s")); + + QRecord dangling = new QRecord().withValue("id", 2).withValue("slug", "s").withValue("ghostId", 7); + TagRuleEngine engine = new TagRuleEngine(); + assertThat(engine.evaluate(rule, List.of(dangling, dangling), List.of()).paths()).containsExactly(InvalidationPath.leaf("/y/s")); + + QContext.getQInstance().getTable("post").getFields().get("blogId").setPossibleValueSourceName("nowhere"); + QRecord toNowhere = new QRecord().withValue("id", 3).withValue("slug", "s").withValue("blogId", 1); + assertThat(new TagRuleEngine().evaluate(rule, List.of(toNowhere), List.of()).paths()).containsExactly(InvalidationPath.leaf("/y/s")); + } + + /******************************************************************************* ** Stored text round-trips; invalid lines are dropped. -- 2.54.0 From 47144c35ef912119beb2ed5926c1bf651c7318a5 Mon Sep 17 00:00:00 2001 From: CircleCI Date: Tue, 13 Oct 2026 00:43:29 +0000 Subject: [PATCH 2/2] Bump version to 0.1.1 [skip ci] --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 5d3d3da..ce0c101 100644 --- a/pom.xml +++ b/pom.xml @@ -18,7 +18,7 @@ - 0.1.0-SNAPSHOT + 0.1.1 21 21 0.8.15 -- 2.54.0