feat(virtualFields): add baseline support for virtualFields #4

Merged
darinkelkhoff merged 2 commits from feature/virtual-field-support into develop 2026-03-09 17:05:44 +00:00
8 changed files with 262 additions and 3 deletions

No files matched your search

@@ -72,6 +72,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.fields.DynamicDefaultValueB
import com.kingsrook.qqq.backend.core.model.metadata.fields.FieldAndJoinTable;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QSupplementalFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QVirtualFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.security.MultiRecordSecurityLock;
import com.kingsrook.qqq.backend.core.model.metadata.security.RecordSecurityLock;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QFieldSection;
@@ -178,6 +179,9 @@ public class CustomizableTableViewsTablePersonalizer implements TableMetaDataPer
Map<String, QFieldMetaData> fieldsToKeep = getFieldsToKeepForTable(tableView, cloneTable, tableActionInput);
cloneTable.setFields(fieldsToKeep);
Map<String, QVirtualFieldMetaData> virtualFieldsToKeep = getVirtualFieldsToKeepForTable(tableView, cloneTable, tableActionInput);
cloneTable.setVirtualFields(virtualFieldsToKeep);
///////////////////////////////////////////////////////////
// remove field names which aren't present from sections //
///////////////////////////////////////////////////////////
@@ -360,6 +364,55 @@ public class CustomizableTableViewsTablePersonalizer implements TableMetaDataPer
/***************************************************************************
*
***************************************************************************/
protected Map<String, QVirtualFieldMetaData> getVirtualFieldsToKeepForTable(TableView tableView, QTableMetaData cloneTable, AbstractTableActionInput tableActionInput)
{
Map<String, QVirtualFieldMetaData> cloneFields = cloneTable.getVirtualFields();
if(cloneFields == null)
{
return Collections.emptyMap();
}
Map<String, QVirtualFieldMetaData> fieldsToKeep = new LinkedHashMap<>();
/////////////////////////////////////////////
// figure out which fields the user gets //
// start with ones in the user's TableView //
/////////////////////////////////////////////
for(TableViewField tableViewField : CollectionUtils.nonNullList(tableView.getFields()))
{
try
{
String fieldName = tableViewField.getFieldName().split("\\.")[1];
QVirtualFieldMetaData fieldMetaData = cloneFields.get(fieldName);
if(fieldMetaData != null)
{
FieldAccessLevel fieldAccessLevel = FieldAccessLevel.getById(tableViewField.getAccessLevel());
if(fieldAccessLevel != null)
{
fieldAccessLevel.apply(fieldMetaData);
}
fieldsToKeep.put(fieldName, fieldMetaData);
}
}
catch(Exception e)
{
LOG.warn("Error processing tableViewField", e, logPair("fieldName", tableViewField.getFieldName()));
}
}
///////////////////////////////////////////////////////////////////////////////
// unlike normal fields, there are no 'always keep' rules for virtual fields //
///////////////////////////////////////////////////////////////////////////////
return fieldsToKeep;
}
/***************************************************************************
* check if any system-defined rules make it so that a field should always
* be kept.
@@ -96,6 +96,12 @@ public class CustomizableTableFieldPVS implements QCustomPossibleValueProvider<S
{
tim-chamberlain commented 2026-03-09 16:38:15 +00:00 (Migrated from github.com)
Review

Possible NPE: table.getVirtualFields() can be null for tables without virtual fields, so this line will throw instead of returning null for unknown ids (e.g., "tableName.doesNotExist"). Please null-check before .get(fieldName).

Possible NPE: `table.getVirtualFields()` can be null for tables without virtual fields, so this line will throw instead of returning null for unknown ids (e.g., `"tableName.doesNotExist"`). Please null-check before `.get(fieldName)`.
return (new QPossibleValue<>(idString, fieldMetaData.getLabel()));
}
fieldMetaData = table.getVirtualFields() == null ? null : table.getVirtualFields().get(fieldName);
if(fieldMetaData != null)
{
return (new QPossibleValue<>(idString, fieldMetaData.getLabel()));
}
}
}
@@ -128,7 +134,13 @@ public class CustomizableTableFieldPVS implements QCustomPossibleValueProvider<S
if(table != null)
{
for(QFieldMetaData field : table.getFields().values())
List<QFieldMetaData> fields = new ArrayList<>(table.getFields().values());
if(table.getVirtualFields() != null)
{
fields.addAll(table.getVirtualFields().values());
}
for(QFieldMetaData field : fields)
{
if(field.getName().equals(table.getPrimaryKeyField()))
{
@@ -24,6 +24,7 @@ package com.kingsrook.qbits.customizabletableviews.model;
import java.util.Objects;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QVirtualFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.PossibleValueEnum;
import com.kingsrook.qqq.backend.core.model.metadata.producers.annotations.QMetaDataProducingPossibleValueEnum;
@@ -204,7 +205,7 @@ public enum FieldAccessLevel implements PossibleValueEnum<String>
return ("This field is defined as hidden by the system - you may not make it " + getLabel());
}
if(!fieldMetaData.getIsEditable())
if(!fieldMetaData.getIsEditable() || fieldMetaData instanceof QVirtualFieldMetaData)
{
if(this == READ_ONLY)
{
@@ -64,7 +64,7 @@ public class TableViewFieldCustomizer implements TableCustomizerInterface
String tableName = parts[0];
fieldName = parts[1];
QFieldMetaData field = QContext.getQInstance().getTable(tableName).getField(fieldName);
QFieldMetaData field = QContext.getQInstance().getTable(tableName).getFieldOrVirtualField(fieldName);
FieldAccessLevel fieldAccessLevel = FieldAccessLevel.getById(accessLevelId);
if(fieldAccessLevel != null)
@@ -59,6 +59,7 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
import com.kingsrook.qqq.backend.core.model.metadata.fields.DynamicDefaultValueBehavior;
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.fields.QVirtualFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.joins.JoinOn;
import com.kingsrook.qqq.backend.core.model.metadata.joins.JoinType;
import com.kingsrook.qqq.backend.core.model.metadata.joins.QJoinMetaData;
@@ -499,6 +500,85 @@ class CustomizableTableViewsTablePersonalizerTest extends BaseTest
/*******************************************************************************
**
*******************************************************************************/
@Test
void testApplyViewToTableWithVirtualFields()
{
CustomizableTableViewsTablePersonalizer personalizer = new CustomizableTableViewsTablePersonalizer();
TableMetaDataInput tableActionInput = new TableMetaDataInput();
QTableMetaData baseTable = new QTableMetaData()
.withName("vfTable")
.withPrimaryKeyField("id")
.withField(new QFieldMetaData("id", QFieldType.STRING).withIsEditable(false))
.withField(new QFieldMetaData("optional", QFieldType.STRING))
.withVirtualField(new QVirtualFieldMetaData("computed", QFieldType.STRING).withLabel("Computed"))
.withVirtualField(new QVirtualFieldMetaData("derived", QFieldType.STRING).withLabel("Derived"))
.withSection(SectionFactory.defaultT1("id").withName("s0"))
.withSection(SectionFactory.defaultT2("optional").withName("s1"));
/////////////////////////////////////////////////////////////////////////////////
// empty view - no virtual fields kept (no "always keep" rules for virtuals) //
/////////////////////////////////////////////////////////////////////////////////
QTableMetaData tableMetaData = personalizer.applyViewToTable(
new TableView().withFields(List.of()),
baseTable.clone(), tableActionInput);
assertEquals(1, tableMetaData.getFields().size());
assertNotNull(tableMetaData.getField("id"));
assertTrue(tableMetaData.getVirtualFields().isEmpty());
///////////////////////////////////////////////////////////////
// view references one virtual field - only that one is kept //
///////////////////////////////////////////////////////////////
tableMetaData = personalizer.applyViewToTable(
new TableView().withFields(List.of(
new TableViewField().withFieldName("vfTable.computed").withAccessLevel(READ_ONLY)
)),
baseTable.clone(), tableActionInput);
assertEquals(1, tableMetaData.getFields().size());
assertEquals(1, tableMetaData.getVirtualFields().size());
assertNotNull(tableMetaData.getVirtualFields().get("computed"));
assertNull(tableMetaData.getVirtualFields().get("derived"));
/////////////////////////////////////////////////////////////////////////////
// view references both virtual fields and a regular field - all are kept //
/////////////////////////////////////////////////////////////////////////////
tableMetaData = personalizer.applyViewToTable(
new TableView().withFields(List.of(
new TableViewField().withFieldName("vfTable.optional").withAccessLevel(EDITABLE_OPTIONAL),
new TableViewField().withFieldName("vfTable.computed").withAccessLevel(READ_ONLY),
new TableViewField().withFieldName("vfTable.derived").withAccessLevel(READ_ONLY)
)),
baseTable.clone(), tableActionInput);
assertEquals(2, tableMetaData.getFields().size()); // id + optional
assertEquals(2, tableMetaData.getVirtualFields().size()); // computed + derived
assertEquals(List.of("optional"), tableMetaData.getSection("s1").getFieldNames());
//////////////////////////////////////////////////////////////////
// table with no virtual fields at all - should not break //
//////////////////////////////////////////////////////////////////
QTableMetaData noVfTable = new QTableMetaData()
.withName("noVfTable")
.withPrimaryKeyField("id")
.withField(new QFieldMetaData("id", QFieldType.STRING).withIsEditable(false))
.withSection(SectionFactory.defaultT1("id").withName("s0"));
tableMetaData = personalizer.applyViewToTable(
new TableView().withFields(List.of()),
noVfTable.clone(), tableActionInput);
assertEquals(1, tableMetaData.getFields().size());
assertTrue(tableMetaData.getVirtualFields() == null || tableMetaData.getVirtualFields().isEmpty());
}
/*******************************************************************************
**
*******************************************************************************/
@@ -32,11 +32,15 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
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.fields.QFieldType;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QVirtualFieldMetaData;
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.model.metadata.tables.SectionFactory;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
/*******************************************************************************
@@ -84,4 +88,60 @@ class CustomizableTableFieldPVSTest extends BaseTest
assertEquals("Optional", possibleValues.get(1).getLabel());
}
/*******************************************************************************
**
*******************************************************************************/
@Test
void testWithVirtualFields() throws QException
{
QTableMetaData testTable = new QTableMetaData()
.withName("testTable")
.withPrimaryKeyField("id")
.withField(new QFieldMetaData("id", QFieldType.STRING).withIsEditable(false))
.withField(new QFieldMetaData("optional", QFieldType.STRING).withLabel("Optional"))
.withVirtualField(new QVirtualFieldMetaData("computed", QFieldType.STRING).withLabel("Computed"))
.withVirtualField(new QVirtualFieldMetaData("hiddenVirtual", QFieldType.STRING).withIsHidden(true).withLabel("Hidden Virtual"))
.withSection(SectionFactory.defaultT1("id").withName("s0"))
.withSection(SectionFactory.defaultT2("optional").withName("s1"));
QContext.getQInstance().addTable(testTable);
new InsertAction().execute(new InsertInput(CustomizableTable.TABLE_NAME).withRecordEntities(List.of(
new CustomizableTable().withTableName(testTable.getName()).withIsActive(true))));
Integer tableViewId = 17;
new InsertAction().execute(new InsertInput(TableView.TABLE_NAME).withRecordEntities(List.of(
new TableView().withId(tableViewId).withCustomizableTableId(1).withName("a"))));
/////////////////////////////////////////////////////////////////////
// search should include the non-hidden virtual field in results //
/////////////////////////////////////////////////////////////////////
List<QPossibleValue<String>> possibleValues = new CustomizableTableFieldPVS().search(new SearchPossibleValueSourceInput().withOtherValues(Map.of("tableViewId", tableViewId)));
assertEquals(2, possibleValues.size());
assertThat(possibleValues)
.anyMatch(pv1 -> "testTable.computed".equals(pv1.getId()) && "Computed".equals(pv1.getLabel()))
.anyMatch(pv1 -> "testTable.optional".equals(pv1.getId()) && "Optional".equals(pv1.getLabel()));
///////////////////////////////////////////////////////////////////
// getPossibleValue should resolve a virtual field by table.name //
///////////////////////////////////////////////////////////////////
QPossibleValue<String> pv = new CustomizableTableFieldPVS().getPossibleValue("testTable.computed");
assertNotNull(pv);
assertEquals("testTable.computed", pv.getId());
assertEquals("Computed", pv.getLabel());
/////////////////////////////////////////////////////
// getPossibleValue for a normal field still works //
/////////////////////////////////////////////////////
QPossibleValue<String> pvNormal = new CustomizableTableFieldPVS().getPossibleValue("testTable.optional");
assertNotNull(pvNormal);
assertEquals("Optional", pvNormal.getLabel());
/////////////////////////////////////////////////////////
// getPossibleValue for a nonexistent field returns null //
/////////////////////////////////////////////////////////
assertNull(new CustomizableTableFieldPVS().getPossibleValue("testTable.doesNotExist"));
}
}
@@ -25,12 +25,16 @@ package com.kingsrook.qbits.customizabletableviews.model;
import java.util.function.BiFunction;
import com.kingsrook.qbits.customizabletableviews.BaseTest;
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.fields.QVirtualFieldMetaData;
import org.junit.jupiter.api.Test;
import static com.kingsrook.qbits.customizabletableviews.QFieldMetaDataAssert.assertThat;
import static com.kingsrook.qbits.customizabletableviews.model.FieldAccessLevel.EDITABLE_OPTIONAL;
import static com.kingsrook.qbits.customizabletableviews.model.FieldAccessLevel.EDITABLE_REQUIRED;
import static com.kingsrook.qbits.customizabletableviews.model.FieldAccessLevel.READ_ONLY;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
/*******************************************************************************
@@ -122,4 +126,31 @@ class FieldAccessLevelTest extends BaseTest
assertThat(doApply.apply(READ_ONLY, hiddenField))/*....*/.isHidden().isNotRequired().isEditable(); // no changes
}
/*******************************************************************************
**
*******************************************************************************/
@Test
void testValidateForVirtualField()
{
///////////////////////////////////////////////////////////////////////////////////
// a virtual field (even if editable) should be treated as read-only by validate //
///////////////////////////////////////////////////////////////////////////////////
QVirtualFieldMetaData editableVirtualField = new QVirtualFieldMetaData("vf", QFieldType.STRING).withIsEditable(true);
assertNull(READ_ONLY.validateForField(editableVirtualField));
assertNotNull(EDITABLE_REQUIRED.validateForField(editableVirtualField));
assertNotNull(EDITABLE_OPTIONAL.validateForField(editableVirtualField));
/////////////////////////////////////////////////
// a non-editable virtual field - same results //
/////////////////////////////////////////////////
QVirtualFieldMetaData readOnlyVirtualField = new QVirtualFieldMetaData("vf", QFieldType.STRING).withIsEditable(false);
assertNull(READ_ONLY.validateForField(readOnlyVirtualField));
assertNotNull(EDITABLE_REQUIRED.validateForField(readOnlyVirtualField));
assertNotNull(EDITABLE_OPTIONAL.validateForField(readOnlyVirtualField));
}
}
@@ -32,6 +32,7 @@ import com.kingsrook.qqq.backend.core.exceptions.QException;
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.fields.QFieldType;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QVirtualFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
@@ -74,6 +75,27 @@ class TableViewFieldCustomizerTest extends BaseTest
/*******************************************************************************
**
*******************************************************************************/
@Test
void testVirtualField() throws QException
{
QContext.getQInstance().addTable(new QTableMetaData()
.withName("testTable2")
.withField(new QFieldMetaData("id", QFieldType.STRING))
.withVirtualField(new QVirtualFieldMetaData("virtualField", QFieldType.STRING).withLabel("Virtual")));
//////////////////////////////////////////////////////////////////////////////
// virtual fields are treated as read-only, so only READ_ONLY should pass. //
//////////////////////////////////////////////////////////////////////////////
assertNoError(new TableViewField().withFieldName("testTable2.virtualField").withAccessLevel(FieldAccessLevel.READ_ONLY));
assertError(new TableViewField().withFieldName("testTable2.virtualField").withAccessLevel(FieldAccessLevel.EDITABLE_REQUIRED));
assertError(new TableViewField().withFieldName("testTable2.virtualField").withAccessLevel(FieldAccessLevel.EDITABLE_OPTIONAL));
}
/***************************************************************************
*
***************************************************************************/