Files
qbit-data-analysis/src/test/java/com/kingsrook/qbits/dataanalysis/DataAnalysisPossibleValuesTest.java
T
james.maesandClaude Opus 5.5 d2c9871d98 feat(saved): saved, shared, starred, scheduled and widget analyses, export, SQL handoff
Extension 1.4.0: saved analyses of every kind re-run as the viewer
(fields they cannot analyze are dropped), owner-or-shared READ through
core record locks with owner-only writes (and a database CHECK so a
private row can never lose its read owner), personal stars, schedules
that run as the owner and mail only recipients who could run the analysis
themselves, CSV/XLSX export, the SQL Workbench handoff, an analysis
dashboard widget, and an Analyses home built from standard widgets.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:23:23 -05:00

172 lines
10 KiB
Java

/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* 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
*
* https://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.dataanalysis;
import java.io.Serializable;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.dataanalysis.engine.EffectiveConfigResolver;
import com.kingsrook.qbits.dataanalysis.model.DataAnalysisFieldPossibleValueProvider;
import com.kingsrook.qbits.dataanalysis.model.DataAnalysisTables;
import com.kingsrook.qqq.backend.core.actions.values.SearchPossibleValueSourceAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.model.actions.values.SearchPossibleValueSourceInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
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.PossibleValueSearchFilterUseCase;
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValue;
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSourceType;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** Dependent possible values through the same search inputs used by form requests.
*******************************************************************************/
class DataAnalysisPossibleValuesTest
{
/*******************************************************************************
** Release the request context.
*******************************************************************************/
@AfterEach
void cleanup()
{
QContext.clear();
}
/*******************************************************************************
** The watch table selection filters saved analyses without widening owner locks.
*******************************************************************************/
@Test
void watchSavedAnalysisDependsOnTable() throws Exception
{
QInstance instance = TestSupport.instance();
for(String name : List.of("orders", "customers"))
{
instance.addTable(new QTableMetaData().withName(name).withField(new QFieldMetaData("table_name", QFieldType.STRING)));
}
QRecord orders = TestSupport.insert(DataAnalysisTables.SAVED, TestSupport.saved("alice", false).withValue("table_name", "orders"));
QRecord customers = TestSupport.insert(DataAnalysisTables.SAVED, TestSupport.saved("alice", false).withValue("table_name", "customers"));
TestSupport.insert(DataAnalysisTables.SAVED, TestSupport.saved("bob", false).withValue("table_name", "orders"));
QContext.init(instance, TestSupport.user("alice").withPermissions("data_analysis.saved.read", "data_analysis.watch.edit"));
QFieldMetaData field = instance.getTable(DataAnalysisTables.WATCH).getField("saved_id");
assertThat(search(field, Map.of("table_name", "orders"))).extracting(value -> (Object) value.getId()).containsExactly(orders.getValueInteger("id"));
assertThat(search(field, Map.of("table_name", "customers"))).extracting(value -> (Object) value.getId()).containsExactly(customers.getValueInteger("id"));
assertThat(search(field, Map.of())).isEmpty();
assertThat(search(field, Map.of("table_name", "missing"))).isEmpty();
}
/*******************************************************************************
** All four table references use the core source, including host-owned sources.
*******************************************************************************/
@Test
void tableNamesUseCoreTablesSource() throws Exception
{
QInstance instance = TestSupport.instance();
instance.addTable(new QTableMetaData().withName("orders").withLabel("Orders"));
for(String tableName : List.of(DataAnalysisTables.TABLE_CONFIG, DataAnalysisTables.SAVED, DataAnalysisTables.WATCH, DataAnalysisTables.RUN))
{
QFieldMetaData field = instance.getTable(tableName).getField("table_name");
assertThat(field.getPossibleValueSourceName()).isEqualTo("tables");
assertThat(search(field, Map.of())).extracting(value -> value.getId().toString()).containsExactlyInAnyOrderElementsOf(instance.getTables().keySet());
}
var hostSource = instance.getPossibleValueSource("tables").withLabel("Host tables");
QInstance host = new QInstance();
host.addBackend(instance.getBackend("memory"));
host.addPossibleValueSource(hostSource);
new DataAnalysisQBitProducer().withQBitConfig(new DataAnalysisQBitConfig().withBackendName("memory")).produce(host);
assertThat(host.getPossibleValueSource("tables")).isSameAs(hostSource);
assertThat(host.getPossibleValueSource("tables").getLabel()).isEqualTo("Host tables");
}
/*******************************************************************************
** Both CSV settings offer only selected-table fields and retain CSV parsing.
*******************************************************************************/
@Test
void fieldPickersDependOnTableAndKeepCsvStorage() throws Exception
{
QInstance instance = TestSupport.instance();
instance.addTable(new QTableMetaData().withName("orders").withLabel("Orders")
.withField(new QFieldMetaData("amount", QFieldType.DECIMAL).withLabel("Order total"))
.withField(new QFieldMetaData("status", QFieldType.STRING)));
instance.addTable(new QTableMetaData().withName("customers").withLabel("Customers")
.withField(new QFieldMetaData("email", QFieldType.STRING).withLabel("Email")));
for(String fieldName : List.of("analyzable_fields", "excluded_fields"))
{
QFieldMetaData field = instance.getTable(DataAnalysisTables.TABLE_CONFIG).getField(fieldName);
assertThat(field.getType()).isEqualTo(QFieldType.TEXT);
assertThat(instance.getPossibleValueSource(field.getPossibleValueSourceName()).getType()).isEqualTo(QPossibleValueSourceType.CUSTOM);
assertThat(search(field, Map.of("table_name", "orders"))).extracting(value -> (Object) value.getId()).containsExactly("amount", "status");
assertThat(search(field, Map.of("table_name", "orders"))).extracting(QPossibleValue::getLabel).containsExactly("Order total (amount)", "status (status)");
assertThat(search(field, Map.of("table_name", "customers"))).extracting(value -> (Object) value.getId()).containsExactly("email");
assertThat(search(field, Map.of())).isEmpty();
assertThat(search(field, Map.of("table_name", ""))).isEmpty();
assertThat(search(field, Map.of("table_name", "missing"))).isEmpty();
assertThat(search(field, null)).isEmpty();
}
SearchPossibleValueSourceInput input = new SearchPossibleValueSourceInput().withPossibleValueSourceName(DataAnalysisFieldPossibleValueProvider.NAME)
.withOtherValues(Map.of("table_name", "orders")).withSearchTerm("TOTAL");
assertThat(new SearchPossibleValueSourceAction().execute(input).getResults()).extracting(value -> (Object) value.getId()).containsExactly("amount");
input.withSearchTerm("AMOUNT");
assertThat(new SearchPossibleValueSourceAction().execute(input).getResults()).extracting(value -> (Object) value.getId()).containsExactly("amount");
input.withIdList(List.of("status", "email"));
assertThat(new SearchPossibleValueSourceAction().execute(input).getResults()).extracting(value -> (Object) value.getId()).containsExactly("status");
DataAnalysisFieldPossibleValueProvider provider = new DataAnalysisFieldPossibleValueProvider();
assertThat(provider.getPossibleValue(null)).isNull();
assertThat(provider.getPossibleValue("amount,status").getId()).isEqualTo("amount,status");
TestSupport.insert(DataAnalysisTables.TABLE_CONFIG, new QRecord().withValue("table_name", "orders")
.withValue("analyzable_fields", "amount, status").withValue("excluded_fields", "status, amount"));
var config = new EffectiveConfigResolver(new DataAnalysisQBitConfig().withBackendName("memory")).resolve("orders");
assertThat(config.getAnalyzableFields()).containsExactlyInAnyOrder("amount", "status");
assertThat(config.getExcludedFields()).containsExactlyInAnyOrder("amount", "status");
}
/*******************************************************************************
** Mirror TablePossibleValuesSpecV1: clone and interpret the field filter as FORM.
*******************************************************************************/
private List<QPossibleValue<?>> search(QFieldMetaData field, Map<String, Serializable> otherValues) throws Exception
{
SearchPossibleValueSourceInput input = new SearchPossibleValueSourceInput()
.withPossibleValueSourceName(field.getPossibleValueSourceName()).withOtherValues(otherValues);
if(field.getPossibleValueSourceFilter() != null)
{
var filter = field.getPossibleValueSourceFilter().clone();
filter.interpretValues(Map.of("input", otherValues), PossibleValueSearchFilterUseCase.FORM);
input.withDefaultQueryFilter(filter);
}
return (new SearchPossibleValueSourceAction().execute(input).getResults());
}
}