fix(tag-rules): resolve joined table through the FK field's possible-value source #10
No files matched your search
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user