Files
qbit-data-analysis/src/main/java/com/kingsrook/qbits/dataanalysis/model/DataAnalysisTables.java
T
james.maesandClaude Opus 5.5 69154de566 fix(tables): a new saved analysis, star, watch or run belongs to the inserting user
Found driving the Next UI against the sample app: the UI cannot send the
user's id, and core checks required fields and record locks before the
customizers run. An insert-only field behavior fills empty owner/user
fields with the session user (never on update, so an administrator's
partial edit cannot take ownership); naming another owner is still
refused.

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

385 lines
23 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.model;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import com.kingsrook.qbits.dataanalysis.DataAnalysisQBitConfig;
import com.kingsrook.qbits.dataanalysis.permissions.DataAnalysisPermissions;
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.ChildRecordListRenderer;
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.metadata.audits.AuditLevel;
import com.kingsrook.qqq.backend.core.model.metadata.audits.QAuditRules;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
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.joins.JoinOn;
import com.kingsrook.qqq.backend.core.model.metadata.joins.JoinType;
import com.kingsrook.qqq.backend.core.model.metadata.joins.QJoinMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
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.security.QSecurityKeyType;
import com.kingsrook.qqq.backend.core.model.metadata.security.RecordSecurityLock;
import com.kingsrook.qqq.backend.core.model.metadata.tables.ExposedJoin;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QFieldSection;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.SectionFactory;
import com.kingsrook.qqq.backend.core.model.metadata.tables.TablesPossibleValueSourceMetaDataProvider;
import com.kingsrook.qqq.backend.core.model.metadata.tables.Tier;
import com.kingsrook.qqq.backend.core.model.metadata.tables.UniqueKey;
/*******************************************************************************
** Visible Data Analysis tables and their enum sources, joins and child lists.
*******************************************************************************/
public final class DataAnalysisTables
{
public static final String TABLE_CONFIG = "data_analysis_table_config";
public static final String SAVED = "data_analysis_saved";
public static final String SAVED_STAR = "data_analysis_saved_star";
public static final String SCHEDULE = "data_analysis_schedule";
public static final String WATCH = "data_analysis_watch";
public static final String RUN = "data_analysis_run";
public static final String USER_KEY = "data_analysis_user";
public static final List<String> ALL = List.of(TABLE_CONFIG, SAVED, SAVED_STAR, SCHEDULE, WATCH, RUN);
public static final List<String> NAV_TABLES = List.of(SAVED, WATCH, TABLE_CONFIG, RUN);
private static final Map<String, List<String>> CHILD_COLUMNS = Map.of(
SAVED_STAR, List.of("user_id", "createDate"),
SCHEDULE, List.of("frequency", "enabled", "next_run_at", "last_status"),
WATCH, List.of("detector", "enabled", "state", "last_evaluated_at"));
/*******************************************************************************
** Utility class.
*******************************************************************************/
private DataAnalysisTables()
{
}
/*******************************************************************************
** Standard CRUD permissions under the given base name.
*******************************************************************************/
public static QPermissionRules permissionRules(String baseName)
{
return (new QPermissionRules().withLevel(PermissionLevel.READ_INSERT_EDIT_DELETE_PERMISSIONS)
.withPermissionBaseName(baseName));
}
/*******************************************************************************
** The host supplies its authenticated user id as the value of this key.
*******************************************************************************/
public static QSecurityKeyType userKeyType()
{
return (new QSecurityKeyType().withName(USER_KEY).withAllAccessKeyName(DataAnalysisPermissions.ADMIN));
}
/*******************************************************************************
** All enum and table possible-value sources.
*******************************************************************************/
public static List<QPossibleValueSource> possibleValueSources()
{
List<QPossibleValueSource> sources = new ArrayList<>();
sources.add(new QPossibleValueSource().withName(DataAnalysisFieldPossibleValueProvider.NAME)
.withType(QPossibleValueSourceType.CUSTOM).withIdType(QFieldType.STRING)
.withCustomCodeReference(new QCodeReference(DataAnalysisFieldPossibleValueProvider.class)));
sources.add(enumSource("kind", "COLUMN_PROFILE", "TABLE_PROFILE", "PIVOT", "COMPARE", "CORRELATION", "CROSSTAB", "SHIFT"));
sources.add(enumSource("chart_kind", "HISTOGRAM", "BOX", "BAR", "PARETO", "TIME_SERIES", "SCATTER", "HEATMAP"));
sources.add(enumSource("detector", "SPIKE", "DROP", "OUT_OF_RANGE", "NEW_CATEGORY", "NULL_RATE_CREEP"));
sources.add(enumSource("watch_state", "OK", "FIRING"));
sources.add(enumSource("watch_bucket", "HOUR", "DAY", "WEEK", "MONTH"));
sources.add(enumSource("watch_severity", "INFO", "WARNING", "ERROR", "CRITICAL"));
sources.add(enumSource("run_status", "RUNNING", "COMPLETE", "FAILED"));
sources.add(enumSource("schedule_frequency", "DAILY", "WEEKLY", "MONTHLY"));
sources.add(enumSource("schedule_format", "HTML_SUMMARY", "CSV", "XLSX"));
for(String table : ALL)
{
sources.add(QPossibleValueSource.newForTable(table));
}
return (sources);
}
/*******************************************************************************
** An enum source with stable string ids and readable labels.
*******************************************************************************/
private static QPossibleValueSource enumSource(String name, String... values)
{
List<QPossibleValue<?>> entries = new ArrayList<>();
for(String value : values)
{
entries.add(new QPossibleValue<>(value, label(value)));
}
return (new QPossibleValueSource().withName("data_analysis_" + name).withType(QPossibleValueSourceType.ENUM).withEnumValues(entries));
}
/*******************************************************************************
** Readable labels for snake case identifiers.
*******************************************************************************/
private static String label(String name)
{
StringBuilder result = new StringBuilder();
for(String word : name.toLowerCase(Locale.ROOT).split("_"))
{
result.append(result.isEmpty() ? "" : " ").append(Character.toUpperCase(word.charAt(0))).append(word.substring(1));
}
return (result.toString());
}
/*******************************************************************************
** Base metadata; backendName identifies the connection, SQL names infer from table names.
*******************************************************************************/
private static QTableMetaData base(DataAnalysisQBitConfig config, String name, String title, String icon, String permissionBase)
{
return (new QTableMetaData().withName(name).withLabel(title).withIcon(new QIcon(icon))
.withBackendName(config.getBackendName()).withPrimaryKeyField("id").withPermissionRules(permissionRules(permissionBase))
.withField(new QFieldMetaData("id", QFieldType.INTEGER).withLabel("ID").withIsEditable(false))
.withField(new QFieldMetaData("createDate", QFieldType.DATE_TIME).withLabel("Created").withIsEditable(false))
.withField(new QFieldMetaData("modifyDate", QFieldType.DATE_TIME).withLabel("Modified").withIsEditable(false)));
}
/*******************************************************************************
** A field with a readable label and bounded strings.
*******************************************************************************/
private static QFieldMetaData field(String name, QFieldType type)
{
QFieldMetaData field = new QFieldMetaData(name, type).withLabel(label(name));
if(type == QFieldType.STRING)
{
field.withMaxLength(250);
}
return (field);
}
/*******************************************************************************
** Owner-only reads and writes, with null owners denied.
*******************************************************************************/
private static RecordSecurityLock ownerLock(String field)
{
return (new RecordSecurityLock().withSecurityKeyType(USER_KEY).withFieldName(field)
.withNullValueBehavior(RecordSecurityLock.NullValueBehavior.DENY));
}
/*******************************************************************************
** The six tables in spec section 7.1.
*******************************************************************************/
public static List<QTableMetaData> tables(DataAnalysisQBitConfig config)
{
List<QTableMetaData> tables = new ArrayList<>();
tables.add(base(config, TABLE_CONFIG, "Analysis Settings", "settings", "data_analysis.settings")
.withRecordLabelFormatAndFields("%s", "table_name")
.withField(field("table_name", QFieldType.STRING).withPossibleValueSourceName(TablesPossibleValueSourceMetaDataProvider.NAME).withIsRequired(true))
.withField(field("enabled", QFieldType.BOOLEAN).withDefaultValue(true))
.withField(field("max_rows", QFieldType.INTEGER))
.withField(field("sample_threshold_rows", QFieldType.INTEGER))
.withField(field("sample_rows", QFieldType.INTEGER))
.withField(field("min_group_size", QFieldType.INTEGER))
// add possibleValueSourceDependsOn("table_name") once core has it
.withField(field("analyzable_fields", QFieldType.TEXT).withPossibleValueSourceName(DataAnalysisFieldPossibleValueProvider.NAME))
// add possibleValueSourceDependsOn("table_name") once core has it
.withField(field("excluded_fields", QFieldType.TEXT).withPossibleValueSourceName(DataAnalysisFieldPossibleValueProvider.NAME))
.withField(field("cache_ttl_minutes", QFieldType.INTEGER))
.withAuditRules(new QAuditRules().withAuditLevel(AuditLevel.FIELD))
.withUniqueKey(new UniqueKey("table_name")));
tables.add(base(config, SAVED, "Saved Analyses", "insights", "data_analysis.saved")
.withRecordLabelFormatAndFields("%s", "name")
.withField(field("name", QFieldType.STRING).withIsRequired(true))
.withField(field("description", QFieldType.TEXT))
.withField(field("table_name", QFieldType.STRING).withPossibleValueSourceName(TablesPossibleValueSourceMetaDataProvider.NAME).withIsRequired(true))
.withField(field("filter_json", QFieldType.TEXT))
.withField(field("kind", QFieldType.STRING).withPossibleValueSourceName("data_analysis_kind").withIsRequired(true))
.withField(field("definition_json", QFieldType.TEXT).withIsRequired(true))
.withField(field("chart_kind", QFieldType.STRING).withPossibleValueSourceName("data_analysis_chart_kind"))
.withField(field("owner_user_id", QFieldType.STRING).withIsRequired(true).withBehavior(InsertingUserBehavior.INSERTING_USER))
.withField(field("shared", QFieldType.BOOLEAN).withDefaultValue(false))
.withField(field("read_owner_user_id", QFieldType.STRING).withIsEditable(false).withIsHidden(true))
.withAuditRules(new QAuditRules().withAuditLevel(AuditLevel.FIELD))
.withRecordSecurityLock(ownerLock("owner_user_id").withLockScope(RecordSecurityLock.LockScope.WRITE))
.withRecordSecurityLock(ownerLock("read_owner_user_id").withLockScope(RecordSecurityLock.LockScope.READ)
.withNullValueBehavior(RecordSecurityLock.NullValueBehavior.ALLOW))
.withCustomizer(com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizers.PRE_INSERT_RECORD,
new QCodeReference(com.kingsrook.qbits.dataanalysis.saved.SavedCustomizer.class))
.withCustomizer(com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizers.PRE_UPDATE_RECORD,
new QCodeReference(com.kingsrook.qbits.dataanalysis.saved.SavedCustomizer.class)));
tables.add(base(config, SAVED_STAR, "Stars", "star", "data_analysis.saved")
.withRecordLabelFormatAndFields("%s", "user_id")
.withField(field("saved_id", QFieldType.INTEGER).withPossibleValueSourceName("data_analysis_saved").withIsRequired(true))
.withField(field("user_id", QFieldType.STRING).withIsRequired(true).withBehavior(InsertingUserBehavior.INSERTING_USER))
.withUniqueKey(new UniqueKey("saved_id", "user_id"))
.withRecordSecurityLock(ownerLock("user_id")));
tables.add(base(config, SCHEDULE, "Schedules", "schedule", "data_analysis.saved")
.withRecordLabelFormatAndFields("%s", "saved_id")
.withField(field("saved_id", QFieldType.INTEGER).withPossibleValueSourceName("data_analysis_saved").withIsRequired(true))
.withField(field("cron", QFieldType.STRING))
.withField(field("frequency", QFieldType.STRING).withPossibleValueSourceName("data_analysis_schedule_frequency"))
.withField(field("day", QFieldType.INTEGER))
.withField(field("time", QFieldType.STRING))
.withField(field("time_zone", QFieldType.STRING))
.withField(field("format", QFieldType.STRING).withDefaultValue("HTML_SUMMARY").withPossibleValueSourceName("data_analysis_schedule_format"))
.withField(field("recipients", QFieldType.TEXT))
.withField(field("enabled", QFieldType.BOOLEAN).withDefaultValue(false))
.withField(field("last_run_at", QFieldType.DATE_TIME))
.withField(field("last_status", QFieldType.TEXT).withIsEditable(false))
.withField(field("next_run_at", QFieldType.DATE_TIME))
.withRecordSecurityLock(ownerLock(SAVED + ".owner_user_id").withJoinNameChain(List.of(joinName(SCHEDULE))))
.withCustomizer(com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizers.PRE_INSERT_RECORD,
new QCodeReference(com.kingsrook.qbits.dataanalysis.schedule.ScheduleCustomizer.class))
.withCustomizer(com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizers.PRE_UPDATE_RECORD,
new QCodeReference(com.kingsrook.qbits.dataanalysis.schedule.ScheduleCustomizer.class)));
tables.add(base(config, WATCH, "Watches", "notifications_active", "data_analysis.watch")
.withRecordLabelFormatAndFields("%s", "detector")
.withField(field("saved_id", QFieldType.INTEGER).withPossibleValueSourceName("data_analysis_saved")
.withPossibleValueSourceFilter(new QQueryFilter(new QFilterCriteria("table_name", QCriteriaOperator.EQUALS, "${input.table_name}"))))
.withField(field("table_name", QFieldType.STRING).withPossibleValueSourceName(TablesPossibleValueSourceMetaDataProvider.NAME))
.withField(field("filter_json", QFieldType.TEXT))
.withField(field("metric_json", QFieldType.TEXT))
.withField(field("detector", QFieldType.STRING).withPossibleValueSourceName("data_analysis_detector").withIsRequired(true))
.withField(field("parameters_json", QFieldType.TEXT))
.withField(field("date_field", QFieldType.STRING).withIsRequired(true))
.withField(field("bucket", QFieldType.STRING).withDefaultValue("DAY").withIsRequired(true).withPossibleValueSourceName("data_analysis_watch_bucket"))
.withField(field("severity", QFieldType.STRING).withDefaultValue("WARNING").withIsRequired(true).withPossibleValueSourceName("data_analysis_watch_severity"))
.withField(field("owner_user_id", QFieldType.STRING).withIsRequired(true).withBehavior(InsertingUserBehavior.INSERTING_USER))
.withField(field("enabled", QFieldType.BOOLEAN).withDefaultValue(false))
.withField(field("last_evaluated_at", QFieldType.DATE_TIME).withIsEditable(false))
.withField(field("last_value", QFieldType.DECIMAL).withIsEditable(false))
.withField(field("last_message", QFieldType.TEXT).withIsEditable(false))
.withField(field("state", QFieldType.STRING).withDefaultValue("OK").withIsEditable(false).withPossibleValueSourceName("data_analysis_watch_state"))
.withField(field("alert_key", QFieldType.STRING).withIsEditable(false))
.withRecordSecurityLock(ownerLock("owner_user_id"))
.withCustomizer(com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizers.PRE_INSERT_RECORD,
new com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference(com.kingsrook.qbits.dataanalysis.watch.WatchCustomizer.class))
.withCustomizer(com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizers.PRE_UPDATE_RECORD,
new com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference(com.kingsrook.qbits.dataanalysis.watch.WatchCustomizer.class)));
tables.add(base(config, RUN, "Analysis Runs", "history", "data_analysis.run")
.withRecordLabelFormatAndFields("%s", "operation")
.withField(field("table_name", QFieldType.STRING).withPossibleValueSourceName(TablesPossibleValueSourceMetaDataProvider.NAME).withIsRequired(true))
.withField(field("operation", QFieldType.STRING).withIsRequired(true))
.withField(field("request_hash", QFieldType.STRING))
.withField(field("filter_hash", QFieldType.STRING))
.withField(field("data_version", QFieldType.STRING))
.withField(field("status", QFieldType.STRING).withDefaultValue("RUNNING").withPossibleValueSourceName("data_analysis_run_status").withIsRequired(true))
.withField(field("started_at", QFieldType.DATE_TIME))
.withField(field("finished_at", QFieldType.DATE_TIME))
.withField(field("rows_scanned", QFieldType.LONG))
.withField(field("sampled", QFieldType.BOOLEAN).withDefaultValue(false))
.withField(field("result_json", QFieldType.TEXT))
.withField(field("error", QFieldType.TEXT))
.withField(field("user_id", QFieldType.STRING).withIsRequired(true).withBehavior(InsertingUserBehavior.INSERTING_USER))
.withRecordSecurityLock(ownerLock("user_id")));
for(QTableMetaData table : tables)
{
List<String> fields = new ArrayList<>(table.getFields().keySet());
fields.removeAll(List.of("id", "createDate", "modifyDate", "read_owner_user_id"));
int headlineSize = Math.min(4, fields.size());
table.withSection(SectionFactory.defaultT1(fields.subList(0, headlineSize).toArray(String[]::new)));
if(fields.size() > headlineSize)
{
table.withSection(new QFieldSection("details", "Details", new QIcon("list"), Tier.T2, fields.subList(headlineSize, fields.size())));
}
table.withSection(SectionFactory.defaultT3("id", "createDate", "modifyDate"));
QInstanceEnricher.setInferredFieldBackendNames(table);
if(SAVED.equals(table.getName()))
{
for(String child : List.of(SAVED_STAR, SCHEDULE, WATCH))
{
String title = child.equals(SAVED_STAR) ? "Stars" : child.equals(SCHEDULE) ? "Schedules" : "Watches";
table.withSection(new QFieldSection(joinName(child), new QIcon("list"), Tier.T2).withLabel(title).withWidgetName(joinName(child)));
table.withExposedJoin(new ExposedJoin().withLabel(title).withJoinTable(child).withJoinPath(List.of(joinName(child))));
}
}
}
return (tables);
}
/*******************************************************************************
** Stable names shared by joins, widgets and schedule locks.
*******************************************************************************/
public static String joinName(String child)
{
return (SAVED + "_to_" + child);
}
/*******************************************************************************
** Saved analyses link to their stars, schedules and watches.
*******************************************************************************/
public static List<QJoinMetaData> joins()
{
List<QJoinMetaData> joins = new ArrayList<>();
for(String child : List.of(SAVED_STAR, SCHEDULE, WATCH))
{
joins.add(new QJoinMetaData().withName(joinName(child)).withLeftTable(SAVED).withRightTable(child)
.withType(JoinType.ONE_TO_MANY).withJoinOn(new JoinOn("id", "saved_id")));
}
return (joins);
}
/*******************************************************************************
** Child lists use the standard renderer and at most four visible columns.
*******************************************************************************/
public static List<QWidgetMetaData> childListWidgets()
{
List<QWidgetMetaData> widgets = new ArrayList<>();
for(QJoinMetaData join : joins())
{
String child = join.getRightTable();
widgets.add(ChildRecordListRenderer.widgetMetaDataBuilder(join).withName(join.getName())
.withLabel(child.equals(SAVED_STAR) ? "Stars" : child.equals(SCHEDULE) ? "Schedules" : "Watches")
.withMaxRows(10).withOnlyIncludeFieldNames(CHILD_COLUMNS.get(child)).getWidgetMetaData());
}
return (widgets);
}
}