feat: contingent possible values (product-scoped references) #10
No files matched your search
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user