feat: Create rows for analyzable tables, Setup card and table help #12

Merged
KofTwentyTwo merged 3 commits from feature/tables-populated-defaults into develop 2026-10-14 22:47:01 +00:00
15 changed files with 1256 additions and 5 deletions

No files matched your search

+4
View File
@@ -6,6 +6,10 @@ Following [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and [Semantic
### Added
- Analysis Settings table action **Create rows for analyzable tables**: writes the effective settings as editable `data_analysis_table_config` rows (insert-only, no change in behavior).
- Setup card on the Analyses home (settings, result cache, saved analyses, watches, schedules).
- Help bundle `help/qbit-data-analysis.yaml`: every table says what it is for and how its rows get created.
- `DataAnalysisTablesGuardrailTest` and `TableConfigDefaultsTest` (including a test that a settings row's `cache_ttl_minutes` changes result caching).
- Phase f saved execution for all seven kinds, core owner-or-shared READ with owner-only WRITE, typed definition validation and viewer-specific field reduction.
- Idempotent personal star endpoints and visible saved list; transient viewer runs, core CSV/XLSX downloads and QueryStat-backed SQL Workbench handoff.
- Five-minute owner-session schedule runner, per-recipient FULL access checks and record-scoped reruns, core messaging with attachments, explicit failure statuses/alerts, timezone/weekly/monthly/cron timing and streamed ETL Send now.
+10
View File
@@ -32,6 +32,16 @@ new DataAnalysisQBitProducer()
The producer installs metadata into the instance. It scans only `com.kingsrook.qbits.dataanalysis.metadata`; helpers and renderers are not scanned as component producers. Set `withParentAppName("admin")` to nest the app under an existing parent.
## Starter defaults and setup
Data Analysis ships no SQL seed: `data_analysis_table_config` depends on which tables the host has and on the code configuration (`defaultMaxRows`, `tableDefaults`, `includeTables`, `excludeTables` and the other defaults), so its rows are computed at run time.
- With no rows, every analyzable table is on with the configured defaults. That is a complete, working setup.
- Open Analysis Settings and run the table action **Create rows for analyzable tables**. It adds one row per analyzable table that has none, holding the settings that already apply (table defaults over global defaults). Nothing changes until you edit a row. It is insert-only: it never touches an existing row, and running it again only adds rows for tables added since.
- Rows pin their values: after a row exists, later changes to the code defaults do not reach that table, edit the row instead. `cache_ttl_minutes` is what the exact-run result cache uses for that table (0 turns caching off); `TableConfigDefaultsTest` proves a row changes caching.
- The **Setup** card on the Analyses home lists settings, result cache, saved analyses, watches and schedules with their state and next step, using the same resolution the runtime uses.
- Every table has help (shown when it is empty) that says how its rows get created. Saved analyses, stars, schedules, watches and runs are user content or logs and start empty by design.
## Tables
QQQ names and physical SQL table names are identical. `backendName` in configuration identifies the backend connection; it is not a SQL table name. All tables have `id`, `createDate` and `modifyDate`; SQL columns use inferred snake_case names.
+8
View File
@@ -0,0 +1,8 @@
# Upgrading
## Unreleased
No schema change and nothing required of hosts.
- New table action **Create rows for analyzable tables** on Analysis Settings. Run it once if you want the effective settings visible and editable as rows. It changes no behavior, is insert-only, and only adds rows for tables that have none. After a row exists it wins over later changes to the code defaults for that table.
- New **Setup** widget first on the Analyses home, and table help text. Hosts that override help on a table keep their own.
@@ -171,6 +171,7 @@ public class DataAnalysisQBitProducer implements QBitMetaDataProducer<DataAnalys
{
instance.getApp(qBitConfig.getParentAppName()).withChild(instance.getApp(qBitConfig.getAppName()));
}
com.kingsrook.qbits.dataanalysis.help.DataAnalysisHelp.apply(instance, qBitConfig);
return (new MetaDataProducerMultiOutput());
}
}
@@ -63,6 +63,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
*******************************************************************************/
public class DataAnalysisHome extends AbstractWidgetRenderer
{
public static final String SETUP = "dataAnalysisSetup";
public static final String HOME_WIDGET = "dataAnalysisHome";
public static final String STARRED = "dataAnalysisStarred";
public static final String FIRING = "dataAnalysisFiring";
@@ -81,7 +82,7 @@ public class DataAnalysisHome extends AbstractWidgetRenderer
*******************************************************************************/
public static List<QWidgetMetaData> widgets()
{
return (List.of(widget(), widget(STARRED, "Starred analyses", WidgetType.TABLE), widget(FIRING, "Firing watches", WidgetType.TABLE), widget(RECENT, "My recent runs", WidgetType.TABLE)));
return (List.of(widget(SETUP, "Setup", WidgetType.TABLE), widget(), widget(STARRED, "Starred analyses", WidgetType.TABLE), widget(FIRING, "Firing watches", WidgetType.TABLE), widget(RECENT, "My recent runs", WidgetType.TABLE)));
}
/*******************************************************************************
@@ -127,6 +128,10 @@ public class DataAnalysisHome extends AbstractWidgetRenderer
*******************************************************************************/
public static QWidgetData render(String name, Instant now) throws QException
{
if(SETUP.equals(name))
{
return (setup());
}
if(HOME_WIDGET.equals(name))
{
return (statistics(now));
@@ -166,6 +171,31 @@ public class DataAnalysisHome extends AbstractWidgetRenderer
return (data);
}
/*******************************************************************************
** The Setup card: each settings or content table, its state and the next step.
*******************************************************************************/
public static TableData setup() throws QException
{
TableData data = new TableData("Setup", List.of(new TableData.Column("html", "Part", "c0", null, "left"), new TableData.Column("text", "State", "c1", null, "left"),
new TableData.Column("text", "What it means", "c2", null, "left"), new TableData.Column("text", "Next step", "c3", null, "left")), new ArrayList<>())
.withNoRowsFoundHTML("Nothing to set up.").withRowsPerPage(10);
for(DataAnalysisSetupStatus.Item item : DataAnalysisSetupStatus.load())
{
String path = QContext.getQInstance().getTablePath(item.tableName());
String part = "<a href=\"" + escape(path) + "\">" + escape(item.label()) + "</a>";
String state = switch(item.state())
{
case OK -> "Set up";
case DEFAULT -> "Using defaults";
case OPTIONAL -> "Optional";
case UNAVAILABLE -> "Unavailable";
case UNKNOWN -> "Needs attention";
};
data.getRows().add(values(part, state, item.detail(), item.nextStep()));
}
return (data);
}
/*******************************************************************************
** One standard group per subject, with real counts or an unavailable explanation.
*******************************************************************************/
@@ -263,7 +293,7 @@ public class DataAnalysisHome extends AbstractWidgetRenderer
/*******************************************************************************
** Explicit checks distinguish denied sections from empty sections.
*******************************************************************************/
private static boolean canRead(String table)
static boolean canRead(String table)
{
CountInput input = new CountInput(table);
input.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
@@ -281,7 +311,7 @@ public class DataAnalysisHome extends AbstractWidgetRenderer
/*******************************************************************************
** Counts use the viewer's session and explicitly enforce permissions.
*******************************************************************************/
private static int count(String table, QQueryFilter filter) throws QException
static int count(String table, QQueryFilter filter) throws QException
{
CountInput input = new CountInput(table).withFilter(filter);
input.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
@@ -0,0 +1,173 @@
/*
* 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.app;
import java.util.ArrayList;
import java.util.List;
import com.kingsrook.qbits.dataanalysis.DataAnalysisQBitConfig;
import com.kingsrook.qbits.dataanalysis.DataAnalysisRuntime;
import com.kingsrook.qbits.dataanalysis.model.DataAnalysisTables;
import com.kingsrook.qbits.dataanalysis.processes.TableConfigDefaultsProcess;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
/*******************************************************************************
** The Setup card on the Analyses home: each configuration or content table,
** whether it is set up, and the next step. Reads settings through the same
** resolver the analysis runtime uses (code defaults, then rows), so the card
** never reports something the runtime would not do. Counts run as the viewer.
*******************************************************************************/
public final class DataAnalysisSetupStatus
{
/*******************************************************************************
** OK: set up. DEFAULT: working on built-in defaults (nothing required).
** OPTIONAL: a feature that stays off until someone uses it. UNAVAILABLE: the
** viewer cannot read the table.
*******************************************************************************/
public enum State
{
OK, DEFAULT, OPTIONAL, UNAVAILABLE, UNKNOWN
}
/*******************************************************************************
** One line of the card.
*******************************************************************************/
public record Item(String label, String tableName, State state, String detail, String nextStep)
{
}
/*******************************************************************************
** Utility class.
*******************************************************************************/
private DataAnalysisSetupStatus()
{
}
/*******************************************************************************
** The card's lines, in the order a person would set things up.
*******************************************************************************/
public static List<Item> load() throws QException
{
DataAnalysisQBitConfig config = DataAnalysisRuntime.service().getConfigResolver().getDefaults();
List<Item> items = new ArrayList<>();
///////////////////////
// analysis settings //
///////////////////////
List<String> analyzable = TableConfigDefaultsProcess.analyzableTables(QContext.getQInstance(), config);
if(!DataAnalysisHome.canRead(DataAnalysisTables.TABLE_CONFIG))
{
items.add(new Item("Analysis settings", DataAnalysisTables.TABLE_CONFIG, State.UNAVAILABLE, "Unavailable: READ permission required.", null));
}
else
{
int rows = DataAnalysisHome.count(DataAnalysisTables.TABLE_CONFIG, new QQueryFilter());
String defaults = "max rows " + config.getDefaultMaxRows() + ", sample " + config.getDefaultSampleRows() + " above " + config.getDefaultSampleThresholdRows()
+ ", smallest group " + config.getDefaultMinGroupSize() + ", results reused " + config.getDefaultCacheTtlMinutes() + " min";
if(rows == 0)
{
items.add(new Item("Analysis settings", DataAnalysisTables.TABLE_CONFIG, State.DEFAULT,
"All " + analyzable.size() + " analyzable tables are on, with the defaults (" + defaults + ").",
"Run Create rows for analyzable tables to see and edit them, or add a row to change one table."));
}
else
{
items.add(new Item("Analysis settings", DataAnalysisTables.TABLE_CONFIG, rows >= analyzable.size() ? State.OK : State.DEFAULT,
rows + " settings row" + (rows == 1 ? "" : "s") + " for " + analyzable.size() + " analyzable tables; tables without a row use the defaults (" + defaults + ").",
rows >= analyzable.size() ? null : "Run Create rows for analyzable tables to add rows for the rest."));
}
}
/////////////////////////////////////////////////////////////////////
// result cache: the resolved value, exactly what caching will use //
/////////////////////////////////////////////////////////////////////
items.add(cacheItem(analyzable, config));
///////////////////////////////////////////////////
// saved analyses, watches, schedules: optional //
///////////////////////////////////////////////////
items.add(optional("Saved analyses", DataAnalysisTables.SAVED, "No saved analyses yet.", "Save an analysis from the Analyze screen, then star or share it."));
String scheduler = config.getSchedulerName() == null ? " No scheduler is configured, so watches only run with Evaluate now." : "";
items.add(optional("Watches", DataAnalysisTables.WATCH, "No watches yet." + scheduler, "Add a watch on a saved analysis to be alerted to spikes, drops and new categories."));
String schedules = config.getSchedulerName() == null ? " No scheduler is configured, so schedules do not run." : "";
items.add(optional("Schedules", DataAnalysisTables.SCHEDULE, "No schedules yet." + schedules, "Add a schedule on a saved analysis to send it by email."));
return (items);
}
/*******************************************************************************
** One optional feature: set up once it has rows.
*******************************************************************************/
private static Item optional(String label, String table, String emptyDetail, String nextStep) throws QException
{
if(!DataAnalysisHome.canRead(table))
{
return (new Item(label, table, State.UNAVAILABLE, "Unavailable: READ permission required.", null));
}
int rows = DataAnalysisHome.count(table, new QQueryFilter());
return (rows == 0 ? new Item(label, table, State.OPTIONAL, emptyDetail, nextStep) : new Item(label, table, State.OK, rows + " in use.", null));
}
/*******************************************************************************
** The Result cache line, from the minutes every analyzable table actually
** resolves to (settings row, then table defaults, then the global default).
*******************************************************************************/
private static Item cacheItem(List<String> analyzable, DataAnalysisQBitConfig config)
{
java.util.TreeMap<Integer, Integer> tablesPerMinutes = new java.util.TreeMap<>();
for(String table : analyzable)
{
try
{
tablesPerMinutes.merge(DataAnalysisRuntime.service().getConfigResolver().resolve(table).getCacheTtlMinutes(), 1, Integer::sum);
}
catch(QException e)
{
return (new Item("Result cache", DataAnalysisTables.RUN, State.UNKNOWN, "The cache settings of " + table + " could not be read: " + e.getMessage(),
"Fix the settings row for that table (a negative limit is invalid)."));
}
}
if(tablesPerMinutes.isEmpty() || (tablesPerMinutes.size() == 1 && tablesPerMinutes.firstKey() == 0))
{
return (new Item("Result cache", DataAnalysisTables.RUN, State.DEFAULT, "Off: every exact run computes fresh.", "Set cache minutes on Analysis Settings to reuse results."));
}
if(tablesPerMinutes.size() == 1)
{
return (new Item("Result cache", DataAnalysisTables.RUN, State.OK, "Exact results are reused for " + tablesPerMinutes.firstKey() + " minutes.", null));
}
String parts = tablesPerMinutes.entrySet().stream().map(e -> e.getValue() + (e.getValue() == 1 ? " table" : " tables") + (e.getKey() == 0 ? ": off" : ": " + e.getKey() + " min"))
.collect(java.util.stream.Collectors.joining(", "));
return (new Item("Result cache", DataAnalysisTables.RUN, State.OK, "Exact results are reused per table (default " + config.getDefaultCacheTtlMinutes() + " min): " + parts + ".", null));
}
}
@@ -65,6 +65,16 @@ public class EffectiveConfigResolver
/*******************************************************************************
** The code configuration this resolver starts from.
*******************************************************************************/
public DataAnalysisQBitConfig getDefaults()
{
return (defaults);
}
/*******************************************************************************
** Return a detached snapshot so callers cannot change memoized policy.
*******************************************************************************/
@@ -0,0 +1,132 @@
/*
* 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.help;
import java.io.IOException;
import java.io.InputStream;
import java.util.LinkedHashMap;
import java.util.Map;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
import com.kingsrook.qbits.dataanalysis.DataAnalysisQBitConfig;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.help.QHelpContent;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
/*******************************************************************************
** Applies help/qbit-data-analysis.yaml (the QQQ help-bundle format) to the
** Analyses app and its data_analysis_* tables: what each table is for and
** how its rows get created, shown on the table screen and when it is empty.
** STAND-IN(core=QRun-IO/qqq#1198, removeBy=4.1.0-RC.8): replace with HelpBundles.register once the pinned
** QQQ release includes it; the bundle file stays the same.
*******************************************************************************/
public final class DataAnalysisHelp
{
public static final String RESOURCE = "/help/qbit-data-analysis.yaml";
public static final String SLOT = "help";
/*******************************************************************************
** Utility class.
*******************************************************************************/
private DataAnalysisHelp()
{
}
/*******************************************************************************
** The bundle's entries by key, validated (fails startup if malformed).
*******************************************************************************/
public static Map<String, JsonNode> entries() throws QException
{
try(InputStream stream = DataAnalysisHelp.class.getResourceAsStream(RESOURCE))
{
if(stream == null)
{
throw (new IOException("missing resource"));
}
JsonNode bundle = new ObjectMapper(new YAMLFactory()).readTree(stream);
if(!"qbit-data-analysis".equals(bundle.path("bundle").asText()) || !"QBIT".equals(bundle.path("layer").asText()))
{
throw (new IOException("invalid bundle header"));
}
Map<String, JsonNode> entries = new LinkedHashMap<>();
for(JsonNode entry : bundle.path("entries"))
{
String key = entry.path("key").asText();
String summary = entry.path("summary").asText();
if(key.isBlank() || summary.isBlank() || summary.length() > 120 || entry.path("content").asText().isBlank() || entries.put(key, entry) != null)
{
throw (new IOException("invalid or duplicate entry: " + key));
}
}
return (entries);
}
catch(IOException | RuntimeException e)
{
throw (new QException("Could not load " + RESOURCE, e));
}
}
/*******************************************************************************
** Apply the help to the app and every data_analysis_* table in the instance.
** A host's own help on a table is kept (the bundle only fills the slot).
*******************************************************************************/
public static void apply(QInstance instance, DataAnalysisQBitConfig config) throws QException
{
Map<String, JsonNode> entries = entries();
for(QTableMetaData table : instance.getTables().values())
{
JsonNode entry = entries.get("table:" + table.getName() + ";slot:" + SLOT);
if(entry != null && (table.getHelpContent() == null || !table.getHelpContent().containsKey(SLOT)))
{
table.withHelpContent(SLOT, content(entry));
}
}
QAppMetaData app = instance.getApp(config.getAppName());
JsonNode appEntry = entries.get("app:dataAnalysis;slot:" + SLOT);
if(app != null && appEntry != null && (app.getHelpContent() == null || !app.getHelpContent().containsKey(SLOT)))
{
app.withHelpContent(SLOT, content(appEntry));
}
}
/*******************************************************************************
** Summary as the first paragraph, then the content, as markdown.
*******************************************************************************/
static QHelpContent content(JsonNode entry)
{
return (new QHelpContent().withContentAsMarkdown(entry.path("summary").asText().strip() + "\n\n" + entry.path("content").asText().strip()));
}
}
@@ -59,6 +59,7 @@ public class DataAnalysisMetaDataProducer extends QBitComponentMetaDataProducer<
DataAnalysisTables.joins().forEach(output::add);
DataAnalysisTables.childListWidgets().forEach(output::add);
output.add(DataAnalysisTables.userKeyType());
output.add(com.kingsrook.qbits.dataanalysis.processes.TableConfigDefaultsProcess.process());
List<QTableMetaData> tables = DataAnalysisTables.tables(getQBitConfig());
for(QTableMetaData table : tables)
{
@@ -0,0 +1,301 @@
/*
* 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.processes;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
import com.kingsrook.qbits.dataanalysis.DataAnalysisQBitConfig;
import com.kingsrook.qbits.dataanalysis.DataAnalysisRuntime;
import com.kingsrook.qbits.dataanalysis.engine.EffectiveConfig;
import com.kingsrook.qbits.dataanalysis.model.DataAnalysisTables;
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionEnforcement;
import com.kingsrook.qqq.backend.core.actions.permissions.TablePermissionSubType;
import com.kingsrook.qqq.backend.core.actions.permissions.UseTablePermissionCustomPermissionChecker;
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine;
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLineInterface;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
import com.kingsrook.qqq.backend.core.model.actions.processes.Status;
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
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.QInstance;
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.processes.QProcessMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractExtractStep;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractLoadStep;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractTransformStep;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ProcessSummaryProviderInterface;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.StreamedETLWithFrontendProcess;
/*******************************************************************************
** "Create rows for analyzable tables": a table action on Analysis Settings
** that writes one row per analyzable table that has none yet, holding the
** settings that already apply to it (code defaults for that table, then the
** global defaults). It changes no behavior on its own: it makes the defaults
** visible and editable. Existing rows are never touched. Standard streamed
** ETL: Review lists what will be added, Result what was added.
*******************************************************************************/
public final class TableConfigDefaultsProcess
{
public static final String NAME = "data_analysis_table_config_create_rows";
/*******************************************************************************
** Utility class.
*******************************************************************************/
private TableConfigDefaultsProcess()
{
}
/*******************************************************************************
** The process: a table-level action (no records need to be selected). Only
** people who may insert Analysis Settings see it; the steps enforce the table
** permissions again.
*******************************************************************************/
public static QProcessMetaData process()
{
Map<String, Serializable> values = new HashMap<>();
values.put(StreamedETLWithFrontendProcess.FIELD_SOURCE_TABLE, DataAnalysisTables.TABLE_CONFIG);
values.put(StreamedETLWithFrontendProcess.FIELD_DESTINATION_TABLE, DataAnalysisTables.TABLE_CONFIG);
values.put(StreamedETLWithFrontendProcess.FIELD_SUPPORTS_FULL_VALIDATION, false);
values.put(StreamedETLWithFrontendProcess.FIELD_PREVIEW_MESSAGE,
"These rows will be added: one per analyzable table that has none, holding the settings that already apply. Existing rows are not changed.");
QProcessMetaData process = StreamedETLWithFrontendProcess.defineProcessMetaData(Extract.class, Transform.class, Load.class, values);
process.setName(NAME);
process.setLabel("Create rows for analyzable tables");
process.setIcon(new QIcon("playlist_add"));
process.setTableName(DataAnalysisTables.TABLE_CONFIG);
process.setMinInputRecords(0);
process.setPermissionRules(new QPermissionRules().withLevel(PermissionLevel.NOT_PROTECTED)
.withCustomPermissionChecker(UseTablePermissionCustomPermissionChecker.build(DataAnalysisTables.TABLE_CONFIG, TablePermissionSubType.INSERT)));
return (process);
}
/*******************************************************************************
** The tables analysis may be used on: everything but the qbit's own tables,
** limited by the includeTables and excludeTables configuration. The same test
** the "analyze" table permission uses.
*******************************************************************************/
public static List<String> analyzableTables(QInstance instance, DataAnalysisQBitConfig config)
{
return (instance.getTables().values().stream().map(QTableMetaData::getName)
.filter(name -> !name.startsWith(DataAnalysisQBitConfig.TABLE_NAME_PREFIX))
.filter(name -> config.getIncludeTables().isEmpty() || config.getIncludeTables().contains(name))
.filter(name -> !config.getExcludeTables().contains(name)).sorted().toList());
}
/*******************************************************************************
** The rows that are missing: one per analyzable table without a row, with
** the effective settings (the same values EffectiveConfigResolver produces).
*******************************************************************************/
public static List<QRecord> missingRows(QInstance instance, DataAnalysisQBitConfig config) throws QException
{
Set<String> existing = existingTables();
DataAnalysisRuntime.service().getConfigResolver().invalidate();
List<QRecord> rows = new ArrayList<>();
for(String table : analyzableTables(instance, config))
{
if(!existing.contains(table))
{
EffectiveConfig c = DataAnalysisRuntime.service().getConfigResolver().resolve(table);
rows.add(new QRecord().withValue("table_name", table).withValue("enabled", c.getEnabled()).withValue("max_rows", c.getMaxRows())
.withValue("sample_threshold_rows", c.getSampleThresholdRows()).withValue("sample_rows", c.getSampleRows())
.withValue("min_group_size", c.getMinGroupSize()).withValue("cache_ttl_minutes", c.getCacheTtlMinutes())
.withValue("analyzable_fields", c.getAnalyzableFields().isEmpty() ? null : String.join(",", new TreeSet<>(c.getAnalyzableFields())))
.withValue("excluded_fields", c.getExcludedFields().isEmpty() ? null : String.join(",", new TreeSet<>(c.getExcludedFields()))));
}
}
return (rows);
}
/*******************************************************************************
** Table names that already have a settings row (read as the signed-in user).
*******************************************************************************/
private static Set<String> existingTables() throws QException
{
QueryInput input = new QueryInput(DataAnalysisTables.TABLE_CONFIG).withFilter(new QQueryFilter());
input.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
Set<String> existing = new HashSet<>();
new QueryAction().execute(input).getRecords().forEach(row -> existing.add(row.getValueString("table_name")));
return (existing);
}
/*******************************************************************************
** Extract: the missing rows, computed (no query of the selected records).
*******************************************************************************/
public static class Extract extends AbstractExtractStep
{
/*******************************************************************************
**
*******************************************************************************/
@Override
public Integer doCount(RunBackendStepInput input) throws QException
{
return (missingRows(QContext.getQInstance(), DataAnalysisRuntime.service().getConfigResolver().getDefaults()).size());
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
for(QRecord row : missingRows(QContext.getQInstance(), DataAnalysisRuntime.service().getConfigResolver().getDefaults()))
{
getRecordPipe().addRecord(row);
}
}
}
/*******************************************************************************
** Transform: lists what will be added for the review screen.
*******************************************************************************/
public static class Transform extends AbstractTransformStep implements ProcessSummaryProviderInterface
{
private final ProcessSummaryLine added = line(Status.OK, "table will get a settings row", "tables will get a settings row",
"table got a settings row", "tables got a settings row");
/*******************************************************************************
**
*******************************************************************************/
@Override
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
for(QRecord record : input.getRecords())
{
added.incrementCount();
output.getRecords().add(record);
}
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
{
ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
added.addSelfToListIfAnyCount(lines);
if(lines.isEmpty())
{
lines.add(new ProcessSummaryLine(Status.INFO, null, "Every analyzable table already has a settings row."));
}
return (lines);
}
}
/*******************************************************************************
** Load: insert through the standard action with the user's own permissions.
*******************************************************************************/
public static class Load extends AbstractLoadStep implements ProcessSummaryProviderInterface
{
private final ProcessSummaryLine created = line(Status.OK, "row was added", "rows were added", "row was added", "rows were added");
/*******************************************************************************
**
*******************************************************************************/
@Override
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
InsertInput insert = new InsertInput(DataAnalysisTables.TABLE_CONFIG).withRecords(input.getRecords());
insert.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
for(QRecord record : new InsertAction().execute(insert).getRecords())
{
if(record.getErrors() != null && !record.getErrors().isEmpty())
{
throw (new QException("Could not add analysis settings: " + record.getErrors()));
}
created.incrementCount();
output.getRecords().add(record);
}
DataAnalysisRuntime.service().getConfigResolver().invalidate();
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
{
ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
created.addSelfToListIfAnyCount(lines);
return (lines);
}
}
/*******************************************************************************
** A summary line with its four messages.
*******************************************************************************/
private static ProcessSummaryLine line(Status status, String singularFuture, String pluralFuture, String singularPast, String pluralPast)
{
ProcessSummaryLine line = new ProcessSummaryLine(status, 0, null);
line.setSingularFutureMessage(singularFuture);
line.setPluralFutureMessage(pluralFuture);
line.setSingularPastMessage(singularPast);
line.setPluralPastMessage(pluralPast);
return (line);
}
}
@@ -0,0 +1,43 @@
bundle: qbit-data-analysis
version: 1
layer: QBIT
# Help for the Analyses app and every data_analysis_* table, in the QQQ help-bundle
# format (qqq#1198). Each table entry says what the table is for and how its rows get
# created, so an empty table explains itself. DataAnalysisHelp applies it until the core
# HelpBundles registry ships in a QQQ release (STAND-IN(core=QRun-IO/qqq#1198, removeBy=4.1.0-RC.8)).
# Summaries stay under 120 characters (DataAnalysisTablesGuardrailTest).
entries:
- key: app:dataAnalysis;slot:help
summary: "Profile, pivot, compare and watch any table, and tune how analysis behaves per table."
content: |
The Setup card on the home lists each part, its state and the next step. Analysis works on every analyzable table from the start with built-in limits, so nothing is required. Open Analysis Settings and run Create rows for analyzable tables to see those limits as rows you can edit per table.
- key: table:data_analysis_table_config;slot:help
summary: "Per-table analysis limits: on or off, row limits, sampling, smallest group, cache minutes and field lists."
content: |
**How rows get created:** run the table action Create rows for analyzable tables once. It adds one row per table holding the settings that already apply, so nothing changes until you edit a row. Or add a row with New for just one table. A table without a row uses the defaults from the configuration, so an empty table means every table is on with the defaults. Cache minutes controls how long an exact result is reused (0 turns caching off). Smallest group hides groups with fewer records.
- key: table:data_analysis_saved;slot:help
summary: "Analyses people saved: the table, filter and view they want to come back to."
content: |
**How rows get created:** a person saves an analysis from the Analyze screen on any table. Each saved analysis belongs to its owner; stars, schedules and watches hang off it. Empty means nobody has saved one yet.
- key: table:data_analysis_saved_star;slot:help
summary: "Which saved analyses each person starred."
content: |
**How rows get created:** a person stars a saved analysis, and the row is theirs. Starred analyses show on the Analyses home. Empty means nothing is starred yet.
- key: table:data_analysis_schedule;slot:help
summary: "Schedules that run a saved analysis daily, weekly or monthly and send the result."
content: |
**How rows get created:** a person adds a schedule on a saved analysis (the Schedules list on the saved analysis). Schedules run only when the host configures a scheduler for the qbit. Empty means no analysis is scheduled.
- key: table:data_analysis_watch;slot:help
summary: "Watches that alert when a saved analysis spikes, drops, goes out of range or gets a new category."
content: |
**How rows get created:** a person adds a watch on a saved analysis (the Watches list on the saved analysis). Enabled watches are evaluated every 15 minutes when a scheduler is configured; Evaluate now checks one by hand. A firing watch raises an alert. Empty means nothing is being watched.
- key: table:data_analysis_run;slot:help
summary: "Each exact analysis run: who, what, when and the stored result that is reused as a cache."
content: |
**How rows get created:** automatically when someone starts an exact analysis. A finished run is reused for the table's cache minutes (Analysis Settings). Empty means nobody has run an exact analysis yet.
@@ -53,7 +53,15 @@ class DataAnalysisHomeTest
{
assertThat(widget.getType()).isIn("multiStatistics", "table");
var data = new DataAnalysisHome().render(new RenderWidgetInput().withWidgetMetaData(widget)).getWidgetData();
if(data instanceof TableData table)
if(data instanceof TableData table && DataAnalysisHome.SETUP.equals(widget.getName()))
{
//////////////////////////////////////////////////////////////////////////////
// the Setup card is never empty: it says what is set up and what is next //
//////////////////////////////////////////////////////////////////////////////
assertThat(table.getColumns()).hasSizeLessThanOrEqualTo(4);
assertThat(table.getRows()).hasSize(5);
}
else if(data instanceof TableData table)
{
assertThat(table.getColumns()).hasSizeLessThanOrEqualTo(4);
assertThat(table.getRows()).isEmpty();
@@ -69,7 +69,7 @@ class DataAnalysisMetadataTest
assertThat(instance.getTables()).containsOnlyKeys(DataAnalysisTables.ALL);
assertThat(instance.getPossibleValueSources().keySet().stream().filter(name -> name.startsWith("data_analysis_"))).hasSize(16);
assertThat(instance.getJoins()).hasSize(3);
assertThat(instance.getWidgets()).hasSize(8).containsKey(DataAnalysisHome.HOME_WIDGET);
assertThat(instance.getWidgets()).hasSize(9).containsKey(DataAnalysisHome.HOME_WIDGET);
assertThat(instance.getApp("dataAnalysis").getSections().getFirst().getProcesses()).isNullOrEmpty();
assertThat(instance.getApp("dataAnalysis").getLabel()).isEqualTo("Analyses");
assertThat(instance.getApp("dataAnalysis").getSections().getFirst().getTables()).containsExactlyElementsOf(DataAnalysisTables.NAV_TABLES);
@@ -0,0 +1,238 @@
/*
* 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.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Stream;
import com.kingsrook.qbits.dataanalysis.help.DataAnalysisHelp;
import com.kingsrook.qbits.dataanalysis.model.DataAnalysisTables;
import com.kingsrook.qbits.dataanalysis.processes.TableConfigDefaultsProcess;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.help.QHelpContent;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** Guardrail (owner requirement 2026-10-14: "all tables should be populated and
** the qbit should be fully using them"). For every data_analysis_* table:
** - runtime code (not just its model, metadata or home screen) reads or writes
** it, so no table exists only on paper;
** - it is either seeded (analysis settings, by the Create rows action, from the
** configuration at run time) or listed here with why it starts empty;
** - it has bundle help that says what it is for and how its rows get created.
** A new table fails this test until it is wired, seeded or explained.
*******************************************************************************/
class DataAnalysisTablesGuardrailTest
{
private static final Path MAIN = Path.of("src", "main", "java", "com", "kingsrook", "qbits", "dataanalysis");
/*******************************************************************************
** Seeded from the code configuration by "Create rows for analyzable tables".
*******************************************************************************/
static final List<String> SEEDED = List.of(DataAnalysisTables.TABLE_CONFIG);
/*******************************************************************************
** Tables that legitimately start empty, and where their rows come from.
*******************************************************************************/
static final Map<String, String> STARTS_EMPTY = Map.of(
DataAnalysisTables.SAVED, "user content: a person saves an analysis",
DataAnalysisTables.SAVED_STAR, "user content: a person stars a saved analysis",
DataAnalysisTables.SCHEDULE, "user content: a person schedules a saved analysis",
DataAnalysisTables.WATCH, "user content: a person adds a watch to a saved analysis",
DataAnalysisTables.RUN, "runtime: someone starts an exact analysis (it is also the result cache)");
private QInstance instance;
/*******************************************************************************
**
*******************************************************************************/
@BeforeEach
void setUp() throws Exception
{
instance = TestSupport.instance();
}
/*******************************************************************************
**
*******************************************************************************/
@AfterEach
void tearDown()
{
QContext.clear();
}
/*******************************************************************************
** Every table is used by runtime code.
*******************************************************************************/
@Test
void everyTableIsUsedAtRuntime() throws Exception
{
Map<String, String> constantByTable = constantByTable();
List<String> runtimeSource;
try(Stream<Path> files = Files.walk(MAIN))
{
runtimeSource = files.filter(p -> p.toString().endsWith(".java"))
.filter(p -> !p.startsWith(MAIN.resolve("model")) && !p.startsWith(MAIN.resolve("metadata")) && !p.startsWith(MAIN.resolve("app")) && !p.startsWith(MAIN.resolve("help"))
&& !p.getFileName().toString().equals("DataAnalysisSchema.java"))
.map(DataAnalysisTablesGuardrailTest::read).toList();
}
for(String table : analysisTables())
{
String constant = constantByTable.get(table);
assertThat(constant).as("constant for " + table).isNotNull();
Pattern use = Pattern.compile("DataAnalysisTables\\." + constant + "\\b");
assertThat(runtimeSource.stream().anyMatch(source -> use.matcher(source).find()))
.as(table + " (" + constant + ") must be read or written by runtime code, not only declared").isTrue();
}
}
/*******************************************************************************
** Every table is seeded or explained.
*******************************************************************************/
@Test
void everyTableIsSeededOrExplained() throws Exception
{
for(String table : analysisTables())
{
boolean seeded = SEEDED.contains(table);
assertThat(seeded || STARTS_EMPTY.containsKey(table)).as(table + " must be seeded or listed in STARTS_EMPTY").isTrue();
assertThat(seeded && STARTS_EMPTY.containsKey(table)).as(table + " cannot be both seeded and listed as starting empty").isFalse();
}
assertThat(STARTS_EMPTY.keySet()).allMatch(table -> instance.getTable(table) != null, "STARTS_EMPTY names only real tables");
///////////////////////////////////////////////////////////////////////////////
// the seeded table has a real action that writes its rows, named in its help //
///////////////////////////////////////////////////////////////////////////////
assertThat(instance.getProcess(TableConfigDefaultsProcess.NAME).getTableName()).isEqualTo(DataAnalysisTables.TABLE_CONFIG);
assertThat(instance.getProcess(TableConfigDefaultsProcess.NAME).getMinInputRecords()).isEqualTo(0);
assertThat(help(DataAnalysisTables.TABLE_CONFIG).getFirst().getContent()).contains(instance.getProcess(TableConfigDefaultsProcess.NAME).getLabel());
}
/*******************************************************************************
** Every table and the app have bundle help that says how rows get created.
*******************************************************************************/
@Test
void everyTableHasEmptyStateHelp() throws Exception
{
assertThat(DataAnalysisHelp.entries()).allSatisfy((key, entry) -> assertThat(entry.path("summary").asText().length()).as(key).isLessThanOrEqualTo(120));
for(String table : analysisTables())
{
List<QHelpContent> help = help(table);
assertThat(help).as(table + " table help").isNotEmpty();
assertThat(help.getFirst().getContent()).as(table + " help says how rows get created").contains("How rows get created");
}
assertThat(instance.getApp("dataAnalysis").getHelpContent()).containsKey(DataAnalysisHelp.SLOT);
assertThat(DataAnalysisHelp.entries().keySet()).allMatch(key -> !key.startsWith("table:") || instance.getTable(key.substring(6, key.indexOf(';'))) != null,
"help names only real tables");
}
/*******************************************************************************
** A host's own help on a table is kept.
*******************************************************************************/
@Test
void hostHelpIsNotOverridden() throws Exception
{
instance.getTable(DataAnalysisTables.SAVED).getHelpContent().put(DataAnalysisHelp.SLOT, List.of(new QHelpContent().withContentAsMarkdown("host text")));
DataAnalysisHelp.apply(instance, new DataAnalysisQBitConfig());
assertThat(help(DataAnalysisTables.SAVED).getFirst().getContent()).isEqualTo("host text");
}
/*******************************************************************************
**
*******************************************************************************/
private List<QHelpContent> help(String table)
{
return (instance.getTable(table).getHelpContent() == null ? null : instance.getTable(table).getHelpContent().get(DataAnalysisHelp.SLOT));
}
/*******************************************************************************
**
*******************************************************************************/
private List<String> analysisTables()
{
List<String> tables = instance.getTables().values().stream().map(QTableMetaData::getName).filter(n -> n.startsWith("data_analysis_")).sorted().toList();
assertThat(tables).containsExactlyInAnyOrderElementsOf(DataAnalysisTables.ALL);
return (tables);
}
/*******************************************************************************
** DataAnalysisTables declares X = "data_analysis_x".
*******************************************************************************/
private static Map<String, String> constantByTable() throws Exception
{
Map<String, String> rs = new HashMap<>();
Matcher m = Pattern.compile("String\\s+([A-Z_]+)\\s*=\\s*\"(data_analysis_[a-z_]+)\"").matcher(read(MAIN.resolve("model").resolve("DataAnalysisTables.java")));
while(m.find())
{
rs.put(m.group(2), m.group(1));
}
return (rs);
}
/*******************************************************************************
**
*******************************************************************************/
private static String read(Path path)
{
try
{
return (Files.readString(path, StandardCharsets.UTF_8));
}
catch(Exception e)
{
throw (new IllegalStateException(e));
}
}
}
@@ -0,0 +1,292 @@
/*
* 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.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Set;
import com.kingsrook.qbits.dataanalysis.app.DataAnalysisHome;
import com.kingsrook.qbits.dataanalysis.app.DataAnalysisSetupStatus;
import com.kingsrook.qbits.dataanalysis.engine.EffectiveConfig;
import com.kingsrook.qbits.dataanalysis.model.DataAnalysisTables;
import com.kingsrook.qbits.dataanalysis.processes.TableConfigDefaultsProcess;
import com.kingsrook.qbits.dataanalysis.result.ColumnProfileRequest;
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionEnforcement;
import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessOutput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.TableData;
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.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.StreamedETLWithFrontendProcess;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** "Create rows for analyzable tables" writes the settings that already apply
** and changes nothing; the rows then drive the runtime (including the result
** cache minutes); and the Setup card reports it all. Runs on the memory backend
** with the instance's own runtime, exactly as a host would.
*******************************************************************************/
class TableConfigDefaultsTest extends EngineFixture
{
/*******************************************************************************
** The settings every analyzable table resolves to, as one comparable string.
*******************************************************************************/
private String effective(String table) throws Exception
{
EffectiveConfig c = DataAnalysisRuntime.service().getConfigResolver().resolve(table);
return (c.getEnabled() + " " + c.getMaxRows() + " " + c.getSampleRows() + " " + c.getSampleThresholdRows() + " " + c.getMinGroupSize() + " "
+ c.getCacheTtlMinutes() + " " + new java.util.TreeSet<>(c.getAnalyzableFields()) + " " + new java.util.TreeSet<>(c.getExcludedFields()));
}
/*******************************************************************************
**
*******************************************************************************/
@SuppressWarnings("unchecked")
private List<ProcessSummaryLine> create() throws Exception
{
RunProcessInput input = new RunProcessInput();
input.setProcessName(TableConfigDefaultsProcess.NAME);
input.setFrontendStepBehavior(RunProcessInput.FrontendStepBehavior.SKIP);
RunProcessOutput output = new RunProcessAction().execute(input);
return ((List<ProcessSummaryLine>) output.getValues().get(StreamedETLWithFrontendProcess.FIELD_PROCESS_SUMMARY));
}
/*******************************************************************************
**
*******************************************************************************/
private List<QRecord> configRows() throws Exception
{
QueryInput input = new QueryInput(DataAnalysisTables.TABLE_CONFIG).withFilter(new QQueryFilter());
input.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
return (new QueryAction().execute(input).getRecords());
}
/*******************************************************************************
** Rows hold the effective settings (table defaults over global defaults),
** excluded tables get none, and every table resolves exactly as before.
*******************************************************************************/
@Test
void rowsRestateTheEffectiveSettingsAndChangeNothing() throws Exception
{
DataAnalysisQBitConfig runtime = DataAnalysisRuntime.service().getConfigResolver().getDefaults();
runtime.withDefaultMaxRows(500).withTableDefaults(Map.of("orders", new TableAnalysisDefaults().withMaxRows(100).withSampleRows(8).withMinGroupSize(2)
.withAnalyzableFields(Set.of("amount", "note")).withExcludedFields(Set.of("note"))));
DataAnalysisRuntime.service().getConfigResolver().invalidate();
String ordersBefore = effective("orders");
String customerBefore = effective("customer");
assertThat(ordersBefore).startsWith("true 100 8 ");
List<ProcessSummaryLine> summary = create();
assertThat(summary).isNotEmpty();
List<QRecord> rows = configRows();
assertThat(rows).extracting(row -> row.getValueString("table_name")).containsExactlyInAnyOrder("customer", "orders");
QRecord orders = rows.stream().filter(row -> "orders".equals(row.getValueString("table_name"))).findFirst().orElseThrow();
assertThat(orders.getValueInteger("max_rows")).isEqualTo(100);
assertThat(orders.getValueInteger("sample_rows")).isEqualTo(8);
assertThat(orders.getValueInteger("min_group_size")).isEqualTo(2);
assertThat(orders.getValueInteger("cache_ttl_minutes")).isEqualTo(60);
assertThat(orders.getValueString("analyzable_fields")).isEqualTo("amount,note");
assertThat(orders.getValueString("excluded_fields")).isEqualTo("note");
QRecord customer = rows.stream().filter(row -> "customer".equals(row.getValueString("table_name"))).findFirst().orElseThrow();
assertThat(customer.getValueInteger("max_rows")).isEqualTo(500);
assertThat(customer.getValueString("analyzable_fields")).isNull();
assertThat(customer.getValueBoolean("enabled")).isTrue();
DataAnalysisRuntime.service().getConfigResolver().invalidate();
assertThat(effective("orders")).isEqualTo(ordersBefore);
assertThat(effective("customer")).isEqualTo(customerBefore);
}
/*******************************************************************************
** Tables excluded by configuration get no row; a table added later gets one on
** the next run; existing rows are never touched and an edit drives the runtime.
*******************************************************************************/
@Test
void insertOnlyExcludedTablesSkippedEditsLive() throws Exception
{
DataAnalysisRuntime.service().getConfigResolver().getDefaults().withExcludeTables(Set.of("customer"));
create();
assertThat(configRows()).extracting(row -> row.getValueString("table_name")).containsExactly("orders");
QRecord orders = configRows().getFirst();
UpdateInput update = new UpdateInput(DataAnalysisTables.TABLE_CONFIG).withRecords(List.of(new QRecord().withValue("id", orders.getValue("id")).withValue("max_rows", 7)));
update.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
new UpdateAction().execute(update);
DataAnalysisRuntime.service().getConfigResolver().invalidate();
assertThat(effective("orders")).startsWith("true 7 ");
instance.addTable(new QTableMetaData().withName("extra").withBackendName("memory").withPrimaryKeyField("id").withField(new QFieldMetaData("id", QFieldType.INTEGER)));
create();
assertThat(configRows()).extracting(row -> row.getValueString("table_name")).containsExactlyInAnyOrder("orders", "extra");
assertThat(configRows().stream().filter(row -> "orders".equals(row.getValueString("table_name"))).findFirst().orElseThrow().getValueInteger("max_rows")).isEqualTo(7);
assertThat(create()).singleElement().satisfies(line -> assertThat(line.getMessage()).contains("already has a settings row"));
assertThat(configRows()).hasSize(2);
}
/*******************************************************************************
** The result cache uses the table's settings row, not only the global default:
** a row with more minutes keeps an aged run as a cache hit (the global 60
** minutes would miss it), a row with 0 turns caching off, and a row with 1
** minute misses a run that is two minutes old.
*******************************************************************************/
@Test
void settingsRowCacheMinutesChangeCaching() throws Exception
{
String id = service.startExactRun(column());
age(id, 7200);
assertThat(service.startExactRun(column())).as("default 60 minutes: a two hour old run is not reused").isNotEqualTo(id);
TestSupport.insert(DataAnalysisTables.TABLE_CONFIG, new QRecord().withValue("table_name", "orders").withValue("cache_ttl_minutes", 240));
service.getConfigResolver().invalidate();
String fresh = service.startExactRun(column());
age(fresh, 7200);
assertThat(service.startExactRun(column())).as("row with 240 minutes: the two hour old run is reused").isEqualTo(fresh);
setTtl(1);
age(fresh, 120);
assertThat(service.startExactRun(column())).as("row with 1 minute: a two minute old run is not reused").isNotEqualTo(fresh);
setTtl(0);
String zero = service.startExactRun(column());
assertThat(service.startExactRun(column())).as("row with 0 minutes: never reused").isNotEqualTo(zero);
}
/*******************************************************************************
**
*******************************************************************************/
private ColumnProfileRequest column()
{
return (new ColumnProfileRequest().withTableName("orders").withFieldName("amount"));
}
/*******************************************************************************
** Make a stored run look older.
*******************************************************************************/
private void age(String id, int seconds) throws Exception
{
UpdateInput update = new UpdateInput(DataAnalysisTables.RUN)
.withRecords(List.of(new QRecord().withValue("id", Integer.valueOf(id)).withValue("finished_at", Instant.now().minusSeconds(seconds))));
update.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
new UpdateAction().execute(update);
}
/*******************************************************************************
** Edit the orders settings row's cache minutes.
*******************************************************************************/
private void setTtl(int minutes) throws Exception
{
QRecord row = configRows().stream().filter(r -> "orders".equals(r.getValueString("table_name"))).findFirst().orElseThrow();
UpdateInput update = new UpdateInput(DataAnalysisTables.TABLE_CONFIG).withRecords(List.of(new QRecord().withValue("id", row.getValue("id")).withValue("cache_ttl_minutes", minutes)));
update.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
new UpdateAction().execute(update);
service.getConfigResolver().invalidate();
}
/*******************************************************************************
** The Result cache line says what each table resolves to, including mixed
** settings, and a settings error is shown as needing attention, not as "on".
*******************************************************************************/
@Test
void resultCacheLineIsAccurate() throws Exception
{
DataAnalysisRuntime.service().getConfigResolver().getDefaults().withDefaultCacheTtlMinutes(0);
DataAnalysisRuntime.service().getConfigResolver().invalidate();
assertThat(DataAnalysisSetupStatus.load().get(1).detail()).startsWith("Off");
TestSupport.insert(DataAnalysisTables.TABLE_CONFIG, new QRecord().withValue("table_name", "orders").withValue("cache_ttl_minutes", 30));
DataAnalysisRuntime.service().getConfigResolver().invalidate();
DataAnalysisSetupStatus.Item mixed = DataAnalysisSetupStatus.load().get(1);
assertThat(mixed.state()).isEqualTo(DataAnalysisSetupStatus.State.OK);
assertThat(mixed.detail()).contains("default 0 min").contains("1 table: off").contains("1 table: 30 min");
TestSupport.insert(DataAnalysisTables.TABLE_CONFIG, new QRecord().withValue("table_name", "customer").withValue("cache_ttl_minutes", -5));
DataAnalysisRuntime.service().getConfigResolver().invalidate();
DataAnalysisSetupStatus.Item broken = DataAnalysisSetupStatus.load().get(1);
assertThat(broken.state()).isEqualTo(DataAnalysisSetupStatus.State.UNKNOWN);
assertThat(broken.detail()).contains("could not be read");
}
/*******************************************************************************
** The Setup card: defaults before rows, set up after, from the runtime's own
** resolution; optional features say what is missing.
*******************************************************************************/
@Test
void setupCard() throws Exception
{
List<DataAnalysisSetupStatus.Item> before = DataAnalysisSetupStatus.load();
assertThat(before).extracting(DataAnalysisSetupStatus.Item::label).containsExactly("Analysis settings", "Result cache", "Saved analyses", "Watches", "Schedules");
assertThat(before.get(0).state()).isEqualTo(DataAnalysisSetupStatus.State.DEFAULT);
assertThat(before.get(0).detail()).contains("All 2 analyzable tables are on");
assertThat(before.get(0).nextStep()).contains("Create rows for analyzable tables");
assertThat(before.get(1).state()).isEqualTo(DataAnalysisSetupStatus.State.OK);
assertThat(before.get(1).detail()).contains("60 minutes");
assertThat(before.get(3).state()).isEqualTo(DataAnalysisSetupStatus.State.OPTIONAL);
assertThat(before.get(3).detail()).contains("No scheduler is configured");
create();
List<DataAnalysisSetupStatus.Item> after = DataAnalysisSetupStatus.load();
assertThat(after.get(0).state()).isEqualTo(DataAnalysisSetupStatus.State.OK);
assertThat(after.get(0).detail()).contains("2 settings rows for 2 analyzable tables");
TestSupport.insert(DataAnalysisTables.SAVED, TestSupport.saved("admin", false));
assertThat(DataAnalysisSetupStatus.load().get(2).state()).isEqualTo(DataAnalysisSetupStatus.State.OK);
TableData card = DataAnalysisHome.setup();
assertThat(card.getRows()).hasSize(5);
assertThat(card.getRows().getFirst().get("c1")).isEqualTo("Set up");
assertThat(String.valueOf(card.getRows().getFirst().get("c0"))).contains("<a href=");
}
}