feat: contingent possible values (product-scoped references) #10

Merged
KofTwentyTwo merged 1 commits from feature/contingent-possible-values into develop 2026-10-14 12:09:10 +00:00
3 changed files with 202 additions and 0 deletions

No files matched your search

+1
View File
@@ -5,6 +5,7 @@ All notable changes to this project are documented here. The format follows Keep
## [0.1.0] - Unreleased
### Added
- Product-contingent possible values for editable campaign, claim, finding, metric and outcome-event references.
- Repository scaffold: build, CI and the repository standard files.
- **Model:** 18 standard `marketing_*` tables, 21 enum possible-value sources, joins and child lists. The PostgreSQL reference DDL (`docs/ddl/postgres.sql`) and an H2 twin are generated from one spec (`scripts/generate-model.py`).
- **Products and evidence:**
@@ -72,6 +72,9 @@ import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizers;
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.ChildRecordListRenderer;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.instances.QInstanceEnricher;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.data.QRecordEntity;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
@@ -224,6 +227,8 @@ public class MarketingMetaDataBuilder
tables.put(spec.name(), buildTable(spec));
}
addContingentPossibleValues();
////////////////////////////////////////////////////////////////////////
// relations: each parent shows its children as lists (owner rule: //
// sub-tables are relations and child lists on their parents) //
@@ -264,6 +269,59 @@ public class MarketingMetaDataBuilder
/*******************************************************************************
** Scope editable foreign keys by product. Content's product is read-only;
** system-written history tables have no editable record forms.
*******************************************************************************/
private void addContingentPossibleValues()
{
// Offer only audiences belonging to the selected product.
tables.get(MarketingCampaign.TABLE_NAME).getField("audienceId").withPossibleValueSourceFilter(
new QQueryFilter(new QFilterCriteria("productId", QCriteriaOperator.EQUALS, "${input.productId}")));
// Offer only evidence sources belonging to the selected product.
tables.get(MarketingClaim.TABLE_NAME).getField("sourceId").withPossibleValueSourceFilter(
new QQueryFilter(new QFilterCriteria("productId", QCriteriaOperator.EQUALS, "${input.productId}")));
// Offer only campaigns belonging to the selected product.
tables.get(MarketingFinding.TABLE_NAME).getField("campaignId").withPossibleValueSourceFilter(
new QQueryFilter(new QFilterCriteria("productId", QCriteriaOperator.EQUALS, "${input.productId}")));
// Offer only content belonging to the selected product.
tables.get(MarketingFinding.TABLE_NAME).getField("contentId").withPossibleValueSourceFilter(
new QQueryFilter(new QFilterCriteria("productId", QCriteriaOperator.EQUALS, "${input.productId}")));
// Offer only claims belonging to the selected product.
tables.get(MarketingFinding.TABLE_NAME).getField("claimId").withPossibleValueSourceFilter(
new QQueryFilter(new QFilterCriteria("productId", QCriteriaOperator.EQUALS, "${input.productId}")));
// Offer only evidence sources belonging to the selected product.
tables.get(MarketingFinding.TABLE_NAME).getField("sourceId").withPossibleValueSourceFilter(
new QQueryFilter(new QFilterCriteria("productId", QCriteriaOperator.EQUALS, "${input.productId}")));
// Offer only agent runs belonging to the selected product.
tables.get(MarketingFinding.TABLE_NAME).getField("agentRunId").withPossibleValueSourceFilter(
new QQueryFilter(new QFilterCriteria("productId", QCriteriaOperator.EQUALS, "${input.productId}")));
// Offer only campaigns belonging to the selected product.
tables.get(MarketingMetric.TABLE_NAME).getField("campaignId").withPossibleValueSourceFilter(
new QQueryFilter(new QFilterCriteria("productId", QCriteriaOperator.EQUALS, "${input.productId}")));
// Offer only campaigns belonging to the selected product.
tables.get(MarketingOutcomeEvent.TABLE_NAME).getField("campaignId").withPossibleValueSourceFilter(
new QQueryFilter(new QFilterCriteria("productId", QCriteriaOperator.EQUALS, "${input.productId}")));
// Offer only content belonging to the selected product.
tables.get(MarketingOutcomeEvent.TABLE_NAME).getField("contentId").withPossibleValueSourceFilter(
new QQueryFilter(new QFilterCriteria("productId", QCriteriaOperator.EQUALS, "${input.productId}")));
// Offer only executions belonging to the selected product.
tables.get(MarketingOutcomeEvent.TABLE_NAME).getField("executionId").withPossibleValueSourceFilter(
new QQueryFilter(new QFilterCriteria("productId", QCriteriaOperator.EQUALS, "${input.productId}")));
}
/*******************************************************************************
** One table.
*******************************************************************************/
@@ -0,0 +1,143 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.marketing;
import java.io.Serializable;
import java.util.Map;
import com.kingsrook.qbits.marketing.metadata.MarketingMetaDataBuilder;
import com.kingsrook.qbits.marketing.model.MarketingAudience;
import com.kingsrook.qqq.backend.core.actions.values.SearchPossibleValueSourceAction;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.values.SearchPossibleValueSourceInput;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.PossibleValueSearchFilterUseCase;
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValue;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.utils.collections.MapBuilder;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** Product-contingent metadata and real memory-backend possible-value searches.
*******************************************************************************/
class ContingentPossibleValuesTest extends BaseMarketingTest
{
/*******************************************************************************
** Every declared dependency uses an editable parent and child and a real
** product column on the referenced table.
*******************************************************************************/
@ParameterizedTest
@CsvSource({
"marketing_campaign, audienceId, marketing_audience",
"marketing_claim, sourceId, marketing_evidence_source",
"marketing_finding, campaignId, marketing_campaign",
"marketing_finding, contentId, marketing_content",
"marketing_finding, claimId, marketing_claim",
"marketing_finding, sourceId, marketing_evidence_source",
"marketing_finding, agentRunId, marketing_agent_run",
"marketing_metric, campaignId, marketing_campaign",
"marketing_outcome_event, campaignId, marketing_campaign",
"marketing_outcome_event, contentId, marketing_content",
"marketing_outcome_event, executionId, marketing_execution"
})
void testMetadata(String tableName, String fieldName, String targetName)
{
QTableMetaData table = qInstance.getTable(tableName);
QFieldMetaData field = table.getField(fieldName);
assertThat(MarketingMetaDataBuilder.SYSTEM_WRITTEN_TABLE_NAMES).doesNotContain(tableName);
assertThat(table.getField("productId").getIsEditable()).isTrue();
assertThat(field.getIsEditable()).isTrue();
assertThat(field.getPossibleValueSourceName()).isEqualTo(targetName);
assertThat(qInstance.getTable(targetName).getField("productId")).isNotNull();
assertThat(field.getPossibleValueSourceFilter()).isEqualTo(
new QQueryFilter(new QFilterCriteria("productId", QCriteriaOperator.EQUALS, "${input.productId}")));
}
/*******************************************************************************
** Read-only dependencies stay unfiltered, and the inventory is exhaustive.
*******************************************************************************/
@Test
void testSkippedFields()
{
int filterCount = 0;
for(QTableMetaData table : qInstance.getTables().values())
{
for(QFieldMetaData field : table.getFields().values())
{
if(MarketingMetaDataBuilder.SYSTEM_WRITTEN_TABLE_NAMES.contains(table.getName()) || "marketing_content".equals(table.getName()))
{
assertThat(field.getPossibleValueSourceFilter()).as(table.getName() + "." + field.getName()).isNull();
}
if(field.getPossibleValueSourceFilter() != null)
{
filterCount++;
}
}
}
assertThat(filterCount).isEqualTo(11);
}
/*******************************************************************************
** Three different target tables: selected products isolate choices, while
** missing products give no form choices and all filter-builder choices.
*******************************************************************************/
@ParameterizedTest
@CsvSource({"marketing_campaign, audienceId", "marketing_claim, sourceId", "marketing_metric, campaignId"})
void testSearch(String tableName, String fieldName) throws Exception
{
Integer firstProduct = product("first");
Integer secondProduct = product("second");
campaign(firstProduct);
campaign(secondProduct);
MarketingStore.insert(new MarketingAudience().withProductId(firstProduct).withName("First audience"));
MarketingStore.insert(new MarketingAudience().withProductId(secondProduct).withName("Second audience"));
QFieldMetaData field = qInstance.getTable(tableName).getField(fieldName);
String targetName = field.getPossibleValueSourceName();
Serializable firstId = MarketingStore.queryBy(targetName, "productId", firstProduct).get(0).getValue("id");
Serializable secondId = MarketingStore.queryBy(targetName, "productId", secondProduct).get(0).getValue("id");
assertThat(search(field, Map.of("productId", firstProduct), PossibleValueSearchFilterUseCase.FORM)).containsExactly(firstId);
assertThat(search(field, Map.of("productId", secondProduct), PossibleValueSearchFilterUseCase.FORM)).containsExactly(secondId);
assertThat(search(field, Map.of(), PossibleValueSearchFilterUseCase.FORM)).isEmpty();
assertThat(search(field, Map.of(), PossibleValueSearchFilterUseCase.FILTER)).containsExactlyInAnyOrder(firstId, secondId);
assertThat(field.getPossibleValueSourceFilter().getCriteria().get(0).getValues()).containsExactly("${input.productId}");
}
/*******************************************************************************
** Interpret a clone as the UI does, then search the actual table PVS.
*******************************************************************************/
private java.util.List<Serializable> search(QFieldMetaData field, Map<String, Serializable> input, PossibleValueSearchFilterUseCase useCase) throws Exception
{
QQueryFilter filter = field.getPossibleValueSourceFilter().clone();
filter.interpretValues(MapBuilder.of("input", input), useCase);
return (new SearchPossibleValueSourceAction().execute(new SearchPossibleValueSourceInput()
.withPossibleValueSourceName(field.getPossibleValueSourceName()).withDefaultQueryFilter(filter))
.getResults().stream().map(QPossibleValue::getId).map(id -> (Serializable) id).toList());
}
}