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>
213 lines
13 KiB
Java
213 lines
13 KiB
Java
/*
|
|
* QQQ - Low-code Application Framework for Engineers.
|
|
* Copyright (C) 2021-2025. Kingsrook, LLC
|
|
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
|
* contact@kingsrook.com
|
|
* https://github.com/Kingsrook/
|
|
*
|
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
* you may not use this file except in compliance with the License.
|
|
* You may obtain a copy of the License at
|
|
*
|
|
* https://www.apache.org/licenses/LICENSE-2.0
|
|
*
|
|
* Unless required by applicable law or agreed to in writing, software
|
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
* See the License for the specific language governing permissions and
|
|
* limitations under the License.
|
|
*/
|
|
|
|
package com.kingsrook.qbits.dataanalysis;
|
|
|
|
import java.io.ByteArrayInputStream;
|
|
import java.nio.charset.StandardCharsets;
|
|
import java.util.List;
|
|
import java.util.Map;
|
|
import com.kingsrook.qbits.dataanalysis.app.SavedAnalysisWidget;
|
|
import com.kingsrook.qbits.dataanalysis.model.DataAnalysisTables;
|
|
import com.kingsrook.qbits.dataanalysis.saved.AnalysisExport;
|
|
import com.kingsrook.qbits.dataanalysis.saved.SavedAnalyses;
|
|
import com.kingsrook.qbits.dataanalysis.saved.SavedDefinition;
|
|
import com.kingsrook.qbits.dataanalysis.saved.SqlHandoff;
|
|
import com.kingsrook.qbits.dataanalysis.watch.WatchRecords;
|
|
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
|
|
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
|
|
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.insert.InsertInput;
|
|
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
|
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
|
|
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.ChartData;
|
|
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
|
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
|
|
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
|
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
|
|
import org.apache.poi.ss.usermodel.WorkbookFactory;
|
|
import org.junit.jupiter.api.Test;
|
|
import static org.assertj.core.api.Assertions.assertThat;
|
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
|
|
|
class SavedAnalysesTest extends EngineFixture
|
|
{
|
|
static QSession viewer(String id, int customer)
|
|
{
|
|
return (TestSupport.user(id).withPermissions("data_analysis.saved.read", "data_analysis.saved.insert", "data_analysis.saved.edit", "data_analysis.saved.delete",
|
|
"orders.read", "orders.analyze", "customer.read").withSecurityKeyValue("customer_scope", customer));
|
|
}
|
|
|
|
static QRecord definition(String owner, boolean shared, String kind, Map<String, Object> body)
|
|
{
|
|
return (TestSupport.saved(owner, shared).withValue("table_name", "orders").withValue("kind", kind).withValue("definition_json", JsonUtils.toJson(body)));
|
|
}
|
|
|
|
@Test
|
|
void sharedReadOwnerWriteAndFieldReduction() throws Exception
|
|
{
|
|
var saved = TestSupport.insert(DataAnalysisTables.SAVED, definition("alice", true, "TABLE_PROFILE", Map.of("fields", List.of("amount", "secret"))));
|
|
var privateRow = TestSupport.insert(DataAnalysisTables.SAVED, definition("alice", false, "COLUMN_PROFILE", Map.of("fieldName", "amount")));
|
|
QContext.init(instance, viewer("bob", 2));
|
|
var api = new SavedAnalyses(service);
|
|
assertThat(api.list()).extracting(SavedAnalyses.Entry::id).contains(saved.getValue("id")).doesNotContain(privateRow.getValue("id"));
|
|
var result = api.run(saved.getValue("id"));
|
|
assertThat(result.kind()).isEqualTo("TABLE_PROFILE");
|
|
assertThat(result.warnings()).containsExactly(SavedDefinition.FIELD_WARNING);
|
|
assertThat(result.tableProfile().getFields()).extracting(f -> f.getField()).containsExactly("amount");
|
|
assertThat(((Number) result.tableProfile().getRowCount().getValue()).intValue()).isEqualTo(4);
|
|
assertThatThrownBy(() -> api.run(privateRow.getValue("id"))).isInstanceOf(QException.class);
|
|
var patch = new QRecord().withValue("id", saved.getValue("id")).withValue("name", "Stolen").withValue("read_owner_user_id", "bob");
|
|
assertThat(new UpdateAction().execute(new UpdateInput(DataAnalysisTables.SAVED).withRecords(List.of(patch))).getRecords().getFirst().getErrors()).isNotEmpty();
|
|
var deleted = new com.kingsrook.qqq.backend.core.actions.tables.DeleteAction().execute(
|
|
new com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput(DataAnalysisTables.SAVED).withPrimaryKeys(List.of(saved.getValue("id"))));
|
|
assertThat(deleted.getDeletedRecordCount()).isZero();
|
|
QContext.init(instance, viewer("alice", 1));
|
|
var updated = new UpdateAction().execute(new UpdateInput(DataAnalysisTables.SAVED).withRecords(List.of(new QRecord().withValue("id", saved.getValue("id")).withValue("shared", false)))).getRecords().getFirst();
|
|
assertThat(updated.getErrors()).isNullOrEmpty();
|
|
QContext.init(instance, viewer("bob", 2));
|
|
assertThat(api.list()).isEmpty();
|
|
}
|
|
|
|
/*******************************************************************************
|
|
** A UI insert without an owner belongs to the inserting user; naming someone
|
|
** else is still refused for a non-administrator.
|
|
*******************************************************************************/
|
|
@Test
|
|
void insertWithoutOwnerBelongsToTheCurrentUser() throws Exception
|
|
{
|
|
QContext.init(instance, viewer("bob", 2));
|
|
QRecord row = definition("bob", false, "COLUMN_PROFILE", Map.of("fieldName", "amount"));
|
|
row.removeValue("owner_user_id");
|
|
QRecord inserted = new InsertAction().execute(new InsertInput(DataAnalysisTables.SAVED).withRecords(List.of(row))).getRecords().getFirst();
|
|
assertThat(inserted.getErrors()).isEmpty();
|
|
assertThat(inserted.getValueString("owner_user_id")).isEqualTo("bob");
|
|
QRecord forged = definition("alice", false, "COLUMN_PROFILE", Map.of("fieldName", "amount"));
|
|
assertThat(new InsertAction().execute(new InsertInput(DataAnalysisTables.SAVED).withRecords(List.of(forged))).getRecords().getFirst().getErrors()).isNotEmpty();
|
|
}
|
|
|
|
@Test
|
|
void starsAreIdempotentAndPersonal() throws Exception
|
|
{
|
|
var saved = TestSupport.insert(DataAnalysisTables.SAVED, definition("alice", true, "COLUMN_PROFILE", Map.of("fieldName", "amount")));
|
|
QContext.init(instance, viewer("bob", 2));
|
|
var api = new SavedAnalyses(service);
|
|
assertThat(api.star(saved.getValue("id"), true)).isTrue();
|
|
api.star(saved.getValue("id"), true);
|
|
assertThat(api.list().getFirst().starred()).isTrue();
|
|
QContext.init(instance, viewer("alice", 1));
|
|
assertThat(api.list().getFirst().starred()).isFalse();
|
|
QContext.init(instance, viewer("bob", 2));
|
|
assertThat(api.star(saved.getValue("id"), false)).isFalse();
|
|
api.star(saved.getValue("id"), false);
|
|
assertThat(api.list().getFirst().starred()).isFalse();
|
|
}
|
|
|
|
@Test
|
|
void malformedDefinitionsAndPartialEditsReturnPlainErrors() throws Exception
|
|
{
|
|
for(String json : List.of("not json", "[]", "{}", "{\"fieldName\":\"missing\"}", "{\"tableName\":\"other\",\"fieldName\":\"amount\"}"))
|
|
{
|
|
QRecord row = definition("admin", false, "COLUMN_PROFILE", Map.of()).withValue("definition_json", json);
|
|
var inserted = new InsertAction().execute(new InsertInput(DataAnalysisTables.SAVED).withRecords(List.of(row))).getRecords().getFirst();
|
|
assertThat(inserted.getErrors()).isNotEmpty();
|
|
}
|
|
assertThatThrownBy(() -> SavedDefinition.parse("UNKNOWN", "orders", Map.of())).hasMessageContaining("kind");
|
|
assertThatThrownBy(() -> SavedDefinition.parse("TABLE_PROFILE", "unknown", Map.of())).hasMessageContaining("table");
|
|
var saved = TestSupport.insert(DataAnalysisTables.SAVED, definition("admin", false, "COLUMN_PROFILE", Map.of("fieldName", "amount")));
|
|
var invalid = new QRecord().withValue("id", saved.getValue("id")).withValue("definition_json", "{\"fieldName\":\"nope\"}");
|
|
assertThat(new UpdateAction().execute(new UpdateInput(DataAnalysisTables.SAVED).withRecords(List.of(invalid))).getRecords().getFirst().getErrors()).isNotEmpty();
|
|
assertThat(WatchRecords.get(DataAnalysisTables.SAVED, saved.getValue("id")).getValueString("definition_json")).contains("amount");
|
|
}
|
|
|
|
@Test
|
|
void allKindsRunAndExportRealTables() throws Exception
|
|
{
|
|
Map<String, Map<String, Object>> definitions = Map.of(
|
|
"COLUMN_PROFILE", Map.of("fieldName", "amount"),
|
|
"TABLE_PROFILE", Map.of("fields", List.of("amount", "status")),
|
|
"PIVOT", Map.of("rows", List.of(Map.of("field", "status")), "values", List.of(Map.of("operator", "COUNT"))),
|
|
"COMPARE", Map.of("fields", List.of("amount", "status")),
|
|
"CORRELATION", Map.of("fields", List.of("amount", "quantity")),
|
|
"CROSSTAB", Map.of("rowField", "status", "columnField", "paid"),
|
|
"SHIFT", Map.of("field", "amount", "dateField", "created", "bucket", "DAY"));
|
|
for(var entry : definitions.entrySet())
|
|
{
|
|
var saved = TestSupport.insert(DataAnalysisTables.SAVED, definition("alice", true, entry.getKey(), entry.getValue()));
|
|
QContext.init(instance, viewer("alice", 1));
|
|
var result = new SavedAnalyses(service).run(saved.getValue("id"));
|
|
assertThat(result.kind()).isEqualTo(entry.getKey());
|
|
assertThat(AnalysisExport.table(result).rows()).as(entry.getKey()).isNotEmpty();
|
|
var csv = AnalysisExport.file(result, "csv");
|
|
assertThat(new String(csv.bytes(), StandardCharsets.UTF_8)).contains(AnalysisExport.table(result).columns().getFirst());
|
|
var xlsx = AnalysisExport.file(result, "xlsx");
|
|
try(var workbook = WorkbookFactory.create(new ByteArrayInputStream(xlsx.bytes())))
|
|
{
|
|
var sheet = workbook.getSheetAt(0);
|
|
assertThat(sheet.getLastRowNum()).isEqualTo(AnalysisExport.table(result).rows().size());
|
|
assertThat(sheet.getRow(0).getCell(0).getStringCellValue()).isEqualTo(AnalysisExport.table(result).columns().getFirst());
|
|
assertThat(sheet.getRow(1).getCell(0).getStringCellValue()).isEqualTo(AnalysisExport.table(result).rows().getFirst().getFirst());
|
|
}
|
|
var widget = SavedAnalysisWidget.widget().withDefaultValue("savedId", saved.getValue("id"));
|
|
var chart = (ChartData) new SavedAnalysisWidget().render(new RenderWidgetInput().withWidgetMetaData(widget)).getWidgetData();
|
|
assertThat(chart.getChartData().getDatasets()).as(entry.getKey()).isNotEmpty();
|
|
assertThat(chart.getFooterHTML()).contains("View saved analysis");
|
|
QContext.init(instance, session());
|
|
}
|
|
assertThatThrownBy(() -> AnalysisExport.format("pdf")).hasMessageContaining("csv or xlsx");
|
|
}
|
|
|
|
@Test
|
|
void unavailableRequiredFieldsAndFiltersNeverBroadenResults() throws Exception
|
|
{
|
|
QContext.init(instance, viewer("alice", 1));
|
|
for(var definition : List.of(
|
|
SavedDefinition.parse("COLUMN_PROFILE", "orders", Map.of("fieldName", "secret")),
|
|
SavedDefinition.parse("CORRELATION", "orders", Map.of("fields", List.of("amount", "secret"))),
|
|
SavedDefinition.parse("TABLE_PROFILE", "orders", Map.of("fields", List.of("amount"), "filter", Map.of("criteria", List.of(Map.of("fieldName", "secret", "operator", "EQUALS", "values", List.of("x"))))))))
|
|
{
|
|
var result = definition.run(service, false);
|
|
assertThat(result.warnings()).contains(SavedDefinition.FIELD_WARNING);
|
|
assertThat(AnalysisExport.table(result).rows()).isEmpty();
|
|
assertThatThrownBy(() -> definition.run(service, true)).hasMessageContaining(SavedDefinition.FIELD_WARNING);
|
|
}
|
|
}
|
|
|
|
@Test
|
|
void savedWidgetUsesViewerAndMemoryHasNoSql() throws Exception
|
|
{
|
|
var saved = TestSupport.insert(DataAnalysisTables.SAVED, definition("alice", true, "COLUMN_PROFILE", Map.of("fieldName", "amount")));
|
|
QContext.init(instance, viewer("bob", 2));
|
|
var empty = (ChartData) new SavedAnalysisWidget().render(new RenderWidgetInput().withWidgetMetaData(SavedAnalysisWidget.widget())).getWidgetData();
|
|
assertThat(empty.getDescription()).contains("Choose a saved analysis");
|
|
var metadata = SavedAnalysisWidget.widget().withDefaultValue("savedId", saved.getValue("id"));
|
|
var data = (ChartData) new SavedAnalysisWidget().render(new RenderWidgetInput().withWidgetMetaData(metadata)).getWidgetData();
|
|
assertThat(data.getChartData().getLabels()).isNotEmpty();
|
|
assertThat(data.getDescription()).contains("4");
|
|
assertThat(data.getFooterHTML()).contains("/" + saved.getValue("id"));
|
|
var definition = SavedDefinition.parse("COLUMN_PROFILE", "orders", Map.of("fieldName", "amount"));
|
|
assertThat(SqlHandoff.execute(definition, service).available()).isFalse();
|
|
instance.addApp(new QAppMetaData().withName("sqlWorkbench"));
|
|
QContext.getQSession().withPermission("sql_workbench.hasAccess");
|
|
assertThat(SqlHandoff.execute(definition, service).note()).contains("non-RDBMS");
|
|
}
|
|
}
|