fix(tag-rules): resolve joined table through the FK field's possible-value source #10

Merged
KofTwentyTwo merged 1 commits from fix/tag-rule-pvs-table into develop 2026-10-13 00:49:44 +00:00
4 changed files with 152 additions and 7 deletions

No files matched your search

+5
View File
@@ -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 `<prefix>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
+12
View File
@@ -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 `<prefix>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
@@ -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<String, QRecord> joinCache = new HashMap<>();
private static final QLogger LOG = QLogger.getLogger(TagRuleEngine.class);
private final Map<String, QRecord> joinCache = new HashMap<>();
private final Set<String> 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);
}
@@ -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.