Files
qbit-data-analysis/src/main/java/com/kingsrook/qbits/dataanalysis/engine/AnalysisLabels.java
T
james.maesandClaude Opus 5.5 43c7571864 feat(watches): anomaly watches through the alerts SPI, contingent pickers
Watches evaluate a metric per time bucket as their owner (SPIKE, DROP,
OUT_OF_RANGE, NEW_CATEGORY, NULL_RATE_CREEP with robust baselines) on a
hidden scheduled process, raise and resolve data.analysis.anomaly alerts
through QAlerts, and offer Evaluate now / Enable / Disable record actions
and a preview endpoint (extension 1.3.0). Pivot and crosstab labels are
human (Yes/No, Average Annual Salary). Contingent fields: a watch's saved
analysis depends on its table, table names use the core tables PVS, and
analyzable/excluded fields pick from the chosen table's fields.

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

200 lines
9.6 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.engine;
import java.io.Serializable;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import com.kingsrook.qqq.backend.core.actions.metadata.personalization.TableMetaDataPersonalizerAction;
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionEnforcement;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.actions.values.QPossibleValueTranslator;
import com.kingsrook.qqq.backend.core.actions.values.QValueFormatter;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
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.possiblevalues.QPossibleValue;
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource;
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSourceType;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
/*******************************************************************************
** Translate through core while enforcing access to every referenced label.
** RC.5's direct table-PVS translation bypasses permissions, so table values are
** read with ENFORCE and handed to the translator as an ephemeral enum source.
** No shared metadata or session is mutated.
*******************************************************************************/
public final class AnalysisLabels
{
/*******************************************************************************
** Utility class.
*******************************************************************************/
private AnalysisLabels()
{
}
/*******************************************************************************
** Missing or denied labels fall back to the already-readable source id.
*******************************************************************************/
public static String translate(QFieldMetaData field, Serializable value) throws QException
{
return (translate(field, value, CoreTierFieldAccessResolver.defaultResolver()));
}
/*******************************************************************************
** Resolve nested PVS label fields without invoking core's trusted lookup path.
*******************************************************************************/
public static String translate(QFieldMetaData field, Serializable value, FieldAnalysisAccessResolver access) throws QException
{
return (translate(field, value, new LinkedHashSet<>(), access));
}
/*******************************************************************************
** Resolve nested labels only through FULL fields under the host policy.
*******************************************************************************/
private static String translate(QFieldMetaData field, Serializable value, Set<String> visited, FieldAnalysisAccessResolver access) throws QException
{
if(value == null)
{
return (null);
}
if(field.getType() == com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType.BOOLEAN)
{
return (com.kingsrook.qqq.backend.core.utils.ValueUtils.getValueAsBoolean(value) ? "Yes" : "No");
}
String fallback = value.toString();
QPossibleValueSource source = QContext.getQInstance().getPossibleValueSource(field.getPossibleValueSourceName());
if(source == null || visited.size() >= 8 || !visited.add(source.getName()))
{
return (fallback);
}
try
{
QPossibleValueTranslator translator = new QPossibleValueTranslator();
if(source.getType() != QPossibleValueSourceType.TABLE)
{
return (translator.translatePossibleValue(field, value));
}
QueryInput input = new QueryInput(source.getTableName());
input.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
QTableMetaData table = TableMetaDataPersonalizerAction.execute(input, true);
QTableMetaData original = QContext.getQInstance().getTable(source.getTableName());
List<String> labelFields = original.getRecordLabelFields() == null ? List.of() : original.getRecordLabelFields();
for(String labelField : labelFields)
{
if(!table.getFields().containsKey(labelField) || access.resolve(table, table.getField(labelField)) != FieldAnalysisAccess.FULL)
{
return (fallback);
}
}
String id = source.getOverrideIdField() == null ? table.getPrimaryKeyField() : source.getOverrideIdField();
Set<String> selection = new LinkedHashSet<>(labelFields);
selection.add(id);
input.withFilter(DrillFilters.condition(id, QCriteriaOperator.EQUALS, value).withLimit(1)).withFieldNamesToInclude(selection)
.withShouldFetchHeavyFields(true).withShouldGenerateDisplayValues(true);
List<QRecord> records = new QueryAction().execute(input).getRecords();
if(records.isEmpty())
{
return (fallback);
}
QRecord record = records.getFirst();
for(String labelField : labelFields)
{
QFieldMetaData labelMetadata = table.getField(labelField);
if(labelMetadata.getPossibleValueSourceName() != null)
{
record.withDisplayValue(labelField, translate(labelMetadata, record.getValue(labelField), visited, access));
}
}
String label = QValueFormatter.formatRecordLabel(table, record);
QPossibleValueSource permitted = new QPossibleValueSource().withName(source.getName()).withType(QPossibleValueSourceType.ENUM)
.withValueFormat(source.getValueFormat()).withValueFields(source.getValueFields()).withEnumValues(List.of(new QPossibleValue<>(value, label)));
return (translator.buildTranslatedPossibleValueList(permitted, List.of(value)).getFirst().getLabel());
}
catch(QPermissionDeniedException e)
{
return (fallback);
}
finally
{
visited.remove(source.getName());
}
}
/*******************************************************************************
** Cache identity includes personalized field and related-label policies, so a
** host changing field/record locks cannot release older translated labels.
*******************************************************************************/
public static java.util.Map<String, Object> policy(String tableName) throws QException
{
return (policy(tableName, CoreTierFieldAccessResolver.defaultResolver()));
}
/*******************************************************************************
** Include host-resolved tiers of related labels in the exact-run identity.
*******************************************************************************/
public static java.util.Map<String, Object> policy(String tableName, FieldAnalysisAccessResolver access) throws QException
{
java.util.Map<String, Object> result = new java.util.TreeMap<>();
java.util.ArrayDeque<String> pending = new java.util.ArrayDeque<>();
pending.add(tableName);
while(!pending.isEmpty())
{
String name = pending.removeFirst();
if(result.containsKey(name))
{
continue;
}
QueryInput input = new QueryInput(name);
input.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
QTableMetaData table = TableMetaDataPersonalizerAction.execute(input, true);
java.util.Map<String, Object> entry = new java.util.TreeMap<>();
entry.put("fields", table.getFields());
entry.put("fullFields", table.getFields().values().stream().filter(f -> access.resolve(table, f) == FieldAnalysisAccess.FULL).map(QFieldMetaData::getName).sorted().toList());
entry.put("locks", table.getRecordSecurityLocks());
entry.put("labelFields", table.getRecordLabelFields());
entry.put("labelFormat", table.getRecordLabelFormat());
entry.put("permissions", table.getPermissionRules());
result.put(name, entry);
for(QFieldMetaData field : table.getFields().values())
{
QPossibleValueSource source = QContext.getQInstance().getPossibleValueSource(field.getPossibleValueSourceName());
if(source != null && source.getType() == QPossibleValueSourceType.TABLE)
{
pending.add(source.getTableName());
}
}
}
return (result);
}
}