From fa98cc082bb0f28903385fce116aac05e5cb4baf Mon Sep 17 00:00:00 2001 From: James Maes Date: Tue, 13 Oct 2026 20:25:11 -0500 Subject: [PATCH] feat: contingent possible values for scoped foreign keys Dependent choices follow their parent selection on the same form (QQQ possibleValueSourceFilter with ${input.x}); filter builders still list everything. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + .../metadata/MarketingMetaDataBuilder.java | 58 +++++++ .../ContingentPossibleValuesTest.java | 143 ++++++++++++++++++ 3 files changed, 202 insertions(+) create mode 100644 src/test/java/com/kingsrook/qbits/marketing/ContingentPossibleValuesTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 031cb12..50dbef1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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:** diff --git a/src/main/java/com/kingsrook/qbits/marketing/metadata/MarketingMetaDataBuilder.java b/src/main/java/com/kingsrook/qbits/marketing/metadata/MarketingMetaDataBuilder.java index ce0ea2a..faf70b2 100644 --- a/src/main/java/com/kingsrook/qbits/marketing/metadata/MarketingMetaDataBuilder.java +++ b/src/main/java/com/kingsrook/qbits/marketing/metadata/MarketingMetaDataBuilder.java @@ -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. *******************************************************************************/ diff --git a/src/test/java/com/kingsrook/qbits/marketing/ContingentPossibleValuesTest.java b/src/test/java/com/kingsrook/qbits/marketing/ContingentPossibleValuesTest.java new file mode 100644 index 0000000..98e103f --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/marketing/ContingentPossibleValuesTest.java @@ -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 search(QFieldMetaData field, Map 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()); + } +} -- 2.54.0